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