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
180 changes: 180 additions & 0 deletions src-tauri/src/account_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, (String, serde_json::Value, i64, bool, i64)> =
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::<Vec<_>>();
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<StoredAccount>,
current_auth_override: Option<&(String, serde_json::Value)>,
Expand Down
14 changes: 14 additions & 0 deletions src-tauri/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64> {
let token = auth_token_object(auth_json)
.and_then(|obj| obj.get("access_token"))
.and_then(Value::as_str)?;

// JWT: <header>.<payload>.<signature> — 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<PathBuf, String> {
app_paths::codex_auth_path()
}
Expand Down
9 changes: 7 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")]
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,9 @@ pub(crate) struct AppSettings {
pub(crate) remote_servers: Vec<RemoteServerConfig>,
pub(crate) api_proxy_api_key: Option<String>,
pub(crate) locale: AppLocale,
/// Unix seconds; updated whenever the daemon successfully refreshes a token.
#[serde(default)]
pub(crate) last_token_refresh_at: Option<i64>,
pub(crate) skipped_update_version: Option<String>,
}

Expand All @@ -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,
}
}
Expand Down
23 changes: 23 additions & 0 deletions src-tauri/src/token_refresh_daemon.rs
Original file line number Diff line number Diff line change
@@ -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::<AppState>();

loop {
account_service::daemon_refresh_next_expiring(&app, state.inner()).await;
tokio::time::sleep(Duration::from_secs(CHECK_INTERVAL_SECS)).await;
}
}
4 changes: 2 additions & 2 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"createUpdaterArtifacts": false,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
Expand All @@ -43,7 +43,7 @@
},
"plugins": {
"updater": {
"active": true,
"active": false,
"dialog": false,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFBREFDRDcyNUY0MzE4NTAKUldSUUdFTmZjczNhR3FrQ2JQSTFLOXFHUU11SDkycFVJMU5ySEdkcEY3MWRiSEZWTDN0dFNtVlkK",
"endpoints": [
Expand Down
33 changes: 28 additions & 5 deletions src/hooks/useCodexController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -563,6 +564,13 @@ export function useCodexController() {

const installPendingUpdate = useCallback(
async (knownUpdate?: NonNullable<Awaited<ReturnType<typeof check>>>) => {
if (!AUTO_UPDATE_ENABLED) {
setPendingUpdate(null);
setUpdateDialogOpen(false);
setUpdateProgress(null);
return;
}

if (installingUpdateRef.current) {
return;
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down