From b748388987e741a1f624d095288f75a946c75dfe Mon Sep 17 00:00:00 2001 From: jhm1909 Date: Sun, 10 May 2026 13:55:16 +0900 Subject: [PATCH 1/2] feat: integrate cockpit architecture patterns and multi-instance management --- src-tauri/Cargo.lock | 11 + src-tauri/Cargo.toml | 3 + src-tauri/src/api/mod.rs | 1 + src-tauri/src/api/usage_poller.rs | 146 ++++++++++ src-tauri/src/auth/atomic_write.rs | 251 ++++++++++++++++ src-tauri/src/auth/instance_manager.rs | 379 +++++++++++++++++++++++++ src-tauri/src/auth/mod.rs | 3 + src-tauri/src/auth/storage.rs | 7 +- src-tauri/src/auth/switcher.rs | 3 +- src-tauri/src/auth/token_keeper.rs | 96 +++++++ src-tauri/src/commands/instance.rs | 53 ++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/usage.rs | 21 ++ src-tauri/src/lib.rs | 28 +- src/App.tsx | 51 +++- src/components/InstancePanel.tsx | 247 ++++++++++++++++ src/components/index.ts | 1 + src/hooks/useAccounts.ts | 48 +++- src/hooks/useInstances.ts | 94 ++++++ src/types/index.ts | 9 + 20 files changed, 1420 insertions(+), 34 deletions(-) create mode 100644 src-tauri/src/api/usage_poller.rs create mode 100644 src-tauri/src/auth/atomic_write.rs create mode 100644 src-tauri/src/auth/instance_manager.rs create mode 100644 src-tauri/src/auth/token_keeper.rs create mode 100644 src-tauri/src/commands/instance.rs create mode 100644 src/components/InstancePanel.tsx create mode 100644 src/hooks/useInstances.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5340fa16..8f7a2051 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -535,6 +535,7 @@ dependencies = [ "dirs", "flate2", "futures", + "junction", "pbkdf2", "rand 0.9.2", "reqwest 0.12.28", @@ -2177,6 +2178,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "junction" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + [[package]] name = "keyboard-types" version = "0.7.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3e505a34..8c39c932 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,3 +39,6 @@ url = "2" flate2 = "1" chacha20poly1305 = "0.10" pbkdf2 = "0.12" + +[target.'cfg(windows)'.dependencies] +junction = "1" diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs index 06091bae..2157aa05 100644 --- a/src-tauri/src/api/mod.rs +++ b/src-tauri/src/api/mod.rs @@ -1,5 +1,6 @@ //! API client module pub mod usage; +pub mod usage_poller; pub use usage::*; diff --git a/src-tauri/src/api/usage_poller.rs b/src-tauri/src/api/usage_poller.rs new file mode 100644 index 00000000..6c1ab855 --- /dev/null +++ b/src-tauri/src/api/usage_poller.rs @@ -0,0 +1,146 @@ +//! Background Usage Poller — auto-refreshes quota data at configurable intervals. +//! +//! Inspired by cockpit-tools `codex_quota.rs`. +//! Periodically polls `wham/usage` for all ChatGPT accounts and emits +//! Tauri events so the frontend can update in realtime. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use tauri::{AppHandle, Emitter}; +use tokio::sync::Mutex; +use tokio::time::{sleep, Duration}; + +use super::usage::refresh_all_usage; +use crate::auth::load_accounts; +use crate::types::AuthData; + +/// Default polling interval in minutes. +const DEFAULT_INTERVAL_MINUTES: u64 = 5; + +/// Minimum allowed interval (prevent abuse). +const MIN_INTERVAL_MINUTES: u64 = 1; + +/// Global state for the poller. +struct PollerState { + running: AtomicBool, + interval_minutes: Mutex, + stop_signal: Mutex>>, +} + +static POLLER: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn get_poller() -> &'static PollerState { + POLLER.get_or_init(|| PollerState { + running: AtomicBool::new(false), + interval_minutes: Mutex::new(DEFAULT_INTERVAL_MINUTES), + stop_signal: Mutex::new(None), + }) +} + +/// Start the auto-usage-poll background loop. +/// Returns `true` if started, `false` if already running. +pub async fn start_polling(app_handle: AppHandle, interval_minutes: Option) -> bool { + let poller = get_poller(); + + if poller.running.load(Ordering::SeqCst) { + println!("[UsagePoller] Already running, skipping duplicate start"); + return false; + } + + let interval = interval_minutes + .unwrap_or(DEFAULT_INTERVAL_MINUTES) + .max(MIN_INTERVAL_MINUTES); + *poller.interval_minutes.lock().await = interval; + + let (tx, rx) = tokio::sync::watch::channel(false); + *poller.stop_signal.lock().await = Some(tx); + poller.running.store(true, Ordering::SeqCst); + + println!("[UsagePoller] Starting auto-poll every {interval} minutes"); + + tauri::async_runtime::spawn(async move { + run_poll_loop(app_handle, rx).await; + }); + + true +} + +/// Stop the auto-usage-poll background loop. +/// Returns `true` if stopped, `false` if wasn't running. +pub async fn stop_polling() -> bool { + let poller = get_poller(); + + if !poller.running.load(Ordering::SeqCst) { + return false; + } + + if let Some(tx) = poller.stop_signal.lock().await.take() { + let _ = tx.send(true); + } + poller.running.store(false, Ordering::SeqCst); + println!("[UsagePoller] Stopped"); + true +} + +/// Check if the poller is currently active. +pub fn is_running() -> bool { + get_poller().running.load(Ordering::SeqCst) +} + +async fn run_poll_loop(app_handle: AppHandle, mut stop_rx: tokio::sync::watch::Receiver) { + loop { + let interval = { + let minutes = get_poller().interval_minutes.lock().await; + Duration::from_secs(*minutes * 60) + }; + + // Wait for the interval OR a stop signal + tokio::select! { + _ = sleep(interval) => {} + _ = stop_rx.changed() => { + println!("[UsagePoller] Received stop signal"); + break; + } + } + + // Check if we should stop + if *stop_rx.borrow() { + break; + } + + println!("[UsagePoller] Auto-polling usage for all accounts..."); + + let accounts = match load_accounts() { + Ok(store) => store.accounts, + Err(err) => { + println!("[UsagePoller] Failed to load accounts: {err}"); + continue; + } + }; + + // Only poll ChatGPT accounts (API key accounts don't have usage info) + let chatgpt_accounts: Vec<_> = accounts + .into_iter() + .filter(|a| matches!(a.auth_data, AuthData::ChatGPT { .. })) + .collect(); + + if chatgpt_accounts.is_empty() { + continue; + } + + let results = refresh_all_usage(&chatgpt_accounts).await; + + // Emit event to frontend + if let Err(err) = app_handle.emit("usage-auto-refreshed", &results) { + println!("[UsagePoller] Failed to emit event: {err}"); + } else { + println!( + "[UsagePoller] Emitted usage-auto-refreshed ({} accounts)", + results.len() + ); + } + } + + get_poller().running.store(false, Ordering::SeqCst); + println!("[UsagePoller] Loop exited"); +} diff --git a/src-tauri/src/auth/atomic_write.rs b/src-tauri/src/auth/atomic_write.rs new file mode 100644 index 00000000..f30825f7 --- /dev/null +++ b/src-tauri/src/auth/atomic_write.rs @@ -0,0 +1,251 @@ +//! Atomic file write helpers — crash-safe writes with automatic backup. +//! +//! Adapted from cockpit-tools `atomic_write.rs`. +//! Key patterns: +//! 1. Write to temp file → `fs::rename()` (atomic on same filesystem) +//! 2. Auto-create `.bak` before overwriting existing files +//! 3. `parse_json_with_auto_restore()` — auto-recover from corrupt JSON + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{bail, Context, Result}; +use serde::de::DeserializeOwned; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn build_backup_path(path: &Path) -> Result { + let parent = path.parent().context("Cannot determine parent directory")?; + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .context("Cannot determine file name")?; + Ok(parent.join(format!("{file_name}.bak"))) +} + +fn build_temp_path(parent: &Path, target: &Path, suffix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let base = target + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file"); + parent.join(format!(".{base}.tmp.{}.{nanos}.{suffix}", std::process::id())) +} + +fn is_json_path(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("json")) + .unwrap_or(false) +} + +/// Returns `true` if `content` looks valid enough to use as a backup source. +fn content_is_safe_backup(path: &Path, content: &str) -> bool { + if content.trim().is_empty() || content.as_bytes().contains(&0) { + return false; + } + if !is_json_path(path) { + return true; + } + // For JSON files, verify the content actually parses + serde_json::from_str::(content).is_ok() +} + +/// Write bytes to a temp file and `sync_all` before returning. +fn write_synced_temp(temp_path: &Path, content: &[u8]) -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(temp_path) + .with_context(|| format!("Failed to create temp file: {}", temp_path.display()))?; + file.write_all(content) + .with_context(|| format!("Failed to write temp file: {}", temp_path.display()))?; + file.sync_all() + .with_context(|| format!("Failed to sync temp file: {}", temp_path.display()))?; + Ok(()) +} + +fn write_atomic_internal(path: &Path, content: &str, create_backup: bool) -> Result<()> { + let parent = path.parent().context("Cannot determine parent directory")?; + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + + // Backup existing file (only if it's valid content) + if create_backup && path.exists() { + let backup_path = build_backup_path(path)?; + if let Ok(existing) = fs::read_to_string(path) { + if content_is_safe_backup(path, &existing) { + // Write backup atomically too (but without creating a backup of the backup) + write_atomic_internal(&backup_path, &existing, false)?; + } + } + } + + // Write new content via temp → rename + let temp_path = build_temp_path(parent, path, "atomic"); + if let Err(err) = write_synced_temp(&temp_path, content.as_bytes()) { + let _ = fs::remove_file(&temp_path); + return Err(err); + } + if let Err(err) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + bail!( + "Failed to rename temp file to target: {} → {} ({})", + temp_path.display(), + path.display(), + err + ); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Atomically write a string to `path`, creating a `.bak` backup of the previous content. +pub fn write_string_atomic(path: &Path, content: &str) -> Result<()> { + write_atomic_internal(path, content, true) +} + +/// Restore the target file from its `.bak` backup. +/// Returns `Ok(true)` if restore succeeded, `Ok(false)` if no valid backup exists. +pub fn restore_from_backup(path: &Path) -> Result { + let backup_path = build_backup_path(path)?; + if !backup_path.exists() { + return Ok(false); + } + + let backup_content = fs::read_to_string(&backup_path) + .with_context(|| format!("Failed to read backup: {}", backup_path.display()))?; + if !content_is_safe_backup(path, &backup_content) { + return Ok(false); + } + write_atomic_internal(path, &backup_content, false)?; + Ok(true) +} + +/// Parse JSON from `content`. If parsing fails and a `.bak` file exists, +/// automatically restore from backup and retry. +pub fn parse_json_with_auto_restore( + path: &Path, + content: &str, +) -> Result { + match serde_json::from_str::(content) { + Ok(value) => Ok(value), + Err(parse_err) => { + println!( + "[AtomicWrite] JSON parse failed for {}: {parse_err}. Attempting backup restore...", + path.display() + ); + + if restore_from_backup(path)? { + let restored = fs::read_to_string(path).with_context(|| { + format!("Failed to read restored file: {}", path.display()) + })?; + serde_json::from_str::(&restored).with_context(|| { + format!( + "Original parse failed: {parse_err}; auto-restored from .bak but still failed" + ) + }) + } else { + bail!( + "JSON parse failed for {} and no valid backup: {parse_err}", + path.display() + ) + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn make_temp_dir(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("{prefix}_{nanos}")); + fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + #[test] + fn atomic_write_creates_backup() { + let dir = make_temp_dir("aw_backup"); + let path = dir.join("data.json"); + let backup = build_backup_path(&path).unwrap(); + + write_string_atomic(&path, r#"{"v":1}"#).unwrap(); + assert!(!backup.exists(), "no backup on first write"); + + write_string_atomic(&path, r#"{"v":2}"#).unwrap(); + assert_eq!(fs::read_to_string(&backup).unwrap(), r#"{"v":1}"#); + assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"v":2}"#); + } + + #[test] + fn corrupt_file_preserves_good_backup() { + let dir = make_temp_dir("aw_corrupt"); + let path = dir.join("data.json"); + let backup = build_backup_path(&path).unwrap(); + + write_string_atomic(&path, r#"{"v":1}"#).unwrap(); + write_string_atomic(&path, r#"{"v":2}"#).unwrap(); + // backup should be v1 + assert_eq!(fs::read_to_string(&backup).unwrap(), r#"{"v":1}"#); + + // Corrupt the file with null bytes + fs::write(&path, vec![0u8; 32]).unwrap(); + write_string_atomic(&path, r#"{"v":3}"#).unwrap(); + + // Backup should still be v1 (corrupt file was not a safe backup source) + assert_eq!(fs::read_to_string(&backup).unwrap(), r#"{"v":1}"#); + assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"v":3}"#); + } + + #[test] + fn restore_rejects_invalid_backup() { + let dir = make_temp_dir("aw_restore"); + let path = dir.join("state.json"); + let backup = build_backup_path(&path).unwrap(); + + fs::write(&path, r#"{"v":1}"#).unwrap(); + fs::write(&backup, vec![0u8; 16]).unwrap(); + + assert!(!restore_from_backup(&path).unwrap()); + assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"v":1}"#); + } + + #[test] + fn parse_json_auto_restore_works() { + let dir = make_temp_dir("aw_autorestore"); + let path = dir.join("cfg.json"); + + // Write a valid first version, then a second (creating backup of first) + write_string_atomic(&path, r#"{"version":1}"#).unwrap(); + write_string_atomic(&path, r#"{"version":2}"#).unwrap(); + + // Now corrupt the current file + fs::write(&path, "NOT VALID JSON!!!").unwrap(); + + // parse_json_with_auto_restore should auto-restore v1 from backup + let val: serde_json::Value = + parse_json_with_auto_restore(&path, &fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(val["version"], 1); + } +} diff --git a/src-tauri/src/auth/instance_manager.rs b/src-tauri/src/auth/instance_manager.rs new file mode 100644 index 00000000..1e3391e4 --- /dev/null +++ b/src-tauri/src/auth/instance_manager.rs @@ -0,0 +1,379 @@ +//! Multi-instance manager — isolated Codex config directories with shared symlinks. +//! +//! Inspired by cockpit-tools `codex_instance.rs`. +//! Each instance gets its own `user_data_dir` (a copy of `~/.codex/`), +//! with `skills/` and `rules/` symlinked to a shared global directory. + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::atomic_write::{parse_json_with_auto_restore, write_string_atomic}; +use super::switcher::get_codex_home; + +// --------------------------------------------------------------------------- +// Data types +// --------------------------------------------------------------------------- + +/// A Codex instance profile. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstanceProfile { + pub id: String, + pub name: String, + /// Absolute path to this instance's data directory. + pub user_data_dir: String, + /// Which account ID to bind to this instance (optional). + pub bind_account_id: Option, + pub created_at: i64, + pub last_used_at: Option, +} + +/// Store for all instances. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct InstanceStore { + pub instances: Vec, + pub active_instance_id: Option, +} + +// --------------------------------------------------------------------------- +// Shared resource directories to symlink +// --------------------------------------------------------------------------- + +const SHARED_DIRS: &[&str] = &["skills", "rules"]; + +// --------------------------------------------------------------------------- +// Storage paths +// --------------------------------------------------------------------------- + +fn get_instances_file() -> Result { + let config_dir = super::storage::get_config_dir()?; + Ok(config_dir.join("codex_instances.json")) +} + +pub fn load_instances() -> Result { + let path = get_instances_file()?; + if !path.exists() { + return Ok(InstanceStore::default()); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read instances file: {}", path.display()))?; + parse_json_with_auto_restore(&path, &content) + .with_context(|| format!("Failed to parse instances file: {}", path.display())) +} + +fn save_instances(store: &InstanceStore) -> Result<()> { + let path = get_instances_file()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(store)?; + write_string_atomic(&path, &content) + .with_context(|| format!("Failed to write instances file: {}", path.display())) +} + +// --------------------------------------------------------------------------- +// CRUD operations +// --------------------------------------------------------------------------- + +/// Create a new isolated Codex instance by copying `~/.codex/` to a new directory. +pub fn create_instance(name: String, user_data_dir: String) -> Result { + let name = name.trim().to_string(); + if name.is_empty() { + bail!("Instance name cannot be empty"); + } + let user_data_dir = user_data_dir.trim().to_string(); + if user_data_dir.is_empty() { + bail!("Instance directory cannot be empty"); + } + + let mut store = load_instances()?; + ensure_unique(&store, &name, &user_data_dir, None)?; + + let target_path = PathBuf::from(&user_data_dir); + let codex_home = get_codex_home()?; + + // Copy default codex home to the new instance directory + if codex_home.exists() { + copy_dir_recursive(&codex_home, &target_path)?; + } else { + fs::create_dir_all(&target_path) + .with_context(|| format!("Failed to create instance dir: {}", target_path.display()))?; + } + + // Set up shared symlinks + setup_shared_symlinks(&target_path, &codex_home)?; + + let instance = InstanceProfile { + id: Uuid::new_v4().to_string(), + name, + user_data_dir, + bind_account_id: None, + created_at: Utc::now().timestamp_millis(), + last_used_at: None, + }; + + store.instances.push(instance.clone()); + save_instances(&store)?; + Ok(instance) +} + +/// Create a new empty instance (no copying from default). +pub fn create_empty_instance(name: String, user_data_dir: String) -> Result { + let name = name.trim().to_string(); + if name.is_empty() { + bail!("Instance name cannot be empty"); + } + let user_data_dir = user_data_dir.trim().to_string(); + if user_data_dir.is_empty() { + bail!("Instance directory cannot be empty"); + } + + let mut store = load_instances()?; + ensure_unique(&store, &name, &user_data_dir, None)?; + + let target_path = PathBuf::from(&user_data_dir); + fs::create_dir_all(&target_path) + .with_context(|| format!("Failed to create instance dir: {}", target_path.display()))?; + + let codex_home = get_codex_home()?; + setup_shared_symlinks(&target_path, &codex_home)?; + + let instance = InstanceProfile { + id: Uuid::new_v4().to_string(), + name, + user_data_dir, + bind_account_id: None, + created_at: Utc::now().timestamp_millis(), + last_used_at: None, + }; + + store.instances.push(instance.clone()); + save_instances(&store)?; + Ok(instance) +} + +/// List all instances. +pub fn list_instances() -> Result> { + let store = load_instances()?; + Ok(store.instances) +} + +/// Get the currently active instance. +pub fn get_active_instance() -> Result> { + let store = load_instances()?; + let active_id = match &store.active_instance_id { + Some(id) => id, + None => return Ok(None), + }; + Ok(store.instances.into_iter().find(|i| i.id == *active_id)) +} + +/// Set the active instance and update CODEX_HOME accordingly. +pub fn set_active_instance(instance_id: &str) -> Result { + let mut store = load_instances()?; + let instance = store + .instances + .iter() + .find(|i| i.id == instance_id) + .context("Instance not found")? + .clone(); + + store.active_instance_id = Some(instance_id.to_string()); + + // Touch last_used_at + if let Some(inst) = store.instances.iter_mut().find(|i| i.id == instance_id) { + inst.last_used_at = Some(Utc::now().timestamp_millis()); + } + + save_instances(&store)?; + + // Set CODEX_HOME environment variable for this process + std::env::set_var("CODEX_HOME", &instance.user_data_dir); + println!( + "[InstanceManager] Set CODEX_HOME to: {}", + instance.user_data_dir + ); + + Ok(instance) +} + +/// Remove an instance (optionally delete its data directory). +pub fn remove_instance(instance_id: &str, delete_data: bool) -> Result<()> { + let mut store = load_instances()?; + let instance = store + .instances + .iter() + .find(|i| i.id == instance_id) + .context("Instance not found")? + .clone(); + + store.instances.retain(|i| i.id != instance_id); + + // Clear active if we removed it + if store.active_instance_id.as_deref() == Some(instance_id) { + store.active_instance_id = store.instances.first().map(|i| i.id.clone()); + } + + save_instances(&store)?; + + if delete_data { + let path = PathBuf::from(&instance.user_data_dir); + if path.exists() { + fs::remove_dir_all(&path).with_context(|| { + format!("Failed to delete instance data: {}", path.display()) + })?; + } + } + + Ok(()) +} + +/// Bind an account to an instance. +pub fn bind_account(instance_id: &str, account_id: Option) -> Result { + let mut store = load_instances()?; + let instance = store + .instances + .iter_mut() + .find(|i| i.id == instance_id) + .context("Instance not found")?; + + instance.bind_account_id = account_id; + let updated = instance.clone(); + save_instances(&store)?; + Ok(updated) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn ensure_unique( + store: &InstanceStore, + name: &str, + user_data_dir: &str, + exclude_id: Option<&str>, +) -> Result<()> { + let mut names = HashSet::new(); + let mut dirs = HashSet::new(); + for inst in &store.instances { + if exclude_id == Some(inst.id.as_str()) { + continue; + } + names.insert(inst.name.to_lowercase()); + dirs.insert(inst.user_data_dir.to_lowercase()); + } + if names.contains(&name.to_lowercase()) { + bail!("An instance with name '{}' already exists", name); + } + if dirs.contains(&user_data_dir.to_lowercase()) { + bail!("An instance with directory '{}' already exists", user_data_dir); + } + Ok(()) +} + +/// Create symlinks for shared directories (`skills/`, `rules/`). +fn setup_shared_symlinks(instance_dir: &Path, default_codex_home: &Path) -> Result<()> { + for dir_name in SHARED_DIRS { + let global_dir = default_codex_home.join(dir_name); + let instance_link = instance_dir.join(dir_name); + + // Ensure the global shared directory exists + fs::create_dir_all(&global_dir).with_context(|| { + format!("Failed to create shared directory: {}", global_dir.display()) + })?; + + // Remove the copied directory if it exists (we'll replace with symlink) + if instance_link.exists() { + let meta = fs::symlink_metadata(&instance_link)?; + if meta.is_dir() && !meta.file_type().is_symlink() { + // Real directory (from copying) — remove it + fs::remove_dir_all(&instance_link).with_context(|| { + format!("Failed to remove copied dir: {}", instance_link.display()) + })?; + } else if meta.file_type().is_symlink() { + // Already a symlink — skip + continue; + } + } + + // Create the symlink + create_directory_symlink(&global_dir, &instance_link)?; + } + Ok(()) +} + +/// Create a directory symlink (platform-specific). +fn create_directory_symlink(target: &Path, link: &Path) -> Result<()> { + #[cfg(windows)] + { + // On Windows, try junction first (no admin rights needed), fall back to symlink + if junction::create(target, link).is_ok() { + println!( + "[InstanceManager] Created junction: {} -> {}", + link.display(), + target.display() + ); + return Ok(()); + } + // Fallback: directory symlink (may need admin/developer mode) + std::os::windows::fs::symlink_dir(target, link).with_context(|| { + format!( + "Failed to create symlink: {} -> {}. Try enabling Developer Mode in Windows Settings.", + link.display(), + target.display() + ) + })?; + } + + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link).with_context(|| { + format!( + "Failed to create symlink: {} -> {}", + link.display(), + target.display() + ) + })?; + } + + println!( + "[InstanceManager] Created symlink: {} -> {}", + link.display(), + target.display() + ); + Ok(()) +} + +/// Recursively copy a directory. +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + if !src.exists() { + bail!("Source directory does not exist: {}", src.display()); + } + + fs::create_dir_all(dst) + .with_context(|| format!("Failed to create target directory: {}", dst.display()))?; + + for entry in fs::read_dir(src) + .with_context(|| format!("Failed to read source directory: {}", src.display()))? + { + let entry = entry?; + let file_type = entry.file_type()?; + let target = dst.join(entry.file_name()); + + if file_type.is_dir() { + copy_dir_recursive(&entry.path(), &target)?; + } else if file_type.is_file() { + fs::copy(entry.path(), &target).with_context(|| { + format!("Failed to copy file: {}", entry.path().display()) + })?; + } + // Skip symlinks during copy — they'll be recreated + } + + Ok(()) +} diff --git a/src-tauri/src/auth/mod.rs b/src-tauri/src/auth/mod.rs index 88271725..380f8c67 100644 --- a/src-tauri/src/auth/mod.rs +++ b/src-tauri/src/auth/mod.rs @@ -1,8 +1,11 @@ //! Authentication module +pub mod atomic_write; +pub mod instance_manager; pub mod oauth_server; pub mod storage; pub mod switcher; +pub mod token_keeper; pub mod token_refresh; pub use oauth_server::*; diff --git a/src-tauri/src/auth/storage.rs b/src-tauri/src/auth/storage.rs index 0ee5573b..ba4fa614 100644 --- a/src-tauri/src/auth/storage.rs +++ b/src-tauri/src/auth/storage.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; +use super::atomic_write::{parse_json_with_auto_restore, write_string_atomic}; use crate::types::{AccountsStore, AuthData, StoredAccount}; /// Get the path to the codex-switcher config directory @@ -30,7 +31,8 @@ pub fn load_accounts() -> Result { let content = fs::read_to_string(&path) .with_context(|| format!("Failed to read accounts file: {}", path.display()))?; - let store: AccountsStore = serde_json::from_str(&content) + // Auto-restore from .bak if JSON is corrupted + let store: AccountsStore = parse_json_with_auto_restore(&path, &content) .with_context(|| format!("Failed to parse accounts file: {}", path.display()))?; Ok(store) @@ -49,7 +51,8 @@ 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) + // Atomic write: temp file → rename, with auto .bak backup + write_string_atomic(&path, &content) .with_context(|| format!("Failed to write accounts file: {}", path.display()))?; // Set restrictive permissions on Unix diff --git a/src-tauri/src/auth/switcher.rs b/src-tauri/src/auth/switcher.rs index e213574e..832923d8 100644 --- a/src-tauri/src/auth/switcher.rs +++ b/src-tauri/src/auth/switcher.rs @@ -40,7 +40,8 @@ pub fn switch_to_account(account: &StoredAccount) -> Result<()> { let content = serde_json::to_string_pretty(&auth_json).context("Failed to serialize auth.json")?; - fs::write(&auth_path, content) + // Atomic write: temp file → rename, with auto .bak backup + super::atomic_write::write_string_atomic(&auth_path, &content) .with_context(|| format!("Failed to write auth.json: {}", auth_path.display()))?; // Set restrictive permissions on Unix diff --git a/src-tauri/src/auth/token_keeper.rs b/src-tauri/src/auth/token_keeper.rs new file mode 100644 index 00000000..5bb17188 --- /dev/null +++ b/src-tauri/src/auth/token_keeper.rs @@ -0,0 +1,96 @@ +//! Background Token Keeper — proactively refreshes ChatGPT tokens before expiry. +//! +//! Inspired by cockpit-tools `provider_token_keeper.rs`. +//! Runs a background loop every `TICK_SECONDS` that checks all ChatGPT accounts +//! and refreshes tokens that are expired or near-expiry. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use tokio::time::{sleep, Duration}; + +use super::storage::load_accounts; +use super::token_refresh::ensure_chatgpt_tokens_fresh; +use crate::types::AuthData; + +/// How often the keeper checks tokens (seconds). +const TICK_SECONDS: u64 = 60; + +/// After a failed refresh, back off for this many seconds before retrying the same account. +const BACKOFF_SECONDS: u64 = 15 * 60; // 15 minutes + +/// Global flag to ensure only one keeper loop runs. +static STARTED: AtomicBool = AtomicBool::new(false); + +/// Start the background token keeper loop. +/// +/// This should be called once during app setup. If called multiple times, +/// subsequent calls are no-ops. +/// +/// The optional `app_handle` can be used in the future to emit Tauri events +/// when tokens are refreshed. +pub fn start(app_handle: tauri::AppHandle) { + if STARTED.swap(true, Ordering::SeqCst) { + println!("[TokenKeeper] Already running, skipping duplicate start"); + return; + } + + println!("[TokenKeeper] Starting background token refresh loop (tick={TICK_SECONDS}s)"); + + tauri::async_runtime::spawn(async move { + run_loop(app_handle).await; + }); +} + +async fn run_loop(_app_handle: tauri::AppHandle) { + // Track per-account backoff timestamps + let mut backoff_until: std::collections::HashMap = + std::collections::HashMap::new(); + + loop { + sleep(Duration::from_secs(TICK_SECONDS)).await; + + let accounts = match load_accounts() { + Ok(store) => store.accounts, + Err(err) => { + println!("[TokenKeeper] Failed to load accounts: {err}"); + continue; + } + }; + + let chatgpt_accounts: Vec<_> = accounts + .iter() + .filter(|a| matches!(a.auth_data, AuthData::ChatGPT { .. })) + .collect(); + + if chatgpt_accounts.is_empty() { + continue; + } + + for account in chatgpt_accounts { + // Skip accounts in backoff period + if let Some(&until) = backoff_until.get(&account.id) { + if std::time::Instant::now() < until { + continue; + } + } + + match ensure_chatgpt_tokens_fresh(account).await { + Ok(_updated) => { + // Clear any previous backoff + backoff_until.remove(&account.id); + } + Err(err) => { + println!( + "[TokenKeeper] Failed to refresh tokens for '{}': {err}", + account.name + ); + // Set backoff + backoff_until.insert( + account.id.clone(), + std::time::Instant::now() + std::time::Duration::from_secs(BACKOFF_SECONDS), + ); + } + } + } + } +} diff --git a/src-tauri/src/commands/instance.rs b/src-tauri/src/commands/instance.rs new file mode 100644 index 00000000..ef121e79 --- /dev/null +++ b/src-tauri/src/commands/instance.rs @@ -0,0 +1,53 @@ +//! Instance management Tauri commands + +use crate::auth::instance_manager::{ + self, InstanceProfile, +}; + +/// List all Codex instances +#[tauri::command] +pub fn list_instances() -> Result, String> { + instance_manager::list_instances().map_err(|e| e.to_string()) +} + +/// Create a new Codex instance (copies from default ~/.codex/) +#[tauri::command] +pub fn create_instance(name: String, user_data_dir: String) -> Result { + instance_manager::create_instance(name, user_data_dir).map_err(|e| e.to_string()) +} + +/// Create an empty Codex instance +#[tauri::command] +pub fn create_empty_instance( + name: String, + user_data_dir: String, +) -> Result { + instance_manager::create_empty_instance(name, user_data_dir).map_err(|e| e.to_string()) +} + +/// Set the active instance (sets CODEX_HOME env var) +#[tauri::command] +pub fn set_active_instance(instance_id: String) -> Result { + instance_manager::set_active_instance(&instance_id).map_err(|e| e.to_string()) +} + +/// Get the currently active instance +#[tauri::command] +pub fn get_active_instance() -> Result, String> { + instance_manager::get_active_instance().map_err(|e| e.to_string()) +} + +/// Remove an instance +#[tauri::command] +pub fn remove_instance(instance_id: String, delete_data: bool) -> Result<(), String> { + instance_manager::remove_instance(&instance_id, delete_data).map_err(|e| e.to_string()) +} + +/// Bind an account to an instance +#[tauri::command] +pub fn bind_instance_account( + instance_id: String, + account_id: Option, +) -> Result { + instance_manager::bind_account(&instance_id, account_id).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 71b49c75..6227d372 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,11 +1,13 @@ //! Tauri commands module pub mod account; +pub mod instance; pub mod oauth; pub mod process; pub mod usage; pub use account::*; +pub use instance::*; pub use oauth::*; pub use process::*; pub use usage::*; diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 4c2acaad..f449db5e 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -99,3 +99,24 @@ pub async fn warmup_all_accounts() -> Result { failed_account_ids, }) } + +/// Start automatic usage polling in the background (interval in minutes) +#[tauri::command] +pub async fn start_auto_usage_poll( + app_handle: tauri::AppHandle, + interval_minutes: Option, +) -> Result { + Ok(crate::api::usage_poller::start_polling(app_handle, interval_minutes).await) +} + +/// Stop automatic usage polling +#[tauri::command] +pub async fn stop_auto_usage_poll() -> Result { + Ok(crate::api::usage_poller::stop_polling().await) +} + +/// Check if automatic usage polling is active +#[tauri::command] +pub fn is_auto_usage_poll_active() -> bool { + crate::api::usage_poller::is_running() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a357b58f..d2e2ae2f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,12 +7,14 @@ pub mod types; pub mod web; use commands::{ - add_account_from_file, cancel_login, check_codex_processes, complete_login, delete_account, + add_account_from_file, bind_instance_account, cancel_login, check_codex_processes, + complete_login, create_empty_instance, create_instance, delete_account, 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_account_metadata, refresh_all_accounts_usage, - rename_account, set_masked_account_ids, start_login, switch_account, warmup_account, - warmup_all_accounts, + get_active_instance, get_masked_account_ids, get_usage, import_accounts_full_encrypted_file, + import_accounts_slim_text, is_auto_usage_poll_active, list_accounts, list_instances, + refresh_account_metadata, refresh_all_accounts_usage, remove_instance, rename_account, + set_active_instance, set_masked_account_ids, start_auto_usage_poll, start_login, + stop_auto_usage_poll, switch_account, warmup_account, warmup_all_accounts, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -25,6 +27,10 @@ pub fn run() { #[cfg(desktop)] app.handle() .plugin(tauri_plugin_updater::Builder::new().build())?; + + // Start background token keeper (refreshes ChatGPT tokens proactively) + auth::token_keeper::start(app.handle().clone()); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -52,6 +58,18 @@ pub fn run() { refresh_all_accounts_usage, warmup_account, warmup_all_accounts, + // Auto-poll + start_auto_usage_poll, + stop_auto_usage_poll, + is_auto_usage_poll_active, + // Instances + list_instances, + create_instance, + create_empty_instance, + set_active_instance, + get_active_instance, + remove_instance, + bind_instance_account, // Process detection check_codex_processes, ]) diff --git a/src/App.tsx b/src/App.tsx index 21c7e924..f1efe538 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useAccounts } from "./hooks/useAccounts"; -import { AccountCard, AddAccountModal, UpdateChecker } from "./components"; +import { useInstances } from "./hooks/useInstances"; +import { AccountCard, AddAccountModal, UpdateChecker, InstancePanel } from "./components"; import type { CodexProcessInfo } from "./types"; import { exportFullBackupFile, @@ -19,6 +20,17 @@ const isMacOs = /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent); function App() { + const { + instances, + activeInstance, + loading: instancesLoading, + createInstance, + createEmptyInstance, + switchInstance, + removeInstance, + bindAccount, + } = useInstances(); + const { accounts, loading, @@ -305,8 +317,7 @@ function App() { if (summary.failed_account_ids.length === 0) { showWarmupToast( - `Warm-up sent for all ${summary.warmed_accounts} account${ - summary.warmed_accounts === 1 ? "" : "s" + `Warm-up sent for all ${summary.warmed_accounts} account${summary.warmed_accounts === 1 ? "" : "s" }` ); } else { @@ -569,8 +580,8 @@ function App() { {processInfo && ( )} + {/* Instances */} + ({ id: a.id, name: a.name }))} + loading={instancesLoading} + onCreateInstance={createInstance} + onCreateEmptyInstance={createEmptyInstance} + onSwitchInstance={switchInstance} + onRemoveInstance={removeInstance} + onBindAccount={bindAccount} + /> + {/* Other Accounts */} {otherAccounts.length > 0 && (
@@ -779,12 +803,12 @@ function App() { onChange={(e) => setOtherAccountsSort( e.target.value as - | "deadline_asc" - | "deadline_desc" - | "remaining_desc" - | "remaining_asc" - | "subscription_asc" - | "subscription_desc" + | "deadline_asc" + | "deadline_desc" + | "remaining_desc" + | "remaining_asc" + | "subscription_asc" + | "subscription_desc" ) } className="appearance-none font-sans text-xs sm:text-sm font-medium pl-3 pr-9 py-2 rounded-xl border border-gray-300 dark:border-gray-700 bg-gradient-to-b from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 text-gray-700 dark:text-gray-200 shadow-sm hover:border-gray-400 dark:hover:border-gray-600 hover:shadow focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-600 focus:border-gray-400 dark:focus:border-gray-600 transition-all" @@ -854,11 +878,10 @@ function App() { {/* Warm-up Toast */} {warmupToast && (
{warmupToast.message}
diff --git a/src/components/InstancePanel.tsx b/src/components/InstancePanel.tsx new file mode 100644 index 00000000..440d88ad --- /dev/null +++ b/src/components/InstancePanel.tsx @@ -0,0 +1,247 @@ +import { useState } from "react"; +import type { InstanceProfile } from "../types"; + +interface InstancePanelProps { + instances: InstanceProfile[]; + activeInstance: InstanceProfile | null; + accounts: { id: string; name: string }[]; + loading: boolean; + onCreateInstance: (name: string, dir: string) => Promise; + onCreateEmptyInstance: (name: string, dir: string) => Promise; + onSwitchInstance: (id: string) => Promise; + onRemoveInstance: (id: string, deleteData: boolean) => Promise; + onBindAccount: (instanceId: string, accountId: string | null) => Promise; +} + +export function InstancePanel({ + instances, + activeInstance, + accounts, + loading, + onCreateInstance, + onCreateEmptyInstance, + onSwitchInstance, + onRemoveInstance, + onBindAccount, +}: InstancePanelProps) { + const [isCreating, setIsCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [newDir, setNewDir] = useState(""); + const [createEmpty, setCreateEmpty] = useState(false); + const [error, setError] = useState(null); + const [switchingId, setSwitchingId] = useState(null); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + const handleCreate = async () => { + if (!newName.trim() || !newDir.trim()) { + setError("Name and directory are required"); + return; + } + try { + setError(null); + if (createEmpty) { + await onCreateEmptyInstance(newName, newDir); + } else { + await onCreateInstance(newName, newDir); + } + setNewName(""); + setNewDir(""); + setIsCreating(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleSwitch = async (id: string) => { + try { + setSwitchingId(id); + await onSwitchInstance(id); + } finally { + setSwitchingId(null); + } + }; + + const handleRemove = async (id: string) => { + if (deleteConfirmId !== id) { + setDeleteConfirmId(id); + setTimeout(() => setDeleteConfirmId(null), 3000); + return; + } + await onRemoveInstance(id, false); + setDeleteConfirmId(null); + }; + + if (loading && instances.length === 0) { + return ( +
+ Loading instances... +
+ ); + } + + return ( +
+
+

+ Instances ({instances.length}) +

+ +
+ + {/* Create Form */} + {isCreating && ( +
+
+ setNewName(e.target.value)} + className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 bg-white dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-600" + /> +
+ setNewDir(e.target.value)} + className="flex-1 px-3 py-2 text-sm rounded-lg border border-gray-300 bg-white dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-600" + /> + +
+ + {error && ( +

{error}

+ )} + +
+
+ )} + + {/* Instance List */} + {instances.length === 0 ? ( +
+ No instances yet. Each instance has its own isolated Codex config. +
+ ) : ( +
+ {instances.map((inst) => { + const isActive = activeInstance?.id === inst.id; + const boundAccount = accounts.find( + (a) => a.id === inst.bind_account_id + ); + + return ( +
+
+
+
+ + {inst.name} + + {isActive && ( + + Active + + )} +
+

+ {inst.user_data_dir} +

+ {boundAccount && ( +

+ → {boundAccount.name} +

+ )} +
+
+ {!isActive && ( + + )} + + +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 748a2107..77078d1c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,3 +2,4 @@ export { AccountCard } from "./AccountCard"; export { UsageBar } from "./UsageBar"; export { AddAccountModal } from "./AddAccountModal"; export { UpdateChecker } from "./UpdateChecker"; +export { InstancePanel } from "./InstancePanel"; diff --git a/src/hooks/useAccounts.ts b/src/hooks/useAccounts.ts index 219cdcc4..b2c91363 100644 --- a/src/hooks/useAccounts.ts +++ b/src/hooks/useAccounts.ts @@ -63,7 +63,7 @@ export function useAccounts() { setLoading(true); setError(null); const accountList = await invokeBackend("list_accounts"); - + if (preserveUsage) { // Preserve existing usage data when just updating account info setAccounts((prev) => { @@ -192,10 +192,10 @@ export function useAccounts() { prev.map((a) => a.id === accountId ? { - ...a, - usage: buildUsageError(accountId, message, a.plan_type ?? null), - usageLoading: false, - } + ...a, + usage: buildUsageError(accountId, message, a.plan_type ?? null), + usageLoading: false, + } : a ) ); @@ -380,13 +380,37 @@ export function useAccounts() { useEffect(() => { loadAccounts().then((accountList) => refreshUsage(accountList)); - - // Auto-refresh usage every 60 seconds (same as official Codex CLI) - const interval = setInterval(() => { - refreshUsage().catch(() => {}); - }, 60000); - - return () => clearInterval(interval); + + // Start backend auto-poll and listen for events instead of frontend setInterval + let unlisten: (() => void) | undefined; + let mounted = true; + + (async () => { + try { + await invokeBackend("start_auto_usage_poll", { intervalMinutes: 1 }); + + // Dynamic import to avoid issues in non-Tauri environments + const { listen } = await import("@tauri-apps/api/event"); + const unlistenFn = await listen("usage-auto-refreshed", () => { + if (mounted) { + refreshUsage().catch(() => { }); + } + }); + unlisten = unlistenFn; + } catch { + // Fallback: if backend poll fails, use frontend interval + const interval = setInterval(() => { + refreshUsage().catch(() => { }); + }, 60000); + unlisten = () => clearInterval(interval); + } + })(); + + return () => { + mounted = false; + unlisten?.(); + invokeBackend("stop_auto_usage_poll").catch(() => { }); + }; }, [loadAccounts, refreshUsage]); return { diff --git a/src/hooks/useInstances.ts b/src/hooks/useInstances.ts new file mode 100644 index 00000000..a1ae4d3f --- /dev/null +++ b/src/hooks/useInstances.ts @@ -0,0 +1,94 @@ +import { useState, useEffect, useCallback } from "react"; +import type { InstanceProfile } from "../types"; +import { invokeBackend } from "../lib/platform"; + +export function useInstances() { + const [instances, setInstances] = useState([]); + const [activeInstance, setActiveInstance] = useState(null); + const [loading, setLoading] = useState(true); + + const loadInstances = useCallback(async () => { + try { + setLoading(true); + const [list, active] = await Promise.all([ + invokeBackend("list_instances"), + invokeBackend("get_active_instance"), + ]); + setInstances(list); + setActiveInstance(active); + } catch (err) { + console.error("Failed to load instances:", err); + } finally { + setLoading(false); + } + }, []); + + const createInstance = useCallback( + async (name: string, userDataDir: string) => { + const instance = await invokeBackend("create_instance", { + name, + userDataDir, + }); + await loadInstances(); + return instance; + }, + [loadInstances] + ); + + const createEmptyInstance = useCallback( + async (name: string, userDataDir: string) => { + const instance = await invokeBackend( + "create_empty_instance", + { name, userDataDir } + ); + await loadInstances(); + return instance; + }, + [loadInstances] + ); + + const switchInstance = useCallback( + async (instanceId: string) => { + await invokeBackend("set_active_instance", { + instanceId, + }); + await loadInstances(); + }, + [loadInstances] + ); + + const removeInstance = useCallback( + async (instanceId: string, deleteData: boolean) => { + await invokeBackend("remove_instance", { instanceId, deleteData }); + await loadInstances(); + }, + [loadInstances] + ); + + const bindAccount = useCallback( + async (instanceId: string, accountId: string | null) => { + await invokeBackend("bind_instance_account", { + instanceId, + accountId, + }); + await loadInstances(); + }, + [loadInstances] + ); + + useEffect(() => { + loadInstances(); + }, [loadInstances]); + + return { + instances, + activeInstance, + loading, + loadInstances, + createInstance, + createEmptyInstance, + switchInstance, + removeInstance, + bindAccount, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index f13f06d8..bdb9be2e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -57,3 +57,12 @@ export interface ImportAccountsSummary { imported_count: number; skipped_count: number; } + +export interface InstanceProfile { + id: string; + name: string; + user_data_dir: string; + bind_account_id: string | null; + created_at: number; + last_used_at: number | null; +} From 74bf84d78b35d10c55e010f824c92a53b6769f82 Mon Sep 17 00:00:00 2001 From: jhm1909 Date: Sat, 16 May 2026 03:02:58 +0900 Subject: [PATCH 2/2] feat: harden account switching and usage handling --- package.json | 2 + scripts/install-desktop.ps1 | 76 ++++++++ src-tauri/src/api/usage.rs | 68 +++++++- src-tauri/src/auth/atomic_write.rs | 15 +- src-tauri/src/auth/instance_manager.rs | 90 ++++++++-- src-tauri/src/auth/storage.rs | 78 +++++++-- src-tauri/src/auth/switcher.rs | 132 +++++++++++++- src-tauri/src/auth/token_refresh.rs | 229 +++++++++++++++++++++++-- src-tauri/src/commands/account.rs | 179 +++++++++++++++---- src-tauri/src/commands/instance.rs | 58 ++++++- src-tauri/src/commands/oauth.rs | 9 +- src-tauri/src/commands/process.rs | 35 +++- src-tauri/src/commands/usage.rs | 35 ++-- src-tauri/src/lib.rs | 20 ++- src-tauri/src/web.rs | 79 ++++++++- src/App.tsx | 122 ++++++++++--- src/components/AccountCard.tsx | 10 +- src/components/InstancePanel.tsx | 68 +++++++- src/components/UsageBar.tsx | 43 ++++- src/hooks/useAccounts.ts | 107 +++++++++--- src/hooks/useInstances.ts | 14 ++ 21 files changed, 1279 insertions(+), 190 deletions(-) create mode 100644 scripts/install-desktop.ps1 diff --git a/package.json b/package.json index be3bf074..040101f9 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "preview": "vite preview", "tauri": "sh ./scripts/tauri.sh", "lan": "pnpm build && cargo run --manifest-path src-tauri/Cargo.toml --bin codex-web", + "desktop:update": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install-desktop.ps1", + "desktop:update:no-launch": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install-desktop.ps1 -NoLaunch", "version:bump": "node scripts/bump-version.mjs", "version:patch": "node scripts/bump-version.mjs patch", "version:minor": "node scripts/bump-version.mjs minor", diff --git a/scripts/install-desktop.ps1 b/scripts/install-desktop.ps1 new file mode 100644 index 00000000..e0d3956a --- /dev/null +++ b/scripts/install-desktop.ps1 @@ -0,0 +1,76 @@ +param( + [switch]$NoLaunch +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$installDir = Join-Path $env:LOCALAPPDATA "Codex Switcher" +$releaseDir = Join-Path $repoRoot "src-tauri\target\release" +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + +function New-Shortcut { + param( + [string]$ShortcutPath, + [string]$TargetPath, + [string]$WorkingDirectory + ) + + $parent = Split-Path -Parent $ShortcutPath + New-Item -ItemType Directory -Path $parent -Force | Out-Null + + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($ShortcutPath) + $shortcut.TargetPath = $TargetPath + $shortcut.WorkingDirectory = $WorkingDirectory + $shortcut.IconLocation = $TargetPath + $shortcut.Save() +} + +Push-Location $repoRoot +try { + Get-Process -Name codex-switcher,codex-web -ErrorAction SilentlyContinue | + Stop-Process -Force + + pnpm exec tauri build --no-bundle + + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + + foreach ($name in @("codex-switcher.exe", "codex-web.exe")) { + $source = Join-Path $releaseDir $name + $target = Join-Path $installDir $name + + if (!(Test-Path -LiteralPath $source)) { + throw "Missing release binary: $source" + } + + if (Test-Path -LiteralPath $target) { + Copy-Item -LiteralPath $target -Destination "$target.bak-$timestamp" -Force + } + + Copy-Item -LiteralPath $source -Destination $target -Force + $item = Get-Item -LiteralPath $target + Write-Host "Installed $($item.FullName) ($($item.Length) bytes)" + } + + $exe = Join-Path $installDir "codex-switcher.exe" + $desktopShortcut = Join-Path ([Environment]::GetFolderPath("DesktopDirectory")) "Codex Switcher.lnk" + $startMenuShortcut = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Codex Switcher.lnk" + + New-Shortcut -ShortcutPath $desktopShortcut -TargetPath $exe -WorkingDirectory $installDir + New-Shortcut -ShortcutPath $startMenuShortcut -TargetPath $exe -WorkingDirectory $installDir + + Write-Host "Updated shortcuts:" + Write-Host "- $desktopShortcut" + Write-Host "- $startMenuShortcut" + + if (!$NoLaunch) { + $process = Start-Process -FilePath $exe -PassThru + Start-Sleep -Seconds 2 + $running = Get-Process -Id $process.Id + Write-Host "Launched $($running.ProcessName) PID $($running.Id)" + } +} +finally { + Pop-Location +} diff --git a/src-tauri/src/api/usage.rs b/src-tauri/src/api/usage.rs index 6c4c1331..230b9cb3 100644 --- a/src-tauri/src/api/usage.rs +++ b/src-tauri/src/api/usage.rs @@ -106,7 +106,10 @@ pub async fn fetch_chatgpt_account_metadata( let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); - anyhow::bail!("Accounts check API error: {status} - {body}"); + anyhow::bail!( + "{}", + format_chatgpt_api_error("Accounts check", status, &body) + ); } let payload: AccountsCheckResponse = response @@ -166,11 +169,9 @@ async fn parse_usage_response( if !status.is_success() { let body = response.text().await.unwrap_or_default(); - println!("[Usage] Error response: {body}"); - return Ok(UsageInfo::error( - account_id.to_string(), - format!("API error: {status}"), - )); + let message = format_chatgpt_api_error("Usage", status, &body); + println!("[Usage] Error response: {}", truncate_text(&message, 300)); + return Ok(UsageInfo::error(account_id.to_string(), message)); } let body_text = response @@ -395,6 +396,42 @@ fn truncate_text(text: &str, max_len: usize) -> String { out } +fn format_chatgpt_api_error(operation: &str, status: StatusCode, body: &str) -> String { + if is_cloudflare_challenge(body) { + return format!( + "{operation} blocked by ChatGPT Cloudflare challenge ({status}). Open chatgpt.com, complete the browser check or sign in again, then retry." + ); + } + + let trimmed = body.trim(); + if trimmed.is_empty() { + return format!("{operation} failed: {status}"); + } + + if looks_like_html(trimmed) { + return format!( + "{operation} failed: {status} (ChatGPT returned an HTML page instead of API JSON). Sign in again, then retry." + ); + } + + format!( + "{operation} failed: {status} - {}", + truncate_text(trimmed, 240) + ) +} + +fn is_cloudflare_challenge(body: &str) -> bool { + let body = body.to_ascii_lowercase(); + body.contains("__cf_chl") + || body.contains("challenge-platform") + || body.contains("enable javascript and cookies to continue") +} + +fn looks_like_html(body: &str) -> bool { + let body = body.to_ascii_lowercase(); + body.starts_with(" Option { let mut last_text: Option = None; for line in body.lines() { @@ -511,3 +548,22 @@ pub async fn refresh_all_usage(accounts: &[StoredAccount]) -> Vec { println!("[Usage] Refresh complete"); results } + +#[cfg(test)] +mod tests { + use super::format_chatgpt_api_error; + use reqwest::StatusCode; + + #[test] + fn cloudflare_challenge_error_is_short_and_actionable() { + let html = r#""#; + + let message = format_chatgpt_api_error("Accounts check", StatusCode::FORBIDDEN, html); + + assert!(message.contains("Cloudflare challenge")); + assert!(message.contains("403 Forbidden")); + assert!(message.contains("sign in again")); + assert!(!message.contains("")); + assert!(message.len() < 220); + } +} diff --git a/src-tauri/src/auth/atomic_write.rs b/src-tauri/src/auth/atomic_write.rs index f30825f7..b93733a2 100644 --- a/src-tauri/src/auth/atomic_write.rs +++ b/src-tauri/src/auth/atomic_write.rs @@ -36,7 +36,10 @@ fn build_temp_path(parent: &Path, target: &Path, suffix: &str) -> PathBuf { .file_name() .and_then(|n| n.to_str()) .unwrap_or("file"); - parent.join(format!(".{base}.tmp.{}.{nanos}.{suffix}", std::process::id())) + parent.join(format!( + ".{base}.tmp.{}.{nanos}.{suffix}", + std::process::id() + )) } fn is_json_path(path: &Path) -> bool { @@ -135,10 +138,7 @@ pub fn restore_from_backup(path: &Path) -> Result { /// Parse JSON from `content`. If parsing fails and a `.bak` file exists, /// automatically restore from backup and retry. -pub fn parse_json_with_auto_restore( - path: &Path, - content: &str, -) -> Result { +pub fn parse_json_with_auto_restore(path: &Path, content: &str) -> Result { match serde_json::from_str::(content) { Ok(value) => Ok(value), Err(parse_err) => { @@ -148,9 +148,8 @@ pub fn parse_json_with_auto_restore( ); if restore_from_backup(path)? { - let restored = fs::read_to_string(path).with_context(|| { - format!("Failed to read restored file: {}", path.display()) - })?; + let restored = fs::read_to_string(path) + .with_context(|| format!("Failed to read restored file: {}", path.display()))?; serde_json::from_str::(&restored).with_context(|| { format!( "Original parse failed: {parse_err}; auto-restored from .bak but still failed" diff --git a/src-tauri/src/auth/instance_manager.rs b/src-tauri/src/auth/instance_manager.rs index 1e3391e4..3b17b104 100644 --- a/src-tauri/src/auth/instance_manager.rs +++ b/src-tauri/src/auth/instance_manager.rs @@ -7,6 +7,7 @@ use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use anyhow::{bail, Context, Result}; use chrono::Utc; @@ -14,7 +15,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::atomic_write::{parse_json_with_auto_restore, write_string_atomic}; -use super::switcher::get_codex_home; +use super::switcher::get_default_codex_home; // --------------------------------------------------------------------------- // Data types @@ -95,7 +96,7 @@ pub fn create_instance(name: String, user_data_dir: String) -> Result Result Result> { Ok(store.instances.into_iter().find(|i| i.id == *active_id)) } +/// Get the active instance data directory without mutating process state. +pub fn active_instance_data_dir() -> Result> { + Ok(get_active_instance()?.map(|instance| PathBuf::from(instance.user_data_dir))) +} + +/// Restore CODEX_HOME from the saved active instance when the app starts. +pub fn restore_active_instance_env() -> Result<()> { + if let Some(dir) = active_instance_data_dir()? { + std::env::set_var("CODEX_HOME", dir); + } + Ok(()) +} + /// Set the active instance and update CODEX_HOME accordingly. pub fn set_active_instance(instance_id: &str) -> Result { let mut store = load_instances()?; @@ -224,9 +238,8 @@ pub fn remove_instance(instance_id: &str, delete_data: bool) -> Result<()> { if delete_data { let path = PathBuf::from(&instance.user_data_dir); if path.exists() { - fs::remove_dir_all(&path).with_context(|| { - format!("Failed to delete instance data: {}", path.display()) - })?; + fs::remove_dir_all(&path) + .with_context(|| format!("Failed to delete instance data: {}", path.display()))?; } } @@ -248,6 +261,56 @@ pub fn bind_account(instance_id: &str, account_id: Option) -> Result Result { + let store = load_instances()?; + let instance = store + .instances + .iter() + .find(|i| i.id == instance_id) + .context("Instance not found")?; + + Ok(build_launch_command(&instance.user_data_dir)) +} + +pub fn launch_codex_for_instance(instance: &InstanceProfile) -> Result<()> { + #[cfg(windows)] + { + Command::new("powershell.exe") + .args([ + "-NoExit", + "-Command", + &build_launch_command(&instance.user_data_dir), + ]) + .spawn() + .with_context(|| format!("Failed to launch Codex for instance {}", instance.name))?; + return Ok(()); + } + + #[cfg(not(windows))] + { + let _ = instance; + bail!("Launching Codex from the app is currently only implemented on Windows"); + } +} + +fn build_launch_command(user_data_dir: &str) -> String { + #[cfg(windows)] + { + format!( + "$env:CODEX_HOME = '{}'; codex", + user_data_dir.replace('\'', "''") + ) + } + + #[cfg(not(windows))] + { + format!( + "CODEX_HOME='{}' codex", + user_data_dir.replace('\'', "'\\''") + ) + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -271,7 +334,10 @@ fn ensure_unique( bail!("An instance with name '{}' already exists", name); } if dirs.contains(&user_data_dir.to_lowercase()) { - bail!("An instance with directory '{}' already exists", user_data_dir); + bail!( + "An instance with directory '{}' already exists", + user_data_dir + ); } Ok(()) } @@ -284,7 +350,10 @@ fn setup_shared_symlinks(instance_dir: &Path, default_codex_home: &Path) -> Resu // Ensure the global shared directory exists fs::create_dir_all(&global_dir).with_context(|| { - format!("Failed to create shared directory: {}", global_dir.display()) + format!( + "Failed to create shared directory: {}", + global_dir.display() + ) })?; // Remove the copied directory if it exists (we'll replace with symlink) @@ -368,9 +437,8 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { if file_type.is_dir() { copy_dir_recursive(&entry.path(), &target)?; } else if file_type.is_file() { - fs::copy(entry.path(), &target).with_context(|| { - format!("Failed to copy file: {}", entry.path().display()) - })?; + fs::copy(entry.path(), &target) + .with_context(|| format!("Failed to copy file: {}", entry.path().display()))?; } // Skip symlinks during copy — they'll be recreated } diff --git a/src-tauri/src/auth/storage.rs b/src-tauri/src/auth/storage.rs index ba4fa614..f9ff0f32 100644 --- a/src-tauri/src/auth/storage.rs +++ b/src-tauri/src/auth/storage.rs @@ -69,7 +69,15 @@ pub fn save_accounts(store: &AccountsStore) -> Result<()> { /// Add a new account to the store pub fn add_account(account: StoredAccount) -> Result { let mut store = load_accounts()?; + let added = add_account_to_store(&mut store, account)?; + save_accounts(&store)?; + Ok(added) +} +fn add_account_to_store( + store: &mut AccountsStore, + account: StoredAccount, +) -> Result { // Check for duplicate names if store.accounts.iter().any(|a| a.name == account.name) { anyhow::bail!("An account with name '{}' already exists", account.name); @@ -77,20 +85,18 @@ pub fn add_account(account: StoredAccount) -> Result { let account_clone = account.clone(); store.accounts.push(account); - - // If this is the first account, make it active - if store.accounts.len() == 1 { - store.active_account_id = Some(account_clone.id.clone()); - } - - save_accounts(&store)?; Ok(account_clone) } /// Remove an account by ID pub fn remove_account(account_id: &str) -> Result<()> { let mut store = load_accounts()?; + remove_account_from_store(&mut store, account_id)?; + save_accounts(&store)?; + Ok(()) +} +fn remove_account_from_store(store: &mut AccountsStore, account_id: &str) -> Result<()> { let initial_len = store.accounts.len(); store.accounts.retain(|a| a.id != account_id); @@ -98,12 +104,12 @@ pub fn remove_account(account_id: &str) -> Result<()> { anyhow::bail!("Account not found: {account_id}"); } - // If we removed the active account, clear it or set to first available + // If we removed the active account, clear it. Only an explicit switch should + // make another account active because switching also writes Codex auth.json. if store.active_account_id.as_deref() == Some(account_id) { - store.active_account_id = store.accounts.first().map(|a| a.id.clone()); + store.active_account_id = None; } - save_accounts(&store)?; Ok(()) } @@ -266,3 +272,55 @@ pub fn set_masked_account_ids(ids: Vec) -> Result<()> { save_accounts(&store)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::{add_account_to_store, remove_account_from_store}; + use crate::types::{AccountsStore, StoredAccount}; + + #[test] + fn adding_first_account_does_not_mark_it_active() { + let mut store = AccountsStore::default(); + let account = StoredAccount::new_api_key("new".to_string(), "sk-test".to_string()); + + let added = add_account_to_store(&mut store, account).expect("account should be added"); + + assert_eq!(store.accounts.len(), 1); + assert_eq!(store.accounts[0].id, added.id); + assert_eq!(store.active_account_id, None); + } + + #[test] + fn adding_account_keeps_existing_active_account() { + let active = StoredAccount::new_api_key("active".to_string(), "sk-active".to_string()); + let active_id = active.id.clone(); + let mut store = AccountsStore { + accounts: vec![active], + active_account_id: Some(active_id.clone()), + ..AccountsStore::default() + }; + + let added = StoredAccount::new_api_key("new".to_string(), "sk-new".to_string()); + add_account_to_store(&mut store, added).expect("account should be added"); + + assert_eq!(store.accounts.len(), 2); + assert_eq!(store.active_account_id.as_deref(), Some(active_id.as_str())); + } + + #[test] + fn removing_active_account_clears_active_instead_of_selecting_another_account() { + let active = StoredAccount::new_api_key("active".to_string(), "sk-active".to_string()); + let active_id = active.id.clone(); + let other = StoredAccount::new_api_key("other".to_string(), "sk-other".to_string()); + let mut store = AccountsStore { + accounts: vec![active, other], + active_account_id: Some(active_id.clone()), + ..AccountsStore::default() + }; + + remove_account_from_store(&mut store, &active_id).expect("account should be removed"); + + assert_eq!(store.accounts.len(), 1); + assert_eq!(store.active_account_id, None); + } +} diff --git a/src-tauri/src/auth/switcher.rs b/src-tauri/src/auth/switcher.rs index 832923d8..f4193dd0 100644 --- a/src-tauri/src/auth/switcher.rs +++ b/src-tauri/src/auth/switcher.rs @@ -1,7 +1,7 @@ //! Account switching logic - writes credentials to ~/.codex/auth.json use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use chrono::Utc; @@ -10,26 +10,63 @@ use crate::types::{ parse_chatgpt_id_token_claims, AuthData, AuthDotJson, StoredAccount, TokenData, }; -/// Get the official Codex home directory -pub fn get_codex_home() -> Result { - // Check for CODEX_HOME environment variable first - if let Ok(codex_home) = std::env::var("CODEX_HOME") { - return Ok(PathBuf::from(codex_home)); - } - +/// Get the default Codex home directory, ignoring Codex Switcher instances. +pub fn get_default_codex_home() -> Result { let home = dirs::home_dir().context("Could not find home directory")?; Ok(home.join(".codex")) } +fn resolve_codex_home( + codex_home_env: Option, + active_instance_dir: Option, + user_home: &Path, +) -> PathBuf { + codex_home_env + .or(active_instance_dir) + .unwrap_or_else(|| user_home.join(".codex")) +} + +/// Get the effective Codex home directory. +pub fn get_codex_home() -> Result { + let home = dirs::home_dir().context("Could not find home directory")?; + let codex_home_env = std::env::var("CODEX_HOME").ok().map(PathBuf::from); + let active_instance_dir = super::instance_manager::active_instance_data_dir()?; + + Ok(resolve_codex_home( + codex_home_env, + active_instance_dir, + &home, + )) +} + /// Get the path to the official auth.json file pub fn get_codex_auth_file() -> Result { Ok(get_codex_home()?.join("auth.json")) } +/// Remove the effective Codex auth.json file if it exists. +pub fn clear_codex_auth() -> Result<()> { + let auth_path = get_codex_auth_file()?; + clear_codex_auth_file_at(&auth_path) +} + +fn clear_codex_auth_file_at(auth_path: &Path) -> Result<()> { + if auth_path.exists() { + fs::remove_file(auth_path) + .with_context(|| format!("Failed to remove auth.json: {}", auth_path.display()))?; + } + + Ok(()) +} + /// Switch to a specific account by writing its credentials to ~/.codex/auth.json pub fn switch_to_account(account: &StoredAccount) -> Result<()> { let codex_home = get_codex_home()?; + switch_to_account_in_dir(account, &codex_home) +} +/// Switch to a specific account by writing its credentials to a Codex home directory. +pub fn switch_to_account_in_dir(account: &StoredAccount, codex_home: &Path) -> Result<()> { // Ensure the codex home directory exists fs::create_dir_all(&codex_home) .with_context(|| format!("Failed to create codex home: {}", codex_home.display()))?; @@ -143,3 +180,82 @@ pub fn has_active_login() -> Result { None => Ok(false), } } + +#[cfg(test)] +mod tests { + use super::{clear_codex_auth_file_at, resolve_codex_home, switch_to_account_in_dir}; + use crate::types::{AuthDotJson, StoredAccount}; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn resolve_codex_home_prefers_env_then_active_instance_then_default_home() { + let env_home = PathBuf::from("C:/codex/env"); + let active_instance = PathBuf::from("C:/codex/instance"); + let user_home = PathBuf::from("C:/Users/example"); + + assert_eq!( + resolve_codex_home( + Some(env_home.clone()), + Some(active_instance.clone()), + &user_home + ), + env_home + ); + assert_eq!( + resolve_codex_home(None, Some(active_instance.clone()), &user_home), + active_instance + ); + assert_eq!( + resolve_codex_home(None, None, &user_home), + user_home.join(".codex") + ); + } + + #[test] + fn switch_to_account_in_dir_writes_auth_json_to_selected_instance_dir() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + let codex_home = std::env::temp_dir().join(format!( + "codex-switcher-instance-auth-test-{}-{unique}", + std::process::id() + )); + let _ = fs::remove_dir_all(&codex_home); + + let account = StoredAccount::new_api_key("work".to_string(), "sk-test".to_string()); + switch_to_account_in_dir(&account, &codex_home).expect("account auth should be written"); + + let auth_path = codex_home.join("auth.json"); + let auth: AuthDotJson = + serde_json::from_str(&fs::read_to_string(&auth_path).expect("auth.json should exist")) + .expect("auth.json should parse"); + + assert_eq!(auth.openai_api_key.as_deref(), Some("sk-test")); + assert!(auth.tokens.is_none()); + + fs::remove_dir_all(&codex_home).expect("test temp dir should be removable"); + } + + #[test] + fn clear_codex_auth_file_removes_existing_auth_json() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + let codex_home = std::env::temp_dir().join(format!( + "codex-switcher-clear-auth-test-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&codex_home).expect("test temp dir should be created"); + let auth_path = codex_home.join("auth.json"); + fs::write(&auth_path, "{}").expect("auth.json should be written"); + + clear_codex_auth_file_at(&auth_path).expect("auth.json should be cleared"); + + assert!(!auth_path.exists()); + fs::remove_dir_all(&codex_home).expect("test temp dir should be removable"); + } +} diff --git a/src-tauri/src/auth/token_refresh.rs b/src-tauri/src/auth/token_refresh.rs index fbba025a..6cb5e2e7 100644 --- a/src-tauri/src/auth/token_refresh.rs +++ b/src-tauri/src/auth/token_refresh.rs @@ -3,15 +3,20 @@ use anyhow::{Context, Result}; use base64::Engine; use chrono::Utc; +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; +use tokio::sync::Mutex; use tokio::time::{sleep, Duration}; -use super::{load_accounts, switch_to_account, update_account_chatgpt_tokens}; -use crate::types::{parse_chatgpt_id_token_claims, AuthData, StoredAccount}; +use super::{load_accounts, read_current_auth, switch_to_account, update_account_chatgpt_tokens}; +use crate::types::{parse_chatgpt_id_token_claims, AuthData, StoredAccount, TokenData}; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const EXPIRY_SKEW_SECONDS: i64 = 60; +static REFRESH_LOCKS: OnceLock>>>> = OnceLock::new(); + #[derive(Debug, serde::Deserialize)] struct RefreshTokenResponse { #[serde(default)] @@ -42,18 +47,39 @@ 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()), - AuthData::ChatGPT { - id_token, - refresh_token, - account_id, - .. - } => (id_token.clone(), refresh_token.clone(), account_id.clone()), - }; + if matches!(account.auth_data, AuthData::ApiKey { .. }) { + return Ok(account.clone()); + } + + let refresh_lock = account_refresh_lock(&account.id).await; + let _guard = refresh_lock.lock().await; + + let latest_account = load_accounts()? + .accounts + .into_iter() + .find(|candidate| candidate.id == account.id) + .unwrap_or_else(|| account.clone()); + let latest_account = sync_account_from_current_auth_if_matching(&latest_account)?; + + if chatgpt_refresh_token(&latest_account) != chatgpt_refresh_token(account) + && !chatgpt_access_token_expired_or_near_expiry(&latest_account) + { + return Ok(latest_account); + } + + let (current_id_token, current_refresh_token, current_account_id) = + match &latest_account.auth_data { + AuthData::ApiKey { .. } => return Ok(account.clone()), + AuthData::ChatGPT { + id_token, + refresh_token, + account_id, + .. + } => (id_token.clone(), refresh_token.clone(), account_id.clone()), + }; if current_refresh_token.is_empty() { - anyhow::bail!("Missing refresh token for account {}", account.name); + anyhow::bail!("Missing refresh token for account {}", latest_account.name); } let refreshed = refresh_tokens_with_refresh_token(¤t_refresh_token).await?; @@ -65,10 +91,11 @@ pub async fn refresh_chatgpt_tokens(account: &StoredAccount) -> Result Result Arc> { + let locks = REFRESH_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut map = locks.lock().await; + map.entry(account_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() +} + +fn sync_account_from_current_auth_if_matching(account: &StoredAccount) -> Result { + let AuthData::ChatGPT { + id_token: stored_id_token, + access_token: stored_access_token, + refresh_token: stored_refresh_token, + account_id: stored_account_id, + } = &account.auth_data + else { + return Ok(account.clone()); + }; + + let Some(auth) = read_current_auth()? else { + return Ok(account.clone()); + }; + let Some(tokens) = auth.tokens else { + return Ok(account.clone()); + }; + + if !same_chatgpt_identity(account, &tokens) { + return Ok(account.clone()); + } + + if tokens.id_token == *stored_id_token + && tokens.access_token == *stored_access_token + && tokens.refresh_token == *stored_refresh_token + { + return Ok(account.clone()); + } + + let claims = parse_chatgpt_id_token_claims(&tokens.id_token); + let next_account_id = claims.account_id.or_else(|| stored_account_id.clone()); + + update_account_chatgpt_tokens( + &account.id, + tokens.id_token, + tokens.access_token, + tokens.refresh_token, + next_account_id, + claims.email, + claims.plan_type, + claims.subscription_expires_at, + ) +} + +fn same_chatgpt_identity(account: &StoredAccount, tokens: &TokenData) -> bool { + let claims = parse_chatgpt_id_token_claims(&tokens.id_token); + let stored_account_id = match &account.auth_data { + AuthData::ChatGPT { account_id, .. } => account_id.as_deref(), + AuthData::ApiKey { .. } => return false, + }; + + if let (Some(stored), Some(current)) = (stored_account_id, claims.account_id.as_deref()) { + return stored == current; + } + + if let (Some(stored), Some(current)) = (account.email.as_deref(), claims.email.as_deref()) { + return stored.eq_ignore_ascii_case(current); + } + + false +} + +fn chatgpt_refresh_token(account: &StoredAccount) -> Option<&str> { + match &account.auth_data { + AuthData::ChatGPT { refresh_token, .. } => Some(refresh_token.as_str()), + AuthData::ApiKey { .. } => None, + } +} + +fn chatgpt_access_token_expired_or_near_expiry(account: &StoredAccount) -> bool { + match &account.auth_data { + AuthData::ChatGPT { access_token, .. } => token_expired_or_near_expiry(access_token), + AuthData::ApiKey { .. } => false, + } +} + /// Build a new ChatGPT account from a refresh token. /// This is used by slim import to recreate full credentials. pub async fn create_chatgpt_account_from_refresh_token( @@ -188,3 +299,93 @@ async fn refresh_tokens_with_refresh_token(refresh_token: &str) -> Result, account_id: Option<&str>) -> TokenData { + TokenData { + id_token: id_token(email, account_id), + access_token: "next-access".to_string(), + refresh_token: "next-refresh".to_string(), + account_id: account_id.map(String::from), + } + } + + fn id_token(email: Option<&str>, account_id: Option<&str>) -> String { + let mut payload = serde_json::Map::new(); + if let Some(email) = email { + payload.insert("email".to_string(), serde_json::json!(email)); + } + + let mut auth = serde_json::Map::new(); + if let Some(account_id) = account_id { + auth.insert( + "chatgpt_account_id".to_string(), + serde_json::json!(account_id), + ); + } + payload.insert( + "https://api.openai.com/auth".to_string(), + serde_json::Value::Object(auth), + ); + + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::Value::Object(payload).to_string()); + format!("header.{encoded}.signature") + } +} diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 16c1c6ff..5607a7cd 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -28,6 +28,15 @@ use std::os::windows::process::CommandExt; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; +#[cfg(windows)] +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "PascalCase")] +struct WindowsRestartCandidate { + process_id: u32, + #[serde(default)] + command_line: String, +} + const SLIM_EXPORT_PREFIX: &str = "css1."; const SLIM_FORMAT_VERSION: u8 = 1; const SLIM_AUTH_API_KEY: u8 = 0; @@ -174,6 +183,12 @@ pub async fn delete_account(account_id: String) -> Result<(), String> { Ok(()) } +/// Clear Codex's current auth.json without changing stored accounts. +#[tauri::command] +pub async fn clear_codex_auth() -> Result<(), String> { + crate::auth::clear_codex_auth().map_err(|e| e.to_string()) +} + /// Rename an account #[tauri::command] pub async fn rename_account(account_id: String, new_name: String) -> Result<(), String> { @@ -285,13 +300,7 @@ fn find_antigravity_processes() -> anyhow::Result> { let pid_str = pid_str.trim(); let command = command.trim(); - // Antigravity processes have a specific path format - let is_antigravity = (command.contains(".antigravity/extensions/openai.chatgpt") - || command.contains(".vscode/extensions/openai.chatgpt")) - && (command.ends_with("codex app-server --analytics-default-enabled") - || command.contains("/codex app-server")); - - if is_antigravity { + if is_restartable_antigravity_codex_command(command) { if let Ok(pid) = pid_str.parse::() { pids.push(pid); } @@ -302,25 +311,37 @@ fn find_antigravity_processes() -> anyhow::Result> { #[cfg(windows)] { - // Use tasklist on Windows - // For Windows we might need a more precise WMI query to get command line args, - // but for now we look for codex.exe PIDs and verify they're not ours - let output = std::process::Command::new("tasklist") + const POWERSHELL_SCRIPT: &str = r#" +Get-CimInstance Win32_Process | + Where-Object { $_.Name -ieq 'codex.exe' } | + ForEach-Object { + [PSCustomObject]@{ + ProcessId = [uint32]$_.ProcessId + CommandLine = if ($_.CommandLine) { $_.CommandLine } else { '' } + } + } | + ConvertTo-Json -Compress +"#; + + let output = std::process::Command::new("powershell.exe") .creation_flags(CREATE_NO_WINDOW) - .args(["/FI", "IMAGENAME eq codex.exe", "/FO", "CSV", "/NH"]) + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + POWERSHELL_SCRIPT, + ]) .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("PowerShell process query failed: {}", stderr.trim()); + } + let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - let parts: Vec<&str> = line.split(',').collect(); - if parts.len() > 1 { - let name = parts[0].trim_matches('"').to_lowercase(); - if name == "codex.exe" { - let pid_str = parts[1].trim_matches('"'); - if let Ok(pid) = pid_str.parse::() { - pids.push(pid); - } - } + for process in parse_windows_restart_candidates(&stdout)? { + if is_restartable_antigravity_codex_command(&process.command_line) { + pids.push(process.process_id); } } } @@ -328,6 +349,37 @@ fn find_antigravity_processes() -> anyhow::Result> { Ok(pids) } +fn is_restartable_antigravity_codex_command(command: &str) -> bool { + let normalized = command.replace('\\', "/").to_ascii_lowercase(); + + normalized.contains(".antigravity/extensions/openai.chatgpt") + && normalized.contains("/codex") + && normalized.contains("app-server") +} + +#[cfg(windows)] +fn parse_windows_restart_candidates(stdout: &str) -> anyhow::Result> { + let trimmed = stdout.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + + let value: serde_json::Value = + serde_json::from_str(trimmed).context("failed to parse Windows restart process JSON")?; + + match value { + serde_json::Value::Array(values) => values + .into_iter() + .map(|value| { + serde_json::from_value(value) + .context("failed to deserialize Windows restart process entry") + }) + .collect(), + value => Ok(vec![serde_json::from_value(value) + .context("failed to deserialize Windows restart process entry")?]), + } +} + fn encode_slim_payload_from_store(store: &AccountsStore) -> anyhow::Result { let active_name = store.active_account_id.as_ref().and_then(|active_id| { store @@ -679,7 +731,6 @@ fn merge_accounts_store( imported: AccountsStore, ) -> (AccountsStore, ImportAccountsSummary) { let imported_version = imported.version; - let imported_active_id = imported.active_account_id; let total_in_payload = imported.accounts.len(); let mut imported_count = 0usize; let mut existing_ids: HashSet = current.accounts.iter().map(|a| a.id.clone()).collect(); @@ -704,15 +755,7 @@ fn merge_accounts_store( .is_some_and(|id| current.accounts.iter().any(|a| &a.id == id)); if !current_active_is_valid { - if let Some(imported_active) = imported_active_id { - if current.accounts.iter().any(|a| a.id == imported_active) { - current.active_account_id = Some(imported_active); - } else { - current.active_account_id = current.accounts.first().map(|a| a.id.clone()); - } - } else { - current.active_account_id = current.accounts.first().map(|a| a.id.clone()); - } + current.active_account_id = None; } ( @@ -725,6 +768,78 @@ fn merge_accounts_store( ) } +#[cfg(test)] +mod tests { + use super::{is_restartable_antigravity_codex_command, merge_accounts_store}; + use crate::types::{AccountsStore, StoredAccount}; + + #[test] + fn restart_filter_rejects_vscode_codex_extension_process() { + let command = r#"C:\Users\jeong\.vscode\extensions\openai.chatgpt-26.513.21555-win32-x64\bin\windows-x86_64\codex.exe app-server --analytics-default-enabled"#; + + assert!(!is_restartable_antigravity_codex_command(command)); + } + + #[test] + fn restart_filter_accepts_antigravity_codex_app_server() { + let command = r#"C:\Users\jeong\.antigravity\extensions\openai.chatgpt-26.513.21555-win32-x64\bin\windows-x86_64\codex.exe app-server --analytics-default-enabled"#; + + assert!(is_restartable_antigravity_codex_command(command)); + } + + #[test] + fn restart_filter_rejects_plain_codex_cli() { + let command = r#"C:\Users\jeong\AppData\Roaming\npm\codex.cmd"#; + + assert!(!is_restartable_antigravity_codex_command(command)); + } + + #[test] + fn merge_imported_accounts_into_empty_store_does_not_mark_any_active() { + let first = StoredAccount::new_api_key("first".to_string(), "sk-first".to_string()); + let first_id = first.id.clone(); + let second = StoredAccount::new_api_key("second".to_string(), "sk-second".to_string()); + let imported = AccountsStore { + accounts: vec![first, second], + active_account_id: Some(first_id), + ..AccountsStore::default() + }; + + let (merged, summary) = merge_accounts_store(AccountsStore::default(), imported); + + assert_eq!(summary.imported_count, 2); + assert_eq!(merged.accounts.len(), 2); + assert_eq!(merged.active_account_id, None); + } + + #[test] + fn merge_imported_accounts_preserves_valid_current_active_account() { + let active = StoredAccount::new_api_key("active".to_string(), "sk-active".to_string()); + let active_id = active.id.clone(); + let imported_account = + StoredAccount::new_api_key("imported".to_string(), "sk-imported".to_string()); + let current = AccountsStore { + accounts: vec![active], + active_account_id: Some(active_id.clone()), + ..AccountsStore::default() + }; + let imported = AccountsStore { + accounts: vec![imported_account], + active_account_id: None, + ..AccountsStore::default() + }; + + let (merged, summary) = merge_accounts_store(current, imported); + + assert_eq!(summary.imported_count, 1); + assert_eq!(merged.accounts.len(), 2); + assert_eq!( + merged.active_account_id.as_deref(), + Some(active_id.as_str()) + ); + } +} + /// Get the list of masked account IDs #[tauri::command] pub async fn get_masked_account_ids() -> Result, String> { diff --git a/src-tauri/src/commands/instance.rs b/src-tauri/src/commands/instance.rs index ef121e79..12051461 100644 --- a/src-tauri/src/commands/instance.rs +++ b/src-tauri/src/commands/instance.rs @@ -1,8 +1,9 @@ //! Instance management Tauri commands -use crate::auth::instance_manager::{ - self, InstanceProfile, -}; +use std::path::PathBuf; + +use crate::auth::instance_manager::{self, InstanceProfile}; +use crate::auth::{get_account, set_active_account, switch_to_account_in_dir, touch_account}; /// List all Codex instances #[tauri::command] @@ -28,7 +29,7 @@ pub fn create_empty_instance( /// Set the active instance (sets CODEX_HOME env var) #[tauri::command] pub fn set_active_instance(instance_id: String) -> Result { - instance_manager::set_active_instance(&instance_id).map_err(|e| e.to_string()) + activate_instance(&instance_id) } /// Get the currently active instance @@ -49,5 +50,52 @@ pub fn bind_instance_account( instance_id: String, account_id: Option, ) -> Result { - instance_manager::bind_account(&instance_id, account_id).map_err(|e| e.to_string()) + let updated = + instance_manager::bind_account(&instance_id, account_id).map_err(|e| e.to_string())?; + let is_active = instance_manager::get_active_instance() + .map_err(|e| e.to_string())? + .is_some_and(|instance| instance.id == updated.id); + + apply_bound_account_to_instance(&updated, is_active)?; + Ok(updated) +} + +#[tauri::command] +pub fn get_instance_launch_command(instance_id: String) -> Result { + instance_manager::get_instance_launch_command(&instance_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn launch_instance_codex(instance_id: String) -> Result<(), String> { + let instance = activate_instance(&instance_id)?; + instance_manager::launch_codex_for_instance(&instance).map_err(|e| e.to_string()) +} + +fn activate_instance(instance_id: &str) -> Result { + let instance = instance_manager::set_active_instance(instance_id).map_err(|e| e.to_string())?; + apply_bound_account_to_instance(&instance, true)?; + Ok(instance) +} + +fn apply_bound_account_to_instance( + instance: &InstanceProfile, + mark_account_active: bool, +) -> Result<(), String> { + let Some(account_id) = &instance.bind_account_id else { + return Ok(()); + }; + + let account = get_account(account_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Bound account not found: {account_id}"))?; + let codex_home = PathBuf::from(&instance.user_data_dir); + + switch_to_account_in_dir(&account, &codex_home).map_err(|e| e.to_string())?; + + if mark_account_active { + set_active_account(account_id).map_err(|e| e.to_string())?; + touch_account(account_id).map_err(|e| e.to_string())?; + } + + Ok(()) } diff --git a/src-tauri/src/commands/oauth.rs b/src-tauri/src/commands/oauth.rs index f4918147..2c9ec372 100644 --- a/src-tauri/src/commands/oauth.rs +++ b/src-tauri/src/commands/oauth.rs @@ -5,9 +5,7 @@ use std::sync::{Arc, Mutex}; use tokio::sync::oneshot; use crate::auth::oauth_server::{start_oauth_login, wait_for_oauth_login, OAuthLoginResult}; -use crate::auth::{ - add_account, load_accounts, set_active_account, switch_to_account, touch_account, -}; +use crate::auth::{add_account, load_accounts}; use crate::types::{AccountInfo, OAuthLoginInfo}; struct PendingOAuth { @@ -59,11 +57,6 @@ pub async fn complete_login() -> Result { // Add the account to storage let stored = add_account(account).map_err(|e| e.to_string())?; - // Make it active and switch to it - set_active_account(&stored.id).map_err(|e| e.to_string())?; - switch_to_account(&stored).map_err(|e| e.to_string())?; - touch_account(&stored.id).map_err(|e| e.to_string())?; - let store = load_accounts().map_err(|e| e.to_string())?; let active_id = store.active_account_id.as_deref(); diff --git a/src-tauri/src/commands/process.rs b/src-tauri/src/commands/process.rs index d4389e06..c22c862c 100644 --- a/src-tauri/src/commands/process.rs +++ b/src-tauri/src/commands/process.rs @@ -34,7 +34,11 @@ pub struct CodexProcessInfo { pub count: usize, /// Number of ignored background/stale Codex-related processes pub background_count: usize, - /// Whether switching is allowed (no active Codex app instances) + /// Whether switching is allowed. + /// + /// Running Codex sessions are reported for visibility, but they do not + /// block updating auth.json. Existing sessions may keep their current auth + /// until they are restarted. pub can_switch: bool, /// Process IDs of active Codex app instances pub pids: Vec, @@ -44,14 +48,16 @@ pub struct CodexProcessInfo { #[tauri::command] pub async fn check_codex_processes() -> Result { let (pids, bg_count) = find_codex_processes().map_err(|e| e.to_string())?; - let count = pids.len(); + Ok(build_process_info(pids, bg_count)) +} - Ok(CodexProcessInfo { - count, - background_count: bg_count, - can_switch: count == 0, +fn build_process_info(pids: Vec, background_count: usize) -> CodexProcessInfo { + CodexProcessInfo { + count: pids.len(), + background_count, + can_switch: true, pids, - }) + } } /// Find all running codex processes. Returns (active_pids, background_count) @@ -310,3 +316,18 @@ where false } + +#[cfg(test)] +mod tests { + use super::build_process_info; + + #[test] + fn running_codex_processes_do_not_block_switching() { + let info = build_process_info(vec![101, 202], 3); + + assert_eq!(info.count, 2); + assert_eq!(info.background_count, 3); + assert_eq!(info.pids, vec![101, 202]); + assert!(info.can_switch); + } +} diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index f449db5e..284e1d9c 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -4,7 +4,9 @@ use crate::api::usage::{ fetch_chatgpt_account_metadata, get_account_usage, refresh_all_usage, warmup_account as send_warmup, }; -use crate::auth::{get_account, load_accounts, refresh_chatgpt_tokens, update_account_metadata}; +use crate::auth::{ + ensure_chatgpt_tokens_fresh, get_account, load_accounts, update_account_metadata, +}; use crate::types::{AccountInfo, AuthData, UsageInfo, WarmupSummary}; use futures::{stream, StreamExt}; @@ -30,21 +32,26 @@ pub async fn refresh_account_metadata(account_id: String) -> Result account, AuthData::ChatGPT { .. } => { - let refreshed = refresh_chatgpt_tokens(&account) - .await - .map_err(|e| e.to_string())?; - let live_metadata = fetch_chatgpt_account_metadata(&refreshed) + let refreshed = ensure_chatgpt_tokens_fresh(&account) .await .map_err(|e| e.to_string())?; - - update_account_metadata( - &account_id, - None, - None, - live_metadata.plan_type, - Some(live_metadata.subscription_expires_at), - ) - .map_err(|e| e.to_string())? + match fetch_chatgpt_account_metadata(&refreshed).await { + Ok(live_metadata) => update_account_metadata( + &account_id, + None, + None, + live_metadata.plan_type, + Some(live_metadata.subscription_expires_at), + ) + .map_err(|e| e.to_string())?, + Err(err) => { + println!( + "[Usage] Metadata refresh skipped for {}: {err}", + account.name + ); + refreshed + } + } } }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2e2ae2f..0f09409e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,13 +8,14 @@ pub mod web; use commands::{ add_account_from_file, bind_instance_account, cancel_login, check_codex_processes, - complete_login, create_empty_instance, create_instance, delete_account, + clear_codex_auth, complete_login, create_empty_instance, create_instance, delete_account, export_accounts_full_encrypted_file, export_accounts_slim_text, get_active_account_info, - get_active_instance, get_masked_account_ids, get_usage, import_accounts_full_encrypted_file, - import_accounts_slim_text, is_auto_usage_poll_active, list_accounts, list_instances, - refresh_account_metadata, refresh_all_accounts_usage, remove_instance, rename_account, - set_active_instance, set_masked_account_ids, start_auto_usage_poll, start_login, - stop_auto_usage_poll, switch_account, warmup_account, warmup_all_accounts, + get_active_instance, get_instance_launch_command, get_masked_account_ids, get_usage, + import_accounts_full_encrypted_file, import_accounts_slim_text, is_auto_usage_poll_active, + launch_instance_codex, list_accounts, list_instances, refresh_account_metadata, + refresh_all_accounts_usage, remove_instance, rename_account, set_active_instance, + set_masked_account_ids, start_auto_usage_poll, start_login, stop_auto_usage_poll, + switch_account, warmup_account, warmup_all_accounts, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -28,6 +29,10 @@ pub fn run() { app.handle() .plugin(tauri_plugin_updater::Builder::new().build())?; + if let Err(err) = auth::instance_manager::restore_active_instance_env() { + println!("[InstanceManager] Failed to restore active instance: {err}"); + } + // Start background token keeper (refreshes ChatGPT tokens proactively) auth::token_keeper::start(app.handle().clone()); @@ -40,6 +45,7 @@ pub fn run() { add_account_from_file, switch_account, delete_account, + clear_codex_auth, rename_account, export_accounts_slim_text, import_accounts_slim_text, @@ -70,6 +76,8 @@ pub fn run() { get_active_instance, remove_instance, bind_instance_account, + get_instance_launch_command, + launch_instance_codex, // Process detection check_codex_processes, ]) diff --git a/src-tauri/src/web.rs b/src-tauri/src/web.rs index a2bd2f1e..6d7a2168 100644 --- a/src-tauri/src/web.rs +++ b/src-tauri/src/web.rs @@ -10,12 +10,15 @@ use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; use tokio::runtime::Runtime; use crate::commands::{ - add_account_from_auth_json_text, add_account_from_file, cancel_login, check_codex_processes, - complete_login, delete_account, export_accounts_full_encrypted_bytes, - export_accounts_slim_text, get_active_account_info, get_masked_account_ids, get_usage, - import_accounts_full_encrypted_bytes, import_accounts_slim_text, list_accounts, - refresh_account_metadata, refresh_all_accounts_usage, rename_account, set_masked_account_ids, - start_login, switch_account, warmup_account, warmup_all_accounts, + add_account_from_auth_json_text, add_account_from_file, bind_instance_account, cancel_login, + check_codex_processes, clear_codex_auth, complete_login, create_empty_instance, + create_instance, delete_account, export_accounts_full_encrypted_bytes, + export_accounts_slim_text, get_active_account_info, get_active_instance, + get_instance_launch_command, get_masked_account_ids, get_usage, + import_accounts_full_encrypted_bytes, import_accounts_slim_text, launch_instance_codex, + list_accounts, list_instances, refresh_account_metadata, refresh_all_accounts_usage, + remove_instance, rename_account, set_active_instance, set_masked_account_ids, start_login, + switch_account, warmup_account, warmup_all_accounts, }; #[derive(Debug, Deserialize)] @@ -25,6 +28,39 @@ struct AccountIdArgs { account_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct InstanceIdArgs { + #[serde(alias = "instance_id")] + instance_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateInstanceArgs { + name: String, + #[serde(alias = "user_data_dir")] + user_data_dir: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoveInstanceArgs { + #[serde(alias = "instance_id")] + instance_id: String, + #[serde(alias = "delete_data")] + delete_data: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BindInstanceArgs { + #[serde(alias = "instance_id")] + instance_id: String, + #[serde(alias = "account_id")] + account_id: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RenameAccountArgs { @@ -159,6 +195,7 @@ async fn invoke_web_command(command: &str, payload: Value) -> Result to_json(clear_codex_auth().await?), "rename_account" => { let args: RenameAccountArgs = parse_args(payload)?; to_json(rename_account(args.account_id, args.new_name).await?) @@ -190,6 +227,36 @@ async fn invoke_web_command(command: &str, payload: Value) -> Result to_json(list_instances()?), + "get_active_instance" => to_json(get_active_instance()?), + "create_instance" => { + let args: CreateInstanceArgs = parse_args(payload)?; + to_json(create_instance(args.name, args.user_data_dir)?) + } + "create_empty_instance" => { + let args: CreateInstanceArgs = parse_args(payload)?; + to_json(create_empty_instance(args.name, args.user_data_dir)?) + } + "set_active_instance" => { + let args: InstanceIdArgs = parse_args(payload)?; + to_json(set_active_instance(args.instance_id)?) + } + "remove_instance" => { + let args: RemoveInstanceArgs = parse_args(payload)?; + to_json(remove_instance(args.instance_id, args.delete_data)?) + } + "bind_instance_account" => { + let args: BindInstanceArgs = parse_args(payload)?; + to_json(bind_instance_account(args.instance_id, args.account_id)?) + } + "get_instance_launch_command" => { + let args: InstanceIdArgs = parse_args(payload)?; + to_json(get_instance_launch_command(args.instance_id)?) + } + "launch_instance_codex" => { + let args: InstanceIdArgs = parse_args(payload)?; + to_json(launch_instance_codex(args.instance_id)?) + } "check_codex_processes" => to_json(check_codex_processes().await?), _ => Err(format!("Unsupported web command: {command}")), } diff --git a/src/App.tsx b/src/App.tsx index f1efe538..241fb718 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,8 @@ function App() { switchInstance, removeInstance, bindAccount, + getLaunchCommand, + launchCodex, } = useInstances(); const { @@ -62,7 +64,11 @@ function App() { const [configModalError, setConfigModalError] = useState(null); const [configCopied, setConfigCopied] = useState(false); const [switchingId, setSwitchingId] = useState(null); - const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [deleteDialog, setDeleteDialog] = useState<{ + accountId: string; + clearCodexAuth: boolean; + } | null>(null); + const [isDeletingAccount, setIsDeletingAccount] = useState(false); const [processInfo, setProcessInfo] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const [isExportingSlim, setIsExportingSlim] = useState(false); @@ -247,18 +253,29 @@ function App() { } }; - const handleDelete = async (accountId: string) => { - if (deleteConfirmId !== accountId) { - setDeleteConfirmId(accountId); - setTimeout(() => setDeleteConfirmId(null), 3000); - return; - } + const openDeleteDialog = (accountId: string) => { + setDeleteDialog({ accountId, clearCodexAuth: false }); + }; + + const handleDelete = async () => { + if (!deleteDialog) return; try { - await deleteAccount(accountId); - setDeleteConfirmId(null); + setIsDeletingAccount(true); + await deleteAccount(deleteDialog.accountId, { + clearCodexAuth: deleteDialog.clearCodexAuth, + }); + setDeleteDialog(null); + showWarmupToast( + deleteDialog.clearCodexAuth + ? "Account removed and Codex auth.json cleared" + : "Account removed" + ); } catch (err) { console.error("Failed to delete account:", err); + showWarmupToast(`Delete failed: ${formatWarmupError(err)}`, true); + } finally { + setIsDeletingAccount(false); } }; @@ -425,7 +442,11 @@ function App() { const activeAccount = accounts.find((a) => a.is_active); const otherAccounts = accounts.filter((a) => !a.is_active); + const deleteDialogAccount = deleteDialog + ? accounts.find((a) => a.id === deleteDialog.accountId) + : null; const hasRunningProcesses = processInfo && processInfo.count > 0; + const switchBlocked = processInfo ? !processInfo.can_switch : false; const sortedOtherAccounts = useMemo(() => { const getResetDeadline = (resetAt: number | null | undefined) => @@ -758,13 +779,13 @@ function App() { onWarmup={() => handleWarmupAccount(activeAccount.id, activeAccount.name) } - onDelete={() => handleDelete(activeAccount.id)} + onDelete={() => openDeleteDialog(activeAccount.id)} onRefresh={() => refreshSingleUsage(activeAccount.id, { refreshMetadata: true }) } onRename={(newName) => renameAccount(activeAccount.id, newName)} switching={switchingId === activeAccount.id} - switchDisabled={hasRunningProcesses ?? false} + switchDisabled={switchBlocked} warmingUp={isWarmingAll || warmingUpId === activeAccount.id} masked={maskedAccounts.has(activeAccount.id)} onToggleMask={() => toggleMask(activeAccount.id)} @@ -783,6 +804,8 @@ function App() { onSwitchInstance={switchInstance} onRemoveInstance={removeInstance} onBindAccount={bindAccount} + onGetLaunchCommand={getLaunchCommand} + onLaunchCodex={launchCodex} /> {/* Other Accounts */} @@ -813,18 +836,18 @@ function App() { } className="appearance-none font-sans text-xs sm:text-sm font-medium pl-3 pr-9 py-2 rounded-xl border border-gray-300 dark:border-gray-700 bg-gradient-to-b from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 text-gray-700 dark:text-gray-200 shadow-sm hover:border-gray-400 dark:hover:border-gray-600 hover:shadow focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-600 focus:border-gray-400 dark:focus:border-gray-600 transition-all" > - - - + + - - - @@ -849,13 +872,13 @@ function App() { account={account} onSwitch={() => handleSwitch(account.id)} onWarmup={() => handleWarmupAccount(account.id, account.name)} - onDelete={() => handleDelete(account.id)} + onDelete={() => openDeleteDialog(account.id)} onRefresh={() => refreshSingleUsage(account.id, { refreshMetadata: true }) } onRename={(newName) => renameAccount(account.id, newName)} switching={switchingId === account.id} - switchDisabled={hasRunningProcesses ?? false} + switchDisabled={switchBlocked} warmingUp={isWarmingAll || warmingUpId === account.id} masked={maskedAccounts.has(account.id)} onToggleMask={() => toggleMask(account.id)} @@ -887,10 +910,63 @@ function App() { )} - {/* Delete Confirmation Toast */} - {deleteConfirmId && ( -
- Click delete again to confirm removal + {/* Delete Confirmation Modal */} + {deleteDialog && deleteDialogAccount && ( +
+
+

+ Remove account? +

+

+ {deleteDialogAccount.name} will be removed from Codex Switcher. +

+ {deleteDialogAccount.is_active ? ( +
+ This is the active account. Removing it clears Active Account in the app, but + Codex can keep using the current auth.json until you switch another account or + clear it here. +
+ ) : ( +
+ Codex auth.json will not be changed. +
+ )} + {deleteDialogAccount.is_active && ( + + )} +
+ + +
+
)} diff --git a/src/components/AccountCard.tsx b/src/components/AccountCard.tsx index ab4749cb..e9ec089b 100644 --- a/src/components/AccountCard.tsx +++ b/src/components/AccountCard.tsx @@ -246,7 +246,11 @@ export function AccountCard({ {/* Usage */}
- +
{/* Last refresh time */} @@ -279,9 +283,9 @@ export function AccountCard({ ? "bg-gray-200 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed" : "bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-gray-200 text-white dark:text-gray-900" }`} - title={switchDisabled ? "Close all Codex processes first" : undefined} + title={switchDisabled ? "Switching is temporarily unavailable" : undefined} > - {switching ? "Switching..." : switchDisabled ? "Codex Running" : "Switch"} + {switching ? "Switching..." : switchDisabled ? "Unavailable" : "Switch"} )} )} + +