diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 730b65c..440da72 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -44,6 +44,8 @@ jobs:
cache: 'npm'
- uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt
- uses: Swatinem/rust-cache@v2
with:
@@ -60,6 +62,9 @@ jobs:
- name: Check for hardcoded version literals in HTML
run: npm run version:check
+ - name: cargo fmt (check)
+ run: cargo fmt --manifest-path src-tauri/Cargo.toml -- --check
+
- name: cargo check (lib)
run: cargo check --manifest-path src-tauri/Cargo.toml --lib
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 436210d..038d174 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+- macOS proxy support: a Settings field for an HTTP/HTTPS/SOCKS5/SOCKS5h proxy URL, injected into the app's own quota requests (reqwest), the codex app-server / login subprocesses, and the update-check curl (upper- and lower-case proxy env vars, loopback excluded via `NO_PROXY`). Fixes quota refresh failing with region-blocked 403s when the system proxy is not visible to a GUI app; changing the proxy takes effect immediately, no restart.
+- Fixed quota windows landing in the wrong dashboard slot. Root cause: the raw `/wham/usage` schema reports window length as `limit_window_seconds` — the `window_minutes` field this app expected only exists in Codex-CLI-converted payloads (session JSONL / app-server), so HTTP-refreshed quota always fell back to position guessing, and a weekly window arriving in the primary slot rendered as "5h". Window length is now parsed from either field (seconds converted exactly like the CLI does); a `reset_at`-distance heuristic remains as a last resort; and month-scale windows (the free plan's 30-day quota, 43 200 min) route to the weekly slot instead of masquerading as 5h. Known limitation: the dashboard has no dedicated monthly slot, so free-plan quota shows under the weekly label. Reset countdowns longer than a day now render as `Xd Yh Zm`.
- 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
diff --git a/README.md b/README.md
index 065789e..7885df7 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@
- **登录可取消**:进行中的 `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 账号互斥)。
+- **代理配置(macOS)**:设置页可配 HTTP/HTTPS/SOCKS5/SOCKS5h 代理,注入 app 自身的额度请求与 codex 子进程 / 更新检查;解决 GUI 应用读不到终端代理环境变量导致的 403 地区限制报错,修改即时生效。
- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。设置页还提供「自动检测」按钮:忽略可能出错的缓存重新扫描所有常见位置,并用 `codex --version` 验证候选确实可运行——唯一命中直接应用,多个命中时让你选。
- **跨平台原生 Tauri**:macOS arm64 / x64 与 Windows x64 提供原生窗口、原生标题栏 / 关闭按钮,配套 5 套浅色 / 深色主题与中英文界面。
- **本地预览模式**:没有 Tauri 运行时(直接 `vite` 跑前端)时自动使用 mock snapshot,方便单纯调样式。
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 065789e..7885df7 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -28,6 +28,7 @@
- **登录可取消**:进行中的 `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 账号互斥)。
+- **代理配置(macOS)**:设置页可配 HTTP/HTTPS/SOCKS5/SOCKS5h 代理,注入 app 自身的额度请求与 codex 子进程 / 更新检查;解决 GUI 应用读不到终端代理环境变量导致的 403 地区限制报错,修改即时生效。
- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。设置页还提供「自动检测」按钮:忽略可能出错的缓存重新扫描所有常见位置,并用 `codex --version` 验证候选确实可运行——唯一命中直接应用,多个命中时让你选。
- **跨平台原生 Tauri**:macOS arm64 / x64 与 Windows x64 提供原生窗口、原生标题栏 / 关闭按钮,配套 5 套浅色 / 深色主题与中英文界面。
- **本地预览模式**:没有 Tauri 运行时(直接 `vite` 跑前端)时自动使用 mock snapshot,方便单纯调样式。
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 35e0962..1caea71 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -26,4 +26,6 @@ This project mainly performs local file operations. When reading plan/quota data
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.
+The optional proxy setting (macOS) stores the configured proxy URL in plain text in `proxy_state.json` under the runtime directory. If your proxy URL embeds credentials (`http://user:pass@host:port`), treat that file as a secret like the token files above.
+
Main risk is accidental token exposure through Git, screenshots, shared terminals, insecure backups, or loose Windows ACLs.
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 84b8476..5a70354 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -15,7 +15,7 @@ tauri-build = { version = "2.5.6", features = [] }
[dependencies]
base64 = "0.22.1"
chrono = { version = "0.4.44", features = ["serde"] }
-reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
+reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "socks"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
semver = "1.0.28"
diff --git a/src-tauri/mac/front/index.html b/src-tauri/mac/front/index.html
index 4c8502f..810ea16 100644
--- a/src-tauri/mac/front/index.html
+++ b/src-tauri/mac/front/index.html
@@ -155,6 +155,32 @@
Profiles
Change
+
+
Proxy
+
+
+ Save
+ Clear
+
+
+
Version
—
diff --git a/src-tauri/mac/runtime/cli_shim.rs b/src-tauri/mac/runtime/cli_shim.rs
index fc36997..df28c9c 100644
--- a/src-tauri/mac/runtime/cli_shim.rs
+++ b/src-tauri/mac/runtime/cli_shim.rs
@@ -3,7 +3,8 @@ use std::path::{Path, PathBuf};
use crate::errors::{AppError, AppResult};
use crate::shared::paths::{
- get_backup_root, INSTALL_STATE_FILENAME, LOGIN_RUNTIME_DIRNAME, REFRESH_RUNTIME_DIRNAME,
+ get_backup_root, INSTALL_STATE_FILENAME, LOGIN_RUNTIME_DIRNAME, PROXY_STATE_FILENAME,
+ REFRESH_RUNTIME_DIRNAME,
};
pub const MACOS_RUNTIME_DIRNAME: &str = "macos";
@@ -16,6 +17,10 @@ pub fn get_runtime_dir(codex_home: &Path) -> PathBuf {
get_backup_root(Some(codex_home)).join(MACOS_RUNTIME_DIRNAME)
}
+pub fn get_proxy_state_file(codex_home: &Path) -> PathBuf {
+ get_runtime_dir(codex_home).join(PROXY_STATE_FILENAME)
+}
+
pub fn get_refresh_runtime_dir(codex_home: &Path) -> PathBuf {
get_runtime_dir(codex_home).join(REFRESH_RUNTIME_DIRNAME)
}
diff --git a/src-tauri/mac/runtime/process.rs b/src-tauri/mac/runtime/process.rs
index e1fb69a..4ebbf11 100644
--- a/src-tauri/mac/runtime/process.rs
+++ b/src-tauri/mac/runtime/process.rs
@@ -789,6 +789,10 @@ fn build_login_command(real_codex_path: &Path, runtime_codex_home: &Path) -> Com
command.arg("login");
command.current_dir(runtime_codex_home);
command.env("CODEX_HOME", runtime_codex_home);
+ // codex login 的 OAuth 浏览器流程本身由浏览器走系统代理,但 codex
+ // CLI 本地起 callback server / 走 token exchange 时也可能发请求,
+ // 注入代理 env 与 app-server 路径保持一致。
+ crate::shared::proxy::apply_proxy_env(&mut command);
command
}
diff --git a/src-tauri/shared/commands/actions.rs b/src-tauri/shared/commands/actions.rs
index 467a3d3..2ef24d5 100644
--- a/src-tauri/shared/commands/actions.rs
+++ b/src-tauri/shared/commands/actions.rs
@@ -1,8 +1,8 @@
use crate::errors::CommandError;
use crate::models::{
ActionResponse, AddProfilePayload, CodexCliRedetectResult, CodexCliStatus, OpenUrlPayload,
- ProfilePayload, RenameProfilePayload, SetCodexCliPathPayload, UpdateCheckPayload,
- UpdateCheckResponse, UpdateProfileBaseUrlPayload,
+ ProfilePayload, ProxyConfig, RenameProfilePayload, SetCodexCliPathPayload,
+ SetProxyConfigPayload, UpdateCheckPayload, UpdateCheckResponse, UpdateProfileBaseUrlPayload,
};
#[cfg(target_os = "macos")]
@@ -247,6 +247,83 @@ pub fn cancel_codex_login() -> Result
{
Ok(crate::shared::login_cancel::cancel_login_in_progress())
}
+/// 返回当前生效的代理配置。仅 macOS 真正读取 `proxy_state.json`;
+/// Windows / Linux 永远返回默认(直连)状态。
+#[tauri::command]
+pub fn get_proxy_config() -> Result {
+ #[cfg(target_os = "macos")]
+ {
+ let state = crate::shared::proxy::read_proxy_state_cached();
+ Ok(ProxyConfig {
+ proxy_url: state.proxy_url,
+ })
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ Ok(ProxyConfig::default())
+ }
+}
+
+/// 保存代理配置。空字符串视为清空(直连)。立即丢弃已缓存的
+/// reqwest client,使下一次 plan / quota 刷新走新代理。
+///
+/// URL 校验:用 `reqwest::Proxy::all` 试构建,失败返回
+/// `INVALID_PROXY_URL`。支持 `http://` / `https://` / `socks5://`
+/// / `socks5h://`。
+#[tauri::command]
+pub fn set_proxy_config(payload: SetProxyConfigPayload) -> Result {
+ #[cfg(target_os = "macos")]
+ {
+ let trimmed = payload.proxy_url.trim();
+ let next_state = if trimmed.is_empty() {
+ crate::shared::proxy::ProxyState::default()
+ } else {
+ // 用 reqwest 校验 URL 是否可被解析为代理。这一步不发起
+ // 任何网络请求,只是构造 Proxy 内部结构。
+ reqwest::Proxy::all(trimmed).map_err(|error| {
+ CommandError::new(
+ "INVALID_PROXY_URL",
+ format!(
+ "Invalid proxy URL {trimmed:?}: {error}. Expected http://, https://, socks5:// or socks5h://."
+ ),
+ )
+ })?;
+ crate::shared::proxy::ProxyState {
+ proxy_url: Some(trimmed.to_string()),
+ }
+ };
+ crate::shared::proxy::set_proxy_state(None, next_state.clone());
+ // 丢弃旧 client,下一次 build_http_client 按新配置重建。
+ crate::shared::chatgpt_api::invalidate_http_client();
+ Ok(ProxyConfig {
+ proxy_url: next_state.proxy_url,
+ })
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ let _ = payload;
+ Err(CommandError::new(
+ "PROXY_CONFIG_UNSUPPORTED",
+ "Proxy configuration is only supported on macOS in this build.",
+ ))
+ }
+}
+
+/// 清空代理配置(恢复直连),并丢弃缓存的 reqwest client。
+#[tauri::command]
+pub fn clear_proxy_config() -> Result {
+ #[cfg(target_os = "macos")]
+ {
+ crate::shared::proxy::clear_proxy_state(None);
+ crate::shared::chatgpt_api::invalidate_http_client();
+ Ok(ProxyConfig::default())
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ Ok(ProxyConfig::default())
+ }
+}
+
#[tauri::command]
pub fn open_xiaohongshu(app: tauri::AppHandle) -> Result {
let path = platform_runtime::actions::open_xiaohongshu(&app)?;
diff --git a/src-tauri/shared/front/actions.ts b/src-tauri/shared/front/actions.ts
index fde61ff..90fd621 100644
--- a/src-tauri/shared/front/actions.ts
+++ b/src-tauri/shared/front/actions.ts
@@ -19,10 +19,12 @@ import {
checkUpdate,
clearCodexCliPath,
clearProfileAccount,
+ clearProxyConfig,
deleteProfile,
getCodexCliStatus,
getCurrentLiveQuota,
getProfilesSnapshot,
+ getProxyConfig,
loginCurrentProfile,
openCodex,
openContact,
@@ -37,10 +39,11 @@ import {
redetectCodexCliPath,
renameProfile,
setCodexCliPath,
+ setProxyConfig,
switchProfile,
updateProfileBaseUrl,
} from "@front-shared/tauri";
-import type { CodexCliCandidate, CodexCliRedetectResult, CodexCliStatus } from "@front-shared/types";
+import type { CodexCliCandidate, CodexCliRedetectResult, CodexCliStatus, ProxyConfig } from "@front-shared/types";
import {
applyLocale,
elements,
@@ -706,6 +709,51 @@ async function refreshCodexCliSettingsDisplay(): Promise {
}
}
+function applyProxySettingsDisplay(config: ProxyConfig): void {
+ if (!elements.settingsProxyInput) {
+ return;
+ }
+ elements.settingsProxyInput.value = config.proxy_url ?? "";
+}
+
+async function refreshProxySettingsDisplay(): Promise {
+ // win/index.html 不渲染代理 UI,elements 为 null —— 直接 no-op。
+ if (!elements.settingsProxyInput) {
+ return;
+ }
+ try {
+ applyProxySettingsDisplay(await getProxyConfig());
+ } catch {
+ // Best-effort:保留 input 当前值,让用户仍能尝试设置。
+ }
+}
+
+async function handleSaveProxyConfig(): Promise {
+ if (!elements.settingsProxyInput) {
+ return;
+ }
+ const url = elements.settingsProxyInput.value.trim();
+ try {
+ await setProxyConfig(url);
+ showToast(url ? t(state.locale, "settingsProxySaved") : t(state.locale, "settingsProxyCleared"));
+ } catch (error) {
+ showToast(error instanceof Error ? error.message : t(state.locale, "settingsProxySaveFailed"), true);
+ }
+}
+
+async function handleClearProxyConfig(): Promise {
+ if (!elements.settingsProxyInput) {
+ return;
+ }
+ try {
+ await clearProxyConfig();
+ applyProxySettingsDisplay({ proxy_url: null });
+ showToast(t(state.locale, "settingsProxyCleared"));
+ } catch (error) {
+ showToast(error instanceof Error ? error.message : t(state.locale, "settingsProxySaveFailed"), true);
+ }
+}
+
function codexCliSourceLabel(source: CodexCliStatus["source"]): string {
switch (source) {
case "user_override":
@@ -1067,6 +1115,26 @@ export function bootstrap(): void {
elements.settingsCodexCliButton.addEventListener("click", () => {
void openCodexCliDialog();
});
+ // 代理配置(mac 专属 UI;win 上 elements 为 null,绑定跳过)。
+ if (elements.settingsProxySaveButton) {
+ elements.settingsProxySaveButton.addEventListener("click", () => {
+ void handleSaveProxyConfig();
+ });
+ }
+ if (elements.settingsProxyClearButton) {
+ elements.settingsProxyClearButton.addEventListener("click", () => {
+ void handleClearProxyConfig();
+ });
+ }
+ // Enter 提交:与 Update URL 行为一致,省得用户去找 Save 按钮。
+ if (elements.settingsProxyInput) {
+ elements.settingsProxyInput.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void handleSaveProxyConfig();
+ }
+ });
+ }
elements.localeEnButton.addEventListener("click", () => {
setLocale("en");
});
@@ -1110,6 +1178,7 @@ export function bootstrap(): void {
scheduleDailyPlanRefresh();
void refreshCodexCliSettingsDisplay();
+ void refreshProxySettingsDisplay();
state.loading = true;
rerenderDashboard();
diff --git a/src-tauri/shared/front/base.css b/src-tauri/shared/front/base.css
index f663050..d8032b2 100644
--- a/src-tauri/shared/front/base.css
+++ b/src-tauri/shared/front/base.css
@@ -996,6 +996,29 @@ p {
flex: none;
}
+/* Proxy row reuses .settings-cli-inline to lay out an input + Save/Clear
+ buttons. Let the input take the flexible remainder instead of its
+ default width:100% squeezing the buttons. */
+.settings-cli-inline .settings-input {
+ flex: 1;
+ min-width: 0;
+}
+
+.settings-hint {
+ grid-column: 1 / -1;
+ margin: 6px 0 0;
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ line-height: 1.4;
+}
+
+.settings-row--proxy {
+ min-height: auto;
+ align-items: center;
+ gap: 10px;
+ padding: 14px 0;
+}
+
.settings-value--inline {
flex: 1;
min-width: 0;
diff --git a/src-tauri/shared/front/i18n.ts b/src-tauri/shared/front/i18n.ts
index 45c5dc6..aa49e04 100644
--- a/src-tauri/shared/front/i18n.ts
+++ b/src-tauri/shared/front/i18n.ts
@@ -251,6 +251,14 @@ const enMessages = {
settingsCodexCliEmpty: "Not detected",
settingsCodexCliDetect: "Auto-detect",
settingsCodexCliDetecting: "Detecting…",
+ settingsProxy: "Proxy",
+ settingsProxyHint:
+ "HTTP/HTTPS/SOCKS5. Applied to ChatGPT plan/quota refresh, codex login, and the update check. Empty = direct.",
+ settingsProxySave: "Save",
+ settingsProxyClear: "Clear",
+ settingsProxySaved: "Proxy saved.",
+ settingsProxyCleared: "Proxy cleared (direct connection).",
+ settingsProxySaveFailed: "Failed to save proxy.",
codexCliDetectedHeading: "Detected (verified runnable)",
codexCliDetectApplied: "Detected and set: {path}",
codexCliDetectNone: "Couldn't auto-detect codex. Set the path manually below.",
@@ -515,6 +523,14 @@ const messages: Record = {
settingsCodexCliEmpty: "未检测到",
settingsCodexCliDetect: "自动检测",
settingsCodexCliDetecting: "检测中…",
+ settingsProxy: "代理",
+ settingsProxyHint:
+ "支持 HTTP/HTTPS/SOCKS5。应用于 ChatGPT 套餐/配额刷新、codex 登录及更新检查。留空 = 直连。",
+ settingsProxySave: "保存",
+ settingsProxyClear: "清空",
+ settingsProxySaved: "代理已保存。",
+ settingsProxyCleared: "已清空代理(恢复直连)。",
+ settingsProxySaveFailed: "保存代理失败。",
codexCliDetectedHeading: "已检测到(已验证可运行)",
codexCliDetectApplied: "已检测并设置:{path}",
codexCliDetectNone: "未能自动检测到 codex,请在下方手动设置路径。",
diff --git a/src-tauri/shared/front/render.ts b/src-tauri/shared/front/render.ts
index 00cbf62..566e047 100644
--- a/src-tauri/shared/front/render.ts
+++ b/src-tauri/shared/front/render.ts
@@ -36,6 +36,11 @@ function requiredElement(id: string): T {
const hasDeleteProfileUi = document.getElementById("delete-profile-dialog") instanceof HTMLDialogElement;
+// macOS 专属 UI:win/index.html 不渲染代理配置行(Windows 走系统
+// 环境变量代理,无应用层 ProxyState)。按 AGENTS.md 平台隔离原则
+// 用 has* 标志让 elements 在两端都能构造,handler 侧再 null-check。
+const hasProxyConfigUi = document.getElementById("settings-proxy-input") instanceof HTMLInputElement;
+
export const elements = {
profilesHeading: requiredElement("profiles-heading"),
profilesGrid: requiredElement("profiles-grid"),
@@ -119,6 +124,18 @@ export const elements = {
settingsCodexCliValue: requiredElement("settings-codex-cli-value"),
settingsCodexCliButton: requiredElement("settings-codex-cli-button"),
settingsCodexCliDetectButton: requiredElement("settings-codex-cli-detect-button"),
+ settingsProxyInput: hasProxyConfigUi
+ ? requiredElement("settings-proxy-input")
+ : null,
+ settingsProxySaveButton: hasProxyConfigUi
+ ? requiredElement("settings-proxy-save-button")
+ : null,
+ settingsProxyClearButton: hasProxyConfigUi
+ ? requiredElement("settings-proxy-clear-button")
+ : null,
+ settingsProxyHint: hasProxyConfigUi
+ ? requiredElement("settings-proxy-hint")
+ : null,
codexCliDialog: requiredElement("codex-cli-dialog"),
codexCliForm: requiredElement("codex-cli-form"),
codexCliDialogTitle: requiredElement("codex-cli-dialog-title"),
@@ -158,9 +175,12 @@ function formatRefresh(entry: QuotaWindow | undefined): string {
if (entry.reset_at_timestamp != null) {
const diff = entry.reset_at_timestamp - Math.floor(Date.now() / 1000);
if (diff > 0) {
- const h = Math.floor(diff / 3600);
+ const d = Math.floor(diff / 86400);
+ const h = Math.floor((diff % 86400) / 3600);
const m = Math.floor((diff % 3600) / 60);
- if (h > 0) {
+ if (d > 0) {
+ return t(state.locale, "resetsIn", { value: `${d}d ${h}h ${m}m` });
+ } else if (h > 0) {
return t(state.locale, "resetsIn", { value: `${h}h ${m}m` });
} else if (m > 0) {
const s = diff % 60;
diff --git a/src-tauri/shared/front/tauri.ts b/src-tauri/shared/front/tauri.ts
index 9a55b5a..8603b00 100644
--- a/src-tauri/shared/front/tauri.ts
+++ b/src-tauri/shared/front/tauri.ts
@@ -9,6 +9,7 @@ import type {
CurrentQuotaResponse,
ProfileCard,
ProfilesSnapshotResponse,
+ ProxyConfig,
QuotaSummary,
SwitchResponse,
UpdateCheckResponse,
@@ -483,3 +484,22 @@ export function redetectCodexCliPath(): Promise {
export function cancelCodexLogin(): Promise {
return invokeCommand("cancel_codex_login");
}
+
+/** 读取当前生效的代理配置。macOS 上返回 `proxy_state.json` 内容;
+ * Windows / Linux 永远返回 `{ proxy_url: null }`,前端据此隐藏 UI。 */
+export function getProxyConfig(): Promise {
+ return invokeCommand("get_proxy_config");
+}
+
+/** 保存代理配置。空字符串视为清空(直连)。后端会用 reqwest 校验
+ * URL 是否合法,失败抛 `INVALID_PROXY_URL`。 */
+export function setProxyConfig(proxyUrl: string): Promise {
+ return invokeCommand("set_proxy_config", {
+ payload: { proxy_url: proxyUrl },
+ });
+}
+
+/** 清空代理配置(恢复直连)。 */
+export function clearProxyConfig(): Promise {
+ return invokeCommand("clear_proxy_config");
+}
diff --git a/src-tauri/shared/front/types.ts b/src-tauri/shared/front/types.ts
index 92f0b2d..a60eb6b 100644
--- a/src-tauri/shared/front/types.ts
+++ b/src-tauri/shared/front/types.ts
@@ -126,4 +126,15 @@ export interface CodexCliRedetectResult {
status: CodexCliStatus;
}
+/** 当前生效的代理配置。`proxy_url` 为 null 表示直连。
+ * 仅 macOS 启用应用层代理;Windows / Linux 永远返回 null。 */
+export interface ProxyConfig {
+ proxy_url: string | null;
+}
+
+/** `set_proxy_config` 的请求 payload。空字符串视为清空。 */
+export interface SetProxyConfigPayload {
+ proxy_url: string;
+}
+
export type ShellRoute = "dashboard" | "profiles" | "settings" | "guide";
diff --git a/src-tauri/shared/runtime/chatgpt_api.rs b/src-tauri/shared/runtime/chatgpt_api.rs
index fe58de3..12fe3f7 100644
--- a/src-tauri/shared/runtime/chatgpt_api.rs
+++ b/src-tauri/shared/runtime/chatgpt_api.rs
@@ -42,6 +42,11 @@ use crate::models::{QuotaSummary, QuotaWindow, RateLimitResetCredit, RateLimitRe
use super::paths::get_backup_root;
+/// 进程内共享的 `reqwest::blocking::Client` 缓存。代理配置变更时
+/// 通过 `invalidate_http_client` 置 None,下一次 `build_http_client`
+/// 按当前 `ProxyState` 重建。
+static SHARED_CLIENT: std::sync::Mutex> = std::sync::Mutex::new(None);
+
// Lightweight atomic write — stage to a sibling temp file, fsync-free rename
// into place. v1.5.3's `fs_ops` predates the shared `atomic_write_bytes`
// helper; this inline version keeps `auth.json` writes from being torn while
@@ -199,8 +204,21 @@ struct RateLimitWindow {
/// real `token_count` events have been observed where a weekly
/// window arrived in the primary slot — see `quota_routing` for
/// the mapping logic.
+ ///
+ /// Only present in Codex-CLI-produced payloads (session JSONL /
+ /// app-server): `window_minutes` is the CLI's *internal* field
+ /// name, converted from the backend's seconds before it reaches
+ /// those surfaces. The raw `/wham/usage` schema never carries it —
+ /// see `limit_window_seconds`.
#[serde(default)]
window_minutes: Option,
+ /// Length in seconds — the field the raw `/wham/usage` payload
+ /// actually carries (verified against openai/codex
+ /// `codex-backend-openapi-models::RateLimitWindowSnapshot` and a
+ /// live capture on 2026-07-26; e.g. 2_592_000 = the free plan's
+ /// 30-day window).
+ #[serde(default)]
+ limit_window_seconds: Option,
}
/// Cheap predicate the caller can hit before paying for an HTTP round-trip.
@@ -387,31 +405,63 @@ fn build_http_client() -> AppResult {
// Cache the successful build only — `reqwest::blocking::Client`
// wraps an `Arc` so `clone()` is cheap and reuses the TLS
// pool, but caching a Build *error* would poison the cell for
- // the entire process lifetime. Failures are deterministic per
- // binary today (TLS provider init), but a future commit adding
- // proxy / cert config could legitimately fail transiently — so
- // store only `Client` and let each call retry the build until
- // one succeeds.
- static SHARED_CLIENT: std::sync::OnceLock = std::sync::OnceLock::new();
- if let Some(client) = SHARED_CLIENT.get() {
+ // the entire process lifetime.
+ //
+ // 用 `Mutex>` 而不是 `OnceLock`,是为了支持代理
+ // 配置变更后丢弃旧 client、按新配置重建(见
+ // `invalidate_http_client`)。`OnceLock` 一旦填充无法清空,与
+ // "改代理立即生效" 的需求冲突。
+ let mut guard = match SHARED_CLIENT.lock() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+ if let Some(client) = guard.as_ref() {
return Ok(client.clone());
}
- let new_client = Client::builder()
+ let mut builder = Client::builder()
.timeout(HTTP_TIMEOUT)
- .user_agent(CODEX_USER_AGENT)
- .build()
- .map_err(|error| {
- AppError::new(
- "HTTP_CLIENT_BUILD_FAILED",
- format!("Failed to build HTTP client: {error}"),
- )
- })?;
- // `set` may fail if another thread populated first — either
- // way we have a valid client to return.
- let _ = SHARED_CLIENT.set(new_client.clone());
+ .user_agent(CODEX_USER_AGENT);
+ // 仅 macOS 启用应用层代理配置(Windows / Linux 走 reqwest 默认
+ // 的环境变量读取行为)。
+ #[cfg(target_os = "macos")]
+ {
+ if let Some(url) = crate::shared::proxy::read_proxy_state_cached().effective_url() {
+ // `reqwest::Proxy::all` 同时覆盖 http / https / ftp;
+ // socks5 / socks5h URL 需要 reqwest 的 `socks` feature,
+ // 已在 Cargo.toml 启用。
+ match reqwest::Proxy::all(url) {
+ Ok(proxy) => {
+ builder = builder.proxy(proxy);
+ }
+ Err(error) => {
+ return Err(AppError::new(
+ "HTTP_CLIENT_BUILD_FAILED",
+ format!("Failed to apply proxy {url:?}: {error}"),
+ ));
+ }
+ }
+ }
+ }
+ let new_client = builder.build().map_err(|error| {
+ AppError::new(
+ "HTTP_CLIENT_BUILD_FAILED",
+ format!("Failed to build HTTP client: {error}"),
+ )
+ })?;
+ *guard = Some(new_client.clone());
Ok(new_client)
}
+/// 丢弃已缓存的 HTTP client,使下一次 `build_http_client` 按当前
+/// `ProxyState` 重建。`set_proxy_config` / `clear_proxy_config`
+/// command 调用,确保代理变更立即对后续 plan / quota 刷新生效,无
+/// 需重启 app。
+pub fn invalidate_http_client() {
+ if let Ok(mut guard) = SHARED_CLIENT.lock() {
+ *guard = None;
+ }
+}
+
fn build_chatgpt_headers(access_token: &str, account_id: Option<&str>) -> AppResult {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static(CODEX_USER_AGENT));
@@ -721,18 +771,24 @@ 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};
+ use super::quota_routing::{slot_from_reset_at, slot_from_window_minutes, QuotaSlot};
let mut summary = QuotaSummary::default();
summary.rate_limit_reset_credits = payload.rate_limit_reset_credits.clone();
let mut any_data = summary.rate_limit_reset_credits.is_some();
+ let now_secs = Utc::now().timestamp();
+
// Position is just a fallback; `window_minutes` is authoritative.
// OpenAI usually puts the 5h window in primary and weekly in
// secondary, but `token_count` events have been observed with the
// weekly window in the primary slot and secondary null. Routing by
// size means a Team plan whose only enforced window is weekly
// doesn't get its data labeled as 5h on the dashboard.
+ //
+ // When `window_minutes` is missing (observed on `/wham/usage`),
+ // fall back to classifying by `reset_at` distance from now: a 5h
+ // window resets within hours, a weekly window resets in days.
if let Some(rate_limit) = payload.rate_limit.as_ref() {
for (window, fallback) in [
(rate_limit.primary_window.as_ref(), QuotaSlot::FiveHour),
@@ -744,7 +800,23 @@ fn quota_summary_from_payload(payload: &RateLimitStatusPayload) -> Option summary.five_hour = mapped,
QuotaSlot::Weekly => summary.weekly = mapped,
}
@@ -937,6 +1009,60 @@ mod tests {
assert!(error.message.contains("access token"));
}
+ #[test]
+ fn quota_summary_routes_by_limit_window_seconds_from_raw_usage_payload() {
+ // Field names exactly as the live `/wham/usage` capture of
+ // 2026-07-26: windows carry `limit_window_seconds`, never
+ // `window_minutes`. A weekly window sitting in the primary
+ // slot must still land in the weekly bucket.
+ let payload: RateLimitStatusPayload = serde_json::from_value(serde_json::json!({
+ "plan_type": "pro",
+ "rate_limit": {
+ "allowed": true,
+ "limit_reached": false,
+ "primary_window": {
+ "used_percent": 20.0,
+ "limit_window_seconds": 604_800,
+ "reset_after_seconds": 400_000,
+ "reset_at": 1_787_621_501_i64
+ },
+ "secondary_window": null
+ }
+ }))
+ .unwrap();
+
+ let quota = quota_summary_from_payload(&payload).expect("quota");
+ assert_eq!(quota.weekly.remaining_percent, Some(80));
+ assert!(quota.five_hour.remaining_percent.is_none());
+ }
+
+ #[test]
+ fn quota_summary_routes_free_plan_monthly_window_to_weekly_slot() {
+ // Free plan live capture: single 30-day window in primary
+ // (limit_window_seconds 2_592_000 = 43_200 min). Renders in
+ // the weekly slot — the dashboard has no month slot, and the
+ // 5h slot would be outright wrong.
+ let payload: RateLimitStatusPayload = serde_json::from_value(serde_json::json!({
+ "plan_type": "free",
+ "rate_limit": {
+ "allowed": true,
+ "limit_reached": false,
+ "primary_window": {
+ "used_percent": 0.0,
+ "limit_window_seconds": 2_592_000,
+ "reset_after_seconds": 2_592_000,
+ "reset_at": 1_787_621_501_i64
+ },
+ "secondary_window": null
+ }
+ }))
+ .unwrap();
+
+ let quota = quota_summary_from_payload(&payload).expect("quota");
+ assert_eq!(quota.weekly.remaining_percent, Some(100));
+ assert!(quota.five_hour.remaining_percent.is_none());
+ }
+
fn stored_quota_with_credits() -> QuotaSummary {
QuotaSummary {
rate_limit_reset_credits: Some(RateLimitResetCredits {
diff --git a/src-tauri/shared/runtime/codex_app_server.rs b/src-tauri/shared/runtime/codex_app_server.rs
index 622483b..e63fbad 100644
--- a/src-tauri/shared/runtime/codex_app_server.rs
+++ b/src-tauri/shared/runtime/codex_app_server.rs
@@ -32,7 +32,7 @@ use serde_json::{json, Value};
use crate::errors::{AppError, AppResult};
use crate::models::{QuotaSummary, QuotaWindow};
-use super::quota_routing::{slot_from_window_minutes, QuotaSlot};
+use super::quota_routing::{slot_from_reset_at, slot_from_window_minutes, QuotaSlot};
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
/// `account/rateLimits/read` is one HTTPS GET to the same endpoint
@@ -363,7 +363,15 @@ fn parse_rate_limits_response(value: &Value) -> Option {
}
any_data = true;
let window_minutes = window.get("windowDurationMins").and_then(Value::as_i64);
- match slot_from_window_minutes(window_minutes, fallback) {
+ let slot = slot_from_window_minutes(window_minutes, fallback);
+ let slot = if window_minutes.is_none() {
+ let reset_at = window.get("resetsAt").and_then(Value::as_i64);
+ let now_secs = chrono::Utc::now().timestamp();
+ slot_from_reset_at(reset_at, now_secs).unwrap_or(slot)
+ } else {
+ slot
+ };
+ match slot {
QuotaSlot::FiveHour => summary.five_hour = mapped,
QuotaSlot::Weekly => summary.weekly = mapped,
}
diff --git a/src-tauri/shared/runtime/mod.rs b/src-tauri/shared/runtime/mod.rs
index 6ff2c5a..50d90aa 100644
--- a/src-tauri/shared/runtime/mod.rs
+++ b/src-tauri/shared/runtime/mod.rs
@@ -10,6 +10,7 @@ pub mod paths;
pub mod process_lock;
pub mod profiles;
pub mod profiles_index;
+pub mod proxy;
pub mod quota_cache;
pub mod quota_routing;
pub mod runtime_isolation;
diff --git a/src-tauri/shared/runtime/models.rs b/src-tauri/shared/runtime/models.rs
index 5140f49..74d13ff 100644
--- a/src-tauri/shared/runtime/models.rs
+++ b/src-tauri/shared/runtime/models.rs
@@ -264,3 +264,19 @@ pub struct CodexCliRedetectResult {
pub struct SetCodexCliPathPayload {
pub path: String,
}
+
+/// 当前生效的代理配置,回传给前端 Settings 页。`proxy_url` 为 None
+/// 表示直连。仅 macOS 启用应用层代理配置;Windows / Linux 永远
+/// 返回 None。
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[serde(default)]
+pub struct ProxyConfig {
+ pub proxy_url: Option,
+}
+
+/// `set_proxy_config` 的请求 payload。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SetProxyConfigPayload {
+ /// 空字符串视为清空(直连)。
+ pub proxy_url: String,
+}
diff --git a/src-tauri/shared/runtime/paths.rs b/src-tauri/shared/runtime/paths.rs
index 7543cef..0ff8e2a 100644
--- a/src-tauri/shared/runtime/paths.rs
+++ b/src-tauri/shared/runtime/paths.rs
@@ -10,6 +10,7 @@ pub const CURRENT_PROFILE_FILENAME: &str = ".current_profile";
pub const DEFAULT_PROFILES: [&str; 4] = ["a", "b", "c", "d"];
pub const INSTALL_STATE_FILENAME: &str = "install_state.json";
pub const QUOTA_CACHE_FILENAME: &str = "quota_cache.json";
+pub const PROXY_STATE_FILENAME: &str = "proxy_state.json";
pub const PROFILES_INDEX_FILENAME: &str = "profiles.json";
pub const PROFILE_METADATA_FILENAME: &str = "profile.json";
pub const REFRESH_RUNTIME_DIRNAME: &str = "refresh_runtime";
@@ -86,6 +87,10 @@ pub fn get_quota_cache_path(codex_home: Option<&Path>) -> PathBuf {
get_runtime_dir(codex_home).join(QUOTA_CACHE_FILENAME)
}
+pub fn get_proxy_state_file(codex_home: Option<&Path>) -> PathBuf {
+ get_runtime_dir(codex_home).join(PROXY_STATE_FILENAME)
+}
+
pub fn get_profile_metadata_path(profile_name: &str, codex_home: Option<&Path>) -> PathBuf {
get_backup_root(codex_home)
.join(profile_name)
diff --git a/src-tauri/shared/runtime/proxy.rs b/src-tauri/shared/runtime/proxy.rs
new file mode 100644
index 0000000..82adfa4
--- /dev/null
+++ b/src-tauri/shared/runtime/proxy.rs
@@ -0,0 +1,232 @@
+//! 用户配置的 HTTP/HTTPS/SOCKS5 代理。
+//!
+//! 仅在 macOS 上启用:Windows 与 Linux 走 reqwest 默认行为(读环境
+//! 变量代理),不做应用层代理配置。schema + IO 是平台无关的中性
+//! 契约,按 AGENTS.md 平台隔离原则放在 shared/;reqwest 注入与
+//! `apply_proxy_env` 的 `cfg(target_os = "macos")` 限定负责把行为
+//! 收窄到 mac。
+//!
+//! 持久化到 `~/.codex/account_backup//proxy_state.json`
+//! (路径见 `paths::get_proxy_state_file`),全局共享,不区分 profile。
+
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::Mutex;
+
+use serde::{Deserialize, Serialize};
+
+/// 解析 proxy_state.json 的磁盘路径。mac 走 mac 自己的 runtime dir
+/// (`account_backup/macos/`),与 install_state.json 同目录;其他
+/// 平台走 shared 的默认解析(实际为 no-op,因为代理配置仅 mac 启用)。
+fn resolve_proxy_state_file(codex_home: Option<&Path>) -> PathBuf {
+ #[cfg(target_os = "macos")]
+ {
+ // mac 的 cli_shim::get_proxy_state_file 需要 &Path(非 Option),
+ // 用 get_codex_home() 取默认值。
+ let home = codex_home
+ .map(Path::to_path_buf)
+ .unwrap_or_else(crate::shared::paths::get_codex_home);
+ crate::macos::cli_shim::get_proxy_state_file(&home)
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ crate::shared::paths::get_proxy_state_file(codex_home)
+ }
+}
+
+/// 用户配置的代理。`proxy_url` 为空 / None 时表示直连。
+#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
+pub struct ProxyState {
+ /// 形如 `http://127.0.0.1:7890` / `https://host:port` /
+ /// `socks5://host:port`。空或 None = 不走代理。
+ #[serde(default)]
+ pub proxy_url: Option,
+}
+
+impl ProxyState {
+ /// 规范化后的代理 URL:去首尾空白,空字符串视为 None。
+ /// 调用方拿这个值决定是否注入 reqwest / 子进程 env。
+ pub fn effective_url(&self) -> Option<&str> {
+ self.proxy_url
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ }
+}
+
+/// 进程内 ProxyState 缓存,避免每次 HTTP 调用都重读文件。
+/// 首次访问时从磁盘加载;set/clear 后直接更新内存副本,使配置
+/// 变更立即对后续读可见。
+static PROXY_STATE_CACHE: Mutex> = Mutex::new(None);
+
+/// 读取磁盘上的 proxy_state.json。文件不存在或解析失败时返回
+/// 默认(直连)状态,与 `load_install_state` 的容错策略一致 ——
+/// 让上层逻辑继续运行而不是把代理配置错误变成阻断性故障。
+pub fn load_proxy_state(codex_home: Option<&Path>) -> ProxyState {
+ let path = resolve_proxy_state_file(codex_home);
+ let raw = match fs::read_to_string(&path) {
+ Ok(value) => value,
+ Err(_) => return ProxyState::default(),
+ };
+ serde_json::from_str(&raw).unwrap_or_default()
+}
+
+/// 写入 proxy_state.json。失败时静默返回(与 `save_install_state`
+/// 一致),调用方按 best-effort 处理。
+pub fn save_proxy_state(codex_home: Option<&Path>, state: &ProxyState) {
+ let path = resolve_proxy_state_file(codex_home);
+ if let Some(parent) = path.parent() {
+ if fs::create_dir_all(parent).is_err() {
+ return;
+ }
+ }
+ let Ok(serialized) = serde_json::to_string_pretty(state) else {
+ return;
+ };
+ let _ = fs::write(path, format!("{serialized}\n"));
+}
+
+/// 读取当前生效的代理配置,使用进程内缓存避免重复 IO。
+/// 首次调用从磁盘加载并缓存;`set_proxy_state` / `clear_proxy_state`
+/// 调用后会刷新缓存。`chatgpt_api::build_http_client` 与
+/// `apply_proxy_env` 都从这里取值。
+pub fn read_proxy_state_cached() -> ProxyState {
+ let mut guard = match PROXY_STATE_CACHE.lock() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+ if guard.is_none() {
+ *guard = Some(load_proxy_state(None));
+ }
+ guard.clone().unwrap_or_default()
+}
+
+/// 写入新的代理配置并刷新缓存。`set_proxy_config` command 调用。
+pub fn set_proxy_state(codex_home: Option<&Path>, state: ProxyState) {
+ save_proxy_state(codex_home, &state);
+ if let Ok(mut guard) = PROXY_STATE_CACHE.lock() {
+ *guard = Some(state);
+ }
+}
+
+/// 清空代理配置(恢复直连)并刷新缓存。`clear_proxy_config` 调用。
+pub fn clear_proxy_state(codex_home: Option<&Path>) {
+ set_proxy_state(codex_home, ProxyState::default());
+}
+
+/// 把当前代理 env 注入到子进程 Command。仅 macOS 启用 —— Windows
+/// / Linux 的代理走 reqwest 默认行为与环境变量,不需要应用层注入。
+///
+/// 同时设置 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` 是为了让
+/// codex CLI(app-server / login)和 curl(检查更新)等各类子进程
+/// 都能识别:不同工具读的 env 名不同,三管齐下覆盖最广。
+#[cfg(target_os = "macos")]
+pub fn apply_proxy_env(command: &mut Command) {
+ if let Some(url) = read_proxy_state_cached().effective_url() {
+ // 大小写各设一遍:curl 的 `http_proxy` 只认小写(历史 CGI 安全
+ // 原因),reqwest / 多数工具认大写;两套全设覆盖面最广。
+ for key in [
+ "HTTP_PROXY",
+ "http_proxy",
+ "HTTPS_PROXY",
+ "https_proxy",
+ "ALL_PROXY",
+ "all_proxy",
+ ] {
+ command.env(key, url);
+ }
+ // 显式排除 loopback:reqwest / curl 并**不**默认排除
+ // 127.0.0.1,不设 NO_PROXY 会把子进程对本机地址的请求
+ // (如 OAuth 回调自检)也送进代理。
+ for key in ["NO_PROXY", "no_proxy"] {
+ command.env(key, "localhost,127.0.0.1");
+ }
+ }
+}
+
+#[cfg(not(target_os = "macos"))]
+pub fn apply_proxy_env(_command: &mut Command) {
+ // Windows / Linux 不应用层注入代理。
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn effective_url_handles_empty_and_whitespace() {
+ let direct = ProxyState::default();
+ assert_eq!(direct.effective_url(), None);
+
+ let blank = ProxyState {
+ proxy_url: Some(" ".to_string()),
+ };
+ assert_eq!(blank.effective_url(), None);
+
+ let real = ProxyState {
+ proxy_url: Some(" http://127.0.0.1:7890 ".to_string()),
+ };
+ assert_eq!(real.effective_url(), Some("http://127.0.0.1:7890"));
+ }
+
+ #[test]
+ fn load_missing_file_returns_default() {
+ let temp = std::env::temp_dir().join(format!(
+ "codex-proxy-test-missing-{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ assert_eq!(load_proxy_state(Some(&temp)), ProxyState::default());
+ }
+
+ #[test]
+ fn save_then_load_roundtrip() {
+ let temp = std::env::temp_dir().join(format!(
+ "codex-proxy-test-roundtrip-{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ let state = ProxyState {
+ proxy_url: Some("socks5://127.0.0.1:1080".to_string()),
+ };
+ save_proxy_state(Some(&temp), &state);
+ let loaded = load_proxy_state(Some(&temp));
+ assert_eq!(loaded, state);
+ let _ = std::fs::remove_dir_all(&temp);
+ }
+
+ #[test]
+ fn set_and_clear_proxy_state_updates_cache() {
+ // 用一个临时路径初始化缓存,避免污染其他测试。
+ let temp = std::env::temp_dir().join(format!(
+ "codex-proxy-test-cache-{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ // 重置缓存确保本测试自洽。
+ if let Ok(mut guard) = PROXY_STATE_CACHE.lock() {
+ *guard = None;
+ }
+ let state = ProxyState {
+ proxy_url: Some("http://proxy.local:8080".to_string()),
+ };
+ set_proxy_state(Some(&temp), state.clone());
+ assert_eq!(read_proxy_state_cached(), state);
+
+ clear_proxy_state(Some(&temp));
+ assert_eq!(read_proxy_state_cached(), ProxyState::default());
+
+ // 清缓存让后续测试不依赖本次结果。
+ if let Ok(mut guard) = PROXY_STATE_CACHE.lock() {
+ *guard = None;
+ }
+ let _ = std::fs::remove_dir_all(&temp);
+ }
+}
diff --git a/src-tauri/shared/runtime/quota_routing.rs b/src-tauri/shared/runtime/quota_routing.rs
index ce971c0..6cbb522 100644
--- a/src-tauri/shared/runtime/quota_routing.rs
+++ b/src-tauri/shared/runtime/quota_routing.rs
@@ -22,6 +22,12 @@ pub const FIVE_HOUR_WINDOW_MINUTES: i64 = 300;
/// 24h * 60min = 10_080.
pub const WEEKLY_WINDOW_MINUTES: i64 = 10_080;
+/// Threshold (in seconds) used to classify a window by its `reset_at`
+/// distance from now when `window_minutes` is missing. 6 hours
+/// (21600s) cleanly separates a 5h window (resets within a few hours)
+/// from a weekly window (resets in days).
+pub const RESET_AT_CLASSIFY_THRESHOLD_SECONDS: i64 = 6 * 60 * 60;
+
/// Which slot of `QuotaSummary` a rate-limit window belongs in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QuotaSlot {
@@ -36,11 +42,36 @@ pub enum QuotaSlot {
pub fn slot_from_window_minutes(window_minutes: Option, fallback: QuotaSlot) -> QuotaSlot {
match window_minutes {
Some(FIVE_HOUR_WINDOW_MINUTES) => QuotaSlot::FiveHour,
- Some(WEEKLY_WINDOW_MINUTES) => QuotaSlot::Weekly,
+ // Weekly *or longer*: the free plan carries a 30-day window
+ // (43_200 min; `limit_window_seconds` 2_592_000 on
+ // `/wham/usage`, also seen in local session JSONL). The
+ // dashboard only has 5h + weekly slots, so month-scale windows
+ // render in the weekly slot — better than the position
+ // fallback mislabeling a monthly quota as "5h".
+ Some(minutes) if minutes >= WEEKLY_WINDOW_MINUTES => QuotaSlot::Weekly,
_ => fallback,
}
}
+/// Classify a window by the distance between its `reset_at` (Unix
+/// seconds) and `now_secs`. Used when `window_minutes` is missing —
+/// OpenAI's `/wham/usage` payload has been observed omitting
+/// `window_minutes` entirely while still reporting a weekly `reset_at`
+/// several days out. Returns `None` when `reset_at` is missing or in
+/// the past (cannot classify reliably).
+pub fn slot_from_reset_at(reset_at: Option, now_secs: i64) -> Option {
+ let reset_at = reset_at?;
+ let delta = reset_at - now_secs;
+ if delta <= 0 {
+ return None;
+ }
+ if delta <= RESET_AT_CLASSIFY_THRESHOLD_SECONDS {
+ Some(QuotaSlot::FiveHour)
+ } else {
+ Some(QuotaSlot::Weekly)
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -69,6 +100,22 @@ mod tests {
);
}
+ #[test]
+ fn month_scale_window_routes_to_weekly_slot() {
+ // Free plan: 30-day window (43_200 min), observed both in live
+ // `/wham/usage` (limit_window_seconds 2_592_000) and local
+ // session JSONL. Must not fall through to the position hint —
+ // that would label a monthly quota as "5h".
+ assert_eq!(
+ slot_from_window_minutes(Some(43_200), QuotaSlot::FiveHour),
+ QuotaSlot::Weekly
+ );
+ assert_eq!(
+ slot_from_window_minutes(Some(43_200), QuotaSlot::Weekly),
+ QuotaSlot::Weekly
+ );
+ }
+
#[test]
fn missing_or_unknown_window_minutes_falls_back_to_position() {
assert_eq!(
@@ -90,4 +137,31 @@ mod tests {
QuotaSlot::Weekly
);
}
+
+ #[test]
+ fn slot_from_reset_at_classifies_by_distance() {
+ // 1h ahead -> 5h window
+ assert_eq!(slot_from_reset_at(Some(3600), 0), Some(QuotaSlot::FiveHour));
+ // Exactly at the 6h threshold -> 5h
+ assert_eq!(
+ slot_from_reset_at(Some(RESET_AT_CLASSIFY_THRESHOLD_SECONDS), 0),
+ Some(QuotaSlot::FiveHour)
+ );
+ // 1 day ahead -> weekly
+ assert_eq!(slot_from_reset_at(Some(86_400), 0), Some(QuotaSlot::Weekly));
+ // 5 days ahead -> weekly (the observed 2026-07-29 case)
+ assert_eq!(
+ slot_from_reset_at(Some(5 * 86_400), 0),
+ Some(QuotaSlot::Weekly)
+ );
+ }
+
+ #[test]
+ fn slot_from_reset_at_returns_none_for_missing_or_past() {
+ assert_eq!(slot_from_reset_at(None, 0), None);
+ // Already reset (past) -> cannot classify
+ assert_eq!(slot_from_reset_at(Some(-100), 0), None);
+ // Reset exactly now -> ambiguous, treat as unclassifiable
+ assert_eq!(slot_from_reset_at(Some(0), 0), None);
+ }
}
diff --git a/src-tauri/shared/runtime/session_usage.rs b/src-tauri/shared/runtime/session_usage.rs
index 128cf9e..7c6dd40 100644
--- a/src-tauri/shared/runtime/session_usage.rs
+++ b/src-tauri/shared/runtime/session_usage.rs
@@ -8,7 +8,7 @@ use crate::models::{QuotaSummary, QuotaWindow};
use super::paths::get_codex_home;
use super::quota_cache::{file_signature, CachedEntry, CachedSnapshot, QuotaCache};
-use super::quota_routing::{slot_from_window_minutes, QuotaSlot};
+use super::quota_routing::{slot_from_reset_at, slot_from_window_minutes, QuotaSlot};
use super::session_files::{collect_jsonl_files, file_modified_ms};
#[derive(Clone, Debug)]
@@ -134,6 +134,12 @@ fn apply_rate_limit_window(
};
let slot = slot_from_window_minutes(window.window_minutes, fallback);
+ let slot = if window.window_minutes.is_none() {
+ let now_secs = chrono::Utc::now().timestamp();
+ slot_from_reset_at(window.resets_at, now_secs).unwrap_or(slot)
+ } else {
+ slot
+ };
let quota_window = quota_window_from_rate_limit(Some(window));
match slot {
diff --git a/src-tauri/shared/runtime/update.rs b/src-tauri/shared/runtime/update.rs
index 1aea7a2..d7fe84e 100644
--- a/src-tauri/shared/runtime/update.rs
+++ b/src-tauri/shared/runtime/update.rs
@@ -123,25 +123,27 @@ fn normalize_short_version(version: &str) -> Option {
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn fetch_update_json(url: &str) -> AppResult {
- let output = Command::new("curl")
- .args([
- "--fail",
- "--location",
- "--silent",
- "--show-error",
- "--max-time",
- "12",
- "--user-agent",
- UPDATE_USER_AGENT,
- url,
- ])
- .output()
- .map_err(|error| {
- AppError::new(
- "UPDATE_REQUEST_FAILED",
- format!("Failed to start update request: {error}"),
- )
- })?;
+ let mut command = Command::new("curl");
+ command.args([
+ "--fail",
+ "--location",
+ "--silent",
+ "--show-error",
+ "--max-time",
+ "12",
+ "--user-agent",
+ UPDATE_USER_AGENT,
+ url,
+ ]);
+ // macOS:注入应用层代理 env,curl 默认会读 HTTPS_PROXY/ALL_PROXY。
+ // Linux 不启用应用层代理(无 ProxyState 配置 UI),此处为 no-op。
+ crate::shared::proxy::apply_proxy_env(&mut command);
+ let output = command.output().map_err(|error| {
+ AppError::new(
+ "UPDATE_REQUEST_FAILED",
+ format!("Failed to start update request: {error}"),
+ )
+ })?;
parse_fetch_output(output.status.success(), &output.stdout, &output.stderr)
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 579546b..f1cb595 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -61,6 +61,9 @@ pub fn run() {
commands::actions::clear_codex_cli_path,
commands::actions::redetect_codex_cli_path,
commands::actions::cancel_codex_login,
+ commands::actions::get_proxy_config,
+ commands::actions::set_proxy_config,
+ commands::actions::clear_proxy_config,
commands::switch::switch_profile,
])
.run(tauri::generate_context!())