From f1b4c4361befb67099d7a8f09110b67e87c59cca Mon Sep 17 00:00:00 2001 From: XLevon <408251965@qq.com> Date: Fri, 24 Jul 2026 20:00:06 +0800 Subject: [PATCH 1/3] feat(mac): add proxy config and harden quota window routing Proxy configuration (macOS): - Add Settings UI for HTTP/HTTPS/SOCKS5/SOCKS5h proxy configuration - Store proxy_state.json under per-platform runtime dir (macos/) - Inject proxy into reqwest HTTP client (ChatGPT plan/quota API) - Inject proxy env vars into codex app-server and login subprocesses - Inject proxy env vars into update-check curl subprocess - Invalidate cached HTTP client on proxy change for immediate effect - Validate proxy URL via reqwest::Proxy::all before saving - reqwest: enable socks feature for SOCKS5 support Quota window routing: - Fall back to reset_at distance when window_minutes is missing in /wham/usage payload (observed in production). 5h window resets within hours, weekly resets in days; classify by 6h threshold. - Apply reset_at fallback across all three data paths: chatgpt_api, codex_app_server, session_usage - Show "Xd Yh Zm" reset countdown when reset is more than a day out (previously "115h 20m" for weekly resets) --- src-tauri/Cargo.toml | 2 +- src-tauri/mac/front/index.html | 26 +++ src-tauri/mac/runtime/cli_shim.rs | 7 +- src-tauri/mac/runtime/process.rs | 4 + src-tauri/shared/commands/actions.rs | 81 ++++++- src-tauri/shared/front/actions.ts | 71 +++++- src-tauri/shared/front/base.css | 23 ++ src-tauri/shared/front/i18n.ts | 16 ++ src-tauri/shared/front/render.ts | 24 +- src-tauri/shared/front/tauri.ts | 20 ++ src-tauri/shared/front/types.ts | 11 + src-tauri/shared/runtime/chatgpt_api.rs | 91 ++++++-- src-tauri/shared/runtime/codex_app_server.rs | 12 +- src-tauri/shared/runtime/mod.rs | 1 + src-tauri/shared/runtime/models.rs | 16 ++ src-tauri/shared/runtime/paths.rs | 5 + src-tauri/shared/runtime/proxy.rs | 219 +++++++++++++++++++ src-tauri/shared/runtime/quota_routing.rs | 52 +++++ src-tauri/shared/runtime/session_usage.rs | 8 +- src-tauri/shared/runtime/update.rs | 40 ++-- src-tauri/src/lib.rs | 3 + 21 files changed, 682 insertions(+), 50 deletions(-) create mode 100644 src-tauri/shared/runtime/proxy.rs 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

+
+ Proxy +
+ + + +
+

+
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..1b085c0 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 @@ -387,31 +392,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 +758,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 +787,13 @@ fn quota_summary_from_payload(payload: &RateLimitStatusPayload) -> Option summary.five_hour = mapped, QuotaSlot::Weekly => summary.weekly = mapped, } 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..6d8115a --- /dev/null +++ b/src-tauri/shared/runtime/proxy.rs @@ -0,0 +1,219 @@ +//! 用户配置的 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() { + command.env("HTTP_PROXY", url); + command.env("HTTPS_PROXY", url); + command.env("ALL_PROXY", url); + // NO_PROXY 留空:本地 callback server(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..bbc7151 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 { @@ -41,6 +47,25 @@ pub fn slot_from_window_minutes(window_minutes: Option, fallback: QuotaSlot } } +/// 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::*; @@ -90,4 +115,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!()) From 19f32551d86e4a58376afcdf04cd2cf9aa593a39 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 26 Jul 2026 09:13:01 +0800 Subject: [PATCH 2/3] ci: add cargo fmt gate to CI checks Surfaced by #57: formatting drift passed CI because no fmt check ran. rustfmt is not in dtolnay/rust-toolchain's minimal profile, so declare the component explicitly. --- .github/workflows/build.yml | 5 +++++ 1 file changed, 5 insertions(+) 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 From e757c486cd40ae35aea2e3c2bb3b91eb475d6bc3 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Thu, 30 Jul 2026 22:38:53 +0800 Subject: [PATCH 3/3] fix(quota): parse limit_window_seconds; harden proxy env injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw /wham/usage schema reports window length as limit_window_seconds (seconds) — the window_minutes field this app expected only exists in Codex-CLI-converted payloads (session JSONL / app-server), verified against openai/codex codex-backend-openapi-models and a live capture. HTTP-refreshed quota therefore always fell back to position guessing, which is the real root cause behind weekly quota rendering in the 5h slot. Parse either field (seconds converted exactly like the CLI's window_minutes_from_seconds), keep the reset_at-distance heuristic as a last resort for payloads missing both fields, and route month-scale windows (the free plan's 30-day quota, 43200 min) to the weekly slot instead of the position fallback mislabeling them as 5h. Proxy hardening: set both upper- and lower-case proxy env vars for subprocesses (curl only honors lowercase http_proxy), explicitly exclude loopback via NO_PROXY (reqwest/curl do not exclude it by default), and document that a credentialed proxy URL is stored in plain text. --- CHANGELOG.md | 2 + README.md | 1 + README.zh-CN.md | 1 + docs/SECURITY.md | 2 + src-tauri/shared/runtime/chatgpt_api.rs | 81 ++++++++++++++++++++++- src-tauri/shared/runtime/proxy.rs | 23 +++++-- src-tauri/shared/runtime/quota_routing.rs | 24 ++++++- 7 files changed, 126 insertions(+), 8 deletions(-) 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/shared/runtime/chatgpt_api.rs b/src-tauri/shared/runtime/chatgpt_api.rs index 1b085c0..12fe3f7 100644 --- a/src-tauri/shared/runtime/chatgpt_api.rs +++ b/src-tauri/shared/runtime/chatgpt_api.rs @@ -204,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. @@ -787,8 +800,18 @@ fn quota_summary_from_payload(payload: &RateLimitStatusPayload) -> Option QuotaSummary { QuotaSummary { rate_limit_reset_credits: Some(RateLimitResetCredits { diff --git a/src-tauri/shared/runtime/proxy.rs b/src-tauri/shared/runtime/proxy.rs index 6d8115a..82adfa4 100644 --- a/src-tauri/shared/runtime/proxy.rs +++ b/src-tauri/shared/runtime/proxy.rs @@ -124,11 +124,24 @@ pub fn clear_proxy_state(codex_home: Option<&Path>) { #[cfg(target_os = "macos")] pub fn apply_proxy_env(command: &mut Command) { if let Some(url) = read_proxy_state_cached().effective_url() { - command.env("HTTP_PROXY", url); - command.env("HTTPS_PROXY", url); - command.env("ALL_PROXY", url); - // NO_PROXY 留空:本地 callback server(127.0.0.1)通常被各 - // 工具默认排除,无需额外配置。 + // 大小写各设一遍: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"); + } } } diff --git a/src-tauri/shared/runtime/quota_routing.rs b/src-tauri/shared/runtime/quota_routing.rs index bbc7151..6cbb522 100644 --- a/src-tauri/shared/runtime/quota_routing.rs +++ b/src-tauri/shared/runtime/quota_routing.rs @@ -42,7 +42,13 @@ 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, } } @@ -94,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!(