Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ jobs:
cache: 'npm'

- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt

- uses: Swatinem/rust-cache@v2
with:
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,方便单纯调样式。
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,方便单纯调样式。
Expand Down
2 changes: 2 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Regenerate the lockfile after enabling SOCKS

The tracked src-tauri/Cargo.lock was not updated with this feature change and contains neither reqwest's optional tokio-socks dependency nor a corresponding dependency edge. Builds that require the committed lockfile, such as cargo build --locked or reproducible/offline packaging, therefore cannot resolve the newly enabled feature without modifying the checkout; regenerate and commit the lockfile with the manifest change.

Useful? React with 👍 / 👎.

serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
semver = "1.0.28"
Expand Down
26 changes: 26 additions & 0 deletions src-tauri/mac/front/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,32 @@ <h2 id="profiles-heading" class="section-title">Profiles</h2>
<button id="settings-codex-cli-button" class="settings-action-button" type="button">Change</button>
</div>
</div>
<div class="settings-row settings-row--field settings-row--proxy">
<strong data-i18n-key="settingsProxy">Proxy</strong>
<div class="settings-cli-inline">
<input
id="settings-proxy-input"
class="settings-input"
type="text"
autocomplete="off"
spellcheck="false"
placeholder="http://127.0.0.1:7890"
/>
<button
id="settings-proxy-save-button"
class="settings-action-button"
type="button"
data-i18n-key="settingsProxySave"
>Save</button>
<button
id="settings-proxy-clear-button"
class="settings-action-button"
type="button"
data-i18n-key="settingsProxyClear"
>Clear</button>
</div>
<p id="settings-proxy-hint" class="settings-hint" data-i18n-key="settingsProxyHint"></p>
</div>
<div class="settings-row">
<strong data-i18n-key="settingsVersion">Version</strong>
<span id="settings-version-value" class="settings-value settings-value--strong">—</span>
Expand Down
7 changes: 6 additions & 1 deletion src-tauri/mac/runtime/cli_shim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/mac/runtime/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +792 to +795

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Inject the proxy environment into the app-server command

When a configured proxy is required to reach OpenAI, only the login command receives apply_proxy_env; fetch_account_via_app_server still creates its child through build_app_server_command, which sets only CODEX_HOME. Consequently, whenever the proxied direct HTTP fast path fails and refresh falls back to app-server, that fallback connects directly and fails in the region-blocked environment this feature targets. Apply the macOS proxy environment while constructing the app-server command as well.

AGENTS.md reference: AGENTS.md:L5-L8

Useful? React with 👍 / 👎.

command
}

Expand Down
81 changes: 79 additions & 2 deletions src-tauri/shared/commands/actions.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand Down Expand Up @@ -247,6 +247,83 @@ pub fn cancel_codex_login() -> Result<bool, CommandError> {
Ok(crate::shared::login_cancel::cancel_login_in_progress())
}

/// 返回当前生效的代理配置。仅 macOS 真正读取 `proxy_state.json`;
/// Windows / Linux 永远返回默认(直连)状态。
#[tauri::command]
pub fn get_proxy_config() -> Result<ProxyConfig, CommandError> {
#[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<ProxyConfig, CommandError> {
#[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<ProxyConfig, CommandError> {
#[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<ActionResponse, CommandError> {
let path = platform_runtime::actions::open_xiaohongshu(&app)?;
Expand Down
71 changes: 70 additions & 1 deletion src-tauri/shared/front/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import {
checkUpdate,
clearCodexCliPath,
clearProfileAccount,
clearProxyConfig,
deleteProfile,
getCodexCliStatus,
getCurrentLiveQuota,
getProfilesSnapshot,
getProxyConfig,
loginCurrentProfile,
openCodex,
openContact,
Expand All @@ -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,
Expand Down Expand Up @@ -706,6 +709,51 @@ async function refreshCodexCliSettingsDisplay(): Promise<void> {
}
}

function applyProxySettingsDisplay(config: ProxyConfig): void {
if (!elements.settingsProxyInput) {
return;
}
elements.settingsProxyInput.value = config.proxy_url ?? "";
}

async function refreshProxySettingsDisplay(): Promise<void> {
// win/index.html 不渲染代理 UI,elements 为 null —— 直接 no-op。
if (!elements.settingsProxyInput) {
return;
}
try {
applyProxySettingsDisplay(await getProxyConfig());
} catch {
// Best-effort:保留 input 当前值,让用户仍能尝试设置。
}
}

async function handleSaveProxyConfig(): Promise<void> {
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<void> {
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":
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -1110,6 +1178,7 @@ export function bootstrap(): void {
scheduleDailyPlanRefresh();

void refreshCodexCliSettingsDisplay();
void refreshProxySettingsDisplay();

state.loading = true;
rerenderDashboard();
Expand Down
23 changes: 23 additions & 0 deletions src-tauri/shared/front/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions src-tauri/shared/front/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -515,6 +523,14 @@ const messages: Record<Locale, Messages> = {
settingsCodexCliEmpty: "未检测到",
settingsCodexCliDetect: "自动检测",
settingsCodexCliDetecting: "检测中…",
settingsProxy: "代理",
settingsProxyHint:
"支持 HTTP/HTTPS/SOCKS5。应用于 ChatGPT 套餐/配额刷新、codex 登录及更新检查。留空 = 直连。",
settingsProxySave: "保存",
settingsProxyClear: "清空",
settingsProxySaved: "代理已保存。",
settingsProxyCleared: "已清空代理(恢复直连)。",
settingsProxySaveFailed: "保存代理失败。",
codexCliDetectedHeading: "已检测到(已验证可运行)",
codexCliDetectApplied: "已检测并设置:{path}",
codexCliDetectNone: "未能自动检测到 codex,请在下方手动设置路径。",
Expand Down
Loading
Loading