Skip to content
Closed
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
85 changes: 76 additions & 9 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ tauri-plugin-opener = "2"
tauri-plugin-dialog = "2.6"
tauri-plugin-process = "2"
tauri-plugin-updater = "2.10"
tauri-plugin-autostart = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
Expand All @@ -42,3 +43,6 @@ pbkdf2 = "0.12"

[target.'cfg(target_os = "macos")'.dependencies]
plist = "1"

[target.'cfg(unix)'.dependencies]
libc = "0.2"
5 changes: 4 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"opener:default",
"dialog:default",
"updater:default",
"process:default"
"process:default",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled"
]
}
25 changes: 18 additions & 7 deletions src-tauri/src/auth/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,12 @@ pub fn save_app_settings(settings: &AppSettings) -> Result<()> {
}

let content = serde_json::to_string_pretty(settings).context("Failed to serialize settings")?;
fs::write(&path, content)
.with_context(|| format!("Failed to write settings file: {}", path.display()))?;

let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, &content)
.with_context(|| format!("Failed to write tmp settings file: {}", tmp_path.display()))?;
fs::rename(&tmp_path, &path)
.with_context(|| format!("Failed to rename tmp to settings file: {}", path.display()))?;

#[cfg(unix)]
{
Expand All @@ -78,11 +82,12 @@ pub fn save_app_settings(settings: &AppSettings) -> Result<()> {
Ok(())
}

/// Save the accounts store to disk
/// Save the accounts store to disk using an atomic write.
/// Writes to a temp file first then renames so a crash mid-write never
/// leaves a truncated/corrupt accounts.json.
pub fn save_accounts(store: &AccountsStore) -> Result<()> {
let path = get_accounts_file()?;

// Ensure the config directory exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
Expand All @@ -91,10 +96,16 @@ pub fn save_accounts(store: &AccountsStore) -> Result<()> {
let content =
serde_json::to_string_pretty(store).context("Failed to serialize accounts store")?;

fs::write(&path, content)
.with_context(|| format!("Failed to write accounts file: {}", path.display()))?;
// Atomic write: serialize → tmp file → rename.
// If the process crashes after serialization but before rename the original
// accounts.json is untouched; the orphaned .tmp is harmless.
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, &content)
.with_context(|| format!("Failed to write tmp accounts file: {}", tmp_path.display()))?;

fs::rename(&tmp_path, &path)
.with_context(|| format!("Failed to rename tmp to accounts file: {}", path.display()))?;

// Set restrictive permissions on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
Expand Down
81 changes: 81 additions & 0 deletions src-tauri/src/auth/switcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ pub fn get_codex_auth_file() -> Result<PathBuf> {

/// Switch to a specific account by writing its credentials to ~/.codex/auth.json
pub fn switch_to_account(account: &StoredAccount) -> Result<()> {
// Before writing, check if ~/.codex/auth.json already has fresher tokens
// for this same account (Codex rotates tokens during normal use). If so,
// absorb those tokens into accounts.json first so we never write stale
// credentials and trigger a refresh_token_reused error.
let account = &sync_tokens_from_auth_json(account)?;

let codex_home = get_codex_home()?;

// Ensure the codex home directory exists
Expand All @@ -54,6 +60,81 @@ pub fn switch_to_account(account: &StoredAccount) -> Result<()> {
Ok(())
}

/// If ~/.codex/auth.json holds a newer token set for the same ChatGPT account,
/// update accounts.json with those tokens before we proceed. This prevents
/// writing an old refresh token back and causing `refresh_token_reused`.
fn sync_tokens_from_auth_json(account: &StoredAccount) -> Result<StoredAccount> {
use crate::auth::storage::update_account_chatgpt_tokens;
use crate::types::parse_chatgpt_id_token_claims;

// Only relevant for ChatGPT OAuth accounts.
let (stored_refresh_token, stored_account_id) = match &account.auth_data {
AuthData::ChatGPT {
refresh_token,
account_id,
..
} => (refresh_token.clone(), account_id.clone()),
AuthData::ApiKey { .. } => return Ok(account.clone()),
};

let auth_file = match read_current_auth()? {
Some(auth) => auth,
None => return Ok(account.clone()),
};

let disk_tokens = match auth_file.tokens {
Some(tokens) => tokens,
None => return Ok(account.clone()),
};

// Only sync if the auth.json belongs to the same ChatGPT account.
let disk_account_id = disk_tokens
.account_id
.clone()
.or_else(|| parse_chatgpt_id_token_claims(&disk_tokens.id_token).account_id);

let same_account = match (&stored_account_id, &disk_account_id) {
(Some(stored), Some(disk)) => stored == disk,
// No account_id on either side — compare refresh tokens as a fallback.
_ => disk_tokens.refresh_token == stored_refresh_token,
};

if !same_account {
return Ok(account.clone());
}

// Tokens differ — auth.json is newer (Codex rotated them). Absorb them.
if disk_tokens.refresh_token == stored_refresh_token
&& disk_tokens.access_token
== match &account.auth_data {
AuthData::ChatGPT { access_token, .. } => access_token.clone(),
_ => String::new(),
}
{
// Tokens are identical — nothing to sync.
return Ok(account.clone());
}

println!(
"[Auth] auth.json has fresher tokens for account '{}', syncing into accounts.json before switch",
account.name
);

let claims = parse_chatgpt_id_token_claims(&disk_tokens.id_token);
let updated = update_account_chatgpt_tokens(
&account.id,
disk_tokens.id_token,
disk_tokens.access_token,
disk_tokens.refresh_token,
disk_account_id,
claims.email,
claims.plan_type,
claims.subscription_expires_at,
)?;

Ok(updated)
}

/// Create an AuthDotJson structure from a StoredAccount
fn create_auth_json(account: &StoredAccount) -> Result<AuthDotJson> {
match &account.auth_data {
Expand Down
Loading