diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b67896d..730b65c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,6 +54,9 @@ jobs: - name: Type-check front-end run: npx tsc --noEmit + - name: Front-end unit tests + run: npm run test:front + - name: Check for hardcoded version literals in HTML run: npm run version:check diff --git a/CHANGELOG.md b/CHANGELOG.md index d180752..436210d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Reset-credit details on account cards: profile refresh now also queries `GET /wham/rate-limit-reset-credits` (with the same OAuth identity the usage round ultimately used) and shows the available reset-card count plus each card's grant/expiry time, rendered in Asia/Shanghai. Only display fields are persisted — never card IDs or raw response bodies. A detail-endpoint failure neither blocks the main quota refresh nor wipes previously confirmed cards (both the API write-back paths and the session-snapshot paths backfill them from the stored profile). + ## 1.6.1 - 2026-07-12 - **Critical** — adapted the Windows desktop-host discovery for the Codex → ChatGPT app merge (2026-07-09; the Windows counterpart of the macOS adaptation shipped in 1.6.0). The merged host runs as `ChatGPT.exe` (MSIX package family still `OpenAI.Codex_2p2nqsd0c76g0`, non-Store installs under `%LOCALAPPDATA%\Programs\ChatGPT\`), so the old `Codex.exe`-only checks missed the running UI: switching skipped quitting the host (stale in-memory app-server on the old account) and never relaunched it. is-running / quit now enumerate both host names and classify each PID by its executable's install identity — the `OpenAI.Codex*` package family (incl. the beta channel) or a non-Store install that embeds the bundled CLI (`resources\codex.exe`, the Windows analog of the macOS `Contents/Resources/codex` qualifier) counts as ours; ChatGPT Classic (`OpenAI.ChatGPT-Desktop_…`, same `ChatGPT.exe` name) is positively excluded and never killed; unattributable PIDs still count as running (the switch aborts rather than proceeding over a possibly-live host) but are never signalled. Quit signals by PID (graceful `taskkill /PID` then `/F`) instead of `/IM `. Launch prefers the detected Store package (unchanged `shell:AppsFolder` id), falling back to a qualified non-Store `ChatGPT.exe` / `Codex.exe`. CLI discovery and the Settings auto-detect suggestions gain the desktop-bundled CLI locations (MSIX `\app\resources\codex.exe` and the non-Store `Programs\{ChatGPT,Codex}\resources\codex.exe`). Facts anchored from public manifests and tooling (winget, KAPE, third-party Codex tools); not yet verified on a live Windows install. diff --git a/README.md b/README.md index 2185444..065789e 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@ **Codex 账号切换工具**(Codex Switch)是一个面向 **OpenAI Codex CLI** 的本地桌面账号管理器。它把多份 `~/.codex/{auth.json,config.toml,sessions/...}` 状态各自打包到独立账号备份目录,一键切换"当前 Codex 账号",并把每个账号的 plan / 5 小时额度 / 周额度直接渲染在桌面端。 -跟纯账号切换脚本不同,本项目内置 **plan / quota 直读**:登录后通过 ChatGPT OAuth 的 `id_token` + ChatGPT Web API `account/rateLimits` / Codex app-server JSON-RPC fallback 拉取真实计划等级和 rate-limit 窗口剩余比例,不需要本地解析 Codex sessions/jsonl 日志即可看到每个账号"还能用多久"。 +跟纯账号切换脚本不同,本项目内置 **plan / quota 直读**:登录后通过 ChatGPT OAuth 的 `id_token` + ChatGPT Web API `account/rateLimits` / Codex app-server JSON-RPC fallback 拉取真实计划等级和 rate-limit 窗口剩余比例,并查询 `/wham/rate-limit-reset-credits` 显示账号可用重置卡数量及每张卡的授予/过期时间,不需要本地解析 Codex sessions/jsonl 日志即可看到每个账号"还能用多久"。 当前版本 **v1.5.10**(详见 [CHANGELOG](CHANGELOG.md) 与 [Releases](https://github.com/Cmochance/Codex_Account_Switch/releases))。 ## 能做什么 - 同一台机器多账号管理:每个账号一个备份目录(含独立的 `auth.json` / `config.toml` / `sessions/`),切换时把目标账号 swap 进 `~/.codex/`,旧账号自动归档到备份目录。 -- **账号页**:每页 8 个账号条目,横向单行布局展示「账号名 + 套餐」「5 小时额度 + 刷新时间」「周额度 + 刷新时间」「删除 / 重命名 / 刷新 / 登录 / Base / 切换」按钮。 +- **账号页**:每页 8 个账号条目,横向单行布局展示「账号名 + 套餐」「5 小时额度 + 刷新时间」「周额度 + 刷新时间」「重置卡数量 + 授予/过期时间」「删除 / 重命名 / 刷新 / 登录 / Base / 切换」按钮。 - **登录可取消**:进行中的 `codex login` OAuth 流程支持点击同一按钮取消(向子进程 SIGTERM / taskkill),解决浏览器关闭后应用卡在等待回调的场景。 - **plan / quota 智能缓存**:bulk plan refresh 在 6 小时窗口内跳过已确认账号,per-card 刷新按钮也共享同一缓存;切换 / 登录 / 刷新后直接复用 backend 写回的 snapshot,不重复发 IPC。 - **Custom Base URL**:每个账号可独立配置 `OPENAI_BASE_URL`;配置后按钮变红警示(自定义 Base 与 ChatGPT OAuth 账号互斥)。 @@ -57,7 +57,7 @@ codex_switch_<版本>_amd64.AppImage Linux x86_64 通用便携包(chmo 1. 从 Releases 下载并安装 Codex Switch,启动桌面窗口。 2. 进入「设置」页,确认 **Codex CLI 路径** 已自动填好(默认探测 PATH / `~/.codex/bin`);缺失时手动指定,状态变绿即可。 3. 进入「账号」页,点「添加账号」→ 输入账号别名(落地为 `~/.codex/account_backup/<别名>/`)。 -4. 在新账号条目点「登录」→ 浏览器完成 ChatGPT OAuth → 回到应用,套餐 + 额度自动加载。 +4. 在新账号条目点「登录」→ 浏览器完成 ChatGPT OAuth → 回到应用,套餐、额度和重置卡详情自动加载。 5. 多账号场景:在目标条目点「切换」即可一键替换当前 `~/.codex/`,原账号自动归档。 6. 需要清空 quota cache 或重新拉计划时,点对应账号的「刷新」(≥ 6h 走完整 OAuth rotation,< 6h 复用缓存)。 @@ -153,6 +153,8 @@ npm run version:check # CI 用:拒绝把 semver 字面量写回 *. 不会真发 ChatGPT 对话请求。Plan 数据来自 `id_token` claims;quota 走 ChatGPT Web API `account/rateLimits/read`(HTTP GET),或 Codex 0.130+ 的 `app-server` JSON-RPC fallback。没有 LLM 调用,不消耗用户额度。 +重置卡详情同样只读取 ChatGPT 的额度元数据;应用只保存可用数量、授予时间和过期时间,不保存卡片 ID,也不保留原始响应体。 + ### per-card「登录」按钮为什么有时变成「取消」 当 codex 进程已经起来并打开了浏览器,但 OAuth 回调没回来(用户关浏览器 / 网络异常),点同一按钮会向 codex login 子进程发 SIGTERM / taskkill 中止它,释放 `.switch.lock` 全局锁。 @@ -178,7 +180,7 @@ App 暂未做 Apple Developer 代码签名 / notarization。第一次启动按 ## 技术栈 - **后端 / runtime**:Rust 1.80+ · Tauri 2.x · tokio + reqwest(rustls-tls)· chrono · serde -- **协议适配**:`shared/runtime/chatgpt_api.rs`(ChatGPT Web API `account/{read,rateLimits/read}`)+ Codex app-server JSON-RPC fallback(`codex` ≥ 0.130 时 OAuth refresh 失败兜底) +- **协议适配**:`shared/runtime/chatgpt_api.rs`(ChatGPT Web API `account/{read,rateLimits/read}` 与 `/wham/rate-limit-reset-credits`)+ Codex app-server JSON-RPC fallback(`codex` ≥ 0.130 时 OAuth refresh 失败兜底) - **前端**:HTML + CSS + 原生 TypeScript(`shared/front/{render,state,actions,theme,i18n,tauri}.ts`),无前端框架;Vite 7 打包 - **跨平台 runtime**:mac / win runtime 各自实现 spawn / process control / OS-native title bar;shared runtime 处理协议、profile lifecycle、JSON-RPC、缓存、文件锁 - **构建 / 发布**:`tauri build` 单命令出 dmg / pkg / exe;`.github/workflows/build.yml` 出 draft release,全部 asset 由 GitHub Actions 上传 diff --git a/README.zh-CN.md b/README.zh-CN.md index 2185444..065789e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,14 +17,14 @@ **Codex 账号切换工具**(Codex Switch)是一个面向 **OpenAI Codex CLI** 的本地桌面账号管理器。它把多份 `~/.codex/{auth.json,config.toml,sessions/...}` 状态各自打包到独立账号备份目录,一键切换"当前 Codex 账号",并把每个账号的 plan / 5 小时额度 / 周额度直接渲染在桌面端。 -跟纯账号切换脚本不同,本项目内置 **plan / quota 直读**:登录后通过 ChatGPT OAuth 的 `id_token` + ChatGPT Web API `account/rateLimits` / Codex app-server JSON-RPC fallback 拉取真实计划等级和 rate-limit 窗口剩余比例,不需要本地解析 Codex sessions/jsonl 日志即可看到每个账号"还能用多久"。 +跟纯账号切换脚本不同,本项目内置 **plan / quota 直读**:登录后通过 ChatGPT OAuth 的 `id_token` + ChatGPT Web API `account/rateLimits` / Codex app-server JSON-RPC fallback 拉取真实计划等级和 rate-limit 窗口剩余比例,并查询 `/wham/rate-limit-reset-credits` 显示账号可用重置卡数量及每张卡的授予/过期时间,不需要本地解析 Codex sessions/jsonl 日志即可看到每个账号"还能用多久"。 当前版本 **v1.5.10**(详见 [CHANGELOG](CHANGELOG.md) 与 [Releases](https://github.com/Cmochance/Codex_Account_Switch/releases))。 ## 能做什么 - 同一台机器多账号管理:每个账号一个备份目录(含独立的 `auth.json` / `config.toml` / `sessions/`),切换时把目标账号 swap 进 `~/.codex/`,旧账号自动归档到备份目录。 -- **账号页**:每页 8 个账号条目,横向单行布局展示「账号名 + 套餐」「5 小时额度 + 刷新时间」「周额度 + 刷新时间」「删除 / 重命名 / 刷新 / 登录 / Base / 切换」按钮。 +- **账号页**:每页 8 个账号条目,横向单行布局展示「账号名 + 套餐」「5 小时额度 + 刷新时间」「周额度 + 刷新时间」「重置卡数量 + 授予/过期时间」「删除 / 重命名 / 刷新 / 登录 / Base / 切换」按钮。 - **登录可取消**:进行中的 `codex login` OAuth 流程支持点击同一按钮取消(向子进程 SIGTERM / taskkill),解决浏览器关闭后应用卡在等待回调的场景。 - **plan / quota 智能缓存**:bulk plan refresh 在 6 小时窗口内跳过已确认账号,per-card 刷新按钮也共享同一缓存;切换 / 登录 / 刷新后直接复用 backend 写回的 snapshot,不重复发 IPC。 - **Custom Base URL**:每个账号可独立配置 `OPENAI_BASE_URL`;配置后按钮变红警示(自定义 Base 与 ChatGPT OAuth 账号互斥)。 @@ -57,7 +57,7 @@ codex_switch_<版本>_amd64.AppImage Linux x86_64 通用便携包(chmo 1. 从 Releases 下载并安装 Codex Switch,启动桌面窗口。 2. 进入「设置」页,确认 **Codex CLI 路径** 已自动填好(默认探测 PATH / `~/.codex/bin`);缺失时手动指定,状态变绿即可。 3. 进入「账号」页,点「添加账号」→ 输入账号别名(落地为 `~/.codex/account_backup/<别名>/`)。 -4. 在新账号条目点「登录」→ 浏览器完成 ChatGPT OAuth → 回到应用,套餐 + 额度自动加载。 +4. 在新账号条目点「登录」→ 浏览器完成 ChatGPT OAuth → 回到应用,套餐、额度和重置卡详情自动加载。 5. 多账号场景:在目标条目点「切换」即可一键替换当前 `~/.codex/`,原账号自动归档。 6. 需要清空 quota cache 或重新拉计划时,点对应账号的「刷新」(≥ 6h 走完整 OAuth rotation,< 6h 复用缓存)。 @@ -153,6 +153,8 @@ npm run version:check # CI 用:拒绝把 semver 字面量写回 *. 不会真发 ChatGPT 对话请求。Plan 数据来自 `id_token` claims;quota 走 ChatGPT Web API `account/rateLimits/read`(HTTP GET),或 Codex 0.130+ 的 `app-server` JSON-RPC fallback。没有 LLM 调用,不消耗用户额度。 +重置卡详情同样只读取 ChatGPT 的额度元数据;应用只保存可用数量、授予时间和过期时间,不保存卡片 ID,也不保留原始响应体。 + ### per-card「登录」按钮为什么有时变成「取消」 当 codex 进程已经起来并打开了浏览器,但 OAuth 回调没回来(用户关浏览器 / 网络异常),点同一按钮会向 codex login 子进程发 SIGTERM / taskkill 中止它,释放 `.switch.lock` 全局锁。 @@ -178,7 +180,7 @@ App 暂未做 Apple Developer 代码签名 / notarization。第一次启动按 ## 技术栈 - **后端 / runtime**:Rust 1.80+ · Tauri 2.x · tokio + reqwest(rustls-tls)· chrono · serde -- **协议适配**:`shared/runtime/chatgpt_api.rs`(ChatGPT Web API `account/{read,rateLimits/read}`)+ Codex app-server JSON-RPC fallback(`codex` ≥ 0.130 时 OAuth refresh 失败兜底) +- **协议适配**:`shared/runtime/chatgpt_api.rs`(ChatGPT Web API `account/{read,rateLimits/read}` 与 `/wham/rate-limit-reset-credits`)+ Codex app-server JSON-RPC fallback(`codex` ≥ 0.130 时 OAuth refresh 失败兜底) - **前端**:HTML + CSS + 原生 TypeScript(`shared/front/{render,state,actions,theme,i18n,tauri}.ts`),无前端框架;Vite 7 打包 - **跨平台 runtime**:mac / win runtime 各自实现 spawn / process control / OS-native title bar;shared runtime 处理协议、profile lifecycle、JSON-RPC、缓存、文件锁 - **构建 / 发布**:`tauri build` 单命令出 dmg / pkg / exe;`.github/workflows/build.yml` 出 draft release,全部 asset 由 GitHub Actions 上传 diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 50964d1..dcd3bfb 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -41,6 +41,12 @@ - The desktop app does not use a separate local backend or HTTP server at runtime - `windows/` remains only as a historical note directory while the Rust path is the primary runtime and regression target +## Quota and reset-credit details + +- `chatgpt_api` refreshes `/wham/usage` first, then requests `GET /wham/rate-limit-reset-credits` with the access token and optional `chatgpt-account-id` that the usage round ultimately used. +- The reset-credit response is normalized into `QuotaSummary.rate_limit_reset_credits`; only the available count, grant time, and expiry time are persisted — never card IDs or raw response bodies. +- A detail-endpoint failure never blocks the main plan/quota refresh; previously confirmed details are preserved both when a newer session snapshot lacks reset-credit fields and when the detail fetch itself fails (backfilled from the stored profile metadata). + ## Installation behavior - `macOS-backup/install.sh` is now a compatibility entrypoint with `auto`, `desktop`, and `legacy` modes diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f3f79a9..35e0962 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -22,6 +22,8 @@ Treat them as secrets. ## Threat model summary -This project only performs local file operations. It does not transmit tokens over network by design. +This project mainly performs local file operations. When reading plan/quota data it sends the OAuth access token over HTTPS to official ChatGPT/OpenAI endpoints and fetches quota metadata only — no prompts are sent and no models are invoked. + +The reset-credit lookup sends only the credentials required for the account scope; the app persists just the available count, grant time, and expiry time — never card IDs or raw response bodies. Main risk is accidental token exposure through Git, screenshots, shared terminals, insecure backups, or loose Windows ACLs. diff --git a/package-lock.json b/package-lock.json index 9102091..3fb151e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "devDependencies": { "@tauri-apps/cli": "2.10.1", "typescript": "5.9.2", - "vite": "7.1.4" + "vite": "7.1.4", + "vitest": "^4.1.10" } }, "node_modules/@esbuild/aix-ppc64": { @@ -458,6 +459,13 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -808,6 +816,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", @@ -1035,6 +1050,24 @@ "node": ">= 10" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1042,6 +1075,153 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1084,6 +1264,26 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1117,6 +1317,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1136,6 +1346,27 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1230,6 +1461,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1240,6 +1478,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1257,6 +1526,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", @@ -1345,6 +1624,113 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index 41d028a..d879c02 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "macos:artifacts:finalize:release": "node scripts/macos-artifacts.mjs --phase=finalize --mode=release", "macos:pkg": "node scripts/create-macos-pkg.mjs", "test": "cargo test --manifest-path src-tauri/Cargo.toml", + "test:front": "vitest run", "test:rust": "cargo test --manifest-path src-tauri/Cargo.toml", "check:rust:windows": "cargo check --manifest-path src-tauri/Cargo.toml --target x86_64-pc-windows-msvc --lib", "test:rust:windows": "cargo test --manifest-path src-tauri/Cargo.toml --target x86_64-pc-windows-msvc", @@ -36,6 +37,7 @@ "devDependencies": { "@tauri-apps/cli": "2.10.1", "typescript": "5.9.2", - "vite": "7.1.4" + "vite": "7.1.4", + "vitest": "^4.1.10" } } diff --git a/src-tauri/mac/front/styles.css b/src-tauri/mac/front/styles.css index 026615d..30d0e4f 100644 --- a/src-tauri/mac/front/styles.css +++ b/src-tauri/mac/front/styles.css @@ -242,9 +242,9 @@ button.pager-pill:hover:not(:disabled) { } .profile-quota-stack { - display: flex; - flex-direction: row; - gap: 24px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 24px; align-items: center; min-width: 0; } @@ -736,7 +736,7 @@ button.pager-pill:disabled { } .profile-quota-stack { - flex-direction: column; + grid-template-columns: 1fr; align-items: stretch; gap: 8px; } diff --git a/src-tauri/shared/front/base.css b/src-tauri/shared/front/base.css index e734a19..f663050 100644 --- a/src-tauri/shared/front/base.css +++ b/src-tauri/shared/front/base.css @@ -2063,6 +2063,48 @@ button.pager-pill:hover:not(:disabled) { gap: 14px; } +.profile-reset-credits, +.current-reset-credits { + display: grid; + gap: 4px; + margin-top: 4px; +} + +.profile-reset-credits { + grid-column: 1 / -1; +} + +.reset-credits-status { + color: var(--text-body); + font-size: 0.78rem; + font-weight: 650; +} + +.reset-credits-details, +.reset-credit-card { + display: grid; + gap: 2px; +} + +.reset-credits-count, +.reset-credits-details, +.reset-credit-card, +.reset-credits-details-unknown { + white-space: nowrap; +} + +.reset-credit-card + .reset-credit-card { + margin-top: 4px; +} + +.reset-credit-card strong, +.reset-credit-card span, +.reset-credits-details-unknown { + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 500; +} + .profile-quota-metric { display: flex; flex-direction: column; diff --git a/src-tauri/shared/front/i18n.ts b/src-tauri/shared/front/i18n.ts index 8cfcea1..45c5dc6 100644 --- a/src-tauri/shared/front/i18n.ts +++ b/src-tauri/shared/front/i18n.ts @@ -50,6 +50,11 @@ const enMessages = { quotaWillAppear: "Quota data will appear after a profile is configured.", fiveHourAllowance: "5h allowance", weeklyAllowance: "Weekly allowance", + resetCreditsAvailable: "Quota reset cards available: {value}", + resetCreditsCard: "Reset card {value}", + resetCreditsGrantedAt: "Granted {value}", + resetCreditsExpiresAt: "Expires {value}", + resetCreditsDetailsUnknown: "Grant and expiry times not reported by API", refresh: "Refresh {value}", refreshButton: "Refresh", resetsIn: "Resets in {value}", @@ -312,6 +317,11 @@ const messages: Record = { quotaWillAppear: "配置账号后,这里会显示额度信息。", fiveHourAllowance: "5小时额度", weeklyAllowance: "周额度", + resetCreditsAvailable: "可用额度重置卡:{value} 张", + resetCreditsCard: "重置卡 {value}", + resetCreditsGrantedAt: "发放时间:{value}", + resetCreditsExpiresAt: "到期时间:{value}", + resetCreditsDetailsUnknown: "发放及到期时间:接口未报告", refresh: "刷新时间 {value}", refreshButton: "刷新", resetsIn: "{value} 后重置", diff --git a/src-tauri/shared/front/quota-view-model.test.ts b/src-tauri/shared/front/quota-view-model.test.ts new file mode 100644 index 0000000..c588b9c --- /dev/null +++ b/src-tauri/shared/front/quota-view-model.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { buildRateLimitResetCreditsPresentation } from "./quota-view-model"; + +describe("rate-limit reset credit presentation", () => { + it("keeps the available count and every card timestamp", () => { + const result = buildRateLimitResetCreditsPresentation({ + five_hour: { remaining_percent: 80, refresh_at: null, reset_at_timestamp: null }, + weekly: { remaining_percent: 90, refresh_at: null, reset_at_timestamp: null }, + rate_limit_reset_credits: { + available_count: 2, + credits: [ + { granted_at: 1_783_890_765, expires_at: 1_786_482_765 }, + { granted_at: 1_783_900_000, expires_at: 1_786_500_000 }, + ], + }, + }); + + expect(result).toEqual({ + state: "ready", + availableCount: 2, + cards: [ + { grantedAt: 1_783_890_765, expiresAt: 1_786_482_765 }, + { grantedAt: 1_783_900_000, expiresAt: 1_786_500_000 }, + ], + }); + }); + + it("does not invent a count when the API did not report one", () => { + expect( + buildRateLimitResetCreditsPresentation({ + five_hour: { remaining_percent: null, refresh_at: null, reset_at_timestamp: null }, + weekly: { remaining_percent: null, refresh_at: null, reset_at_timestamp: null }, + }), + ).toEqual({ state: "unknown", availableCount: null }); + }); +}); diff --git a/src-tauri/shared/front/quota-view-model.ts b/src-tauri/shared/front/quota-view-model.ts new file mode 100644 index 0000000..44ca46e --- /dev/null +++ b/src-tauri/shared/front/quota-view-model.ts @@ -0,0 +1,27 @@ +import type { QuotaSummary } from "@front-shared/types"; + +export type RateLimitResetCreditsPresentation = + | { state: "unknown"; availableCount: null } + | { + state: "ready"; + availableCount: number; + cards: Array<{ grantedAt: number | null; expiresAt: number | null }>; + }; + +export function buildRateLimitResetCreditsPresentation( + quota: QuotaSummary | null | undefined, +): RateLimitResetCreditsPresentation { + const availableCount = quota?.rate_limit_reset_credits?.available_count; + if (availableCount == null) { + return { state: "unknown", availableCount: null }; + } + + return { + state: "ready", + availableCount, + cards: (quota?.rate_limit_reset_credits?.credits ?? []).map((credit) => ({ + grantedAt: credit.granted_at, + expiresAt: credit.expires_at, + })), + }; +} diff --git a/src-tauri/shared/front/render.ts b/src-tauri/shared/front/render.ts index 6669e6e..00cbf62 100644 --- a/src-tauri/shared/front/render.ts +++ b/src-tauri/shared/front/render.ts @@ -11,6 +11,8 @@ import type { import { t, type MessageKey } from "@front-shared/i18n"; import { state } from "@front-shared/state"; import { getThemeOption, isThemeId } from "@front-shared/theme"; +import { buildRateLimitResetCreditsPresentation } from "@front-shared/quota-view-model"; +import { formatBeijingDateTimeSeconds } from "@front-shared/time-format"; const isWindowsUiTarget = __CODEX_UI_TARGET__ === "windows"; const shellRoutes: readonly ShellRoute[] = ["dashboard", "profiles", "settings", "guide"]; @@ -364,6 +366,46 @@ function buildMetricLineMarkup( `; } +function buildRateLimitResetCreditsMarkup( + quota: QuotaSummary | null | undefined, + unavailable: boolean, + layout: "profile" | "current", +): string { + if (unavailable) { + return ""; + } + + const presentation = buildRateLimitResetCreditsPresentation(quota); + if (presentation.state === "unknown") { + return ""; + } + + const layoutClass = layout === "current" ? "current-reset-credits" : "profile-reset-credits"; + const message = t(state.locale, "resetCreditsAvailable", { + value: presentation.availableCount, + }); + const details = presentation.cards.length === 0 + ? `${escapeHtml(t(state.locale, "resetCreditsDetailsUnknown"))}` + : presentation.cards.map((card, index) => { + const grantedAt = card.grantedAt == null + ? "--" + : (formatBeijingDateTimeSeconds(card.grantedAt, state.locale) ?? "--"); + const expiresAt = card.expiresAt == null + ? "--" + : (formatBeijingDateTimeSeconds(card.expiresAt, state.locale) ?? "--"); + return ` + ${escapeHtml(t(state.locale, "resetCreditsCard", { value: index + 1 }))} + ${escapeHtml(t(state.locale, "resetCreditsGrantedAt", { value: grantedAt }))} + ${escapeHtml(t(state.locale, "resetCreditsExpiresAt", { value: expiresAt }))} + `; + }).join(""); + + return `
+ ${escapeHtml(message)} + ${details} +
`; +} + function buildProfileQuotaMarkup(profile: ProfileCard): string { const unavailable = isProfileUnavailable(profile); const quota = profile.quota; @@ -372,6 +414,7 @@ function buildProfileQuotaMarkup(profile: ProfileCard): string {
${buildMetricLineMarkup(t(state.locale, "fiveHourAllowance"), quota?.five_hour, "blue", unavailable, "profile")} ${buildMetricLineMarkup(t(state.locale, "weeklyAllowance"), quota?.weekly, "pink", unavailable, "profile")} + ${buildRateLimitResetCreditsMarkup(quota, unavailable, "profile")}
`; } @@ -386,6 +429,7 @@ function buildCurrentQuotaMarkup(
${buildMetricLineMarkup(t(state.locale, "fiveHourAllowance"), quota?.five_hour, "blue", unavailable, "current")} ${buildMetricLineMarkup(t(state.locale, "weeklyAllowance"), quota?.weekly, "pink", unavailable, "current")} + ${buildRateLimitResetCreditsMarkup(quota, unavailable, "current")}
`; } diff --git a/src-tauri/shared/front/time-format.test.ts b/src-tauri/shared/front/time-format.test.ts new file mode 100644 index 0000000..ec79c6b --- /dev/null +++ b/src-tauri/shared/front/time-format.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { formatBeijingDateTimeSeconds } from "./time-format"; + +describe("Beijing date time formatting", () => { + it("uses Asia/Shanghai independently of the host timezone", () => { + const formatted = formatBeijingDateTimeSeconds(1_783_890_765, "zh-CN"); + expect(formatted).toContain("2026"); + expect(formatted).toContain("7月13日"); + expect(formatted).toContain("05:12"); + }); +}); diff --git a/src-tauri/shared/front/time-format.ts b/src-tauri/shared/front/time-format.ts new file mode 100644 index 0000000..fb310cc --- /dev/null +++ b/src-tauri/shared/front/time-format.ts @@ -0,0 +1,14 @@ +export function formatBeijingDateTimeSeconds( + timestampSeconds: number | null, + locale: string, +): string | null { + if (timestampSeconds == null) { + return null; + } + + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Asia/Shanghai", + }).format(new Date(timestampSeconds * 1000)); +} diff --git a/src-tauri/shared/front/types.ts b/src-tauri/shared/front/types.ts index 75f88bd..92f0b2d 100644 --- a/src-tauri/shared/front/types.ts +++ b/src-tauri/shared/front/types.ts @@ -4,9 +4,20 @@ export interface QuotaWindow { reset_at_timestamp: number | null; } +export interface RateLimitResetCredit { + granted_at: number | null; + expires_at: number | null; +} + +export interface RateLimitResetCredits { + available_count: number | null; + credits: RateLimitResetCredit[]; +} + export interface QuotaSummary { five_hour: QuotaWindow; weekly: QuotaWindow; + rate_limit_reset_credits?: RateLimitResetCredits | null; } export interface ProfileCard { diff --git a/src-tauri/shared/runtime/chatgpt_api.rs b/src-tauri/shared/runtime/chatgpt_api.rs index 3d7284f..fe58de3 100644 --- a/src-tauri/shared/runtime/chatgpt_api.rs +++ b/src-tauri/shared/runtime/chatgpt_api.rs @@ -38,7 +38,7 @@ use serde::Deserialize; use serde_json::Value; use crate::errors::{AppError, AppResult}; -use crate::models::{QuotaSummary, QuotaWindow}; +use crate::models::{QuotaSummary, QuotaWindow, RateLimitResetCredit, RateLimitResetCredits}; use super::paths::get_backup_root; @@ -100,6 +100,7 @@ const ISSUER: &str = "https://auth.openai.com"; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const CHATGPT_BACKEND: &str = "https://chatgpt.com/backend-api"; const USAGE_PATH: &str = "/wham/usage"; +const RESET_CREDITS_PATH: &str = "/wham/rate-limit-reset-credits"; /// Reserved for future use: surfaces the active subscription / plan tier /// without relying on the id_token claims, useful when migrating between /// `prolite` / `plus` / `pro` / `business`. @@ -174,6 +175,8 @@ struct RateLimitStatusPayload { plan_type: Option, #[serde(default)] rate_limit: Option, + #[serde(default)] + rate_limit_reset_credits: Option, } #[derive(Deserialize)] @@ -276,14 +279,44 @@ pub fn refresh_profile_via_api_with_options( } let client = build_http_client()?; - let usage_payload = + let usage_result = fetch_usage_with_retry(&client, &profile_dir, &auth, options.force_token_rotation)?; + let reset_credit_cards = match fetch_reset_credits(&client, &usage_result.auth) { + Ok(cards) => Some(cards), + Err(error) => { + eprintln!("Reset credits detail unavailable: {error}"); + None + } + }; + let usage_payload = usage_result.payload; let plan_type = usage_payload .plan_type .clone() .filter(|value| !value.trim().is_empty()); - let subscription_expires_at = read_subscription_expiry_from_id_token(&auth.id_token); - let quota = quota_summary_from_payload(&usage_payload); + let subscription_expires_at = + read_subscription_expiry_from_id_token(&usage_result.auth.id_token); + let mut quota = quota_summary_from_payload(&usage_payload); + match reset_credit_cards { + Some(reset_credit_cards) => { + let summary = quota.get_or_insert_with(QuotaSummary::default); + let reset_credits = summary + .rate_limit_reset_credits + .get_or_insert_with(RateLimitResetCredits::default); + if reset_credits.available_count.is_none() { + reset_credits.available_count = u32::try_from(reset_credit_cards.len()).ok(); + } + reset_credits.credits = reset_credit_cards; + } + None => { + // Every API write-back path persists this snapshot wholesale + // (`snapshot.quota.unwrap_or_default()`), so a transient detail + // failure would wipe previously confirmed cards without this + // backfill — the session-snapshot paths already preserve them + // the same way (see `select_current_quota`). + let stored = super::metadata::load_profile_metadata(profile_name, Some(codex_home)); + backfill_reset_credits_from_stored(&mut quota, &stored.quota); + } + } Ok(ChatGptApiSnapshot { quota, @@ -409,7 +442,7 @@ fn fetch_usage_with_retry( profile_dir: &Path, auth: &ProfileAuthFile, force_token_rotation: bool, -) -> AppResult { +) -> AppResult { // 1) Refresh proactively if the JWT is already expired or about to // expire. Avoids a guaranteed 401 + retry pair. When the caller // forces rotation, refresh unconditionally — id_token claims @@ -424,14 +457,25 @@ fn fetch_usage_with_retry( // 2) First attempt. let response = send_usage_request(client, &working_auth)?; if response.status() != StatusCode::UNAUTHORIZED { - return decode_usage_response(response); + return Ok(UsageFetchResult { + payload: decode_usage_response(response)?, + auth: working_auth, + }); } // 3) On 401, refresh once and retry. If the refresh itself fails, the // caller falls back to the legacy `codex exec` path. let refreshed = refresh_oauth_tokens(client, profile_dir, &working_auth)?; let retry = send_usage_request(client, &refreshed)?; - decode_usage_response(retry) + Ok(UsageFetchResult { + payload: decode_usage_response(retry)?, + auth: refreshed, + }) +} + +struct UsageFetchResult { + payload: RateLimitStatusPayload, + auth: ProfileAuthFile, } fn send_usage_request( @@ -451,6 +495,99 @@ fn send_usage_request( }) } +fn fetch_reset_credits( + client: &Client, + auth: &ProfileAuthFile, +) -> AppResult> { + let headers = build_chatgpt_headers(&auth.access_token, auth.account_id.as_deref())?; + let response = client + .get(format!("{CHATGPT_BACKEND}{RESET_CREDITS_PATH}")) + .headers(headers) + .send() + .map_err(|error| { + AppError::new( + "HTTP_REQUEST_FAILED", + format!("ChatGPT reset credits request failed: {error}"), + ) + })?; + let status = response.status(); + if let Some(error) = reset_credits_status_error(status) { + return Err(error); + } + let payload: Value = response.json().map_err(|error| { + AppError::new( + "RESET_CREDITS_PARSE_FAILED", + format!("Failed to parse reset credits response: {error}"), + ) + })?; + reset_credits_from_payload(&payload).ok_or_else(|| { + AppError::new( + "RESET_CREDITS_PARSE_FAILED", + "Reset credits response is missing a `credits` array.", + ) + }) +} + +fn reset_credits_status_error(status: StatusCode) -> Option { + if status == StatusCode::UNAUTHORIZED { + return Some(AppError::new( + "RESET_CREDITS_UNAUTHORIZED", + "Reset credits endpoint returned 401; the access token or account scope may be stale.", + )); + } + (!status.is_success()).then(|| { + AppError::new( + "RESET_CREDITS_HTTP_ERROR", + format!("Reset credits endpoint returned HTTP {status}."), + ) + }) +} + +fn reset_credits_from_payload(payload: &Value) -> Option> { + let credits = payload.get("credits")?.as_array()?; + Some( + credits + .iter() + .map(|credit| RateLimitResetCredit { + granted_at: credit.get("granted_at").and_then(timestamp_seconds), + expires_at: credit.get("expires_at").and_then(timestamp_seconds), + }) + .collect(), + ) +} + +/// Carry previously confirmed reset-credit details over into a fresh +/// snapshot whose detail fetch failed. Only fires when the new snapshot +/// carries no reset-credit information at all: if `/wham/usage` reported a +/// fresh `available_count`, mixing it with stale stored card timestamps +/// could show a count that contradicts the listed cards. +fn backfill_reset_credits_from_stored( + quota: &mut Option, + stored_quota: &QuotaSummary, +) { + if quota + .as_ref() + .is_some_and(|summary| summary.rate_limit_reset_credits.is_some()) + { + return; + } + let Some(stored) = stored_quota.rate_limit_reset_credits.clone() else { + return; + }; + quota + .get_or_insert_with(QuotaSummary::default) + .rate_limit_reset_credits = Some(stored); +} + +fn timestamp_seconds(value: &Value) -> Option { + value.as_i64().or_else(|| { + value + .as_str() + .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw).ok()) + .map(|timestamp| timestamp.timestamp()) + }) +} + fn decode_usage_response( response: reqwest::blocking::Response, ) -> AppResult { @@ -586,9 +723,9 @@ fn persist_refreshed_auth(profile_dir: &Path, auth: &ProfileAuthFile) -> AppResu fn quota_summary_from_payload(payload: &RateLimitStatusPayload) -> Option { use super::quota_routing::{slot_from_window_minutes, QuotaSlot}; - let rate_limit = payload.rate_limit.as_ref()?; let mut summary = QuotaSummary::default(); - let mut any_data = false; + summary.rate_limit_reset_credits = payload.rate_limit_reset_credits.clone(); + let mut any_data = summary.rate_limit_reset_credits.is_some(); // Position is just a fallback; `window_minutes` is authoritative. // OpenAI usually puts the 5h window in primary and weekly in @@ -596,19 +733,21 @@ fn quota_summary_from_payload(payload: &RateLimitStatusPayload) -> Option summary.five_hour = mapped, - QuotaSlot::Weekly => summary.weekly = mapped, + if let Some(rate_limit) = payload.rate_limit.as_ref() { + for (window, fallback) in [ + (rate_limit.primary_window.as_ref(), QuotaSlot::FiveHour), + (rate_limit.secondary_window.as_ref(), QuotaSlot::Weekly), + ] { + let Some(window) = window else { continue }; + let mapped = quota_window_from_rate_limit(window); + if mapped.remaining_percent.is_none() && mapped.refresh_at.is_none() { + continue; + } + any_data = true; + match slot_from_window_minutes(window.window_minutes, fallback) { + QuotaSlot::FiveHour => summary.five_hour = mapped, + QuotaSlot::Weekly => summary.weekly = mapped, + } } } @@ -753,6 +892,124 @@ mod tests { assert!(quota_summary_from_payload(&payload).is_none()); } + #[test] + fn quota_summary_preserves_available_reset_credit_count() { + let payload: RateLimitStatusPayload = serde_json::from_value(serde_json::json!({ + "plan_type": "pro", + "rate_limit_reset_credits": { "available_count": 2 } + })) + .unwrap(); + + let quota = quota_summary_from_payload(&payload).expect("reset credit count"); + let credits = quota + .rate_limit_reset_credits + .expect("reset credit summary"); + assert_eq!(credits.available_count, Some(2)); + assert!(credits.credits.is_empty()); + } + + #[test] + fn reset_credit_details_preserve_grant_and_expiry_without_persisting_ids() { + let payload = serde_json::json!({ + "credits": [{ + "id": "must-not-be-persisted", + "granted_at": "2026-07-12T21:12:45.739898Z", + "expires_at": "2026-08-11T21:12:45.739898Z" + }, { + "id": "also-discarded", + "granted_at": 1_783_900_000_i64, + "expires_at": 1_786_500_000_i64 + }] + }); + + let credits = reset_credits_from_payload(&payload).expect("reset credit details"); + assert_eq!(credits.len(), 2); + assert_eq!(credits[0].granted_at, Some(1_783_890_765)); + assert_eq!(credits[0].expires_at, Some(1_786_482_765)); + assert_eq!(credits[1].granted_at, Some(1_783_900_000)); + assert_eq!(credits[1].expires_at, Some(1_786_500_000)); + } + + #[test] + fn reset_credits_401_is_reported_without_response_body() { + let error = reset_credits_status_error(StatusCode::UNAUTHORIZED).expect("401 error"); + assert_eq!(error.error_code, "RESET_CREDITS_UNAUTHORIZED"); + assert!(error.message.contains("access token")); + } + + fn stored_quota_with_credits() -> QuotaSummary { + QuotaSummary { + rate_limit_reset_credits: Some(RateLimitResetCredits { + available_count: Some(2), + credits: vec![RateLimitResetCredit { + granted_at: Some(1_783_890_765), + expires_at: Some(1_786_482_765), + }], + }), + ..QuotaSummary::default() + } + } + + #[test] + fn backfill_restores_stored_reset_credits_when_detail_fetch_fails() { + let mut quota = Some(QuotaSummary { + five_hour: QuotaWindow { + remaining_percent: Some(80), + ..QuotaWindow::default() + }, + ..QuotaSummary::default() + }); + + backfill_reset_credits_from_stored(&mut quota, &stored_quota_with_credits()); + + let summary = quota.expect("quota kept"); + assert_eq!(summary.five_hour.remaining_percent, Some(80)); + let credits = summary.rate_limit_reset_credits.expect("stored credits"); + assert_eq!(credits.available_count, Some(2)); + assert_eq!(credits.credits[0].expires_at, Some(1_786_482_765)); + } + + #[test] + fn backfill_keeps_fresh_usage_count_over_stale_stored_cards() { + let mut quota = Some(QuotaSummary { + rate_limit_reset_credits: Some(RateLimitResetCredits { + available_count: Some(1), + credits: Vec::new(), + }), + ..QuotaSummary::default() + }); + + backfill_reset_credits_from_stored(&mut quota, &stored_quota_with_credits()); + + let credits = quota + .expect("quota kept") + .rate_limit_reset_credits + .expect("fresh count kept"); + assert_eq!(credits.available_count, Some(1)); + assert!(credits.credits.is_empty()); + } + + #[test] + fn backfill_creates_summary_when_snapshot_has_no_quota() { + let mut quota = None; + + backfill_reset_credits_from_stored(&mut quota, &stored_quota_with_credits()); + + let summary = quota.expect("summary created for stored credits"); + assert!(summary.five_hour.remaining_percent.is_none()); + let credits = summary.rate_limit_reset_credits.expect("stored credits"); + assert_eq!(credits.available_count, Some(2)); + } + + #[test] + fn backfill_is_a_no_op_without_stored_credits() { + let mut quota = None; + + backfill_reset_credits_from_stored(&mut quota, &QuotaSummary::default()); + + assert!(quota.is_none()); + } + #[test] fn quota_summary_routes_by_window_minutes_when_weekly_lands_in_primary_slot() { // Real session JSONL audit on the maintainer's machine surfaced diff --git a/src-tauri/shared/runtime/metadata.rs b/src-tauri/shared/runtime/metadata.rs index 2d5cb47..d105035 100644 --- a/src-tauri/shared/runtime/metadata.rs +++ b/src-tauri/shared/runtime/metadata.rs @@ -754,6 +754,7 @@ mod tests { refresh_at: Some("2026-05-15 12:00".to_string()), ..QuotaWindow::default() }, + rate_limit_reset_credits: None, }, ..ProfileMetadata::default() }; diff --git a/src-tauri/shared/runtime/models.rs b/src-tauri/shared/runtime/models.rs index af96676..5140f49 100644 --- a/src-tauri/shared/runtime/models.rs +++ b/src-tauri/shared/runtime/models.rs @@ -8,11 +8,26 @@ pub struct QuotaWindow { pub reset_at_timestamp: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct RateLimitResetCredit { + pub granted_at: Option, + pub expires_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct RateLimitResetCredits { + pub available_count: Option, + pub credits: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct QuotaSummary { pub five_hour: QuotaWindow, pub weekly: QuotaWindow, + pub rate_limit_reset_credits: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src-tauri/shared/runtime/profiles_index.rs b/src-tauri/shared/runtime/profiles_index.rs index 13d01ae..3f77261 100644 --- a/src-tauri/shared/runtime/profiles_index.rs +++ b/src-tauri/shared/runtime/profiles_index.rs @@ -346,6 +346,7 @@ pub(crate) fn quota_summary_has_data(quota: &crate::models::QuotaSummary) -> boo || quota.five_hour.refresh_at.is_some() || quota.weekly.remaining_percent.is_some() || quota.weekly.refresh_at.is_some() + || quota.rate_limit_reset_credits.is_some() } fn select_current_quota( @@ -362,7 +363,12 @@ fn select_current_quota( // (0), so any live session still wins there. match live_snapshot { Some(snapshot) if snapshot.source_mtime_ms.unwrap_or(0) > stored_updated_at_ms => { - snapshot.quota.clone() + let mut quota = snapshot.quota.clone(); + if quota.rate_limit_reset_credits.is_none() { + quota.rate_limit_reset_credits = + entry.stored_quota.rate_limit_reset_credits.clone(); + } + quota } _ => entry.stored_quota.clone(), } @@ -601,7 +607,7 @@ mod tests { fs::write(profile_dir.join("auth.json"), "profile-b-auth\n").unwrap(); fs::write( profile_dir.join("profile.json"), - r#"{"folder_name":"b","account_label":"b@example.com","quota":{"five_hour":{"remaining_percent":41},"weekly":{"remaining_percent":63}},"quota_updated_at_ms":1}"#, + r#"{"folder_name":"b","account_label":"b@example.com","quota":{"five_hour":{"remaining_percent":41},"weekly":{"remaining_percent":63},"rate_limit_reset_credits":{"available_count":1,"credits":[{"granted_at":1783890765,"expires_at":1786482765}]}},"quota_updated_at_ms":1}"#, ) .unwrap(); write_active_marker(&profile_dir, "2020-01-01T00:00:00Z"); @@ -613,6 +619,11 @@ mod tests { let quota = response.quota.expect("expected quota"); assert_eq!(quota.five_hour.remaining_percent, Some(89)); assert_eq!(quota.weekly.remaining_percent, Some(88)); + let reset_credits = quota + .rate_limit_reset_credits + .expect("stored reset credit details"); + assert_eq!(reset_credits.available_count, Some(1)); + assert_eq!(reset_credits.credits[0].expires_at, Some(1_786_482_765)); let _ = fs::remove_dir_all(&codex_home); } diff --git a/src-tauri/shared/runtime/quota_cache.rs b/src-tauri/shared/runtime/quota_cache.rs index 1f0c6fc..5b02b9c 100644 --- a/src-tauri/shared/runtime/quota_cache.rs +++ b/src-tauri/shared/runtime/quota_cache.rs @@ -266,6 +266,7 @@ mod tests { refresh_at: None, ..QuotaWindow::default() }, + rate_limit_reset_credits: None, } } diff --git a/src-tauri/shared/runtime/session_usage.rs b/src-tauri/shared/runtime/session_usage.rs index 0f86e41..128cf9e 100644 --- a/src-tauri/shared/runtime/session_usage.rs +++ b/src-tauri/shared/runtime/session_usage.rs @@ -102,6 +102,7 @@ pub fn normalize_quota_summary( QuotaSummary { five_hour: normalize_quota_window(quota.five_hour), weekly: normalize_quota_window(quota.weekly), + rate_limit_reset_credits: quota.rate_limit_reset_credits, } } @@ -439,6 +440,7 @@ mod tests { ..QuotaWindow::default() }, weekly: QuotaWindow::default(), + rate_limit_reset_credits: None, }, source_mtime_ms: Some(signature.0), }); diff --git a/src-tauri/shared/runtime/switch_core.rs b/src-tauri/shared/runtime/switch_core.rs index 490ea47..b4eaf1e 100644 --- a/src-tauri/shared/runtime/switch_core.rs +++ b/src-tauri/shared/runtime/switch_core.rs @@ -67,12 +67,13 @@ pub(crate) fn sync_live_quota_and_refresh_root(profile: &str, codex_home: &Path) let live_is_newer = snapshot.source_mtime_ms.unwrap_or(0) > metadata.quota_updated_at_ms.unwrap_or(0); if live_is_newer || !quota_summary_has_data(&metadata.quota) { - metadata = sync_profile_quota( - profile, - snapshot.quota, - snapshot.source_mtime_ms, - Some(codex_home), - )?; + let mut quota = snapshot.quota; + if quota.rate_limit_reset_credits.is_none() { + quota.rate_limit_reset_credits = + metadata.quota.rate_limit_reset_credits.clone(); + } + metadata = + sync_profile_quota(profile, quota, snapshot.source_mtime_ms, Some(codex_home))?; } } } diff --git a/src-tauri/win/front/styles.css b/src-tauri/win/front/styles.css index 7feaeec..eae75b9 100644 --- a/src-tauri/win/front/styles.css +++ b/src-tauri/win/front/styles.css @@ -242,9 +242,9 @@ button.pager-pill:hover:not(:disabled) { } .profile-quota-stack { - display: flex; - flex-direction: row; - gap: 24px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 24px; align-items: center; min-width: 0; } @@ -736,7 +736,7 @@ button.pager-pill:disabled { } .profile-quota-stack { - flex-direction: column; + grid-template-columns: 1fr; align-items: stretch; gap: 8px; } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..294f6d0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath, URL } from "node:url"; + +import { defineConfig } from "vitest/config"; + +// Standalone config (not merged into vite.config.ts): the Vite `root` +// points at the per-platform front-end directory, so an inherited root +// would never scan the shared front-end tests. +export default defineConfig({ + resolve: { + alias: { + "@front-shared": fileURLToPath(new URL("./src-tauri/shared/front", import.meta.url)), + "@win-front": fileURLToPath(new URL("./src-tauri/win/front", import.meta.url)), + "@mac-front": fileURLToPath(new URL("./src-tauri/mac/front", import.meta.url)), + }, + }, + test: { + include: ["src-tauri/**/front/**/*.test.ts"], + }, +});