diff --git a/src-tauri/src/account_service.rs b/src-tauri/src/account_service.rs index 641b1cb..e86aaa8 100644 --- a/src-tauri/src/account_service.rs +++ b/src-tauri/src/account_service.rs @@ -19,9 +19,11 @@ use crate::auth::current_auth_variant_key; use crate::auth::extract_auth; use crate::auth::normalize_imported_auth_json; use crate::auth::normalize_plan_type_key; +use crate::auth::parse_access_token_exp; use crate::auth::read_current_codex_auth; use crate::auth::read_current_codex_auth_optional; use crate::auth::refresh_chatgpt_auth_tokens_serialized; +use crate::auth::write_active_codex_auth; use crate::models::dedupe_account_variants; use crate::models::AccountSourceKind; use crate::models::AccountSummary; @@ -512,6 +514,184 @@ 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 current_auth_override: Option<(String, serde_json::Value)> = + read_current_codex_auth_optional() + .ok() + .flatten() + .and_then(|auth_json| { + extract_auth(&auth_json).ok().map(|auth| { + ( + account_group_key(&auth.principal_id, &auth.account_id), + auth_json, + ) + }) + }); + let mut targets_by_account_key: HashMap = + HashMap::new(); + + for account in &store.accounts { + if matches!(account.source_kind.clone(), AccountSourceKind::Relay) + || account.auth_refresh_blocked + { + continue; + } + + let account_key = account.account_key(); + let current_override = current_auth_override + .as_ref() + .filter(|(current_account_key, _)| current_account_key == &account_key); + let auth_is_current = current_override.is_some(); + let auth_json = current_override + .map(|(_, auth_json)| auth_json.clone()) + .unwrap_or_else(|| account.auth_json.clone()); + let exp = parse_access_token_exp(&auth_json).unwrap_or(0); + if exp >= deadline { + continue; + } + + let candidate = ( + account.label.clone(), + auth_json, + exp, + auth_is_current, + account.updated_at, + ); + match targets_by_account_key.get_mut(&account_key) { + Some(existing) => { + let replace = (candidate.3 && !existing.3) + || (candidate.3 == existing.3 && candidate.2 < existing.2) + || (candidate.3 == existing.3 + && candidate.2 == existing.2 + && candidate.4 > existing.4); + if replace { + *existing = candidate; + } + } + None => { + targets_by_account_key.insert(account_key, candidate); + } + } + } + + let mut targets = targets_by_account_key + .into_iter() + .map( + |(account_key, (label, auth_json, exp, auth_is_current, updated_at))| { + ( + account_key, + label, + auth_json, + exp, + auth_is_current, + updated_at, + ) + }, + ) + .collect::>(); + targets.sort_by(|left, right| { + left.3 + .cmp(&right.3) + .then(right.4.cmp(&left.4)) + .then(right.5.cmp(&left.5)) + }); + targets + .into_iter() + .map(|(account_key, label, auth_json, _, _, _)| (account_key, label, auth_json)) + .collect() +} + +/// 刷新单个账号组的 token,成功后写回 store 并同步当前活跃 auth。 +async fn refresh_one_token( + app: &AppHandle, + state: &AppState, + account_key: &str, + account_label: &str, + account_auth_json: serde_json::Value, +) -> Result<(), String> { + let refreshed = + match refresh_chatgpt_auth_tokens_serialized(&account_auth_json, &state.auth_refresh_lock) + .await + { + Ok(v) => v, + Err(err) => { + log::warn!("token 刷新失败 [{account_label}]: {err}"); + return Err(err); + } + }; + let now = now_unix_seconds(); + + let should_sync_current_auth = { + let _guard = state.store_lock.lock().await; + let mut store = load_store(app)?; + let mut found = false; + for account in store + .accounts + .iter_mut() + .filter(|account| account.account_key() == account_key) + { + if matches!(account.source_kind.clone(), AccountSourceKind::Relay) { + continue; + } + account.auth_refresh_blocked = false; + account.auth_refresh_error = None; + account.auth_json = refreshed.clone(); + account.updated_at = now; + found = true; + } + if !found { + log::warn!("token 刷新后账号组已不存在 [{account_label}],跳过写入"); + return Ok(()); + } + store.settings.last_token_refresh_at = Some(now); + save_store(app, &store)?; + current_auth_account_key().as_deref() == Some(account_key) + }; + + if should_sync_current_auth { + write_active_codex_auth(&refreshed)?; + } + + 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((account_key, label, auth_json)) = target else { + return false; + }; + + if let Err(err) = refresh_one_token(app, state, &account_key, &label, auth_json).await { + log::warn!("token daemon: 刷新失败 [{label}]: {err}"); + } + true +} + fn build_refresh_targets( accounts: Vec, current_auth_override: Option<&(String, serde_json::Value)>, diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 55005c9..a69082a 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -704,6 +704,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 { app_paths::codex_auth_path() } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd4cee7..23bdb8b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ mod remote_service; mod settings_service; mod state; mod store; +mod token_refresh_daemon; mod token_usage; mod tray; mod usage; @@ -1511,9 +1512,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 7322054..1f9205b 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -442,6 +442,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, pub(crate) skipped_update_version: Option, } @@ -467,6 +470,7 @@ impl Default for AppSettings { remote_servers: Vec::new(), api_proxy_api_key: None, locale: AppLocale::default(), + last_token_refresh_at: None, skipped_update_version: None, } } diff --git a/src-tauri/src/token_refresh_daemon.rs b/src-tauri/src/token_refresh_daemon.rs new file mode 100644 index 0000000..f22233c --- /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; + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 996c63e..310f8d0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ "bundle": { "active": true, "targets": "all", - "createUpdaterArtifacts": true, + "createUpdaterArtifacts": false, "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -43,7 +43,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 7e38cba..b76c022 100644 --- a/src/hooks/useCodexController.ts +++ b/src/hooks/useCodexController.ts @@ -38,6 +38,7 @@ const REFRESH_MS = 30_000; const TOKEN_USAGE_REFRESH_MS = 60_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 API_PROXY_USAGE_POLL_MS = 2_000; const CLOUDFLARED_POLL_MS = 3_000; @@ -563,6 +564,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; } @@ -618,6 +626,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); } @@ -721,7 +738,9 @@ export function useCodexController() { await loadCloudflaredStatus(); await refreshUsage(true); await refreshTokenUsage(true); - await checkForAppUpdate(true); + if (AUTO_UPDATE_ENABLED) { + await checkForAppUpdate(true); + } } finally { if (!cancelled) { setLoading(false); @@ -744,16 +763,20 @@ 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(tokenUsageTimer); clearInterval(editorTimer); - clearInterval(updateTimer); + if (updateTimer !== null) { + clearInterval(updateTimer); + } }; }, [ checkForAppUpdate,