From be5674932d8635cda1770b07188a699fd86ad0ac Mon Sep 17 00:00:00 2001 From: Luo Xiu Date: Fri, 27 Mar 2026 18:00:33 +0800 Subject: [PATCH 1/5] fix(auth): sync refreshed tokens to ~/.codex/auth.json for active account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After refresh_all_usage updates the current account’s auth_json, also\nwrite_active_codex_auth so rotated refresh tokens are not reused from disk. Made-with: Cursor --- src-tauri/src/account_service.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/account_service.rs b/src-tauri/src/account_service.rs index ed47cf94..61e7d64b 100644 --- a/src-tauri/src/account_service.rs +++ b/src-tauri/src/account_service.rs @@ -20,6 +20,7 @@ use crate::auth::normalize_plan_type_key; use crate::auth::read_current_codex_auth; use crate::auth::read_current_codex_auth_optional; use crate::auth::refresh_chatgpt_auth_tokens; +use crate::auth::write_active_codex_auth; use crate::models::dedupe_account_variants; use crate::models::AccountSummary; use crate::models::AccountsStore; @@ -382,9 +383,10 @@ pub(crate) async fn refresh_all_usage_internal( } } - let store = { + let (store, refreshed_active_auth_json) = { let _guard = state.store_lock.lock().await; let mut latest_store = load_store(app)?; + let mut refreshed_active_auth_json: Option = None; for account in &mut latest_store.accounts { let Some(outcome) = outcomes.get(&account.id) else { @@ -393,6 +395,9 @@ pub(crate) async fn refresh_all_usage_internal( account.updated_at = outcome.updated_at; account.auth_json = outcome.auth_json.clone(); + if outcome.auth_is_current && outcome.auth_refreshed { + refreshed_active_auth_json = Some(outcome.auth_json.clone()); + } account.email = outcome.auth_email.clone().or(account.email.clone()); let trusted_auth_plan_type = if outcome.auth_is_current || outcome.auth_refreshed { outcome.auth_plan_type.clone() @@ -419,8 +424,11 @@ pub(crate) async fn refresh_all_usage_internal( dedupe_account_variants(&mut latest_store.accounts); save_store(app, &latest_store)?; - latest_store + (latest_store, refreshed_active_auth_json) }; + if let Some(refreshed_auth_json) = refreshed_active_auth_json.as_ref() { + write_active_codex_auth(refreshed_auth_json)?; + } // 与当前 auth 文件重新对齐,确保 current 标签准确。 let current_account_key = current_auth_account_key(); From 5815e867a282ce5186da0128894fea033d9c05bc Mon Sep 17 00:00:00 2001 From: Luo Xiu Date: Fri, 27 Mar 2026 18:47:09 +0800 Subject: [PATCH 2/5] fix(update): disable automatic updater execution Turn off updater activation and guard the frontend update flow behind a disabled flag so the app keeps update UI/codepaths but no longer performs automatic check/download/install. Made-with: Cursor --- src-tauri/tauri.conf.json | 2 +- src/hooks/useCodexController.ts | 33 ++++++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4e90bbaa..d72cc1a5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -39,7 +39,7 @@ }, "plugins": { "updater": { - "active": true, + "active": false, "dialog": false, "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFBREFDRDcyNUY0MzE4NTAKUldSUUdFTmZjczNhR3FrQ2JQSTFLOXFHUU11SDkycFVJMU5ySEdkcEY3MWRiSEZWTDN0dFNtVlkK", "endpoints": [ diff --git a/src/hooks/useCodexController.ts b/src/hooks/useCodexController.ts index ab4e07a0..1f672743 100644 --- a/src/hooks/useCodexController.ts +++ b/src/hooks/useCodexController.ts @@ -32,6 +32,7 @@ import { pickBestRemainingAccount, sortAccountsByRemaining } from "../utils/acco const REFRESH_MS = 30_000; const EDITOR_SCAN_MS = 60_000; const UPDATE_CHECK_MS = 60 * 60 * 1000; +const AUTO_UPDATE_ENABLED = false; const API_PROXY_POLL_MS = 4_000; const CLOUDFLARED_POLL_MS = 3_000; const DEFAULT_SETTINGS: AppSettings = { @@ -395,6 +396,13 @@ export function useCodexController() { const installPendingUpdate = useCallback( async (knownUpdate?: NonNullable>>) => { + if (!AUTO_UPDATE_ENABLED) { + setPendingUpdate(null); + setUpdateDialogOpen(false); + setUpdateProgress(null); + return; + } + if (installingUpdateRef.current) { return; } @@ -450,6 +458,15 @@ export function useCodexController() { const checkForAppUpdate = useCallback( async (quiet = false) => { + if (!AUTO_UPDATE_ENABLED) { + setPendingUpdate(null); + setUpdateDialogOpen(false); + if (!quiet) { + setNotice({ type: "info", message: "Auto update is disabled." }); + } + return; + } + if (!quiet) { setCheckingUpdate(true); } @@ -531,7 +548,9 @@ export function useCodexController() { await loadApiProxyStatus(); await loadCloudflaredStatus(); await refreshUsage(true); - await checkForAppUpdate(true); + if (AUTO_UPDATE_ENABLED) { + await checkForAppUpdate(true); + } } finally { if (!cancelled) { setLoading(false); @@ -550,15 +569,19 @@ export function useCodexController() { void loadOpencodeDesktopAppInstalled(); }, EDITOR_SCAN_MS); - const updateTimer = setInterval(() => { - void checkForAppUpdate(true); - }, UPDATE_CHECK_MS); + const updateTimer = AUTO_UPDATE_ENABLED + ? setInterval(() => { + void checkForAppUpdate(true); + }, UPDATE_CHECK_MS) + : null; return () => { cancelled = true; clearInterval(usageTimer); clearInterval(editorTimer); - clearInterval(updateTimer); + if (updateTimer !== null) { + clearInterval(updateTimer); + } }; }, [ checkForAppUpdate, From ea1ce4260bf3401912fe597e41f59a7f98263ca3 Mon Sep 17 00:00:00 2001 From: Luo Xiu Date: Sat, 28 Mar 2026 14:41:23 +0800 Subject: [PATCH 3/5] feat(auth): add background daemon to keep all account tokens fresh Every 60s, pick the account with the soonest-expiring access_token (within 10-min threshold) and refresh it. Refreshed tokens are written back to accounts.json; if the refreshed account is currently active, ~/.codex/auth.json is also updated atomically inside the store lock. Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/account_service.rs | 137 +++++++++++++++++++++++++- src-tauri/src/auth.rs | 14 +++ src-tauri/src/lib.rs | 9 +- src-tauri/src/models.rs | 4 + src-tauri/src/settings_service.rs | 1 - src-tauri/src/token_refresh_daemon.rs | 23 +++++ 6 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/token_refresh_daemon.rs diff --git a/src-tauri/src/account_service.rs b/src-tauri/src/account_service.rs index 61e7d64b..76cb2dad 100644 --- a/src-tauri/src/account_service.rs +++ b/src-tauri/src/account_service.rs @@ -3,7 +3,6 @@ use std::fs; use std::io::Write; use std::path::Path; use std::path::PathBuf; - use rfd::FileDialog; use tauri::AppHandle; use zip::write::FileOptions; @@ -16,6 +15,7 @@ use crate::auth::current_auth_account_key; use crate::auth::current_auth_variant_key; use crate::auth::extract_auth; use crate::auth::normalize_imported_auth_json; +use crate::auth::parse_access_token_exp; use crate::auth::normalize_plan_type_key; use crate::auth::read_current_codex_auth; use crate::auth::read_current_codex_auth_optional; @@ -235,6 +235,7 @@ pub(crate) async fn refresh_all_usage_internal( #[derive(Debug)] struct RefreshTarget { record_id: String, + label: String, auth_json: serde_json::Value, auth_is_current: bool, } @@ -268,6 +269,7 @@ pub(crate) async fn refresh_all_usage_internal( RefreshTarget { record_id: account.id, + label: account.label.clone(), auth_json, auth_is_current, } @@ -277,8 +279,11 @@ pub(crate) async fn refresh_all_usage_internal( #[derive(Debug)] struct RefreshOutcome { + label: String, usage: Option, usage_error: Option, + /// Raw `refresh_chatgpt_auth_tokens` error when that step failed. + token_refresh_error: Option, updated_at: i64, auth_plan_type: Option, auth_email: Option, @@ -340,8 +345,10 @@ pub(crate) async fn refresh_all_usage_internal( let updated_at = now_unix_seconds(); let outcome = match fetch_result { Ok(snapshot) => RefreshOutcome { + label: target.label.clone(), usage: Some(snapshot), usage_error: None, + token_refresh_error: refresh_error.clone(), updated_at, auth_plan_type, auth_email, @@ -350,15 +357,17 @@ pub(crate) async fn refresh_all_usage_internal( auth_refreshed, }, Err(err) => { - let combined_error = if let Some(refresh_err) = refresh_error { + let combined_error = if let Some(refresh_err) = refresh_error.as_ref() { format!("{err} | 令牌刷新失败: {refresh_err}") } else { err }; let display_error = normalize_usage_error_message(&combined_error); RefreshOutcome { + label: target.label.clone(), usage: None, usage_error: Some(display_error), + token_refresh_error: refresh_error.clone(), updated_at, auth_plan_type, auth_email, @@ -383,6 +392,12 @@ pub(crate) async fn refresh_all_usage_internal( } } + for outcome in outcomes.values() { + if let Some(err) = outcome.token_refresh_error.as_ref() { + log::warn!("用量刷新时 token 刷新失败 [{}]: {err}", outcome.label); + } + } + let (store, refreshed_active_auth_json) = { let _guard = state.store_lock.lock().await; let mut latest_store = load_store(app)?; @@ -447,6 +462,124 @@ pub(crate) async fn refresh_all_usage_internal( Ok(summaries) } +/// Token 过期阈值:提前 10 分钟刷新。 +const TOKEN_EXPIRY_THRESHOLD_SECS: i64 = 10 * 60; + +/// 返回所有 access_token 将在 `threshold_secs` 秒内过期的账号,按过期时间升序排列(最紧迫的在前)。 +fn expiring_accounts_sorted( + store: &crate::models::AccountsStore, + threshold_secs: i64, +) -> Vec<(String, String, serde_json::Value)> { + let now = now_unix_seconds(); + let deadline = now + threshold_secs; + let mut targets: Vec<_> = store + .accounts + .iter() + .filter_map(|account| { + let exp = parse_access_token_exp(&account.auth_json).unwrap_or(0); + if exp < deadline { + Some(( + account.id.clone(), + account.label.clone(), + account.auth_json.clone(), + exp, + )) + } else { + None + } + }) + .collect(); + targets.sort_by_key(|(_, _, _, exp)| *exp); + targets + .into_iter() + .map(|(id, label, auth, _)| (id, label, auth)) + .collect() +} + +/// 刷新单个账号的 token,成功后写回 store 并(若为活跃账号)同步 `~/.codex/auth.json`。 +async fn refresh_one_token( + app: &AppHandle, + state: &AppState, + account_id: &str, + account_label: &str, + account_auth_json: serde_json::Value, +) -> Result<(), String> { + let refreshed = match refresh_chatgpt_auth_tokens(&account_auth_json).await { + Ok(v) => v, + Err(err) => { + log::warn!("token 刷新失败 [{account_label}]: {err}"); + return Err(err); + } + }; + + let now = now_unix_seconds(); + let target_variant_key = auth_variant_key(&refreshed).unwrap_or_default(); + + let write_active = { + let _guard = state.store_lock.lock().await; + // 在锁内读取当前活跃账号,确保与写入 accounts.json 原子一致。 + let current_variant_key = current_auth_variant_key(); + let mut store = load_store(app)?; + let mut found = false; + for account in &mut store.accounts { + if account.id == account_id { + account.auth_json = refreshed.clone(); + account.updated_at = now; + found = true; + break; + } + } + if !found { + log::warn!("token 刷新后账号已不存在 [{account_label}],跳过写入"); + return Ok(()); + } + store.settings.last_token_refresh_at = Some(now); + save_store(app, &store)?; + current_variant_key + .as_deref() + .filter(|k| *k == target_variant_key.as_str()) + .map(|_| refreshed.clone()) + }; + + if let Some(auth_json) = write_active { + if let Err(err) = write_active_codex_auth(&auth_json) { + log::warn!("同步活跃账号 auth.json 失败 [{account_label}]: {err}"); + return Err(err); + } + } + + log::info!("token 刷新成功 [{account_label}]"); + Ok(()) +} + +/// 由 daemon 调用:刷新最紧迫的一个即将过期的账号 token。 +/// 返回 `true` 表示找到并尝试了刷新(不论成功与否)。 +pub(crate) async fn daemon_refresh_next_expiring(app: &AppHandle, state: &AppState) -> bool { + let target = { + let _guard = state.store_lock.lock().await; + let store = match load_store(app) { + Ok(s) => s, + Err(err) => { + log::warn!("token daemon: 读取账号存储失败: {err}"); + return false; + } + }; + expiring_accounts_sorted(&store, TOKEN_EXPIRY_THRESHOLD_SECS) + .into_iter() + .next() + }; + + let Some((id, label, auth_json)) = target else { + return false; + }; + + if let Err(err) = refresh_one_token(app, state, &id, &label, auth_json).await { + log::warn!("token daemon: 刷新失败 [{label}]: {err}"); + } + true +} + + fn should_retry_with_token_refresh( fetch_result: &Result, ) -> bool { diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 09f254b7..dfa26920 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -558,6 +558,20 @@ fn build_auth_json_from_oauth_tokens(token_response: OAuthTokenResponse) -> Resu })) } +/// Decode the JWT payload of an access_token stored in `auth_json` and return its `exp` field +/// (Unix seconds). Returns `None` if the token is missing, malformed, or has no `exp`. +pub(crate) fn parse_access_token_exp(auth_json: &Value) -> Option { + let token = auth_token_object(auth_json) + .and_then(|obj| obj.get("access_token")) + .and_then(Value::as_str)?; + + // JWT:
.. — payload is base64url-encoded, no padding. + let payload_b64 = token.splitn(3, '.').nth(1)?; + let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?; + let payload: Value = serde_json::from_slice(&payload_bytes).ok()?; + payload.get("exp")?.as_i64() +} + fn codex_auth_path() -> Result { let home = dirs::home_dir().ok_or_else(|| "无法读取 HOME 目录".to_string())?; Ok(home.join(".codex").join("auth.json")) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 02a7da1d..2e0f87fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod remote_service; mod settings_service; mod state; mod store; +mod token_refresh_daemon; mod tray; mod usage; mod utils; @@ -1106,9 +1107,13 @@ pub fn run() { app.run(|app_handle, event| match event { tauri::RunEvent::Ready => { - let app_handle = app_handle.clone(); + let handle = app_handle.clone(); tauri::async_runtime::spawn(async move { - auto_start_api_proxy_if_enabled(app_handle).await; + auto_start_api_proxy_if_enabled(handle).await; + }); + let handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + token_refresh_daemon::run(handle).await; }); } #[cfg(target_os = "macos")] diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index bdbe5ffd..32590490 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -323,6 +323,9 @@ pub(crate) struct AppSettings { pub(crate) remote_servers: Vec, pub(crate) api_proxy_api_key: Option, pub(crate) locale: AppLocale, + /// Unix seconds; updated whenever the daemon successfully refreshes a token. + #[serde(default)] + pub(crate) last_token_refresh_at: Option, } impl Default for AppSettings { @@ -341,6 +344,7 @@ impl Default for AppSettings { remote_servers: Vec::new(), api_proxy_api_key: None, locale: AppLocale::default(), + last_token_refresh_at: None, } } } diff --git a/src-tauri/src/settings_service.rs b/src-tauri/src/settings_service.rs index 0a97a63f..62c127ad 100644 --- a/src-tauri/src/settings_service.rs +++ b/src-tauri/src/settings_service.rs @@ -70,7 +70,6 @@ pub(crate) async fn update_app_settings_internal( if let Some(value) = patch.locale { store.settings.locale = value; } - let settings = store.settings.clone(); save_store(app, &store)?; settings diff --git a/src-tauri/src/token_refresh_daemon.rs b/src-tauri/src/token_refresh_daemon.rs new file mode 100644 index 00000000..f22233ca --- /dev/null +++ b/src-tauri/src/token_refresh_daemon.rs @@ -0,0 +1,23 @@ +use std::time::Duration; + +use tauri::AppHandle; +use tauri::Manager; + +use crate::account_service; +use crate::state::AppState; + +/// 应用启动后等待这么久再开始首次检查,避免与初始化流程竞争。 +const STARTUP_DELAY_SECS: u64 = 30; +/// 每次检查之间的间隔。 +const CHECK_INTERVAL_SECS: u64 = 60; + +pub(crate) async fn run(app: AppHandle) { + tokio::time::sleep(Duration::from_secs(STARTUP_DELAY_SECS)).await; + + let state = app.state::(); + + loop { + account_service::daemon_refresh_next_expiring(&app, state.inner()).await; + tokio::time::sleep(Duration::from_secs(CHECK_INTERVAL_SECS)).await; + } +} From 1bf83736c9435a23696309b2d532f635a82128c4 Mon Sep 17 00:00:00 2001 From: Luo Xiu Date: Sat, 28 Mar 2026 14:52:43 +0800 Subject: [PATCH 4/5] chore: disable updater artifacts --- src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d72cc1a5..7d6a907a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,7 +28,7 @@ "bundle": { "active": true, "targets": "all", - "createUpdaterArtifacts": true, + "createUpdaterArtifacts": false, "icon": [ "icons/32x32.png", "icons/128x128.png", From 16fb397f7629918eca49825dc2c2ab794d00ee23 Mon Sep 17 00:00:00 2001 From: luoxiu Date: Wed, 20 May 2026 21:49:53 +0800 Subject: [PATCH 5/5] chore: tidy fork changes before tag --- src-tauri/src/account_service.rs | 4 +++- src-tauri/src/settings_service.rs | 1 + src/hooks/useCodexController.ts | 3 --- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/account_service.rs b/src-tauri/src/account_service.rs index 62c5f302..e86aaa89 100644 --- a/src-tauri/src/account_service.rs +++ b/src-tauri/src/account_service.rs @@ -1,10 +1,11 @@ -use rfd::FileDialog; use std::collections::HashMap; use std::collections::HashSet; use std::fs; use std::io::Write; use std::path::Path; use std::path::PathBuf; + +use rfd::FileDialog; use tauri::AppHandle; use zip::write::FileOptions; use zip::CompressionMethod; @@ -944,6 +945,7 @@ async fn persist_account_refresh_state( )?; Ok(()) } + fn should_retry_with_token_refresh( fetch_result: &Result, ) -> bool { diff --git a/src-tauri/src/settings_service.rs b/src-tauri/src/settings_service.rs index 879ede78..c85b4b71 100644 --- a/src-tauri/src/settings_service.rs +++ b/src-tauri/src/settings_service.rs @@ -91,6 +91,7 @@ pub(crate) async fn update_app_settings_internal( if let Some(value) = patch.skipped_update_version { store.settings.skipped_update_version = value; } + let settings = store.settings.clone(); save_store(app, &store)?; settings diff --git a/src/hooks/useCodexController.ts b/src/hooks/useCodexController.ts index b79a2616..b76c022b 100644 --- a/src/hooks/useCodexController.ts +++ b/src/hooks/useCodexController.ts @@ -737,9 +737,6 @@ export function useCodexController() { await loadApiProxyUsageStats(DEFAULT_API_PROXY_USAGE_RANGE); await loadCloudflaredStatus(); await refreshUsage(true); - if (AUTO_UPDATE_ENABLED) { - await checkForAppUpdate(true); - } await refreshTokenUsage(true); if (AUTO_UPDATE_ENABLED) { await checkForAppUpdate(true);