diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f062a82..639debf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -526,7 +526,7 @@ dependencies = [ [[package]] name = "codex-switcher" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/src/auth/chatgpt.rs b/src-tauri/src/auth/chatgpt.rs new file mode 100644 index 0000000..7f686d2 --- /dev/null +++ b/src-tauri/src/auth/chatgpt.rs @@ -0,0 +1,67 @@ +//! Shared helpers for working with ChatGPT OAuth tokens. + +use base64::Engine; +use chrono::Utc; + +#[derive(Debug, Clone, Default)] +pub struct ChatGptTokenClaims { + pub email: Option, + pub plan_type: Option, + pub account_id: Option, +} + +/// Parse claims from a JWT ID token without signature validation. +pub fn parse_chatgpt_token_claims(id_token: &str) -> ChatGptTokenClaims { + let parts: Vec<&str> = id_token.split('.').collect(); + if parts.len() != 3 { + return ChatGptTokenClaims::default(); + } + + let payload = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(parts[1]) { + Ok(bytes) => bytes, + Err(_) => return ChatGptTokenClaims::default(), + }; + + let json: serde_json::Value = match serde_json::from_slice(&payload) { + Ok(v) => v, + Err(_) => return ChatGptTokenClaims::default(), + }; + + let auth_claims = json.get("https://api.openai.com/auth"); + + ChatGptTokenClaims { + email: json + .get("email") + .and_then(|value| value.as_str()) + .map(String::from), + plan_type: auth_claims + .and_then(|auth| auth.get("chatgpt_plan_type")) + .and_then(|value| value.as_str()) + .map(String::from), + account_id: auth_claims + .and_then(|auth| auth.get("chatgpt_account_id")) + .and_then(|value| value.as_str()) + .map(String::from), + } +} + +/// Parse the exp claim from any JWT without signature validation. +pub fn parse_jwt_exp(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .ok()?; + let json: serde_json::Value = serde_json::from_slice(&payload).ok()?; + json.get("exp").and_then(|value| value.as_i64()) +} + +pub fn token_expired_or_near_expiry(access_token: &str, skew_seconds: i64) -> bool { + match parse_jwt_exp(access_token) { + Some(expiry) => expiry <= Utc::now().timestamp() + skew_seconds, + None => false, + } +} diff --git a/src-tauri/src/auth/live_sync.rs b/src-tauri/src/auth/live_sync.rs new file mode 100644 index 0000000..1d1b391 --- /dev/null +++ b/src-tauri/src/auth/live_sync.rs @@ -0,0 +1,296 @@ +//! Reconcile the live Codex auth.json with the switcher's account store. + +use anyhow::{Context, Result}; +use tokio::time::{sleep, Duration}; + +use super::{get_codex_auth_file, load_accounts, read_current_auth, save_accounts}; +use crate::auth::chatgpt::parse_chatgpt_token_claims; +use crate::types::{AuthData, AuthMode, LiveAuthSyncResult, StoredAccount}; + +const AUTH_READ_RETRIES: usize = 3; +const AUTH_READ_RETRY_DELAY_MS: u64 = 120; + +#[derive(Debug, Clone)] +struct LiveChatGptAuth { + id_token: String, + access_token: String, + refresh_token: String, + account_id: Option, + email: Option, + plan_type: Option, +} + +#[derive(Debug, Clone)] +enum LiveAuthState { + None, + ApiKey { key: String }, + ChatGpt(LiveChatGptAuth), +} + +pub async fn sync_live_auth_to_store() -> Result { + let live_auth = read_live_auth_with_retry().await?; + let mut store = load_accounts()?; + let previous_active_id = store.active_account_id.clone(); + let mut created_account_id = None; + let mut updated_account_id = None; + let mut cleared_active_account = false; + let mut changed = false; + + match live_auth { + LiveAuthState::None => { + if store.active_account_id.take().is_some() { + cleared_active_account = true; + changed = true; + } + } + LiveAuthState::ApiKey { key } => { + let active_id = if let Some(index) = + find_matching_api_key_account(&store.accounts, &key) + { + store.accounts[index].id.clone() + } else { + let account = + StoredAccount::new_api_key(build_unique_name(&store.accounts, "api-key"), key); + let account_id = account.id.clone(); + store.accounts.push(account); + created_account_id = Some(account_id.clone()); + changed = true; + account_id + }; + + if store.active_account_id.as_deref() != Some(active_id.as_str()) { + store.active_account_id = Some(active_id); + changed = true; + } + } + LiveAuthState::ChatGpt(live) => { + let active_id = + if let Some(index) = find_matching_chatgpt_account(&store.accounts, &live) { + let account = &mut store.accounts[index]; + let mut account_changed = false; + + match &mut account.auth_data { + AuthData::ChatGPT { + id_token, + access_token, + refresh_token, + account_id, + } => { + if *id_token != live.id_token { + *id_token = live.id_token.clone(); + account_changed = true; + } + if *access_token != live.access_token { + *access_token = live.access_token.clone(); + account_changed = true; + } + if *refresh_token != live.refresh_token { + *refresh_token = live.refresh_token.clone(); + account_changed = true; + } + if *account_id != live.account_id { + *account_id = live.account_id.clone(); + account_changed = true; + } + } + AuthData::ApiKey { .. } => { + anyhow::bail!("Matched a non-ChatGPT account while syncing live auth"); + } + } + + if account.email != live.email { + account.email = live.email.clone(); + account_changed = true; + } + + if account.plan_type != live.plan_type { + account.plan_type = live.plan_type.clone(); + account_changed = true; + } + + if account_changed { + updated_account_id = Some(account.id.clone()); + changed = true; + } + + account.id.clone() + } else { + let account = StoredAccount::new_chatgpt( + build_chatgpt_account_name(&store.accounts, live.email.as_deref()), + live.email.clone(), + live.plan_type.clone(), + live.id_token, + live.access_token, + live.refresh_token, + live.account_id, + ); + let account_id = account.id.clone(); + store.accounts.push(account); + created_account_id = Some(account_id.clone()); + changed = true; + account_id + }; + + if store.active_account_id.as_deref() != Some(active_id.as_str()) { + store.active_account_id = Some(active_id); + changed = true; + } + } + } + + if previous_active_id != store.active_account_id { + changed = true; + } + + if changed { + save_accounts(&store)?; + } + + Ok(LiveAuthSyncResult { + changed, + active_account_id: store.active_account_id, + created_account_id, + updated_account_id, + cleared_active_account, + }) +} + +async fn read_live_auth_with_retry() -> Result { + let auth_path = get_codex_auth_file()?; + if !auth_path.exists() { + return Ok(LiveAuthState::None); + } + + let mut last_error = None; + + for attempt in 0..AUTH_READ_RETRIES { + match read_current_auth() { + Ok(auth) => return auth_dot_json_to_live_state(auth), + Err(err) => { + last_error = Some(err); + if attempt + 1 < AUTH_READ_RETRIES { + sleep(Duration::from_millis(AUTH_READ_RETRY_DELAY_MS)).await; + } + } + } + } + + Err(last_error + .context("Failed to read live auth.json after retries")? + .into()) +} + +fn auth_dot_json_to_live_state(auth: Option) -> Result { + let Some(auth) = auth else { + return Ok(LiveAuthState::None); + }; + + if let Some(tokens) = auth.tokens { + if tokens.id_token.trim().is_empty() + || tokens.access_token.trim().is_empty() + || tokens.refresh_token.trim().is_empty() + { + anyhow::bail!("Live auth.json contains incomplete ChatGPT tokens"); + } + + let claims = parse_chatgpt_token_claims(&tokens.id_token); + return Ok(LiveAuthState::ChatGpt(LiveChatGptAuth { + id_token: tokens.id_token, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + account_id: claims.account_id.or(tokens.account_id), + email: claims.email, + plan_type: claims.plan_type, + })); + } + + if let Some(api_key) = auth.openai_api_key { + if api_key.trim().is_empty() { + anyhow::bail!("Live auth.json contains an empty API key"); + } + + return Ok(LiveAuthState::ApiKey { key: api_key }); + } + + Ok(LiveAuthState::None) +} + +fn find_matching_api_key_account(accounts: &[StoredAccount], live_key: &str) -> Option { + accounts + .iter() + .position(|account| match &account.auth_data { + AuthData::ApiKey { key } => key == live_key, + AuthData::ChatGPT { .. } => false, + }) +} + +fn find_matching_chatgpt_account( + accounts: &[StoredAccount], + live: &LiveChatGptAuth, +) -> Option { + if let Some(account_id) = live.account_id.as_deref() { + if let Some(index) = accounts + .iter() + .position(|account| match &account.auth_data { + AuthData::ChatGPT { + account_id: Some(stored_account_id), + .. + } => stored_account_id == account_id, + _ => false, + }) + { + return Some(index); + } + } + + if let Some(email) = live.email.as_deref() { + if let Some(index) = accounts.iter().position(|account| { + matches!(account.auth_mode, AuthMode::ChatGPT) + && account + .email + .as_deref() + .is_some_and(|stored_email| stored_email.eq_ignore_ascii_case(email)) + }) { + return Some(index); + } + } + + accounts + .iter() + .position(|account| match &account.auth_data { + AuthData::ChatGPT { + refresh_token, + access_token, + .. + } => refresh_token == &live.refresh_token || access_token == &live.access_token, + AuthData::ApiKey { .. } => false, + }) +} + +fn build_chatgpt_account_name(accounts: &[StoredAccount], email: Option<&str>) -> String { + let preferred = email + .and_then(|value| value.split('@').next()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("chatgpt"); + build_unique_name(accounts, preferred) +} + +fn build_unique_name(accounts: &[StoredAccount], preferred: &str) -> String { + let base = preferred.trim(); + if base.is_empty() { + return build_unique_name(accounts, "account"); + } + + if accounts.iter().all(|account| account.name != base) { + return base.to_string(); + } + + let mut suffix = 2usize; + loop { + let candidate = format!("{base}-{suffix}"); + if accounts.iter().all(|account| account.name != candidate) { + return candidate; + } + suffix += 1; + } +} diff --git a/src-tauri/src/auth/mod.rs b/src-tauri/src/auth/mod.rs index 8827172..39268eb 100644 --- a/src-tauri/src/auth/mod.rs +++ b/src-tauri/src/auth/mod.rs @@ -1,10 +1,14 @@ //! Authentication module +pub mod chatgpt; +pub mod live_sync; pub mod oauth_server; pub mod storage; pub mod switcher; pub mod token_refresh; +pub use chatgpt::*; +pub use live_sync::*; pub use oauth_server::*; pub use storage::*; pub use switcher::*; diff --git a/src-tauri/src/auth/oauth_server.rs b/src-tauri/src/auth/oauth_server.rs index 61b62aa..f134fa1 100644 --- a/src-tauri/src/auth/oauth_server.rs +++ b/src-tauri/src/auth/oauth_server.rs @@ -12,6 +12,7 @@ use sha2::{Digest, Sha256}; use tiny_http::{Header, Request, Response, Server}; use tokio::sync::oneshot; +use crate::auth::chatgpt::parse_chatgpt_token_claims; use crate::types::{OAuthLoginInfo, StoredAccount}; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; @@ -124,40 +125,6 @@ async fn exchange_code_for_tokens( Ok(tokens) } -/// Parse claims from JWT ID token -fn parse_id_token_claims(id_token: &str) -> (Option, Option, Option) { - let parts: Vec<&str> = id_token.split('.').collect(); - if parts.len() != 3 { - return (None, None, None); - } - - let payload = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(parts[1]) { - Ok(bytes) => bytes, - Err(_) => return (None, None, None), - }; - - let json: serde_json::Value = match serde_json::from_slice(&payload) { - Ok(v) => v, - Err(_) => return (None, None, None), - }; - - let email = json.get("email").and_then(|v| v.as_str()).map(String::from); - - let auth_claims = json.get("https://api.openai.com/auth"); - - let plan_type = auth_claims - .and_then(|auth| auth.get("chatgpt_plan_type")) - .and_then(|v| v.as_str()) - .map(String::from); - - let account_id = auth_claims - .and_then(|auth| auth.get("chatgpt_account_id")) - .and_then(|v| v.as_str()) - .map(String::from); - - (email, plan_type, account_id) -} - /// OAuth login flow result pub struct OAuthLoginResult { pub account: StoredAccount, @@ -362,18 +329,17 @@ async fn handle_oauth_request( Ok(tokens) => { println!("[OAuth] Token exchange successful!"); // Parse claims from ID token - let (email, plan_type, chatgpt_account_id) = - parse_id_token_claims(&tokens.id_token); + let claims = parse_chatgpt_token_claims(&tokens.id_token); // Create the account let account = StoredAccount::new_chatgpt( account_name.to_string(), - email, - plan_type, + claims.email, + claims.plan_type, tokens.id_token, tokens.access_token, tokens.refresh_token, - chatgpt_account_id, + claims.account_id, ); // Send success response diff --git a/src-tauri/src/auth/switcher.rs b/src-tauri/src/auth/switcher.rs index 8fb4959..25d2fd6 100644 --- a/src-tauri/src/auth/switcher.rs +++ b/src-tauri/src/auth/switcher.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use chrono::Utc; +use super::parse_chatgpt_token_claims; use crate::types::{AuthData, AuthDotJson, StoredAccount, TokenData}; /// Get the official Codex home directory @@ -56,6 +57,7 @@ pub fn switch_to_account(account: &StoredAccount) -> Result<()> { fn create_auth_json(account: &StoredAccount) -> Result { match &account.auth_data { AuthData::ApiKey { key } => Ok(AuthDotJson { + auth_mode: Some("api_key".to_string()), openai_api_key: Some(key.clone()), tokens: None, last_refresh: None, @@ -66,6 +68,7 @@ fn create_auth_json(account: &StoredAccount) -> Result { refresh_token, account_id, } => Ok(AuthDotJson { + auth_mode: Some("chatgpt".to_string()), openai_api_key: None, tokens: Some(TokenData { id_token: id_token.clone(), @@ -91,53 +94,22 @@ pub fn import_from_auth_json(path: &str, account_name: String) -> Result (Option, Option) { - let parts: Vec<&str> = id_token.split('.').collect(); - if parts.len() != 3 { - return (None, None); - } - - // Decode the payload (second part) - let payload = - match base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, parts[1]) { - Ok(bytes) => bytes, - Err(_) => return (None, None), - }; - - let json: serde_json::Value = match serde_json::from_slice(&payload) { - Ok(v) => v, - Err(_) => return (None, None), - }; - - let email = json.get("email").and_then(|v| v.as_str()).map(String::from); - - // Look for plan type in the OpenAI auth claims - let plan_type = json - .get("https://api.openai.com/auth") - .and_then(|auth| auth.get("chatgpt_plan_type")) - .and_then(|v| v.as_str()) - .map(String::from); - - (email, plan_type) -} - /// Read the current auth.json file if it exists pub fn read_current_auth() -> Result> { let path = get_codex_auth_file()?; diff --git a/src-tauri/src/auth/token_refresh.rs b/src-tauri/src/auth/token_refresh.rs index 017f6c9..9e8933a 100644 --- a/src-tauri/src/auth/token_refresh.rs +++ b/src-tauri/src/auth/token_refresh.rs @@ -1,11 +1,13 @@ //! ChatGPT OAuth token refresh helpers use anyhow::{Context, Result}; -use base64::Engine; -use chrono::Utc; use tokio::time::{sleep, Duration}; -use super::{load_accounts, switch_to_account, update_account_chatgpt_tokens}; +use super::{ + load_accounts, parse_chatgpt_token_claims, switch_to_account, + token_expired_or_near_expiry as chatgpt_token_expired_or_near_expiry, + update_account_chatgpt_tokens, +}; use crate::types::{AuthData, StoredAccount}; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; @@ -62,8 +64,8 @@ pub async fn refresh_chatgpt_tokens(account: &StoredAccount) -> Result Result bool { - match parse_jwt_exp(access_token) { - Some(expiry) => expiry <= Utc::now().timestamp() + EXPIRY_SKEW_SECONDS, - None => false, - } -} - -fn parse_jwt_exp(token: &str) -> Option { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return None; - } - - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(parts[1]) - .ok()?; - let json: serde_json::Value = serde_json::from_slice(&payload).ok()?; - json.get("exp").and_then(|v| v.as_i64()) -} - -fn parse_id_token_claims(id_token: &str) -> (Option, Option, Option) { - let parts: Vec<&str> = id_token.split('.').collect(); - if parts.len() != 3 { - return (None, None, None); - } - - let payload = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(parts[1]) { - Ok(bytes) => bytes, - Err(_) => return (None, None, None), - }; - - let json: serde_json::Value = match serde_json::from_slice(&payload) { - Ok(v) => v, - Err(_) => return (None, None, None), - }; - - let email = json.get("email").and_then(|v| v.as_str()).map(String::from); - let auth_claims = json.get("https://api.openai.com/auth"); - let plan_type = auth_claims - .and_then(|auth| auth.get("chatgpt_plan_type")) - .and_then(|v| v.as_str()) - .map(String::from); - let account_id = auth_claims - .and_then(|auth| auth.get("chatgpt_account_id")) - .and_then(|v| v.as_str()) - .map(String::from); - - (email, plan_type, account_id) + chatgpt_token_expired_or_near_expiry(access_token, EXPIRY_SKEW_SECONDS) } async fn refresh_tokens_with_refresh_token(refresh_token: &str) -> Result { diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index e25b6f5..fc8255e 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -2,10 +2,12 @@ use crate::auth::{ add_account, create_chatgpt_account_from_refresh_token, get_active_account, - import_from_auth_json, load_accounts, remove_account, save_accounts, set_active_account, - switch_to_account, touch_account, + import_from_auth_json, load_accounts, refresh_chatgpt_tokens, remove_account, save_accounts, + set_active_account, switch_to_account, sync_live_auth_to_store, touch_account, +}; +use crate::types::{ + AccountInfo, AccountsStore, AuthData, ImportAccountsSummary, LiveAuthSyncResult, StoredAccount, }; -use crate::types::{AccountInfo, AccountsStore, AuthData, ImportAccountsSummary, StoredAccount}; use anyhow::Context; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; @@ -109,9 +111,17 @@ pub async fn add_account_from_file(path: String, name: String) -> Result Result { + sync_live_auth_to_store().await.map_err(|e| e.to_string()) +} + /// Switch to a different account #[tauri::command] pub async fn switch_account(account_id: String) -> Result<(), String> { + sync_live_auth_to_store().await.map_err(|e| e.to_string())?; + let store = load_accounts().map_err(|e| e.to_string())?; // Find the account @@ -119,10 +129,18 @@ pub async fn switch_account(account_id: String) -> Result<(), String> { .accounts .iter() .find(|a| a.id == account_id) + .cloned() .ok_or_else(|| format!("Account not found: {account_id}"))?; + let account = match account.auth_mode { + crate::types::AuthMode::ApiKey => account, + crate::types::AuthMode::ChatGPT => refresh_chatgpt_tokens(&account) + .await + .map_err(|e| format!("Failed to validate account before switch: {e}"))?, + }; + // Write to ~/.codex/auth.json - switch_to_account(account).map_err(|e| e.to_string())?; + switch_to_account(&account).map_err(|e| e.to_string())?; // Update the active account in our store set_active_account(&account_id).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e4ea19..76cac6a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,7 +10,8 @@ use commands::{ export_accounts_full_encrypted_file, export_accounts_slim_text, get_active_account_info, get_masked_account_ids, get_usage, import_accounts_full_encrypted_file, import_accounts_slim_text, list_accounts, refresh_all_accounts_usage, rename_account, - set_masked_account_ids, start_login, switch_account, warmup_account, warmup_all_accounts, + set_masked_account_ids, start_login, switch_account, sync_live_auth, warmup_account, + warmup_all_accounts, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -31,6 +32,7 @@ pub fn run() { get_active_account_info, add_account_from_file, switch_account, + sync_live_auth, delete_account, rename_account, export_accounts_slim_text, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index dc3d2cc..8361d32 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -132,6 +132,9 @@ pub enum AuthData { /// The official Codex auth.json format #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthDotJson { + /// Explicit auth mode used by the official app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_mode: Option, /// OpenAI API key (for API key auth mode) #[serde(rename = "OPENAI_API_KEY", skip_serializing_if = "Option::is_none")] pub openai_api_key: Option, @@ -259,6 +262,16 @@ pub struct ImportAccountsSummary { pub skipped_count: usize, } +/// Summary returned after reconciling live auth.json with stored accounts. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiveAuthSyncResult { + pub changed: bool, + pub active_account_id: Option, + pub created_account_id: Option, + pub updated_account_id: Option, + pub cleared_active_account: bool, +} + /// OAuth login information returned to frontend #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuthLoginInfo { diff --git a/src/App.tsx b/src/App.tsx index f0ffdaa..d09d267 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ function App() { accounts, loading, error, + syncLiveAuth, refreshUsage, refreshSingleUsage, warmupAccount, @@ -160,7 +161,8 @@ function App() { setIsRefreshing(true); setRefreshSuccess(false); try { - await refreshUsage(); + const syncedAccounts = await syncLiveAuth(); + await refreshUsage(syncedAccounts ?? undefined); setRefreshSuccess(true); setTimeout(() => setRefreshSuccess(false), 2000); } finally { diff --git a/src/hooks/useAccounts.ts b/src/hooks/useAccounts.ts index a60b55b..08cfd3a 100644 --- a/src/hooks/useAccounts.ts +++ b/src/hooks/useAccounts.ts @@ -6,6 +6,7 @@ import type { AccountWithUsage, WarmupSummary, ImportAccountsSummary, + LiveAuthSyncResult, } from "../types"; export function useAccounts() { @@ -88,6 +89,22 @@ export function useAccounts() { } }, []); + const syncLiveAuth = useCallback( + async (reloadOnChange = true) => { + try { + const result = await invoke("sync_live_auth"); + if (reloadOnChange && result.changed) { + return await loadAccounts(true); + } + return null; + } catch (err) { + console.error("Failed to sync live auth:", err); + return null; + } + }, + [loadAccounts] + ); + const refreshUsage = useCallback( async (accountList?: AccountInfo[] | AccountWithUsage[]) => { try { @@ -344,21 +361,48 @@ export function useAccounts() { }, []); useEffect(() => { - loadAccounts().then((accountList) => refreshUsage(accountList)); - - // Auto-refresh usage every 60 seconds (same as official Codex CLI) - const interval = setInterval(() => { + let cancelled = false; + + const initialize = async () => { + await syncLiveAuth(false); + if (cancelled) return; + + const accountList = await loadAccounts(); + if (cancelled) return; + + await refreshUsage(accountList); + }; + + void initialize(); + + const syncInterval = setInterval(() => { + syncLiveAuth(true).catch(() => {}); + }, 5000); + + const usageInterval = setInterval(() => { refreshUsage().catch(() => {}); }, 60000); - - return () => clearInterval(interval); - }, [loadAccounts, refreshUsage]); + + const handleWindowFocus = () => { + syncLiveAuth(true).catch(() => {}); + }; + + window.addEventListener("focus", handleWindowFocus); + + return () => { + cancelled = true; + clearInterval(syncInterval); + clearInterval(usageInterval); + window.removeEventListener("focus", handleWindowFocus); + }; + }, [loadAccounts, refreshUsage, syncLiveAuth]); return { accounts, loading, error, loadAccounts, + syncLiveAuth, refreshUsage, refreshSingleUsage, warmupAccount, diff --git a/src/types/index.ts b/src/types/index.ts index 6b2110b..3b5bb93 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,3 +56,11 @@ export interface ImportAccountsSummary { imported_count: number; skipped_count: number; } + +export interface LiveAuthSyncResult { + changed: boolean; + active_account_id: string | null; + created_account_id: string | null; + updated_account_id: string | null; + cleared_active_account: boolean; +}