diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..760723be --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This is a Tauri 2 desktop app with a React 19 + TypeScript frontend and Rust backend. Frontend code lives in `src/`: `components/` for UI, `hooks/` for React state logic, `lib/platform.ts` for Tauri/web backend calls, and `types/` for shared TypeScript shapes. Rust code lives in `src-tauri/src/`, grouped by `auth/`, `api/`, `commands/`, and `web.rs`. Icons and platform assets are under `src-tauri/icons/`; release/version helpers are in `scripts/`. + +## Build, Test, and Development Commands + +- `pnpm install`: install JavaScript dependencies from `pnpm-lock.yaml`. +- `pnpm tauri dev`: run the desktop app in development mode. +- `pnpm build`: run TypeScript checks and build the Vite frontend. +- `pnpm tauri build`: build the production desktop bundles under `src-tauri/target/release/bundle/`. +- `pnpm lan`: build the frontend and run the Rust web dashboard on `0.0.0.0:3210`. +- `cargo test --manifest-path src-tauri/Cargo.toml`: run Rust tests when backend tests are added. + +## Coding Style & Naming Conventions + +Use TypeScript strict mode as enforced by `tsconfig.json`: no unused locals or parameters, no implicit type looseness, and JSX via `react-jsx`. Match existing formatting: two-space indentation, double-quoted imports, semicolons, and named exports for components. Name React components in `PascalCase`, hooks as `useSomething`, and shared types/interfaces descriptively. For Rust, follow `rustfmt`, use `snake_case` for modules/functions, and keep Tauri command handlers in `src-tauri/src/commands/`. + +## Testing Guidelines + +There is no dedicated frontend test runner configured yet, so treat `pnpm build` as required validation for TypeScript and Vite changes. For backend logic, add focused Rust unit tests near the module under test or integration tests under `src-tauri/tests/`. Name tests by behavior, for example `refreshes_expired_token`. Manually verify UI changes with `pnpm tauri dev`; include platform checks when changing Tauri config, updater behavior, process handling, or file dialogs. + +## Commit & Pull Request Guidelines + +Recent history uses short imperative subjects, with Conventional-style prefixes for release chores, for example `chore: release 0.2.2` and `Add release script`. Keep commits focused and describe the user-visible change or maintenance task. Pull requests should include a summary, validation commands, linked issues when applicable, and screenshots or recordings for UI changes. For Tauri/Rust changes, note tested platforms and relevant environment variables such as `CODEX_SWITCHER_WEB_HOST` or `CODEX_SWITCHER_WEB_PORT`. + +## Security & Configuration Tips + +This app manages Codex account data. Do not commit `auth.json`, decrypted exports, local backups, tokens, or machine-specific secrets. Prefer encrypted full exports for backups, and document new configuration in `README.md`. + + +## Language +Use **Chinese** for: +- Task execution results and error messages +- Confirmations and clarifications with the user +- Solution descriptions and to-do items +- Commit info for git + diff --git a/README.md b/README.md index 9b84f490..22630687 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,21 @@ - **Multi-Account Management** – Add and manage multiple Codex accounts in one place - **Quick Switching** – Switch between accounts with a single click - **Usage Monitoring** – View real-time usage for both 5-hour and weekly limits +- **Usage Automation** – Configure low-usage warnings, automatic switching, and manual account priority - **Dual Login Mode** – OAuth authentication or import existing `auth.json` files +## Usage Automation + +Codex Switcher can warn you when the active account is running low on 5-hour quota and can optionally switch to another account automatically. + +- Warning threshold defaults to `10%` remaining. +- Auto-switch threshold defaults to `5%` remaining. +- Auto-switch is disabled by default. +- When auto-switch is enabled, choose either **Usage first** or **Reset first**. +- When auto-switch is disabled, adjust the manual account priority and switch accounts yourself. + +Automatic switching only uses 5-hour limit data. If Codex is currently running, the app will pause automatic switching and show a reminder instead of changing `~/.codex/auth.json`. + ## Installation ### Prerequisites diff --git a/src-tauri/src/auth/storage.rs b/src-tauri/src/auth/storage.rs index 0ee5573b..3a1784a0 100644 --- a/src-tauri/src/auth/storage.rs +++ b/src-tauri/src/auth/storage.rs @@ -1,12 +1,13 @@ //! Account storage module - manages reading and writing accounts.json +use std::collections::HashSet; use std::fs; use std::path::PathBuf; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; -use crate::types::{AccountsStore, AuthData, StoredAccount}; +use crate::types::{AccountsStore, AuthData, StoredAccount, UsageAutomationSettings}; /// Get the path to the codex-switcher config directory pub fn get_config_dir() -> Result { @@ -63,6 +64,37 @@ pub fn save_accounts(store: &AccountsStore) -> Result<()> { Ok(()) } +/// Return a priority list containing each current account exactly once. +pub fn normalized_priority_account_ids( + accounts: &[StoredAccount], + priority_account_ids: &[String], +) -> Vec { + let account_ids: HashSet<&str> = accounts.iter().map(|account| account.id.as_str()).collect(); + let mut seen = HashSet::new(); + let mut normalized = Vec::with_capacity(accounts.len()); + + for account_id in priority_account_ids { + if account_ids.contains(account_id.as_str()) && seen.insert(account_id.as_str()) { + normalized.push(account_id.clone()); + } + } + + for account in accounts { + if seen.insert(account.id.as_str()) { + normalized.push(account.id.clone()); + } + } + + normalized +} + +fn sync_usage_automation_priority(store: &mut AccountsStore) { + store.usage_automation.priority_account_ids = normalized_priority_account_ids( + &store.accounts, + &store.usage_automation.priority_account_ids, + ); +} + /// Add a new account to the store pub fn add_account(account: StoredAccount) -> Result { let mut store = load_accounts()?; @@ -74,6 +106,7 @@ pub fn add_account(account: StoredAccount) -> Result { let account_clone = account.clone(); store.accounts.push(account); + sync_usage_automation_priority(&mut store); // If this is the first account, make it active if store.accounts.len() == 1 { @@ -99,6 +132,7 @@ pub fn remove_account(account_id: &str) -> Result<()> { if store.active_account_id.as_deref() == Some(account_id) { store.active_account_id = store.accounts.first().map(|a| a.id.clone()); } + sync_usage_automation_priority(&mut store); save_accounts(&store)?; Ok(()) @@ -263,3 +297,79 @@ pub fn set_masked_account_ids(ids: Vec) -> Result<()> { save_accounts(&store)?; Ok(()) } + +/// Get usage warning and automatic switching settings. +pub fn get_usage_automation_settings() -> Result { + let store = load_accounts()?; + let mut settings = store.usage_automation.clone(); + settings.priority_account_ids = + normalized_priority_account_ids(&store.accounts, &settings.priority_account_ids); + Ok(settings) +} + +/// Set usage warning and automatic switching settings. +pub fn set_usage_automation_settings(mut settings: UsageAutomationSettings) -> Result<()> { + if !settings.warning_remaining_percent.is_finite() + || !settings.auto_switch_remaining_percent.is_finite() + { + anyhow::bail!("Usage thresholds must be finite numbers"); + } + + if !(0.0..=100.0).contains(&settings.warning_remaining_percent) + || !(0.0..=100.0).contains(&settings.auto_switch_remaining_percent) + { + anyhow::bail!("Usage thresholds must be between 0 and 100"); + } + + if settings.warning_remaining_percent < settings.auto_switch_remaining_percent { + anyhow::bail!("Warning threshold must be greater than or equal to auto-switch threshold"); + } + + let mut store = load_accounts()?; + settings.priority_account_ids = + normalized_priority_account_ids(&store.accounts, &settings.priority_account_ids); + store.usage_automation = settings; + save_accounts(&store)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::normalized_priority_account_ids; + use crate::types::StoredAccount; + + fn account_with_id(id: &str) -> StoredAccount { + let mut account = StoredAccount::new_api_key(id.to_string(), format!("key-{id}")); + account.id = id.to_string(); + account + } + + #[test] + fn normalized_priority_removes_invalid_and_duplicate_ids() { + let accounts = vec![ + account_with_id("first"), + account_with_id("second"), + account_with_id("third"), + ]; + + let normalized = normalized_priority_account_ids( + &accounts, + &[ + "missing".to_string(), + "second".to_string(), + "second".to_string(), + "first".to_string(), + ], + ); + + assert_eq!(normalized, vec!["second", "first", "third"]); + } + + #[test] + fn normalized_priority_preserves_account_order_when_empty() { + let accounts = vec![account_with_id("first"), account_with_id("second")]; + let normalized = normalized_priority_account_ids(&accounts, &[]); + + assert_eq!(normalized, vec!["first", "second"]); + } +} diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 16c1c6ff..8ef3a96f 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -2,10 +2,14 @@ use crate::auth::{ add_account, create_chatgpt_account_from_refresh_token, get_active_account, - import_from_auth_json, import_from_auth_json_contents, load_accounts, remove_account, - save_accounts, set_active_account, switch_to_account, touch_account, + import_from_auth_json, import_from_auth_json_contents, load_accounts, + normalized_priority_account_ids, remove_account, save_accounts, set_active_account, + switch_to_account, touch_account, +}; +use crate::types::{ + AccountInfo, AccountsStore, AuthData, ImportAccountsSummary, StoredAccount, + UsageAutomationSettings, }; -use crate::types::{AccountInfo, AccountsStore, AuthData, ImportAccountsSummary, StoredAccount}; use anyhow::Context; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; @@ -18,7 +22,7 @@ use futures::{stream, StreamExt}; use pbkdf2::pbkdf2_hmac; use rand::RngCore; use sha2::Sha256; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::{Read, Write}; @@ -71,10 +75,26 @@ struct SlimAccountPayload { pub async fn list_accounts() -> Result, String> { let store = load_accounts().map_err(|e| e.to_string())?; let active_id = store.active_account_id.as_deref(); - - let accounts: Vec = store - .accounts + let priority_account_ids = normalized_priority_account_ids( + &store.accounts, + &store.usage_automation.priority_account_ids, + ); + let priority_index: HashMap<&str, usize> = priority_account_ids .iter() + .enumerate() + .map(|(index, account_id)| (account_id.as_str(), index)) + .collect(); + + let mut accounts = store.accounts.iter().collect::>(); + accounts.sort_by_key(|account| { + priority_index + .get(account.id.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + + let accounts: Vec = accounts + .into_iter() .map(|a| AccountInfo::from_stored(a, active_id)) .collect(); @@ -482,6 +502,10 @@ async fn build_store_from_slim_payload( accounts, active_account_id, masked_account_ids: Vec::new(), + usage_automation: UsageAutomationSettings { + priority_account_ids: Vec::new(), + ..UsageAutomationSettings::default() + }, }) } @@ -678,8 +702,10 @@ fn merge_accounts_store( mut current: AccountsStore, imported: AccountsStore, ) -> (AccountsStore, ImportAccountsSummary) { + let current_was_empty = current.accounts.is_empty(); let imported_version = imported.version; let imported_active_id = imported.active_account_id; + let imported_usage_automation = imported.usage_automation; 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(); @@ -697,6 +723,13 @@ fn merge_accounts_store( } current.version = current.version.max(imported_version).max(1); + if current_was_empty { + current.usage_automation = imported_usage_automation; + } + current.usage_automation.priority_account_ids = normalized_priority_account_ids( + ¤t.accounts, + ¤t.usage_automation.priority_account_ids, + ); let current_active_is_valid = current .active_account_id @@ -736,3 +769,17 @@ pub async fn get_masked_account_ids() -> Result, String> { pub async fn set_masked_account_ids(ids: Vec) -> Result<(), String> { crate::auth::storage::set_masked_account_ids(ids).map_err(|e| e.to_string()) } + +/// Get usage warning and automatic switching settings +#[tauri::command] +pub async fn get_usage_automation_settings() -> Result { + crate::auth::storage::get_usage_automation_settings().map_err(|e| e.to_string()) +} + +/// Set usage warning and automatic switching settings +#[tauri::command] +pub async fn set_usage_automation_settings( + settings: UsageAutomationSettings, +) -> Result<(), String> { + crate::auth::storage::set_usage_automation_settings(settings).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/process.rs b/src-tauri/src/commands/process.rs index d4389e06..6eb3e2e6 100644 --- a/src-tauri/src/commands/process.rs +++ b/src-tauri/src/commands/process.rs @@ -1,6 +1,8 @@ //! Process detection commands use std::process::Command; +use std::thread::sleep; +use std::time::{Duration, Instant}; #[cfg(windows)] use anyhow::Context; @@ -54,6 +56,166 @@ pub async fn check_codex_processes() -> Result { }) } +/// Close active Codex desktop or CLI processes and verify they are gone. +#[tauri::command] +pub async fn close_codex_processes() -> Result { + close_active_codex_processes().map_err(|e| e.to_string()) +} + +/// Open the Codex desktop app. +#[tauri::command] +pub async fn open_codex_app() -> Result { + open_codex_desktop_app().map_err(|e| e.to_string()) +} + +fn close_active_codex_processes() -> anyhow::Result { + let (pids, _) = find_codex_processes()?; + + for pid in &pids { + terminate_process(*pid, false); + } + + let (mut remaining_pids, mut background_count) = + wait_for_active_codex_exit(Duration::from_secs(4))?; + + if !remaining_pids.is_empty() { + for pid in &remaining_pids { + terminate_process(*pid, true); + } + (remaining_pids, background_count) = wait_for_active_codex_exit(Duration::from_secs(8))?; + } + + if !remaining_pids.is_empty() { + anyhow::bail!( + "Timed out while closing Codex processes: {}", + remaining_pids + .iter() + .map(u32::to_string) + .collect::>() + .join(", ") + ); + } + + Ok(CodexProcessInfo { + count: 0, + background_count, + can_switch: true, + pids: Vec::new(), + }) +} + +fn open_codex_desktop_app() -> anyhow::Result { + #[cfg(windows)] + { + const POWERSHELL_SCRIPT: &str = r#" +$app = Get-StartApps | + Where-Object { $_.Name -eq 'Codex' -and $_.AppID -notmatch 'codex-switcher' } | + Select-Object -First 1 + +if (-not $app) { + $app = Get-StartApps | + Where-Object { $_.AppID -match '^OpenAI\.Codex_' } | + Select-Object -First 1 +} + +if (-not $app) { + throw 'Codex desktop app was not found.' +} + +Start-Process "shell:AppsFolder\$($app.AppID)" +"#; + + let output = Command::new("powershell.exe") + .creation_flags(CREATE_NO_WINDOW) + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + POWERSHELL_SCRIPT, + ]) + .output() + .context("failed to launch Codex desktop app")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Failed to launch Codex desktop app: {}", stderr.trim()); + } + } + + #[cfg(target_os = "macos")] + { + let output = Command::new("open") + .args(["-a", "Codex"]) + .output() + .context("failed to launch Codex desktop app")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Failed to launch Codex desktop app: {}", stderr.trim()); + } + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + Command::new("codex") + .spawn() + .context("failed to launch codex")?; + } + + let (pids, background_count) = wait_for_active_codex_start(Duration::from_secs(10))?; + Ok(CodexProcessInfo { + count: pids.len(), + background_count, + can_switch: pids.is_empty(), + pids, + }) +} + +fn wait_for_active_codex_exit(timeout: Duration) -> anyhow::Result<(Vec, usize)> { + let deadline = Instant::now() + timeout; + loop { + let (pids, background_count) = find_codex_processes()?; + if pids.is_empty() || Instant::now() >= deadline { + return Ok((pids, background_count)); + } + sleep(Duration::from_millis(250)); + } +} + +fn wait_for_active_codex_start(timeout: Duration) -> anyhow::Result<(Vec, usize)> { + let deadline = Instant::now() + timeout; + loop { + let (pids, background_count) = find_codex_processes()?; + if !pids.is_empty() || Instant::now() >= deadline { + return Ok((pids, background_count)); + } + sleep(Duration::from_millis(250)); + } +} + +fn terminate_process(pid: u32, force: bool) { + #[cfg(unix)] + { + let signal = if force { "-KILL" } else { "-TERM" }; + let _ = Command::new("kill") + .args([signal, &pid.to_string()]) + .output(); + } + + #[cfg(windows)] + { + let mut command = Command::new("taskkill"); + command.creation_flags(CREATE_NO_WINDOW); + command.args(["/PID", &pid.to_string(), "/T"]); + if force { + command.arg("/F"); + } + let _ = command.output(); + } +} + /// Find all running codex processes. Returns (active_pids, background_count) fn find_codex_processes() -> anyhow::Result<(Vec, usize)> { #[cfg(unix)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a357b58f..9881cea8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,11 +7,12 @@ pub mod types; pub mod web; use commands::{ - add_account_from_file, cancel_login, check_codex_processes, complete_login, 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, + add_account_from_file, cancel_login, check_codex_processes, close_codex_processes, + complete_login, delete_account, export_accounts_full_encrypted_file, export_accounts_slim_text, + get_active_account_info, get_masked_account_ids, get_usage, get_usage_automation_settings, + import_accounts_full_encrypted_file, import_accounts_slim_text, list_accounts, open_codex_app, + refresh_account_metadata, refresh_all_accounts_usage, rename_account, set_masked_account_ids, + set_usage_automation_settings, start_login, switch_account, warmup_account, warmup_all_accounts, }; @@ -42,6 +43,9 @@ pub fn run() { // Masked accounts get_masked_account_ids, set_masked_account_ids, + // Usage automation + get_usage_automation_settings, + set_usage_automation_settings, // OAuth start_login, complete_login, @@ -54,6 +58,8 @@ pub fn run() { warmup_all_accounts, // Process detection check_codex_processes, + close_codex_processes, + open_codex_app, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 0024aef4..2dd0468e 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -17,6 +17,9 @@ pub struct AccountsStore { /// Set of account IDs that are masked (hidden) #[serde(default)] pub masked_account_ids: Vec, + /// Usage warning and auto-switch settings + #[serde(default)] + pub usage_automation: UsageAutomationSettings, } impl Default for AccountsStore { @@ -26,6 +29,56 @@ impl Default for AccountsStore { accounts: Vec::new(), active_account_id: None, masked_account_ids: Vec::new(), + usage_automation: UsageAutomationSettings::default(), + } + } +} + +/// Strategy used when choosing an automatic switch target. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AutoSwitchStrategy { + /// Choose the account with the most remaining 5h usage. + RemainingDesc, + /// Choose the account whose 5h usage window resets soonest. + ResetTimeAsc, +} + +impl Default for AutoSwitchStrategy { + fn default() -> Self { + Self::RemainingDesc + } +} + +/// Configurable usage warning and automatic switching behavior. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageAutomationSettings { + pub warning_remaining_percent: f64, + pub auto_switch_remaining_percent: f64, + pub auto_switch_enabled: bool, + #[serde(default)] + pub auto_close_codex_on_switch: bool, + #[serde(default = "default_auto_reopen_codex_after_switch")] + pub auto_reopen_codex_after_switch: bool, + pub auto_switch_strategy: AutoSwitchStrategy, + #[serde(default)] + pub priority_account_ids: Vec, +} + +fn default_auto_reopen_codex_after_switch() -> bool { + true +} + +impl Default for UsageAutomationSettings { + fn default() -> Self { + Self { + warning_remaining_percent: 10.0, + auto_switch_remaining_percent: 5.0, + auto_switch_enabled: false, + auto_close_codex_on_switch: false, + auto_reopen_codex_after_switch: true, + auto_switch_strategy: AutoSwitchStrategy::default(), + priority_account_ids: Vec::new(), } } } diff --git a/src-tauri/src/web.rs b/src-tauri/src/web.rs index a2bd2f1e..0a971223 100644 --- a/src-tauri/src/web.rs +++ b/src-tauri/src/web.rs @@ -11,12 +11,14 @@ 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, + close_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, + get_usage_automation_settings, import_accounts_full_encrypted_bytes, import_accounts_slim_text, + list_accounts, open_codex_app, refresh_account_metadata, refresh_all_accounts_usage, + rename_account, set_masked_account_ids, set_usage_automation_settings, start_login, + switch_account, warmup_account, warmup_all_accounts, }; +use crate::types::UsageAutomationSettings; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -51,6 +53,11 @@ struct MaskedIdsArgs { ids: Vec, } +#[derive(Debug, Deserialize)] +struct UsageAutomationSettingsArgs { + settings: UsageAutomationSettings, +} + #[derive(Debug, Deserialize)] struct UploadAuthJsonArgs { name: String, @@ -190,7 +197,14 @@ async fn invoke_web_command(command: &str, payload: Value) -> Result to_json(get_usage_automation_settings().await?), + "set_usage_automation_settings" => { + let args: UsageAutomationSettingsArgs = parse_args(payload)?; + to_json(set_usage_automation_settings(args.settings).await?) + } "check_codex_processes" => to_json(check_codex_processes().await?), + "close_codex_processes" => to_json(close_codex_processes().await?), + "open_codex_app" => to_json(open_codex_app().await?), _ => Err(format!("Unsupported web command: {command}")), } } diff --git a/src/App.tsx b/src/App.tsx index 21c7e924..472652ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,12 @@ 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 type { CodexProcessInfo } from "./types"; +import type { + AutoSwitchStrategy, + CodexProcessInfo, + UsageAutomationSettings, + UsageInfo, +} from "./types"; import { exportFullBackupFile, importFullBackupFile, @@ -13,11 +18,42 @@ import "./App.css"; const THEME_STORAGE_KEY = "codex-switcher-theme"; type ThemeMode = "light" | "dark"; +type OtherAccountsSort = + | "priority" + | "deadline_asc" + | "deadline_desc" + | "remaining_desc" + | "remaining_asc" + | "subscription_asc" + | "subscription_desc"; + +const DEFAULT_USAGE_AUTOMATION_SETTINGS: UsageAutomationSettings = { + warning_remaining_percent: 10, + auto_switch_remaining_percent: 5, + auto_switch_enabled: false, + auto_close_codex_on_switch: false, + auto_reopen_codex_after_switch: true, + auto_switch_strategy: "remaining_desc", + priority_account_ids: [], +}; + const appWindow = getCurrentWindow(); const isMacOs = typeof navigator !== "undefined" && /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent); +function getPrimaryRemainingPercent(usage: UsageInfo | undefined): number | null { + if (!usage || usage.error) return null; + if (usage.primary_used_percent === null || usage.primary_used_percent === undefined) { + return null; + } + return Math.max(0, 100 - usage.primary_used_percent); +} + +function formatThreshold(value: number): string { + return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1); +} + function App() { const { accounts, @@ -39,9 +75,12 @@ function App() { cancelOAuthLogin, loadMaskedAccountIds, saveMaskedAccountIds, + loadUsageAutomationSettings, + saveUsageAutomationSettings, } = useAccounts(); const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [configModalMode, setConfigModalMode] = useState<"slim_export" | "slim_import">( "slim_export" @@ -65,16 +104,18 @@ function App() { isError: boolean; } | null>(null); const [maskedAccounts, setMaskedAccounts] = useState>(new Set()); - const [otherAccountsSort, setOtherAccountsSort] = useState< - | "deadline_asc" - | "deadline_desc" - | "remaining_desc" - | "remaining_asc" - | "subscription_asc" - | "subscription_desc" - >("deadline_asc"); + const [usageAutomationSettings, setUsageAutomationSettings] = + useState(DEFAULT_USAGE_AUTOMATION_SETTINGS); + const [settingsDraft, setSettingsDraft] = + useState(DEFAULT_USAGE_AUTOMATION_SETTINGS); + const [settingsError, setSettingsError] = useState(null); + const [isSavingSettings, setIsSavingSettings] = useState(false); + const [settingsLoaded, setSettingsLoaded] = useState(false); + const [otherAccountsSort, setOtherAccountsSort] = + useState("priority"); const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false); const actionsMenuRef = useRef(null); + const lastAutomationActionRef = useRef(null); const [themeMode, setThemeMode] = useState(() => { if (typeof window === "undefined") return "light"; try { @@ -86,6 +127,61 @@ function App() { }); const [isWindowMaximized, setIsWindowMaximized] = useState(false); + const accountIds = useMemo(() => accounts.map((account) => account.id), [accounts]); + + const normalizePriorityAccountIds = useCallback( + (priorityAccountIds: string[]) => { + const validIds = new Set(accountIds); + const seen = new Set(); + const normalized: string[] = []; + + priorityAccountIds.forEach((accountId) => { + if (validIds.has(accountId) && !seen.has(accountId)) { + seen.add(accountId); + normalized.push(accountId); + } + }); + + accountIds.forEach((accountId) => { + if (!seen.has(accountId)) { + seen.add(accountId); + normalized.push(accountId); + } + }); + + return normalized; + }, + [accountIds] + ); + + const effectiveUsageAutomationSettings = useMemo( + () => ({ + ...usageAutomationSettings, + priority_account_ids: normalizePriorityAccountIds( + usageAutomationSettings.priority_account_ids + ), + }), + [normalizePriorityAccountIds, usageAutomationSettings] + ); + + const priorityIndex = useMemo(() => { + return new Map( + effectiveUsageAutomationSettings.priority_account_ids.map((accountId, index) => [ + accountId, + index, + ]) + ); + }, [effectiveUsageAutomationSettings.priority_account_ids]); + + const accountsById = useMemo(() => { + return new Map(accounts.map((account) => [account.id, account])); + }, [accounts]); + + const settingsDraftPriorityAccountIds = useMemo( + () => normalizePriorityAccountIds(settingsDraft.priority_account_ids), + [normalizePriorityAccountIds, settingsDraft.priority_account_ids] + ); + const handleTitlebarDrag = useCallback( (event: React.MouseEvent) => { if (!isTauriRuntime() || event.button !== 0) return; @@ -147,6 +243,18 @@ function App() { } }, []); + const closeCodexProcesses = useCallback(async () => { + const info = await invokeBackend("close_codex_processes"); + setProcessInfo(info); + return info; + }, []); + + const openCodexApp = useCallback(async () => { + const info = await invokeBackend("open_codex_app"); + setProcessInfo(info); + return info; + }, []); + // Check processes on mount and periodically useEffect(() => { checkProcesses(); @@ -163,6 +271,30 @@ function App() { }); }, [loadMaskedAccountIds]); + // Load usage automation settings from storage on mount + useEffect(() => { + let cancelled = false; + + loadUsageAutomationSettings() + .then((settings) => { + if (cancelled) return; + setUsageAutomationSettings(settings); + setSettingsDraft(settings); + }) + .catch((err) => { + console.error("Failed to load usage automation settings:", err); + }) + .finally(() => { + if (!cancelled) { + setSettingsLoaded(true); + } + }); + + return () => { + cancelled = true; + }; + }, [loadUsageAutomationSettings]); + useEffect(() => { if (!isActionsMenuOpen) return; @@ -262,12 +394,12 @@ function App() { } }; - const showWarmupToast = (message: string, isError = false) => { + const showWarmupToast = useCallback((message: string, isError = false) => { setWarmupToast({ message, isError }); setTimeout(() => setWarmupToast(null), 2500); - }; + }, []); - const formatWarmupError = (err: unknown) => { + const formatWarmupError = useCallback((err: unknown) => { if (!err) return "Unknown error"; if (err instanceof Error && err.message) return err.message; if (typeof err === "string") return err; @@ -276,6 +408,107 @@ function App() { } catch { return "Unknown error"; } + }, []); + + const openSettingsModal = () => { + setSettingsDraft({ + ...effectiveUsageAutomationSettings, + priority_account_ids: normalizePriorityAccountIds( + effectiveUsageAutomationSettings.priority_account_ids + ), + }); + setSettingsError(null); + setIsSettingsModalOpen(true); + }; + + const updateSettingsDraftPercent = ( + key: "warning_remaining_percent" | "auto_switch_remaining_percent", + value: string + ) => { + const parsed = Number(value); + setSettingsDraft((prev) => ({ + ...prev, + [key]: Number.isFinite(parsed) ? parsed : 0, + })); + }; + + const movePriorityAccount = (accountId: string, direction: -1 | 1) => { + setSettingsDraft((prev) => { + const priorityIds = normalizePriorityAccountIds(prev.priority_account_ids); + const currentIndex = priorityIds.indexOf(accountId); + const nextIndex = currentIndex + direction; + + if ( + currentIndex < 0 || + nextIndex < 0 || + nextIndex >= priorityIds.length + ) { + return prev; + } + + const nextPriorityIds = [...priorityIds]; + const [moved] = nextPriorityIds.splice(currentIndex, 1); + nextPriorityIds.splice(nextIndex, 0, moved); + + return { + ...prev, + priority_account_ids: nextPriorityIds, + }; + }); + }; + + const handleSaveSettings = async () => { + const nextSettings: UsageAutomationSettings = { + ...settingsDraft, + warning_remaining_percent: Number(settingsDraft.warning_remaining_percent), + auto_switch_remaining_percent: Number( + settingsDraft.auto_switch_remaining_percent + ), + priority_account_ids: normalizePriorityAccountIds( + settingsDraft.priority_account_ids + ), + }; + + if ( + !Number.isFinite(nextSettings.warning_remaining_percent) || + !Number.isFinite(nextSettings.auto_switch_remaining_percent) + ) { + setSettingsError("Thresholds must be valid numbers."); + return; + } + + if ( + nextSettings.warning_remaining_percent < 0 || + nextSettings.warning_remaining_percent > 100 || + nextSettings.auto_switch_remaining_percent < 0 || + nextSettings.auto_switch_remaining_percent > 100 + ) { + setSettingsError("Thresholds must be between 0 and 100."); + return; + } + + if ( + nextSettings.warning_remaining_percent < + nextSettings.auto_switch_remaining_percent + ) { + setSettingsError("Warning threshold must be greater than or equal to auto-switch threshold."); + return; + } + + try { + setIsSavingSettings(true); + setSettingsError(null); + await saveUsageAutomationSettings(nextSettings); + setUsageAutomationSettings(nextSettings); + setSettingsDraft(nextSettings); + setIsSettingsModalOpen(false); + showWarmupToast("Usage automation settings saved."); + } catch (err) { + console.error("Failed to save usage automation settings:", err); + setSettingsError(formatWarmupError(err)); + } finally { + setIsSavingSettings(false); + } }; const handleWarmupAccount = async (accountId: string, accountName: string) => { @@ -415,6 +648,183 @@ function App() { const activeAccount = accounts.find((a) => a.is_active); const otherAccounts = accounts.filter((a) => !a.is_active); const hasRunningProcesses = processInfo && processInfo.count > 0; + const activeRemainingPercent = getPrimaryRemainingPercent(activeAccount?.usage); + + const usageWarningMessage = + activeAccount && + activeRemainingPercent !== null && + activeRemainingPercent < effectiveUsageAutomationSettings.warning_remaining_percent + ? `${activeAccount.name} has ${formatThreshold(activeRemainingPercent)}% 5h usage remaining.` + : null; + + useEffect(() => { + if (!settingsLoaded || !activeAccount) return; + if (switchingId || accounts.some((account) => account.usageLoading)) return; + + const remainingPercent = getPrimaryRemainingPercent(activeAccount.usage); + if ( + remainingPercent === null || + remainingPercent >= + effectiveUsageAutomationSettings.auto_switch_remaining_percent + ) { + lastAutomationActionRef.current = null; + return; + } + + if (!effectiveUsageAutomationSettings.auto_switch_enabled) return; + + const accountUsageSnapshot = accounts + .map((account) => { + const remaining = getPrimaryRemainingPercent(account.usage); + return [ + account.id, + remaining === null ? "unknown" : formatThreshold(remaining), + account.usage?.primary_resets_at ?? "no-reset", + ].join("/"); + }) + .join("|"); + + const actionKey = [ + activeAccount.id, + formatThreshold(remainingPercent), + effectiveUsageAutomationSettings.auto_switch_remaining_percent, + effectiveUsageAutomationSettings.auto_switch_strategy, + effectiveUsageAutomationSettings.auto_close_codex_on_switch ? "close" : "pause", + effectiveUsageAutomationSettings.auto_reopen_codex_after_switch ? "reopen" : "stay-closed", + effectiveUsageAutomationSettings.priority_account_ids.join(","), + processInfo?.count ?? "unknown", + accountUsageSnapshot, + ].join(":"); + + if (lastAutomationActionRef.current === actionKey) return; + lastAutomationActionRef.current = actionKey; + + let cancelled = false; + + const runAutoSwitch = async () => { + const latestProcessInfo = await checkProcesses(); + if (cancelled) return; + let closedCodexForSwitch = false; + + if (!latestProcessInfo) { + showWarmupToast("Auto-switch paused. Could not check Codex processes.", true); + return; + } + + if (!latestProcessInfo.can_switch) { + if (!effectiveUsageAutomationSettings.auto_close_codex_on_switch) { + showWarmupToast( + "Auto-switch paused. Close all Codex processes before switching.", + true + ); + return; + } + + try { + showWarmupToast("Auto-switch is closing Codex before switching."); + const closedInfo = await closeCodexProcesses(); + if (cancelled) return; + if (!closedInfo.can_switch) { + showWarmupToast("Auto-switch paused. Codex is still running.", true); + return; + } + closedCodexForSwitch = true; + } catch (err) { + console.error("Failed to close Codex before auto-switch:", err); + showWarmupToast( + `Auto-switch paused. Could not close Codex: ${formatWarmupError(err)}`, + true + ); + return; + } + } + + const switchThreshold = + effectiveUsageAutomationSettings.auto_switch_remaining_percent; + const priorityRank = (accountId: string) => + priorityIndex.get(accountId) ?? Number.MAX_SAFE_INTEGER; + const candidates = accounts + .filter((account) => { + if (account.id === activeAccount.id) return false; + const accountRemaining = getPrimaryRemainingPercent(account.usage); + return accountRemaining !== null && accountRemaining >= switchThreshold; + }) + .sort((a, b) => { + const aRemaining = getPrimaryRemainingPercent(a.usage) ?? -1; + const bRemaining = getPrimaryRemainingPercent(b.usage) ?? -1; + + if ( + effectiveUsageAutomationSettings.auto_switch_strategy === + "remaining_desc" + ) { + const remainingDiff = bRemaining - aRemaining; + if (remainingDiff !== 0) return remainingDiff; + } else { + const aReset = a.usage?.primary_resets_at ?? Number.POSITIVE_INFINITY; + const bReset = b.usage?.primary_resets_at ?? Number.POSITIVE_INFINITY; + const resetDiff = aReset - bReset; + if (resetDiff !== 0) return resetDiff; + } + + const priorityDiff = priorityRank(a.id) - priorityRank(b.id); + if (priorityDiff !== 0) return priorityDiff; + return a.name.localeCompare(b.name); + }); + + const nextAccount = candidates[0]; + if (!nextAccount) { + showWarmupToast("Auto-switch skipped. No eligible account is available.", true); + return; + } + + try { + setSwitchingId(nextAccount.id); + await switchAccount(nextAccount.id); + if ( + closedCodexForSwitch && + effectiveUsageAutomationSettings.auto_reopen_codex_after_switch + ) { + try { + await openCodexApp(); + showWarmupToast(`Auto-switched to ${nextAccount.name} and reopened Codex.`); + } catch (err) { + console.error("Failed to reopen Codex after auto-switch:", err); + showWarmupToast( + `Auto-switched to ${nextAccount.name}, but Codex did not reopen: ${formatWarmupError(err)}`, + true + ); + } + } else { + showWarmupToast(`Auto-switched to ${nextAccount.name}.`); + } + } catch (err) { + console.error("Failed to auto-switch account:", err); + showWarmupToast(`Auto-switch failed: ${formatWarmupError(err)}`, true); + } finally { + setSwitchingId(null); + } + }; + + void runAutoSwitch(); + + return () => { + cancelled = true; + }; + }, [ + accounts, + activeAccount, + checkProcesses, + closeCodexProcesses, + effectiveUsageAutomationSettings, + formatWarmupError, + openCodexApp, + priorityIndex, + processInfo?.count, + settingsLoaded, + showWarmupToast, + switchingId, + switchAccount, + ]); const sortedOtherAccounts = useMemo(() => { const getResetDeadline = (resetAt: number | null | undefined) => @@ -466,6 +876,19 @@ function App() { getRemainingPercent(a.usage?.primary_used_percent); if (remainingDiff !== 0) return remainingDiff; + const priorityDiff = + (priorityIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (priorityIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER); + if (priorityDiff !== 0) return priorityDiff; + + return a.name.localeCompare(b.name); + } + + if (otherAccountsSort === "priority") { + const priorityDiff = + (priorityIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (priorityIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER); + if (priorityDiff !== 0) return priorityDiff; return a.name.localeCompare(b.name); } @@ -498,7 +921,7 @@ function App() { if (deadlineDiff !== 0) return deadlineDiff; return a.name.localeCompare(b.name); }); - }, [otherAccounts, otherAccountsSort]); + }, [otherAccounts, otherAccountsSort, priorityIndex]); return (
@@ -629,6 +1052,24 @@ function App() { > + +
+ +
+
+ + + +
+ +
+
+
+ Auto-switch +
+
+ {settingsDraft.auto_switch_enabled ? "Enabled" : "Disabled"} +
+
+ +
+ + {settingsDraft.auto_switch_enabled ? ( +
+
+
+
+ Close Codex before switching +
+
+ {settingsDraft.auto_close_codex_on_switch + ? "Codex will be closed when it blocks auto-switch." + : "Auto-switch pauses while Codex is running."} +
+
+ +
+ + {settingsDraft.auto_close_codex_on_switch && ( +
+
+
+ Reopen Codex after switching +
+
+ {settingsDraft.auto_reopen_codex_after_switch + ? "Codex will launch again after the account changes." + : "Codex stays closed after the account changes."} +
+
+ +
+ )} + +
+ Auto-switch strategy +
+
+ {( + [ + ["remaining_desc", "Usage first"], + ["reset_time_asc", "Reset first"], + ] as Array<[AutoSwitchStrategy, string]> + ).map(([strategy, label]) => ( + + ))} +
+
+ ) : ( +
+
+ Manual priority +
+
+ {settingsDraftPriorityAccountIds.length === 0 ? ( +
+ No accounts +
+ ) : ( + settingsDraftPriorityAccountIds.map((accountId, index) => { + const account = accountsById.get(accountId); + if (!account) return null; + + return ( +
+
+ {index + 1} +
+
+
+ {account.name} +
+ {account.email && ( +
+ {account.email} +
+ )} +
+
+ + +
+
+ ); + }) + )} +
+
+ )} + + {settingsError && ( +
+ {settingsError} +
+ )} +
+ +
+ + +
+ + + )} + {/* Import/Export Config Modal */} {isConfigModalOpen && (
diff --git a/src/hooks/useAccounts.ts b/src/hooks/useAccounts.ts index 219cdcc4..e63f66ad 100644 --- a/src/hooks/useAccounts.ts +++ b/src/hooks/useAccounts.ts @@ -5,6 +5,7 @@ import type { AccountWithUsage, WarmupSummary, ImportAccountsSummary, + UsageAutomationSettings, } from "../types"; import { invokeBackend, type FileSource } from "../lib/platform"; @@ -378,6 +379,18 @@ export function useAccounts() { } }, []); + const loadUsageAutomationSettings = useCallback(async () => { + return await invokeBackend("get_usage_automation_settings"); + }, []); + + const saveUsageAutomationSettings = useCallback( + async (settings: UsageAutomationSettings) => { + await invokeBackend("set_usage_automation_settings", { settings }); + await loadAccounts(true); + }, + [loadAccounts] + ); + useEffect(() => { loadAccounts().then((accountList) => refreshUsage(accountList)); @@ -411,5 +424,7 @@ export function useAccounts() { cancelOAuthLogin, loadMaskedAccountIds, saveMaskedAccountIds, + loadUsageAutomationSettings, + saveUsageAutomationSettings, }; } diff --git a/src/types/index.ts b/src/types/index.ts index f13f06d8..daf586af 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,7 @@ // Types matching the Rust backend export type AuthMode = "api_key" | "chat_g_p_t"; +export type AutoSwitchStrategy = "remaining_desc" | "reset_time_asc"; export interface AccountInfo { id: string; @@ -39,6 +40,16 @@ export interface AccountWithUsage extends AccountInfo { usageLoading?: boolean; } +export interface UsageAutomationSettings { + warning_remaining_percent: number; + auto_switch_remaining_percent: number; + auto_switch_enabled: boolean; + auto_close_codex_on_switch: boolean; + auto_reopen_codex_after_switch: boolean; + auto_switch_strategy: AutoSwitchStrategy; + priority_account_ids: string[]; +} + export interface CodexProcessInfo { count: number; background_count: number;