From 8b708c235c35da62edd06d13aff4a73319097e6e Mon Sep 17 00:00:00 2001 From: Rushi Ranpise Date: Wed, 22 Jul 2026 23:49:35 -0700 Subject: [PATCH] feat: reliability, UX, and security improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## New Features ### Switch accounts while Codex is running Previously the Switch button was disabled when Codex was running. It now shows an amber 'Switch & Close' button and opens a warning dialog before force-closing and switching. Covers both main window and tray-initiated switches. ### Open Codex after switching New 'Open Codex after switch' toggle in the Settings menu (Tauri only). When enabled, Codex launches automatically after every successful account switch. Setting is persisted in localStorage. ### Launch at Login and Start Minimized Two new toggles in Settings (Tauri only): - Launch at Login: registers with OS autostart via tauri-plugin-autostart (LaunchAgent on macOS, registry on Windows, XDG on Linux) - Start Minimized: hides the main window at startup so the app runs silently in the tray without showing a window ### Re-authenticate expired accounts in-place When a usage error indicates an expired/invalid session, an amber 'Re-authenticate' banner appears on the account card. Clicking it runs a fresh OAuth flow and replaces the tokens in-place. The account name, settings, and history are preserved — no need to delete and re-add. ### Open Codex button in both tray menus 'Open Codex' added to both the native right-click tray menu (between 'Open Codex Switcher' and 'Quit') and the React tray popup footer. ### Warmup and refresh failure details - Warm-up all: toast now lists each failed account with its error reason instead of just a count. Multi-line error toasts stay for 8 seconds. - Refresh all: shows per-account errors when any usage fetch fails instead of always showing a generic success message. ### Active account pinned at top in both tray menus Both the native right-click menu and the React tray popup always show the active account first, separated from the rest by a divider. Previously the order depended on account creation order. ## Bug Fixes ### Cloudflare 403 on chatgpt.com backend API All requests to chatgpt.com/backend-api now use a real Chrome User-Agent and standard browser headers (Accept, Accept-Language, Origin, Referer, sec-fetch-*) to bypass Cloudflare bot detection. The 403 error message no longer blames stale tokens or suggests removing the account. ### refresh_token_reused errors eliminated Two root causes fixed: 1. Token sync on switch: before writing auth.json, the app reads the current file and absorbs any fresher tokens Codex rotated since the last switch, preventing stale token overwrites. 2. Single-instance enforcement: uses flock (Unix) / exclusive file lock (Windows) to prevent multiple app instances from concurrently refreshing tokens and triggering refresh_token_reused. ### Token refresh stability - Expiry skew increased from 60s to 5 minutes so tokens are only refreshed when genuinely close to expiry, not on every 60-second usage poll. - Per-account refresh lock (LazyLock>) prevents concurrent warmup and usage polling from both triggering a token refresh for the same account simultaneously. ### Atomic accounts.json write save_accounts() and save_app_settings() now write to a .tmp file then rename atomically, so a crash mid-write can never corrupt the accounts file (fixes 'malformed fragment at the end' parse errors). ## UI Polish - Switch button: whitespace-nowrap prevents wrapping in narrow cards; label shortened to 'Switch & Close' - 'Account' dropdown renamed to 'Settings' (it now manages startup behaviour, import/export, and other settings beyond accounts) --- src-tauri/Cargo.lock | 85 ++++++++++++-- src-tauri/Cargo.toml | 4 + src-tauri/capabilities/default.json | 5 +- src-tauri/src/auth/storage.rs | 25 +++-- src-tauri/src/auth/switcher.rs | 81 ++++++++++++++ src-tauri/src/auth/token_refresh.rs | 47 +++++++- src-tauri/src/commands/oauth.rs | 101 +++++++++++++++++ src-tauri/src/commands/usage.rs | 35 ++++-- src-tauri/src/commands/window.rs | 41 +++++++ src-tauri/src/lib.rs | 106 +++++++++++++++++- src-tauri/src/tray.rs | 19 +++- src-tauri/src/types.rs | 17 +++ src/App.tsx | 133 ++++++++++++++++++++-- src/TrayMenu.tsx | 97 ++++++++++------ src/components/AccountCard.tsx | 49 ++++++++- src/components/ReAuthModal.tsx | 164 ++++++++++++++++++++++++++++ src/components/index.ts | 1 + src/types/index.ts | 1 + 18 files changed, 928 insertions(+), 83 deletions(-) create mode 100644 src/components/ReAuthModal.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e6fc2360..fd733f89 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -232,6 +232,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -532,9 +543,10 @@ dependencies = [ "base64 0.22.1", "chacha20poly1305", "chrono", - "dirs", + "dirs 6.0.0", "flate2", "futures", + "libc", "pbkdf2", "plist", "rand 0.9.2", @@ -544,6 +556,7 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-process", @@ -836,13 +849,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -853,7 +886,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -965,7 +998,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -3354,6 +3387,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4328,7 +4372,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4378,7 +4422,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4450,6 +4494,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -4529,7 +4587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -5021,7 +5079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -5978,6 +6036,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -6092,7 +6159,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b491ae8f..4ec4ab6a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ tauri-plugin-opener = "2" tauri-plugin-dialog = "2.6" tauri-plugin-process = "2" tauri-plugin-updater = "2.10" +tauri-plugin-autostart = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } @@ -42,3 +43,6 @@ pbkdf2 = "0.12" [target.'cfg(target_os = "macos")'.dependencies] plist = "1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e8ccdf97..a5cddf7b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,9 @@ "opener:default", "dialog:default", "updater:default", - "process:default" + "process:default", + "autostart:allow-enable", + "autostart:allow-disable", + "autostart:allow-is-enabled" ] } diff --git a/src-tauri/src/auth/storage.rs b/src-tauri/src/auth/storage.rs index ace0f25f..cc8e727c 100644 --- a/src-tauri/src/auth/storage.rs +++ b/src-tauri/src/auth/storage.rs @@ -65,8 +65,12 @@ pub fn save_app_settings(settings: &AppSettings) -> Result<()> { } let content = serde_json::to_string_pretty(settings).context("Failed to serialize settings")?; - fs::write(&path, content) - .with_context(|| format!("Failed to write settings file: {}", path.display()))?; + + let tmp_path = path.with_extension("json.tmp"); + fs::write(&tmp_path, &content) + .with_context(|| format!("Failed to write tmp settings file: {}", tmp_path.display()))?; + fs::rename(&tmp_path, &path) + .with_context(|| format!("Failed to rename tmp to settings file: {}", path.display()))?; #[cfg(unix)] { @@ -78,11 +82,12 @@ pub fn save_app_settings(settings: &AppSettings) -> Result<()> { Ok(()) } -/// Save the accounts store to disk +/// Save the accounts store to disk using an atomic write. +/// Writes to a temp file first then renames so a crash mid-write never +/// leaves a truncated/corrupt accounts.json. pub fn save_accounts(store: &AccountsStore) -> Result<()> { let path = get_accounts_file()?; - // Ensure the config directory exists if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("Failed to create config directory: {}", parent.display()))?; @@ -91,10 +96,16 @@ pub fn save_accounts(store: &AccountsStore) -> Result<()> { let content = serde_json::to_string_pretty(store).context("Failed to serialize accounts store")?; - fs::write(&path, content) - .with_context(|| format!("Failed to write accounts file: {}", path.display()))?; + // Atomic write: serialize → tmp file → rename. + // If the process crashes after serialization but before rename the original + // accounts.json is untouched; the orphaned .tmp is harmless. + let tmp_path = path.with_extension("json.tmp"); + fs::write(&tmp_path, &content) + .with_context(|| format!("Failed to write tmp accounts file: {}", tmp_path.display()))?; + + fs::rename(&tmp_path, &path) + .with_context(|| format!("Failed to rename tmp to accounts file: {}", path.display()))?; - // Set restrictive permissions on Unix #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; diff --git a/src-tauri/src/auth/switcher.rs b/src-tauri/src/auth/switcher.rs index e213574e..e7368f12 100644 --- a/src-tauri/src/auth/switcher.rs +++ b/src-tauri/src/auth/switcher.rs @@ -28,6 +28,12 @@ pub fn get_codex_auth_file() -> Result { /// Switch to a specific account by writing its credentials to ~/.codex/auth.json pub fn switch_to_account(account: &StoredAccount) -> Result<()> { + // Before writing, check if ~/.codex/auth.json already has fresher tokens + // for this same account (Codex rotates tokens during normal use). If so, + // absorb those tokens into accounts.json first so we never write stale + // credentials and trigger a refresh_token_reused error. + let account = &sync_tokens_from_auth_json(account)?; + let codex_home = get_codex_home()?; // Ensure the codex home directory exists @@ -54,6 +60,81 @@ pub fn switch_to_account(account: &StoredAccount) -> Result<()> { Ok(()) } +/// If ~/.codex/auth.json holds a newer token set for the same ChatGPT account, +/// update accounts.json with those tokens before we proceed. This prevents +/// writing an old refresh token back and causing `refresh_token_reused`. +fn sync_tokens_from_auth_json(account: &StoredAccount) -> Result { + use crate::auth::storage::update_account_chatgpt_tokens; + use crate::types::parse_chatgpt_id_token_claims; + + // Only relevant for ChatGPT OAuth accounts. + let (stored_refresh_token, stored_account_id) = match &account.auth_data { + AuthData::ChatGPT { + refresh_token, + account_id, + .. + } => (refresh_token.clone(), account_id.clone()), + AuthData::ApiKey { .. } => return Ok(account.clone()), + }; + + let auth_file = match read_current_auth()? { + Some(auth) => auth, + None => return Ok(account.clone()), + }; + + let disk_tokens = match auth_file.tokens { + Some(tokens) => tokens, + None => return Ok(account.clone()), + }; + + // Only sync if the auth.json belongs to the same ChatGPT account. + let disk_account_id = disk_tokens + .account_id + .clone() + .or_else(|| parse_chatgpt_id_token_claims(&disk_tokens.id_token).account_id); + + let same_account = match (&stored_account_id, &disk_account_id) { + (Some(stored), Some(disk)) => stored == disk, + // No account_id on either side — compare refresh tokens as a fallback. + _ => disk_tokens.refresh_token == stored_refresh_token, + }; + + if !same_account { + return Ok(account.clone()); + } + + // Tokens differ — auth.json is newer (Codex rotated them). Absorb them. + if disk_tokens.refresh_token == stored_refresh_token + && disk_tokens.access_token + == match &account.auth_data { + AuthData::ChatGPT { access_token, .. } => access_token.clone(), + _ => String::new(), + } + { + // Tokens are identical — nothing to sync. + return Ok(account.clone()); + } + + println!( + "[Auth] auth.json has fresher tokens for account '{}', syncing into accounts.json before switch", + account.name + ); + + let claims = parse_chatgpt_id_token_claims(&disk_tokens.id_token); + let updated = update_account_chatgpt_tokens( + &account.id, + disk_tokens.id_token, + disk_tokens.access_token, + disk_tokens.refresh_token, + disk_account_id, + claims.email, + claims.plan_type, + claims.subscription_expires_at, + )?; + + Ok(updated) +} + /// Create an AuthDotJson structure from a StoredAccount fn create_auth_json(account: &StoredAccount) -> Result { match &account.auth_data { diff --git a/src-tauri/src/auth/token_refresh.rs b/src-tauri/src/auth/token_refresh.rs index fbba025a..25385879 100644 --- a/src-tauri/src/auth/token_refresh.rs +++ b/src-tauri/src/auth/token_refresh.rs @@ -3,6 +3,8 @@ use anyhow::{Context, Result}; use base64::Engine; use chrono::Utc; +use std::collections::HashSet; +use std::sync::{LazyLock, Mutex}; use tokio::time::{sleep, Duration}; use super::{load_accounts, switch_to_account, update_account_chatgpt_tokens}; @@ -10,7 +12,18 @@ use crate::types::{parse_chatgpt_id_token_claims, AuthData, StoredAccount}; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; -const EXPIRY_SKEW_SECONDS: i64 = 60; +/// Refresh the token only when it expires within 5 minutes. +/// The old value was 60 seconds which caused unnecessary refreshes on +/// every warmup/usage cycle, burning refresh tokens and causing +/// refresh_token_reused errors when Codex CLI or another instance +/// also refreshed at the same time. +const EXPIRY_SKEW_SECONDS: i64 = 5 * 60; + +/// In-memory set of account IDs currently being refreshed. +/// Prevents two concurrent calls from both starting a refresh for the +/// same account (race that burns the refresh token). +static REFRESHING_IDS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); #[derive(Debug, serde::Deserialize)] struct RefreshTokenResponse { @@ -41,6 +54,9 @@ pub async fn ensure_chatgpt_tokens_fresh(account: &StoredAccount) -> Result Result { let (current_id_token, current_refresh_token, current_account_id) = match &account.auth_data { AuthData::ApiKey { .. } => return Ok(account.clone()), @@ -56,7 +72,33 @@ pub async fn refresh_chatgpt_tokens(account: &StoredAccount) -> Result Result Result<(), String> { } Ok(()) } + +/// Start an OAuth re-authentication flow for an existing account. +/// Same as start_login but does not create a new account — used to +/// replace stale tokens on an account that has expired credentials. +#[tauri::command] +pub async fn start_reauth(account_id: String) -> Result { + // Reuse the same pending-oauth slot; cancel any previous flow first. + if let Some(previous) = { + let mut pending = PENDING_OAUTH.lock().unwrap(); + pending.take() + } { + previous.cancelled.store(true, Ordering::Relaxed); + } + + // Load the account name so the OAuth flow can display it. + let account_name = { + use crate::auth::load_accounts; + let store = load_accounts().map_err(|e| e.to_string())?; + store + .accounts + .iter() + .find(|a| a.id == account_id) + .map(|a| a.name.clone()) + .ok_or_else(|| format!("Account not found: {account_id}"))? + }; + + let (info, rx, cancelled) = start_oauth_login(account_name) + .await + .map_err(|e| e.to_string())?; + + { + let mut pending = PENDING_OAUTH.lock().unwrap(); + *pending = Some(PendingOAuth { rx, cancelled }); + } + + Ok(info) +} + +/// Complete an OAuth re-authentication flow, updating the existing account's +/// tokens in-place rather than creating a new account entry. +#[tauri::command] +pub async fn complete_reauth(account_id: String) -> Result { + use crate::auth::storage::update_account_chatgpt_tokens; + use crate::types::{parse_chatgpt_id_token_claims, AuthData}; + + let pending = { + let mut pending = PENDING_OAUTH.lock().unwrap(); + pending + .take() + .ok_or_else(|| "No pending OAuth login".to_string())? + }; + + let fresh_account = wait_for_oauth_login(pending.rx) + .await + .map_err(|e| e.to_string())?; + + // Extract the new tokens from the OAuth result. + let (id_token, access_token, refresh_token, chatgpt_account_id) = match &fresh_account.auth_data + { + AuthData::ChatGPT { + id_token, + access_token, + refresh_token, + account_id, + } => ( + id_token.clone(), + access_token.clone(), + refresh_token.clone(), + account_id.clone(), + ), + AuthData::ApiKey { .. } => { + return Err("Re-auth returned an API key account unexpectedly".to_string()) + } + }; + + let claims = parse_chatgpt_id_token_claims(&id_token); + + // Update the existing account's tokens in-place, preserving its name and id. + let updated = update_account_chatgpt_tokens( + &account_id, + id_token, + access_token, + refresh_token, + chatgpt_account_id, + claims.email, + claims.plan_type, + claims.subscription_expires_at, + ) + .map_err(|e| e.to_string())?; + + // If this is the active account, sync auth.json too. + let store = load_accounts().map_err(|e| e.to_string())?; + if store.active_account_id.as_deref() == Some(&account_id) { + switch_to_account(&updated).map_err(|e| e.to_string())?; + } + + touch_account(&account_id).map_err(|e| e.to_string())?; + + let active_id = store.active_account_id.as_deref(); + Ok(AccountInfo::from_stored(&updated, active_id)) +} diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 045a8e5d..b138d46f 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -90,25 +90,44 @@ pub async fn warmup_all_accounts() -> Result { let total_accounts = store.accounts.len(); let concurrency = total_accounts.min(10).max(1); - let results: Vec<(String, bool)> = stream::iter(store.accounts.into_iter()) + // Collect (account_id, account_name, Option) + let results: Vec<(String, String, Option)> = stream::iter(store.accounts.into_iter()) .map(|account| async move { - let account_id = account.id.clone(); - let failed = send_warmup(&account).await.is_err(); - (account_id, failed) + let id = account.id.clone(); + let name = account.name.clone(); + match send_warmup(&account).await { + Ok(()) => (id, name, None), + Err(e) => { + // Truncate long error messages for the toast + let msg = e.to_string(); + let short = if msg.len() > 120 { + format!("{}…", &msg[..120]) + } else { + msg + }; + (id, name, Some(short)) + } + } }) .buffer_unordered(concurrency) .collect() .await; - let failed_account_ids = results - .into_iter() - .filter_map(|(account_id, failed)| failed.then_some(account_id)) - .collect::>(); + let mut failed_account_ids = Vec::new(); + let mut failed_account_errors = Vec::new(); + + for (id, name, err) in results { + if let Some(e) = err { + failed_account_ids.push(id); + failed_account_errors.push((name, e)); + } + } let warmed_accounts = total_accounts.saturating_sub(failed_account_ids.len()); Ok(WarmupSummary { total_accounts, warmed_accounts, failed_account_ids, + failed_account_errors, }) } diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index af276cde..48842c71 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -175,3 +175,44 @@ pub fn should_prompt_for_close_behavior() -> bool { false } } + +/// Get both startup settings in one call. +#[tauri::command] +pub fn get_startup_settings(app: AppHandle) -> (bool, bool) { + use tauri_plugin_autostart::ManagerExt; + + let launch_at_login = >::autolaunch(&app) + .is_enabled() + .unwrap_or_else(|_| load_app_settings().unwrap_or_default().launch_at_login); + + let start_minimized = load_app_settings().unwrap_or_default().start_minimized; + (launch_at_login, start_minimized) +} + +/// Set Launch at Login. Registers / unregisters the app with the OS autostart +/// mechanism via tauri-plugin-autostart. +#[tauri::command] +pub fn set_launch_at_login(app: AppHandle, enabled: bool) -> Result<(), String> { + use tauri_plugin_autostart::ManagerExt; + + let manager = >::autolaunch(&app); + if enabled { + manager.enable().map_err(|e| e.to_string())?; + } else { + manager.disable().map_err(|e| e.to_string())?; + } + + let mut settings = load_app_settings().unwrap_or_default(); + settings.launch_at_login = enabled; + save_app_settings(&settings).map_err(|e| e.to_string())?; + Ok(()) +} + +/// Set Start Minimized. Persisted to settings.json; applied on the next launch. +#[tauri::command] +pub fn set_start_minimized(enabled: bool) -> Result<(), String> { + let mut settings = load_app_settings().unwrap_or_default(); + settings.start_minimized = enabled; + save_app_settings(&settings).map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72956a7c..f0c545f3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,13 +12,14 @@ pub mod web; use commands::{ ack_close_behavior_prompt, add_account_from_file, cancel_login, check_codex_processes, - complete_close_behavior, complete_login, delete_account, export_accounts_full_encrypted_file, - export_accounts_slim_text, get_account_usage_stats, get_active_account_info, - get_dock_display_mode, get_masked_account_ids, get_usage, hide_tray_window, - import_accounts_full_encrypted_file, import_accounts_slim_text, kill_codex_processes, - list_accounts, open_main_window, quit_app, refresh_account_metadata, + complete_close_behavior, complete_login, complete_reauth, delete_account, + export_accounts_full_encrypted_file, export_accounts_slim_text, get_account_usage_stats, + get_active_account_info, get_dock_display_mode, get_masked_account_ids, get_startup_settings, + get_usage, hide_tray_window, import_accounts_full_encrypted_file, import_accounts_slim_text, + kill_codex_processes, list_accounts, open_main_window, quit_app, refresh_account_metadata, refresh_all_accounts_usage, rename_account, report_usage, set_dock_display_mode, - set_masked_account_ids, start_login, switch_account, warmup_account, warmup_all_accounts, + set_launch_at_login, set_masked_account_ids, set_start_minimized, start_login, start_reauth, + switch_account, warmup_account, warmup_all_accounts, }; use tauri::Emitter; @@ -28,13 +29,35 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + )) .setup(|app| { #[cfg(desktop)] { + // Enforce single instance: if another copy is already running, + // exit immediately. This prevents concurrent token refreshes + // across multiple instances from causing refresh_token_reused. + #[cfg(unix)] + enforce_single_instance_unix(app)?; + + #[cfg(windows)] + enforce_single_instance_windows()?; + app.handle() .plugin(tauri_plugin_updater::Builder::new().build())?; app_menu::setup(app.handle())?; tray::setup(app.handle())?; + + // Apply start-minimized: hide the main window on launch when set. + let settings = crate::auth::load_app_settings().unwrap_or_default(); + if settings.start_minimized { + use tauri::Manager; + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + } } Ok(()) }) @@ -79,6 +102,8 @@ pub fn run() { start_login, complete_login, cancel_login, + start_reauth, + complete_reauth, // Usage get_usage, get_account_usage_stats, @@ -98,6 +123,9 @@ pub fn run() { set_dock_display_mode, complete_close_behavior, ack_close_behavior_prompt, + get_startup_settings, + set_launch_at_login, + set_start_minimized, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -108,3 +136,69 @@ pub fn run() { } }); } + +/// Unix single-instance enforcement using flock(2) on a lock file. +/// The lock is held for the entire process lifetime via a leaked file descriptor. +#[cfg(unix)] +fn enforce_single_instance_unix(app: &tauri::App) -> Result<(), Box> { + use std::io::Write; + use std::os::unix::io::AsRawFd; + use tauri::Manager; + + let lock_path = app.path().app_data_dir()?.join("codex-switcher.lock"); + + if let Some(parent) = lock_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&lock_path)?; + + let _ = write!(file, "{}", std::process::id()); + + let locked = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) == 0 }; + + if !locked { + println!("[App] Another instance is already running. Exiting."); + std::process::exit(0); + } + + // Leak the file so the fd stays open (and the lock held) for the process lifetime. + std::mem::forget(file); + Ok(()) +} + +/// Windows single-instance enforcement using an exclusive lockfile. +/// We open the file with `share_mode(0)` (no sharing), which prevents any +/// second instance from opening the same file and signals it to exit. +#[cfg(windows)] +fn enforce_single_instance_windows() -> Result<(), Box> { + use std::os::windows::fs::OpenOptionsExt; + + // TEMP dir is per-user on Windows, so this naturally scopes to the user. + let lock_path = std::env::temp_dir().join("codex-switcher.lock"); + + let result = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .share_mode(0) // FILE_SHARE_NONE — exclusive, no other process can open + .open(&lock_path); + + match result { + Ok(file) => { + // We hold the exclusive lock. Leak the handle so it stays open + // (and locked) for the entire process lifetime. + std::mem::forget(file); + Ok(()) + } + Err(_) => { + // Another instance has the file open exclusively. + println!("[App] Another instance is already running. Exiting."); + std::process::exit(0); + } + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 7f6760e7..ffd45877 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -29,6 +29,7 @@ const ACCOUNTS_CHANGED_EVENT: &str = "accounts-changed"; const SWITCH_ACCOUNT_BLOCKED_EVENT: &str = "switch-account-blocked"; const ACCOUNT_ITEM_PREFIX: &str = "account:"; const OPEN_ITEM_ID: &str = "open"; +const OPEN_CODEX_ITEM_ID: &str = "open-codex"; const QUIT_ITEM_ID: &str = "quit"; const TRAY_WIDTH: f64 = 300.0; const TRAY_HEIGHT: f64 = 420.0; @@ -190,7 +191,17 @@ fn build_menu(app: &AppHandle, store: &AccountsStore) -> tauri::R .build(app)?, )?; } else { - for account in &store.accounts { + // Pin the active account at the top, then the rest in storage order. + let mut sorted = store.accounts.iter().collect::>(); + sorted.sort_by_key(|a| { + if store.active_account_id.as_deref() == Some(&a.id) { + 0 + } else { + 1 + } + }); + + for account in sorted { let label = format!("{}{}", account.name, usage_suffix(&account.id)); let item = CheckMenuItemBuilder::with_id(account_menu_id(&account.id), menu_label(&label)) @@ -206,6 +217,7 @@ fn build_menu(app: &AppHandle, store: &AccountsStore) -> tauri::R #[cfg(target_os = "macos")] menu.append(&PredefinedMenuItem::separator(app)?)?; menu.append(&MenuItemBuilder::with_id(OPEN_ITEM_ID, "Open Codex Switcher").build(app)?)?; + menu.append(&MenuItemBuilder::with_id(OPEN_CODEX_ITEM_ID, "Open Codex").build(app)?)?; menu.append(&MenuItemBuilder::with_id(QUIT_ITEM_ID, "Quit").build(app)?)?; Ok(menu) } @@ -243,6 +255,11 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { match item_id { OPEN_ITEM_ID => show_main_window(app), + OPEN_CODEX_ITEM_ID => { + tauri::async_runtime::spawn(async move { + let _ = crate::commands::open_codex_app().await; + }); + } QUIT_ITEM_ID => app.exit(0), _ => { let Some(account_id) = item_id.strip_prefix(ACCOUNT_ITEM_PREFIX) else { diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index de1abfd2..f25fd508 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -40,6 +40,14 @@ fn default_close_behavior_prompt_enabled() -> bool { true } +fn default_launch_at_login() -> bool { + false +} + +fn default_start_minimized() -> bool { + false +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct AppSettings { @@ -47,6 +55,10 @@ pub struct AppSettings { pub dock_display_mode: DockDisplayMode, #[serde(default = "default_close_behavior_prompt_enabled")] pub close_behavior_prompt_enabled: bool, + #[serde(default = "default_launch_at_login")] + pub launch_at_login: bool, + #[serde(default = "default_start_minimized")] + pub start_minimized: bool, } impl Default for AppSettings { @@ -55,6 +67,8 @@ impl Default for AppSettings { tray_display_mode: TrayDisplayMode::default(), dock_display_mode: DockDisplayMode::default(), close_behavior_prompt_enabled: true, + launch_at_login: false, + start_minimized: false, } } } @@ -349,6 +363,9 @@ pub struct WarmupSummary { pub warmed_accounts: usize, /// Account IDs whose warm-up request failed pub failed_account_ids: Vec, + /// Per-failure details: (account_name, error_message) + #[serde(default)] + pub failed_account_errors: Vec<(String, String)>, } /// Import summary for account config import operations. diff --git a/src/App.tsx b/src/App.tsx index a4f1edd4..1b3c0d2c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useAccounts } from "./hooks/useAccounts"; import { useForceCloseCodexProcesses } from "./hooks/useForceCloseCodexProcesses"; -import { AccountCard, AddAccountModal, UpdateChecker } from "./components"; +import { AccountCard, AddAccountModal, ReAuthModal, UpdateChecker } from "./components"; import type { AccountWithUsage, CodexProcessInfo, DockDisplayMode, UsageInfo } from "./types"; import { exportFullBackupFile, @@ -232,6 +232,13 @@ function App() { const [closeBehaviorPromptOpen, setCloseBehaviorPromptOpen] = useState(false); const [closeBehaviorDontAskAgain, setCloseBehaviorDontAskAgain] = useState(false); const [isCompletingCloseBehavior, setIsCompletingCloseBehavior] = useState(false); + const [launchAtLogin, setLaunchAtLogin] = useState(false); + const [startMinimized, setStartMinimized] = useState(false); + const [reAuthAccountId, setReAuthAccountId] = useState(null); + const [openAfterSwitch, setOpenAfterSwitch] = useState(() => { + try { return window.localStorage.getItem("codex-switcher-open-after-switch") === "true"; } + catch { return false; } + }); const accountsRef = useRef(accounts); const autoWarmupAccountIdsRef = useRef(autoWarmupAccountIds); const autoWarmupLedgerRef = useRef(autoWarmupLedger); @@ -490,12 +497,19 @@ function App() { // Check processes before switching const latestProcessInfo = await checkProcesses(); if (latestProcessInfo && !latestProcessInfo.can_switch) { + // Codex is running — ask the user if they want to force-close it first, + // then switch. The confirm modal already handles this flow. + setPendingTraySwitchAccountId(accountId); + setForceCloseConfirmOpen(true); return; } try { setSwitchingId(accountId); await switchAccount(accountId); + if (openAfterSwitch && isTauriRuntime()) { + void invokeBackend("open_codex_app").catch(() => {}); + } } catch (err) { console.error("Failed to switch account:", err); } finally { @@ -523,16 +537,60 @@ function App() { setRefreshSuccess(false); try { await refreshUsage(undefined, { refreshMetadata: true }); - setRefreshSuccess(true); - setTimeout(() => setRefreshSuccess(false), 2000); + // Count how many had errors after refresh + const failed = accounts.filter((a) => a.usage?.error).length; + if (failed > 0) { + const total = accounts.length; + const ok = total - failed; + const names = accounts + .filter((a) => a.usage?.error) + .map((a) => `${a.name}: ${a.usage!.error}`) + .join("\n"); + showWarmupToast( + `Refreshed ${ok}/${total}. ${failed} failed:\n${names}`, + true + ); + } else { + setRefreshSuccess(true); + setTimeout(() => setRefreshSuccess(false), 2000); + } } finally { setIsRefreshing(false); } }; + // Load startup settings on mount + useEffect(() => { + if (!isTauriRuntime()) return; + invokeBackend<[boolean, boolean]>("get_startup_settings").then(([lal, sm]) => { + setLaunchAtLogin(lal); + setStartMinimized(sm); + }).catch(console.error); + }, []); + + const handleSetLaunchAtLogin = async (enabled: boolean) => { + try { + await invokeBackend("set_launch_at_login", { enabled }); + setLaunchAtLogin(enabled); + } catch (err) { + console.error("Failed to set launch at login:", err); + } + }; + + const handleSetStartMinimized = async (enabled: boolean) => { + try { + await invokeBackend("set_start_minimized", { enabled }); + setStartMinimized(enabled); + } catch (err) { + console.error("Failed to set start minimized:", err); + } + }; + const showWarmupToast = useCallback((message: string, isError = false) => { setWarmupToast({ message, isError }); - setTimeout(() => setWarmupToast(null), 2500); + // Give error toasts with multi-line details longer to read + const duration = isError && message.includes("\n") ? 8000 : 2500; + setTimeout(() => setWarmupToast(null), duration); }, []); const formatWarmupError = useCallback((err: unknown) => { @@ -683,6 +741,9 @@ function App() { await switchAccount(accountId); setPendingTraySwitchAccountId(null); showWarmupToast("Switched account after force closing Codex."); + if (openAfterSwitch && isTauriRuntime()) { + void invokeBackend("open_codex_app").catch(() => {}); + } } catch (err) { console.error("Failed to switch account after force close:", err); setPendingTraySwitchAccountId(null); @@ -742,8 +803,12 @@ function App() { }` ); } else { + // Build a readable failure summary: "name: reason" per failed account + const details = (summary.failed_account_errors ?? []) + .map(([name, err]) => `• ${name}: ${err}`) + .join("\n"); showWarmupToast( - `Warmed ${summary.warmed_accounts}/${summary.total_accounts}. Failed: ${summary.failed_account_ids.length}`, + `Warmed ${summary.warmed_accounts}/${summary.total_accounts}. ${summary.failed_account_ids.length} failed:\n${details}`, true ); } @@ -1450,7 +1515,7 @@ function App() { onClick={() => setIsActionsMenuOpen((prev) => !prev)} className="h-10 px-4 py-2 text-sm font-medium rounded-lg bg-gray-900 text-white transition-colors hover:bg-gray-800 dark:bg-black dark:hover:bg-neutral-900 shrink-0 whitespace-nowrap" > - Account ▾ + Settings ▾ {isActionsMenuOpen && (
@@ -1503,6 +1568,42 @@ function App() { > {isImportingFull ? "Importing..." : "Import Full Encrypted File"} + {isTauriRuntime() && ( + <> +
+ + + + + )}
)}
@@ -1560,6 +1661,7 @@ function App() { refreshSingleUsage(activeAccount.id, { refreshMetadata: true }) } onRename={(newName) => renameAccount(activeAccount.id, newName)} + onReAuth={activeAccount.auth_mode === "chat_g_p_t" ? () => setReAuthAccountId(activeAccount.id) : undefined} switching={switchingId === activeAccount.id} switchDisabled={hasRunningProcesses ?? false} warmingUp={ @@ -1652,6 +1754,7 @@ function App() { refreshSingleUsage(account.id, { refreshMetadata: true }) } onRename={(newName) => renameAccount(account.id, newName)} + onReAuth={account.auth_mode === "chat_g_p_t" ? () => setReAuthAccountId(account.id) : undefined} switching={switchingId === account.id} switchDisabled={hasRunningProcesses ?? false} warmingUp={ @@ -1690,7 +1793,7 @@ function App() { {/* Warm-up Toast */} {warmupToast && (
)} + {/* Re-Auth Modal */} + {reAuthAccountId && (() => { + const account = accounts.find((a) => a.id === reAuthAccountId); + if (!account) return null; + return ( + setReAuthAccountId(null)} + onSuccess={async () => { + setReAuthAccountId(null); + await refreshSingleUsage(reAuthAccountId, { refreshMetadata: true }); + }} + /> + ); + })()} + {/* Add Account Modal */} ) : ( - accounts.map((account) => { - const plan = formatPlan(account.plan_type); - const usage = usageById[account.id]; - const stats = statsById[account.id]; - const windows = - usage && !usage.error - ? ([ - { - label: "Session", - used: usage.primary_used_percent, - resetAt: usage.primary_resets_at, - }, - { - label: "Weekly", - used: usage.secondary_used_percent, - resetAt: usage.secondary_resets_at, - }, - ].filter((w) => w.used != null) as { - label: string; - used: number; - resetAt: number | null; - }[]) - : []; + (() => { + // Always pin the active account at the top, followed by others in + // their original backend order. + const active = accounts.filter((a) => a.is_active); + const others = accounts.filter((a) => !a.is_active); + const sorted = [...active, ...others]; - return ( - - ); - }) + + ); + }); + })() )}
@@ -551,6 +566,16 @@ function TrayMenu() { > Open Codex Switcher + + + )} + {/* Last refresh time */}
@@ -431,15 +470,15 @@ export function AccountCard({ ) : ( )} +
+ + {/* Content */} +
+ {/* Expiry warning */} +
+ +

+ This account's session has expired. Sign in again to refresh the + credentials — your account settings and history will be preserved. +

+
+ + {oauthPending ? ( +
+
+

+ Waiting for browser login... +

+

+ Open the link below in your browser to complete authentication: +

+
+ + + +
+ {!tauriRuntime && ( +

+ OAuth login must finish on the same host machine because the + callback redirects to localhost. +

+ )} +
+ ) : ( +

+ Click the button below to generate a login link. You will be asked + to sign in with ChatGPT in your browser. +

+ )} + + {error && ( +
+ {error} +
+ )} +
+ + {/* Footer */} +
+ + {!oauthPending && ( + + )} +
+
+
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 072fe112..cccadeb5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,5 +1,6 @@ export { AccountCard } from "./AccountCard"; export { UsageBar } from "./UsageBar"; export { AddAccountModal } from "./AddAccountModal"; +export { ReAuthModal } from "./ReAuthModal"; export { AccountUsageStats } from "./AccountUsageStats"; export { UpdateChecker } from "./UpdateChecker"; diff --git a/src/types/index.ts b/src/types/index.ts index 57c30bd8..6756f703 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -115,6 +115,7 @@ export interface WarmupSummary { total_accounts: number; warmed_accounts: number; failed_account_ids: string[]; + failed_account_errors: [string, string][]; // [account_name, error_message] } export interface ImportAccountsSummary {