Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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`.

<!-- chinese-language-config:start -->
## 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
<!-- chinese-language-config:end -->
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 111 additions & 1 deletion src-tauri/src/auth/storage.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
//! 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 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<PathBuf> {
Expand Down Expand Up @@ -62,6 +63,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<String> {
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<StoredAccount> {
let mut store = load_accounts()?;
Expand All @@ -73,6 +105,7 @@ pub fn add_account(account: StoredAccount) -> Result<StoredAccount> {

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 {
Expand All @@ -98,6 +131,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(())
Expand Down Expand Up @@ -251,3 +285,79 @@ pub fn set_masked_account_ids(ids: Vec<String>) -> Result<()> {
save_accounts(&store)?;
Ok(())
}

/// Get usage warning and automatic switching settings.
pub fn get_usage_automation_settings() -> Result<UsageAutomationSettings> {
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"]);
}
}
61 changes: 54 additions & 7 deletions src-tauri/src/commands/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _};
Expand All @@ -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};

Expand Down Expand Up @@ -71,10 +75,26 @@ struct SlimAccountPayload {
pub async fn list_accounts() -> Result<Vec<AccountInfo>, String> {
let store = load_accounts().map_err(|e| e.to_string())?;
let active_id = store.active_account_id.as_deref();

let accounts: Vec<AccountInfo> = 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::<Vec<_>>();
accounts.sort_by_key(|account| {
priority_index
.get(account.id.as_str())
.copied()
.unwrap_or(usize::MAX)
});

let accounts: Vec<AccountInfo> = accounts
.into_iter()
.map(|a| AccountInfo::from_stored(a, active_id))
.collect();

Expand Down Expand Up @@ -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()
},
})
}

Expand Down Expand Up @@ -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<String> = current.accounts.iter().map(|a| a.id.clone()).collect();
Expand All @@ -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(
&current.accounts,
&current.usage_automation.priority_account_ids,
);

let current_active_is_valid = current
.active_account_id
Expand Down Expand Up @@ -736,3 +769,17 @@ pub async fn get_masked_account_ids() -> Result<Vec<String>, String> {
pub async fn set_masked_account_ids(ids: Vec<String>) -> 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<UsageAutomationSettings, String> {
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())
}
11 changes: 8 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ 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_all_accounts_usage, rename_account,
set_masked_account_ids, start_login, switch_account, warmup_account, warmup_all_accounts,
get_masked_account_ids, get_usage, get_usage_automation_settings,
import_accounts_full_encrypted_file, import_accounts_slim_text, list_accounts,
refresh_all_accounts_usage, rename_account, set_masked_account_ids,
set_usage_automation_settings, start_login, switch_account, warmup_account,
warmup_all_accounts,
};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
Expand Down Expand Up @@ -41,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,
Expand Down
Loading