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/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!())