diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b98382..626b0de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- **Critical** — fixed the quota backfill reverting fresh data and bleeding across accounts. The root `~/.codex/profile.json` copy was only ever written at switch-in, so every write-back (switch, app launch, window close) copied that frozen snapshot over the profile's card and silently reverted the quota / plan updates recorded while it was active — permanently for API-key profiles, which no API refresh repairs; Windows had patched the switch path only (keyed off the possibly-stale `.current_profile` marker) and macOS had nothing. The write-back now folds the freshest live-session usage into the identity-verified target's card and refreshes the root copy first, on both platforms and on all four write-back paths (switch, app launch, window close, current-profile login); the legacy `codex-switch.sh` no longer copies the root `profile.json` back at all, closing its variant of the same clobber. Live-session data is scoped to entries written since the profile's activation (`activated_at` from the active marker): `~/.codex/sessions` is not swapped on switch, so the previously-live account's newer entries used to win the freshness race and show up as the current profile's quota for minutes after a switch — and when a profile has no parseable activation marker (an identity-drift target, a hand-edited marker) the fold is skipped outright rather than attributing unattributable sessions. A present-but-unreadable `profile.json` now aborts the write-back loudly instead of laundering a transient read failure into a blanked card. The duplicated Windows display path (which had already drifted: it lacked the unmanaged-account guard) now re-exports the shared implementation; an empty stored card with a newer timestamp (the deliberate downgrade-to-free clear) is no longer overridden by older live sessions; and switching kicks an immediate silent API refresh for the new card instead of priming its stored timestamp and waiting out the 5-minute tick. Known residual: a codex CLI session started under the previous account that keeps running across the switch keeps appending to a session file whose mtime passes the activation cutoff, so its usage can still be misattributed until that session exits. - **Critical** — adapted macOS desktop/CLI discovery for the Codex → ChatGPT.app merge. OpenAI now ships Codex inside `/Applications/ChatGPT.app` (bundle id still `com.openai.codex`, process name `ChatGPT`, CLI at `Contents/Resources/codex`). The app previously only probed `Codex.app` and `pgrep -x Codex`, so after the merge: (1) auto-detect / login / app-server fallback reported `REAL_CODEX_NOT_FOUND` even with a healthy install, and (2) profile switch skipped quitting the running desktop host, leaving its in-memory app-server on the old account. Discovery now prefers `ChatGPT.app` over legacy `Codex.app` across both `/Applications` and `~/Applications`, and a bundle only qualifies when it actually embeds the codex CLI — a chat-only consumer ChatGPT.app (`com.openai.chat`) is never selected. is-running / quit key off the stable bundle id, and their process-name fallbacks verify each PID's bundle identity before trusting or signalling it — the consumer ChatGPT chat client is never killed as collateral, and a PID whose identity can't be established still counts as "running" (the switch aborts rather than proceeding over a possibly-live host) but is never signalled; the polite AppleScript quit is fired without waiting (a quit-confirmation dialog or the first-run Automation/TCC consent prompt can no longer hang the switch) and is cancelled once the host is gone so a consent granted later can't close the relaunched app. activate uses the bundle id; open tries the on-disk bundle path, then the bundle id, then the unambiguous legacy `Codex` display name (never `ChatGPT` by name — it could resolve to the consumer chat client), checking each `open`'s exit status so a silent launch failure surfaces as a warning instead of a fake success. macOS-only; Windows Store path still needs a separate pass if the Win host also renamed. - **Critical** — fixed account cross-contamination ("串号") during profile switch. Switching, and the launch-time `sync_root_state_to_current_profile`, used to copy the live `~/.codex` state back into whatever profile the `.current_profile` marker named, with no check that the account actually sitting in `~/.codex/auth.json` is the one that profile holds. If the live account had drifted away from the marker — a manual `codex login` outside the app, the official Codex app re-authing, or hand-edits to `~/.codex` — the next switch (or merely relaunching the app, since bootstrap runs the same write-back) silently overwrote an unrelated profile's stored credentials with the wrong account. Write-back is now gated by an identity check (`resolve_backup_target`): the live account is identified by its `tokens.account_id` and/or id_token `email` — matched on *either*, so a legacy email-only card still matches the same account after a later refresh adds an id — and only saved into the profile that genuinely owns it. A live account that drifted to a *different* managed profile is rerouted to its real owner and the marker is healed; a live account that belongs to no profile is refused rather than blind-copied. apikey / placeholder cards with no resolvable identity keep their previous behavior, so non-OAuth setups are unaffected. macOS + Windows symmetric. - When the live `~/.codex` account belongs to **no saved card** (e.g. a fresh `codex login` outside the app), the launch-time sync now clears the stale current-profile marker instead of leaving a wrong card flagged as "current", and the dashboard shows a one-time prompt naming the unmanaged account so you can switch to — or create — the matching card. diff --git a/macOS-backup/codex-switch.sh b/macOS-backup/codex-switch.sh index 2baba5b..aa47c5f 100755 --- a/macOS-backup/codex-switch.sh +++ b/macOS-backup/codex-switch.sh @@ -83,6 +83,12 @@ backup_root_state_to_profile() { [[ -e "$entry" ]] || continue name="$(basename "$entry")" [[ "$name" == ".DS_Store" || "$name" == "$ACTIVE_MARKER_FILE" ]] && continue + # Never copy the root profile.json back: it is frozen at switch-in + # time while the profile-side copy keeps moving via the app's quota + # refreshes — copying it back would revert those updates. The app + # refreshes the root copy before its own write-backs; this script + # simply leaves the profile-side copy authoritative. + [[ "$name" == "profile.json" ]] && continue if [[ "$dedup" != *"::$name::"* ]]; then managed_names+=("$name") dedup+="${name}::" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 664afd2..97a9012 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,14 @@ semver = "1.0.28" tauri = { version = "=2.10.1", features = [] } tauri-plugin-opener = "2.5.3" +# False positives, not dead deps: the crate's module tree lives outside +# src/ (shared/, mac/, win/ are mounted via #[path] in src/lib.rs), and +# cargo-machete's file discovery only scans the conventional src/ layout, +# so it never sees the real `use` sites (verified: every entry below has +# active call sites under shared/ / mac/ / win/). +[package.metadata.cargo-machete] +ignored = ["base64", "chrono", "reqwest", "semver", "serde", "serde_json"] + [features] custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/examples/live_chatgpt_merge_check.rs b/src-tauri/examples/live_chatgpt_merge_check.rs index 5a1431e..62420c3 100644 --- a/src-tauri/examples/live_chatgpt_merge_check.rs +++ b/src-tauri/examples/live_chatgpt_merge_check.rs @@ -16,9 +16,7 @@ use codex_switch_lib::macos::process::{ MACOS_CODEX_PATH_RESOLVER, }; #[cfg(target_os = "macos")] -use codex_switch_lib::shared::codex_cli_path::{ - build_codex_cli_status, redetect_codex_cli_path, -}; +use codex_switch_lib::shared::codex_cli_path::{build_codex_cli_status, redetect_codex_cli_path}; #[cfg(target_os = "macos")] use codex_switch_lib::shared::paths::get_codex_home; @@ -94,7 +92,10 @@ fn main() { // 3) Redetect must verify the ChatGPT CLI is runnable and capture a version. let redetect = redetect_runnable_codex_cli_paths(Some(&codex_home)); - println!("\n[3] redetect_runnable_codex_cli_paths ({}):", redetect.len()); + println!( + "\n[3] redetect_runnable_codex_cli_paths ({}):", + redetect.len() + ); for candidate in &redetect { println!( " - {} ({})", @@ -103,7 +104,10 @@ fn main() { ); } if chatgpt_cli.is_file() { - match redetect.iter().find(|c| c.path == chatgpt_cli.to_string_lossy()) { + match redetect + .iter() + .find(|c| c.path == chatgpt_cli.to_string_lossy()) + { Some(hit) if hit.version.as_deref().unwrap_or("").contains("codex") => { println!("PASS: ChatGPT CLI probed runnable: {:?}", hit.version); } diff --git a/src-tauri/examples/live_quota_check.rs b/src-tauri/examples/live_quota_check.rs index 43bb208..1ff6ec7 100644 --- a/src-tauri/examples/live_quota_check.rs +++ b/src-tauri/examples/live_quota_check.rs @@ -73,9 +73,7 @@ fn main() { } if let Some(profile) = target_profile { - println!( - "\n--- chatgpt_api::profile_supports_api_refresh({profile}) ---" - ); + println!("\n--- chatgpt_api::profile_supports_api_refresh({profile}) ---"); let backup_root = codex_home.join("account_backup").join(&profile); let supports = chatgpt_api::profile_supports_api_refresh(&backup_root); println!("supports api refresh: {supports}"); @@ -107,8 +105,6 @@ fn main() { } } } else { - println!( - "\n(skip chatgpt_api refresh: pass a profile folder name as argv[1] to test)" - ); + println!("\n(skip chatgpt_api refresh: pass a profile folder name as argv[1] to test)"); } } diff --git a/src-tauri/mac/runtime/process.rs b/src-tauri/mac/runtime/process.rs index 2d23cd8..e1fb69a 100644 --- a/src-tauri/mac/runtime/process.rs +++ b/src-tauri/mac/runtime/process.rs @@ -7,9 +7,9 @@ use std::thread; use std::time::Duration; use crate::errors::{AppError, AppResult}; +use crate::models::CodexCliCandidate; use crate::platform::hooks::PlatformHooks; use crate::shared::codex_app_server::{fetch_account_snapshot, AppServerSnapshot}; -use crate::models::CodexCliCandidate; use crate::shared::codex_cli_path::CodexPathResolver; pub use crate::shared::codex_cli_path::{InstallState, RealCodexPathSource}; use crate::shared::login_cancel::wait_for_login_or_cancel; @@ -221,10 +221,7 @@ pub(super) fn validate_user_codex_cli_path( Ok(candidate) } -pub fn set_user_codex_cli_path( - codex_home: Option<&Path>, - raw_input: &str, -) -> AppResult { +pub fn set_user_codex_cli_path(codex_home: Option<&Path>, raw_input: &str) -> AppResult { let resolved = validate_user_codex_cli_path(codex_home, raw_input)?; let mut state = load_install_state(codex_home); let next = Some(resolved.to_string_lossy().into_owned()); @@ -251,10 +248,7 @@ pub struct MacosCodexPathResolver; pub static MACOS_CODEX_PATH_RESOLVER: MacosCodexPathResolver = MacosCodexPathResolver; impl CodexPathResolver for MacosCodexPathResolver { - fn resolve_with_source( - &self, - codex_home: &Path, - ) -> Option<(PathBuf, RealCodexPathSource)> { + fn resolve_with_source(&self, codex_home: &Path) -> Option<(PathBuf, RealCodexPathSource)> { resolve_real_codex_cli_with_source(Some(codex_home)) } @@ -618,7 +612,10 @@ fn warn_osascript_probe_degraded(detail: &str) { fn is_codex_app_running_via_bundle_id() -> Option { let output = match Command::new("osascript") - .args(["-e", &format!("application id \"{APP_BUNDLE_ID}\" is running")]) + .args([ + "-e", + &format!("application id \"{APP_BUNDLE_ID}\" is running"), + ]) .stdin(Stdio::null()) .output() { @@ -720,7 +717,11 @@ pub fn open_or_activate_codex_app(_codex_home: Option<&Path>) -> AppResult return Ok(label), Ok(output) => { last_error = format!( @@ -988,11 +989,7 @@ impl PlatformHooks for MacosPlatformHooks { reopen_codex_app_if_needed(app_was_running, codex_home) } - fn run_codex_login( - &self, - cli_codex_home: &Path, - runtime_codex_home: &Path, - ) -> AppResult<()> { + fn run_codex_login(&self, cli_codex_home: &Path, runtime_codex_home: &Path) -> AppResult<()> { run_codex_login(cli_codex_home, runtime_codex_home) } @@ -1015,8 +1012,8 @@ mod tests { build_app_server_command, bundle_root_from_executable_path, codex_app_candidates, codex_cli_from_app_bundle, desktop_app_process_names, discover_real_codex_cli_path, parse_osascript_is_running, resolve_real_codex_cli_with_source, set_user_codex_cli_path, - validate_user_codex_cli_path, APP_BUNDLE_ID, APP_BUNDLE_NAMES, APP_NAME_CHATGPT, - APP_NAME_CODEX, RealCodexPathSource, + validate_user_codex_cli_path, RealCodexPathSource, APP_BUNDLE_ID, APP_BUNDLE_NAMES, + APP_NAME_CHATGPT, APP_NAME_CODEX, }; use crate::macos::cli_shim::real_codex_resolver_path; use std::fs; diff --git a/src-tauri/mac/runtime/profile_actions.rs b/src-tauri/mac/runtime/profile_actions.rs index 590f1cf..bd080b2 100644 --- a/src-tauri/mac/runtime/profile_actions.rs +++ b/src-tauri/mac/runtime/profile_actions.rs @@ -8,6 +8,7 @@ use crate::models::ProfileMetadata; use crate::platform; use crate::shared::config::sync_root_openai_base_url_from_profile_metadata; use crate::shared::fs_ops::{backup_root_state_to_profile, remove_path}; +use crate::shared::login_runtime::login_profile_with_home; use crate::shared::metadata::{ load_profile_metadata, save_profile_metadata, sync_profile_metadata_from_auth, sync_profile_openai_base_url, @@ -16,7 +17,6 @@ use crate::shared::paths::{ get_backup_root, get_codex_home, validate_profile_name, ACTIVE_MARKER_FILE, CONTACT_URL, RELEASES_URL, XIAOHONGSHU_URL, }; -use crate::shared::login_runtime::login_profile_with_home; use crate::shared::profiles::resolve_current_profile; use crate::shared::profiles_index::load_profiles_index; @@ -78,6 +78,10 @@ pub fn login_current_profile() -> AppResult { )); } + // Same invariant as every other write-back: refresh the root + // profile.json copy first, or the frozen switch-in snapshot below + // reverts the quota updates recorded while this profile was active. + crate::shared::switch_core::sync_live_quota_and_refresh_root(¤t_profile, &codex_home)?; backup_root_state_to_profile(¤t_profile, &codex_home, &backup_root)?; sync_profile_metadata_from_auth(¤t_profile, None, Some(&codex_home))?; load_profiles_index(Some(&codex_home))?; diff --git a/src-tauri/mac/runtime/refresh_runtime.rs b/src-tauri/mac/runtime/refresh_runtime.rs index 5b7694f..c481ebb 100644 --- a/src-tauri/mac/runtime/refresh_runtime.rs +++ b/src-tauri/mac/runtime/refresh_runtime.rs @@ -32,8 +32,7 @@ fn should_force_oauth_rotation(profile_name: &str, codex_home: &Path) -> bool { .and_then(|value| u64::try_from(value.as_millis()).ok()); match now_ms { Some(now) => { - now.saturating_sub(last_check) - >= crate::shared::chatgpt_api::PLAN_FRESHNESS_TTL_MS + now.saturating_sub(last_check) >= crate::shared::chatgpt_api::PLAN_FRESHNESS_TTL_MS } // Clock unreadable: be conservative and rotate. None => true, @@ -208,7 +207,9 @@ pub fn refresh_profile(profile_name: &str) -> AppResult { // of paying for an LLM round-trip. Falls through to the app-server // RPC path on any failure so existing behavior is preserved. if crate::shared::chatgpt_api::profile_supports_api_refresh(&profile_dir) { - if let Some(profile_path) = try_refresh_via_chatgpt_api(&profile_name, &codex_home, &profile_dir)? { + if let Some(profile_path) = + try_refresh_via_chatgpt_api(&profile_name, &codex_home, &profile_dir)? + { return Ok(profile_path); } } diff --git a/src-tauri/mac/runtime/windowing.rs b/src-tauri/mac/runtime/windowing.rs index a69a36b..6ed8501 100644 --- a/src-tauri/mac/runtime/windowing.rs +++ b/src-tauri/mac/runtime/windowing.rs @@ -13,7 +13,12 @@ pub fn install(app: &mut App) -> tauri::Result<()> { window.on_window_event(move |event| { if let WindowEvent::CloseRequested { .. } = event { - let _ = crate::platform::sync_on_window_close(); + // Last persistence opportunity before the app goes away — + // a swallowed failure here silently loses the session's + // quota / auth write-back, so leave a trace. + if let Err(error) = crate::platform::sync_on_window_close() { + eprintln!("codex_switch: window-close sync failed: {}", error.message); + } } }); diff --git a/src-tauri/shared/commands/actions.rs b/src-tauri/shared/commands/actions.rs index 1b86cd6..467a3d3 100644 --- a/src-tauri/shared/commands/actions.rs +++ b/src-tauri/shared/commands/actions.rs @@ -202,9 +202,7 @@ pub fn get_codex_cli_status() -> Result { } #[tauri::command] -pub fn set_codex_cli_path( - payload: SetCodexCliPathPayload, -) -> Result { +pub fn set_codex_cli_path(payload: SetCodexCliPathPayload) -> Result { let codex_home = platform_runtime::paths::get_codex_home(); Ok(crate::shared::codex_cli_path::set_codex_cli_path( platform_runtime::codex_cli_resolver(), diff --git a/src-tauri/shared/commands/dashboard.rs b/src-tauri/shared/commands/dashboard.rs index 53be7f8..78a566d 100644 --- a/src-tauri/shared/commands/dashboard.rs +++ b/src-tauri/shared/commands/dashboard.rs @@ -57,22 +57,19 @@ fn refresh_active_profile_quota_silent_inner() -> Result Result Result { // cards. With it, repeat launches within a working day are // free. if let Some(last_check) = entry.last_plan_check_ms { - if now_ms.saturating_sub(last_check) - < crate::shared::chatgpt_api::PLAN_FRESHNESS_TTL_MS + if now_ms.saturating_sub(last_check) < crate::shared::chatgpt_api::PLAN_FRESHNESS_TTL_MS { continue; } diff --git a/src-tauri/shared/front/actions.ts b/src-tauri/shared/front/actions.ts index cc74b51..fde61ff 100644 --- a/src-tauri/shared/front/actions.ts +++ b/src-tauri/shared/front/actions.ts @@ -183,10 +183,26 @@ async function refreshCurrentQuota(showError = false): Promise { } } +// In-flight dedup for the silent refresh: the post-switch kick can +// otherwise overlap a 5-min ticker fire. Two concurrent silent refreshes +// both pass the backend's staleness gate (neither has persisted yet) and +// can race the same refresh_token rotation — OpenAI's reuse detection +// then invalidates the session ("refresh_token_reused"). +let activeQuotaSilentRefreshInFlight: Promise | null = null; + // Silent companion to refreshCurrentQuota. Backend gates on >5min staleness // and silently swallows non-OAuth / HTTP / parse failures, so any error here // just means "skip this tick". -async function refreshActiveQuotaSilently(): Promise { +function refreshActiveQuotaSilently(): Promise { + if (!activeQuotaSilentRefreshInFlight) { + activeQuotaSilentRefreshInFlight = refreshActiveQuotaSilentlyInner().finally(() => { + activeQuotaSilentRefreshInFlight = null; + }); + } + return activeQuotaSilentRefreshInFlight; +} + +async function refreshActiveQuotaSilentlyInner(): Promise { if (state.loading || !state.snapshot) { return; } @@ -275,6 +291,12 @@ async function handleSwitchProfile(profile: string): Promise { showToast(t(state.locale, "switchedTo", { profile })); await refreshAllData(); }); + // The switched-to card's stored quota is as old as its last refresh — + // kick the silent API refresh now instead of waiting for the next + // 5-min tick. Its >5-min staleness gate makes this a no-op when the + // card is already fresh; it must run after runBlockingAction so the + // state.loading guard inside refreshActiveQuotaSilently doesn't skip it. + void refreshActiveQuotaSilently(); } catch (error) { showToast(error instanceof Error ? error.message : t(state.locale, "failedToSwitchProfile"), true); } diff --git a/src-tauri/shared/platform/hooks.rs b/src-tauri/shared/platform/hooks.rs index 20a5141..86eb588 100644 --- a/src-tauri/shared/platform/hooks.rs +++ b/src-tauri/shared/platform/hooks.rs @@ -17,11 +17,7 @@ pub trait PlatformHooks: Send + Sync { /// codex sees as `CODEX_HOME` — for the legacy "log in as the /// active profile" flow they are the same, but the per-card login /// flow points it at a sandboxed sibling. - fn run_codex_login( - &self, - cli_codex_home: &Path, - runtime_codex_home: &Path, - ) -> AppResult<()>; + fn run_codex_login(&self, cli_codex_home: &Path, runtime_codex_home: &Path) -> AppResult<()>; /// Drive `codex app-server` to fetch the live account plan + rate /// limits without paying for an LLM round-trip. Replaces the /// historical `codex exec "Reply with the single word OK."` hack diff --git a/src-tauri/shared/runtime/chatgpt_api.rs b/src-tauri/shared/runtime/chatgpt_api.rs index 46593f6..3d7284f 100644 --- a/src-tauri/shared/runtime/chatgpt_api.rs +++ b/src-tauri/shared/runtime/chatgpt_api.rs @@ -232,11 +232,7 @@ pub fn refresh_profile_via_api( profile_name: &str, codex_home: &Path, ) -> AppResult { - refresh_profile_via_api_with_options( - profile_name, - codex_home, - RefreshOptions::default(), - ) + refresh_profile_via_api_with_options(profile_name, codex_home, RefreshOptions::default()) } /// Knobs for `refresh_profile_via_api_with_options`. Today the only knob @@ -325,9 +321,9 @@ fn read_auth_file(profile_dir: &Path) -> AppResult { .and_then(Value::as_str) .map(str::to_ascii_lowercase); - let tokens = parsed - .get("tokens") - .ok_or_else(|| AppError::new("PROFILE_AUTH_INVALID", "auth.json missing `tokens` field."))?; + let tokens = parsed.get("tokens").ok_or_else(|| { + AppError::new("PROFILE_AUTH_INVALID", "auth.json missing `tokens` field.") + })?; let take = |key: &str| -> String { tokens .get(key) @@ -488,7 +484,10 @@ fn refresh_oauth_tokens( let response = client .post(format!("{ISSUER}/oauth/token")) - .header(reqwest::header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) .body(format!( "grant_type=refresh_token&refresh_token={}&client_id={}", url_encode(&auth.refresh_token), @@ -557,8 +556,14 @@ fn persist_refreshed_auth(profile_dir: &Path, auth: &ProfileAuthFile) -> AppResu "auth.json missing tokens object during refresh persist.", ) })?; - tokens.insert("access_token".to_string(), Value::String(auth.access_token.clone())); - tokens.insert("refresh_token".to_string(), Value::String(auth.refresh_token.clone())); + tokens.insert( + "access_token".to_string(), + Value::String(auth.access_token.clone()), + ); + tokens.insert( + "refresh_token".to_string(), + Value::String(auth.refresh_token.clone()), + ); if !auth.id_token.is_empty() { tokens.insert("id_token".to_string(), Value::String(auth.id_token.clone())); } @@ -619,9 +624,12 @@ fn quota_window_from_rate_limit(window: &RateLimitWindow) -> QuotaWindow { .used_percent .map(|used| (100.0 - used).round().clamp(0.0, 100.0) as u8); let refresh_at = window.reset_at.and_then(|seconds| { - Utc.timestamp_opt(seconds, 0) - .single() - .map(|datetime| datetime.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M").to_string()) + Utc.timestamp_opt(seconds, 0).single().map(|datetime| { + datetime + .with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M") + .to_string() + }) }); QuotaWindow { remaining_percent, @@ -768,8 +776,7 @@ mod tests { let quota = quota_summary_from_payload(&payload).unwrap(); assert!( - quota.five_hour.remaining_percent.is_none() - && quota.five_hour.refresh_at.is_none(), + quota.five_hour.remaining_percent.is_none() && quota.five_hour.refresh_at.is_none(), "5h slot must stay empty when only the weekly window is present" ); // 100 - 12 = 88 diff --git a/src-tauri/shared/runtime/codex_app_server.rs b/src-tauri/shared/runtime/codex_app_server.rs index b91e651..622483b 100644 --- a/src-tauri/shared/runtime/codex_app_server.rs +++ b/src-tauri/shared/runtime/codex_app_server.rs @@ -208,9 +208,7 @@ impl Session { let parsed: Value = serde_json::from_str(trimmed).map_err(|error| { AppError::new( "APP_SERVER_PARSE_FAILED", - format!( - "Failed to parse codex app-server message ({error}): {trimmed}" - ), + format!("Failed to parse codex app-server message ({error}): {trimmed}"), ) })?; // Skip notifications or replies addressed to a different id — @@ -379,23 +377,18 @@ fn parse_rate_limits_response(value: &Value) -> Option { } fn quota_window_from_app_server(window: &Value) -> QuotaWindow { - let used_percent = window.get("usedPercent").and_then(|value| { - value - .as_f64() - .or_else(|| value.as_i64().map(|v| v as f64)) - }); - let remaining_percent = - used_percent.map(|used| (100.0 - used).round().clamp(0.0, 100.0) as u8); + let used_percent = window + .get("usedPercent") + .and_then(|value| value.as_f64().or_else(|| value.as_i64().map(|v| v as f64))); + let remaining_percent = used_percent.map(|used| (100.0 - used).round().clamp(0.0, 100.0) as u8); let reset_at_timestamp = window.get("resetsAt").and_then(Value::as_i64); let refresh_at = reset_at_timestamp.and_then(|seconds| { - Utc.timestamp_opt(seconds, 0) - .single() - .map(|datetime| { - datetime - .with_timezone(&Local) - .format("%Y-%m-%d %H:%M") - .to_string() - }) + Utc.timestamp_opt(seconds, 0).single().map(|datetime| { + datetime + .with_timezone(&Local) + .format("%Y-%m-%d %H:%M") + .to_string() + }) }); QuotaWindow { remaining_percent, diff --git a/src-tauri/shared/runtime/codex_cli_path.rs b/src-tauri/shared/runtime/codex_cli_path.rs index 8986abd..5a8a860 100644 --- a/src-tauri/shared/runtime/codex_cli_path.rs +++ b/src-tauri/shared/runtime/codex_cli_path.rs @@ -68,8 +68,7 @@ impl RealCodexPathSource { pub trait CodexPathResolver { /// Resolve the real codex CLI path with provenance, or `None` if /// nothing is found. - fn resolve_with_source(&self, codex_home: &Path) - -> Option<(PathBuf, RealCodexPathSource)>; + fn resolve_with_source(&self, codex_home: &Path) -> Option<(PathBuf, RealCodexPathSource)>; /// Validate + persist a user-provided override. Returns the /// canonicalised path that was actually saved (Windows resolves @@ -118,10 +117,7 @@ pub fn build_codex_cli_status( } } -pub fn get_codex_cli_status( - resolver: &dyn CodexPathResolver, - codex_home: &Path, -) -> CodexCliStatus { +pub fn get_codex_cli_status(resolver: &dyn CodexPathResolver, codex_home: &Path) -> CodexCliStatus { build_codex_cli_status(resolver, codex_home) } @@ -134,10 +130,7 @@ pub fn set_codex_cli_path( Ok(build_codex_cli_status(resolver, codex_home)) } -pub fn clear_codex_cli_path( - resolver: &dyn CodexPathResolver, - codex_home: &Path, -) -> CodexCliStatus { +pub fn clear_codex_cli_path(resolver: &dyn CodexPathResolver, codex_home: &Path) -> CodexCliStatus { resolver.clear_user_path(codex_home); build_codex_cli_status(resolver, codex_home) } @@ -315,8 +308,7 @@ mod tests { return Err(error); } let path = PathBuf::from(raw_input); - *self.state.borrow_mut() = - Some((path.clone(), RealCodexPathSource::UserOverride)); + *self.state.borrow_mut() = Some((path.clone(), RealCodexPathSource::UserOverride)); Ok(path) } @@ -340,8 +332,7 @@ mod tests { let codex_home = PathBuf::from("/fake/home"); let target = "/fake/codex/cli"; - let status = - set_codex_cli_path(&resolver, &codex_home, target).expect("set ok"); + let status = set_codex_cli_path(&resolver, &codex_home, target).expect("set ok"); // Wrapper must report the *new* state, not the pre-set state. assert_eq!(status.resolved_path.as_deref(), Some(target)); @@ -357,10 +348,8 @@ mod tests { fn set_propagates_resolver_error_via_question_mark() { let resolver = FakeResolver::new(); let codex_home = PathBuf::from("/fake/home"); - *resolver.set_error.borrow_mut() = Some(AppError::new( - "CODEX_CLI_PATH_INVALID", - "synthetic failure", - )); + *resolver.set_error.borrow_mut() = + Some(AppError::new("CODEX_CLI_PATH_INVALID", "synthetic failure")); let err = set_codex_cli_path(&resolver, &codex_home, "/whatever") .expect_err("expected propagated error"); @@ -498,7 +487,10 @@ mod tests { // relies on this to reject "ran but failed" candidates. let mut bad = Command::new("/bin/sh"); bad.args(["-c", "exit 3"]); - assert_eq!(probe_version_with_timeout(bad, Duration::from_secs(5)), None); + assert_eq!( + probe_version_with_timeout(bad, Duration::from_secs(5)), + None + ); } #[cfg(unix)] diff --git a/src-tauri/shared/runtime/login_cancel.rs b/src-tauri/shared/runtime/login_cancel.rs index b18dea2..3d225e5 100644 --- a/src-tauri/shared/runtime/login_cancel.rs +++ b/src-tauri/shared/runtime/login_cancel.rs @@ -175,12 +175,8 @@ pub fn wait_for_login_or_cancel(mut child: Child) -> AppResult { format!("`codex login` reap failed: {error}"), ) })?; - let stdout = stdout_drainer - .map(join_drainer) - .unwrap_or_default(); - let stderr = stderr_drainer - .map(join_drainer) - .unwrap_or_default(); + let stdout = stdout_drainer.map(join_drainer).unwrap_or_default(); + let stderr = stderr_drainer.map(join_drainer).unwrap_or_default(); return Ok(Output { status, stdout, @@ -219,9 +215,7 @@ pub fn wait_for_login_or_cancel(mut child: Child) -> AppResult { } } -fn spawn_pipe_drainer( - mut pipe: P, -) -> std::thread::JoinHandle> { +fn spawn_pipe_drainer(mut pipe: P) -> std::thread::JoinHandle> { thread::spawn(move || { let mut buf = Vec::new(); // Best-effort: an I/O error mid-read (broken pipe, EINTR storm, diff --git a/src-tauri/shared/runtime/login_runtime.rs b/src-tauri/shared/runtime/login_runtime.rs index fe29348..d184cb7 100644 --- a/src-tauri/shared/runtime/login_runtime.rs +++ b/src-tauri/shared/runtime/login_runtime.rs @@ -236,8 +236,7 @@ mod tests { .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); - let path = std::env::temp_dir() - .join(format!("codex-switch-login-runtime-{name}-{unique}")); + let path = std::env::temp_dir().join(format!("codex-switch-login-runtime-{name}-{unique}")); fs::create_dir_all(&path).unwrap(); path } @@ -319,12 +318,11 @@ mod tests { #[test] fn login_profile_writes_fresh_auth_to_target_profile_folder() { - let (codex_home, profile_dir, runtime_home) = - setup("writes-fresh-auth"); + let (codex_home, profile_dir, runtime_home) = setup("writes-fresh-auth"); let hooks = FakeLoginHooks::new(FAKE_AUTH_JSON); - let returned = login_profile_with_home(&hooks, "a", Some(&codex_home), &runtime_home) - .unwrap(); + let returned = + login_profile_with_home(&hooks, "a", Some(&codex_home), &runtime_home).unwrap(); assert_eq!(hooks.calls(), 1); assert_eq!(returned, profile_dir.to_string_lossy()); @@ -398,12 +396,15 @@ mod tests { .unwrap(); let hooks = FakeLoginHooks::new(FAKE_AUTH_JSON); - let result = - login_profile_with_home(&hooks, "a", Some(&codex_home), &runtime_home); + let result = login_profile_with_home(&hooks, "a", Some(&codex_home), &runtime_home); let error = result.unwrap_err(); assert_eq!(error.error_code, "LOGIN_BUSY"); - assert_eq!(hooks.calls(), 0, "login must not be invoked under contention"); + assert_eq!( + hooks.calls(), + 0, + "login must not be invoked under contention" + ); } #[test] @@ -438,8 +439,7 @@ mod tests { } let (codex_home, _profile_dir, runtime_home) = setup("missing-auth"); - let result = - login_profile_with_home(&EmptyLogin, "a", Some(&codex_home), &runtime_home); + let result = login_profile_with_home(&EmptyLogin, "a", Some(&codex_home), &runtime_home); assert_eq!(result.unwrap_err().error_code, "LOGIN_AUTH_MISSING"); } } diff --git a/src-tauri/shared/runtime/metadata.rs b/src-tauri/shared/runtime/metadata.rs index 8cced7c..2d5cb47 100644 --- a/src-tauri/shared/runtime/metadata.rs +++ b/src-tauri/shared/runtime/metadata.rs @@ -230,7 +230,9 @@ pub fn auth_is_empty_placeholder(auth_path: &Path) -> bool { .get(field) .and_then(serde_json::Value::as_str) .map(str::trim) - .is_some_and(|value| !value.is_empty() && !value.eq_ignore_ascii_case("replace-me")) + .is_some_and(|value| { + !value.is_empty() && !value.eq_ignore_ascii_case("replace-me") + }) }); if has_real_token { return false; @@ -275,6 +277,45 @@ fn load_or_init_profile_metadata(profile_name: &str, codex_home: Option<&Path>) .unwrap_or_else(|| ProfileMetadata::with_folder_name(profile_name)) } +/// Strict counterpart of `load_profile_metadata` for callers about to +/// OVERWRITE metadata copies (the write-back root refresh): a missing +/// file is a legitimate fresh profile and loads as the default card, +/// but a *present* file that fails to read / parse / validate surfaces +/// as an error instead. Falling back to the default there would launder +/// a transient read failure (AV lock, truncated write, schema drift) +/// into permanently blanked quota / plan data the moment the write-back +/// copies that default over the stored card. +pub(crate) fn load_profile_metadata_strict( + profile_name: &str, + codex_home: Option<&Path>, +) -> Result { + let profile_name = validate_profile_name(profile_name)?; + let metadata_path = get_profile_metadata_path(&profile_name, codex_home); + let stored = if metadata_path.is_file() { + let raw = fs::read_to_string(&metadata_path).map_err(|error| { + crate::errors::AppError::new( + "PROFILE_METADATA_READ_FAILED", + format!("Failed to read {}: {error}", metadata_path.display()), + ) + })?; + serde_json::from_str::(&raw) + .ok() + .and_then(ProfileMetadata::validate) + .ok_or_else(|| { + crate::errors::AppError::new( + "PROFILE_METADATA_INVALID", + format!( + "Refusing to overwrite {}: the existing metadata does not parse/validate", + metadata_path.display() + ), + ) + })? + } else { + ProfileMetadata::with_folder_name(&profile_name) + }; + Ok(hydrate_profile_metadata(stored, &profile_name, codex_home)) +} + fn is_free_plan(plan_name: Option<&str>) -> bool { plan_name .map(str::trim) @@ -495,6 +536,36 @@ pub fn sync_profile_openai_base_url( }) } +/// Write the root copy of the profile metadata (`~/.codex/profile.json`). +/// +/// The root copy is otherwise only written by the switch-time overlay, so +/// it goes stale the moment any refresh path updates the profile-side +/// copy. Every `backup_root_state_to_profile` write-back must refresh it +/// first (see `switch_core::sync_live_quota_and_refresh_root`) or the +/// stale root snapshot would clobber the profile copy it is written over. +pub fn write_root_profile_metadata( + codex_home: &Path, + metadata: &ProfileMetadata, +) -> Result<(), crate::errors::AppError> { + let metadata_path = codex_home.join(crate::shared::paths::PROFILE_METADATA_FILENAME); + let serialized = serde_json::to_string_pretty(metadata).map_err(|error| { + crate::errors::AppError::new( + "PROFILE_METADATA_INVALID", + format!("Failed to serialize metadata: {error}"), + ) + })?; + + fs::write(&metadata_path, format!("{serialized}\n")).map_err(|error| { + crate::errors::AppError::new( + "PROFILE_METADATA_WRITE_FAILED", + format!( + "Failed to write root profile metadata {}: {error}", + metadata_path.display() + ), + ) + }) +} + pub fn save_profile_metadata( profile_name: &str, metadata: &ProfileMetadata, @@ -720,8 +791,7 @@ mod tests { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); - let path = std::env::temp_dir() - .join(format!("codex-switch-metadata-{name}-{unique}")); + let path = std::env::temp_dir().join(format!("codex-switch-metadata-{name}-{unique}")); std::fs::create_dir_all(&path).unwrap(); path } @@ -839,7 +909,10 @@ mod tests { // apikey mode (no tokens) → no resolvable identity. let dir3 = temp_dir("identity-apikey"); std::fs::write(dir3.join("auth.json"), r#"{"auth_mode":"apikey"}"#).unwrap(); - assert_eq!(load_account_identity_from_path(&dir3.join("auth.json")), None); + assert_eq!( + load_account_identity_from_path(&dir3.join("auth.json")), + None + ); // Placeholder account_id (`replace-me`) with no email → no identity. let dir4 = temp_dir("identity-placeholder"); @@ -848,7 +921,10 @@ mod tests { r#"{"tokens":{"account_id":"replace-me"}}"#, ) .unwrap(); - assert_eq!(load_account_identity_from_path(&dir4.join("auth.json")), None); + assert_eq!( + load_account_identity_from_path(&dir4.join("auth.json")), + None + ); } #[test] @@ -906,7 +982,10 @@ mod tests { r#"{"tokens":{"access_token":"replace-me","account_id":"replace-me"}}"#, ) .unwrap(); - assert!(auth_is_empty_placeholder(&path), "replace-me seed is seatable"); + assert!( + auth_is_empty_placeholder(&path), + "replace-me seed is seatable" + ); // Empty file → seatable. std::fs::write(&path, " \n").unwrap(); @@ -914,19 +993,31 @@ mod tests { // API-key card (auth_mode) → NOT a placeholder. std::fs::write(&path, r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-x"}"#).unwrap(); - assert!(!auth_is_empty_placeholder(&path), "apikey card is not seatable"); + assert!( + !auth_is_empty_placeholder(&path), + "apikey card is not seatable" + ); // Bare OPENAI_API_KEY without auth_mode → NOT a placeholder. std::fs::write(&path, r#"{"OPENAI_API_KEY":"sk-y"}"#).unwrap(); - assert!(!auth_is_empty_placeholder(&path), "raw api key is not seatable"); + assert!( + !auth_is_empty_placeholder(&path), + "raw api key is not seatable" + ); // Real OAuth token material → NOT a placeholder. std::fs::write(&path, r#"{"tokens":{"account_id":"acct_real"}}"#).unwrap(); - assert!(!auth_is_empty_placeholder(&path), "real oauth is not seatable"); + assert!( + !auth_is_empty_placeholder(&path), + "real oauth is not seatable" + ); // Malformed JSON → conservative false (don't overwrite the unknown). std::fs::write(&path, "{not json").unwrap(); - assert!(!auth_is_empty_placeholder(&path), "malformed is not seatable"); + assert!( + !auth_is_empty_placeholder(&path), + "malformed is not seatable" + ); // Missing file → false. assert!(!auth_is_empty_placeholder(&dir.join("nope.json"))); diff --git a/src-tauri/shared/runtime/mod.rs b/src-tauri/shared/runtime/mod.rs index 44d9382..6ff2c5a 100644 --- a/src-tauri/shared/runtime/mod.rs +++ b/src-tauri/shared/runtime/mod.rs @@ -7,8 +7,8 @@ pub mod login_cancel; pub mod login_runtime; pub mod metadata; pub mod paths; -pub mod profiles; pub mod process_lock; +pub mod profiles; pub mod profiles_index; pub mod quota_cache; pub mod quota_routing; diff --git a/src-tauri/shared/runtime/process_lock.rs b/src-tauri/shared/runtime/process_lock.rs index 232859a..8f73956 100644 --- a/src-tauri/shared/runtime/process_lock.rs +++ b/src-tauri/shared/runtime/process_lock.rs @@ -134,8 +134,7 @@ mod tests { .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); - let path = std::env::temp_dir() - .join(format!("codex-switch-process-lock-{name}-{unique}")); + let path = std::env::temp_dir().join(format!("codex-switch-process-lock-{name}-{unique}")); fs::create_dir_all(path.join("account_backup")).unwrap(); path } diff --git a/src-tauri/shared/runtime/profiles.rs b/src-tauri/shared/runtime/profiles.rs index 02da591..0787de2 100644 --- a/src-tauri/shared/runtime/profiles.rs +++ b/src-tauri/shared/runtime/profiles.rs @@ -3,7 +3,9 @@ use std::path::Path; use chrono::{DateTime, Local, NaiveDate}; use super::fs_ops::read_text_stripped; -use super::metadata::{auth_is_empty_placeholder, load_account_identity_from_path, AccountIdentity}; +use super::metadata::{ + auth_is_empty_placeholder, load_account_identity_from_path, AccountIdentity, +}; use super::paths::{get_current_profile_file, list_profile_dirs, ACTIVE_MARKER_FILE}; pub fn build_display_title(profile_name: &str, account_label: Option<&str>) -> String { @@ -47,6 +49,45 @@ pub fn resolve_current_profile(backup_root: &Path) -> Option { None } +/// One-shot marker for the "marker exists but activated_at is +/// unparseable" warning: without it a corrupt marker silently degrades +/// activation scoping and is indistinguishable from a legitimately +/// missing one when diagnosing cross-account quota bleed. +static ACTIVATION_PARSE_WARNING: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + +/// Epoch-ms timestamp of when `profile` last became the active profile, +/// parsed from the `activated_at=` line its `.active_profile` +/// marker was stamped with (`set_active_marker`). `None` when the marker +/// is missing or carries no parseable `activated_at` — consumers then +/// treat the live sessions as unattributable (the display path falls +/// back to the unfiltered freshness race; the write-back fold skips). +/// +/// This is the boundary that scopes the live session JSONL to the +/// current account: the app never places a `sessions/` dir inside +/// profile folders, so `~/.codex/sessions` survives switches un-swapped +/// and entries written before the activation belong to whichever +/// account was live previously — they must not be attributed to +/// `profile`. +pub fn profile_activated_at_ms(profile: &str, backup_root: &Path) -> Option { + let marker_path = backup_root.join(profile).join(ACTIVE_MARKER_FILE); + let raw = std::fs::read_to_string(&marker_path).ok()?; + let parsed = raw + .lines() + .find_map(|line| line.strip_prefix("activated_at=")) + .and_then(|value| DateTime::parse_from_rfc3339(value.trim()).ok()) + .and_then(|value| u64::try_from(value.timestamp_millis()).ok()); + if parsed.is_none() { + ACTIVATION_PARSE_WARNING.get_or_init(|| { + eprintln!( + "codex_switch: active marker {} has no parseable activated_at; \ + activation scoping is disabled for this profile until it is re-stamped", + marker_path.display() + ); + }); + } + parsed +} + /// Decide which profile slot the live `~/.codex` state should be written back /// into, verified against the *actual account identity* in `auth.json` rather /// than blindly trusting the `.current_profile` marker. @@ -160,7 +201,10 @@ mod tests { /// Minimal real auth.json whose stable identity is `acct:`. fn auth_with_account(account_id: &str) -> String { - format!("{{\"tokens\":{{\"account_id\":{}}}}}", serde_json::Value::String(account_id.to_string())) + format!( + "{{\"tokens\":{{\"account_id\":{}}}}}", + serde_json::Value::String(account_id.to_string()) + ) } fn write_profile(backup_root: &Path, profile: &str, auth_body: &str) { @@ -235,7 +279,11 @@ mod tests { fn seats_new_account_into_placeholder_marker_slot() { let (codex_home, backup_root) = setup("placeholder-seat"); // Placeholder card has no resolvable identity. - write_profile(&backup_root, "a", r#"{"tokens":{"account_id":"replace-me"}}"#); + write_profile( + &backup_root, + "a", + r#"{"tokens":{"account_id":"replace-me"}}"#, + ); set_marker(&backup_root, "a"); fs::write(codex_home.join("auth.json"), auth_with_account("acct_NEW")).unwrap(); @@ -272,7 +320,11 @@ mod tests { write_profile(&backup_root, "a", apikey); set_marker(&backup_root, "a"); // Live root drifted to an OAuth account owned by no card. - fs::write(codex_home.join("auth.json"), auth_with_account("acct_OAUTH")).unwrap(); + fs::write( + codex_home.join("auth.json"), + auth_with_account("acct_OAUTH"), + ) + .unwrap(); // Must refuse (case 5), not seat into the API-key card (case 4)… assert_eq!(resolve_backup_target(&backup_root, &codex_home), None); @@ -321,7 +373,10 @@ mod tests { Some("a") ); // …and not flagged unmanaged. - assert_eq!(detect_unmanaged_live_account(&backup_root, &codex_home), None); + assert_eq!( + detect_unmanaged_live_account(&backup_root, &codex_home), + None + ); let _ = fs::remove_dir_all(&codex_home); } @@ -347,7 +402,10 @@ mod tests { write_profile(&backup_root, "a", &auth_with_account("acct_X")); fs::write(codex_home.join("auth.json"), auth_with_account("acct_X")).unwrap(); - assert_eq!(detect_unmanaged_live_account(&backup_root, &codex_home), None); + assert_eq!( + detect_unmanaged_live_account(&backup_root, &codex_home), + None + ); let _ = fs::remove_dir_all(&codex_home); } @@ -358,7 +416,10 @@ mod tests { write_profile(&backup_root, "a", &auth_with_account("acct_X")); fs::write(codex_home.join("auth.json"), r#"{"auth_mode":"apikey"}"#).unwrap(); - assert_eq!(detect_unmanaged_live_account(&backup_root, &codex_home), None); + assert_eq!( + detect_unmanaged_live_account(&backup_root, &codex_home), + None + ); let _ = fs::remove_dir_all(&codex_home); } } diff --git a/src-tauri/shared/runtime/profiles_index.rs b/src-tauri/shared/runtime/profiles_index.rs index 831c4e1..13d01ae 100644 --- a/src-tauri/shared/runtime/profiles_index.rs +++ b/src-tauri/shared/runtime/profiles_index.rs @@ -16,9 +16,9 @@ use super::paths::{ }; use super::profiles::{ build_display_title, compute_subscription_days_left, detect_unmanaged_live_account, - resolve_current_profile, + profile_activated_at_ms, resolve_current_profile, }; -use super::session_usage::{load_latest_local_quota_snapshot, normalize_quota_summary}; +use super::session_usage::{load_latest_local_quota_snapshot_since, normalize_quota_summary}; const PROFILES_INDEX_SCHEMA_VERSION: u32 = 3; @@ -38,8 +38,9 @@ struct CachedProfilesIndex { fn cache_slot() -> &'static std::sync::Mutex> { - static SLOT: OnceLock>> = - OnceLock::new(); + static SLOT: OnceLock< + std::sync::Mutex>, + > = OnceLock::new(); SLOT.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) } @@ -340,27 +341,27 @@ fn build_current_card(entry: &ProfileIndexEntry, codex_home: &Path) -> CurrentCa } } -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] -fn quota_summary_has_data(quota: &crate::models::QuotaSummary) -> bool { +pub(crate) fn quota_summary_has_data(quota: &crate::models::QuotaSummary) -> bool { quota.five_hour.remaining_percent.is_some() || quota.five_hour.refresh_at.is_some() || quota.weekly.remaining_percent.is_some() || quota.weekly.refresh_at.is_some() } -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] fn select_current_quota( entry: &ProfileIndexEntry, live_snapshot: Option<&super::session_usage::LocalQuotaSnapshot>, ) -> crate::models::QuotaSummary { - let stored_is_populated = quota_summary_has_data(&entry.stored_quota); let stored_updated_at_ms = entry.stored_quota_updated_at_ms.unwrap_or(0); + // Strictly newer-wins, even when the stored summary is empty: an + // empty stored card with a *newer* timestamp is the deliberate + // downgrade-to-free clear written by the API refresh paths, and an + // older live session must not resurrect the pre-downgrade numbers + // next to a Free label. A never-refreshed card has no timestamp + // (0), so any live session still wins there. match live_snapshot { - Some(snapshot) - if snapshot.source_mtime_ms.unwrap_or(0) > stored_updated_at_ms - || !stored_is_populated => - { + Some(snapshot) if snapshot.source_mtime_ms.unwrap_or(0) > stored_updated_at_ms => { snapshot.quota.clone() } _ => entry.stored_quota.clone(), @@ -413,15 +414,15 @@ pub fn load_profiles_snapshot(codex_home: Option<&Path>) -> AppResult) -> AppResult { let codex_home = codex_home.map(PathBuf::from).unwrap_or_else(get_codex_home); + let backup_root = get_backup_root(Some(&codex_home)); let index = load_profiles_index(Some(&codex_home))?; // Mirror load_profiles_snapshot: when the live account is unmanaged the // `.current_profile` marker is stale, so report no current quota rather than // the drifted-away card's numbers. - if detect_unmanaged_live_account(&get_backup_root(Some(&codex_home)), &codex_home).is_some() { + if detect_unmanaged_live_account(&backup_root, &codex_home).is_some() { return Ok(CurrentQuotaResponse { profile: None, quota: None, @@ -445,7 +446,13 @@ pub fn load_current_live_quota(codex_home: Option<&Path>) -> AppResult) -> AppResult PathBuf { @@ -500,7 +507,10 @@ mod tests { assert_eq!(snapshot.unmanaged_live_account, None); assert_eq!( - snapshot.current_card.as_ref().map(|card| card.folder_name.as_str()), + snapshot + .current_card + .as_ref() + .map(|card| card.folder_name.as_str()), Some("a") ); let _ = fs::remove_dir_all(&codex_home); @@ -521,9 +531,116 @@ mod tests { ); assert!(snapshot.current_quota_card.is_none()); assert!( - snapshot.profiles.iter().all(|card| card.status != "current"), + snapshot + .profiles + .iter() + .all(|card| card.status != "current"), "no card in the list should be flagged current" ); let _ = fs::remove_dir_all(&codex_home); } + + fn write_quota_session(path: &Path) { + let line = r#"{"type":"event_msg","payload":{"type":"token_count","rate_limits":{"limit_id":"codex","primary":{"used_percent":11.0,"resets_at":1730000000,"window_minutes":300},"secondary":{"used_percent":12.0,"resets_at":1730600000,"window_minutes":10080}}}}"#; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, format!("{line}\n")).unwrap(); + } + + fn write_active_marker(profile_dir: &Path, activated_at: &str) { + fs::write( + profile_dir.join(ACTIVE_MARKER_FILE), + format!("activated_at={activated_at}\n"), + ) + .unwrap(); + } + + fn utc_string(offset_seconds: i64) -> String { + (chrono::Utc::now() + chrono::Duration::seconds(offset_seconds)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string() + } + + // Session JSONL written by the previously-live account (mtime before + // this profile's activation) must not be attributed to the current + // profile — the cross-account bleed regression. + #[test] + fn current_live_quota_ignores_foreign_live_session_older_than_activation() { + let codex_home = temp_codex_home("live-quota-foreign-session"); + let profile_dir = codex_home.join("account_backup").join("b"); + fs::create_dir_all(&profile_dir).unwrap(); + fs::write(get_current_profile_file(Some(&codex_home)), "b\n").unwrap(); + fs::write(profile_dir.join("auth.json"), "profile-b-auth\n").unwrap(); + fs::write( + profile_dir.join("profile.json"), + r#"{"folder_name":"b","account_label":"b@example.com","quota":{"five_hour":{},"weekly":{}},"quota_updated_at_ms":1}"#, + ) + .unwrap(); + write_quota_session(&codex_home.join("sessions").join("session-001.jsonl")); + // Activation stamped after the session file's mtime → the session + // belongs to whichever account was live before the switch. + write_active_marker(&profile_dir, &utc_string(5)); + + let response = load_current_live_quota(Some(&codex_home)).unwrap(); + + assert_eq!(response.profile.as_deref(), Some("b")); + let quota = response.quota.expect("expected quota"); + assert_eq!(quota.five_hour.remaining_percent, None); + assert_eq!(quota.weekly.remaining_percent, None); + + let _ = fs::remove_dir_all(&codex_home); + } + + #[test] + fn current_live_quota_uses_live_session_written_after_activation() { + let codex_home = temp_codex_home("live-quota-own-session"); + let profile_dir = codex_home.join("account_backup").join("b"); + fs::create_dir_all(&profile_dir).unwrap(); + fs::write(get_current_profile_file(Some(&codex_home)), "b\n").unwrap(); + fs::write(profile_dir.join("auth.json"), "profile-b-auth\n").unwrap(); + fs::write( + profile_dir.join("profile.json"), + r#"{"folder_name":"b","account_label":"b@example.com","quota":{"five_hour":{"remaining_percent":41},"weekly":{"remaining_percent":63}},"quota_updated_at_ms":1}"#, + ) + .unwrap(); + write_active_marker(&profile_dir, "2020-01-01T00:00:00Z"); + write_quota_session(&codex_home.join("sessions").join("session-001.jsonl")); + + let response = load_current_live_quota(Some(&codex_home)).unwrap(); + + assert_eq!(response.profile.as_deref(), Some("b")); + let quota = response.quota.expect("expected quota"); + assert_eq!(quota.five_hour.remaining_percent, Some(89)); + assert_eq!(quota.weekly.remaining_percent, Some(88)); + + let _ = fs::remove_dir_all(&codex_home); + } + + // Pre-existing installs have markers without a parseable + // activated_at (or none at all): fall back to the unfiltered + // legacy freshness race. + #[test] + fn current_live_quota_falls_back_unfiltered_without_activation_marker() { + let codex_home = temp_codex_home("live-quota-no-marker"); + let profile_dir = codex_home.join("account_backup").join("b"); + fs::create_dir_all(&profile_dir).unwrap(); + fs::write(get_current_profile_file(Some(&codex_home)), "b\n").unwrap(); + fs::write(profile_dir.join("auth.json"), "profile-b-auth\n").unwrap(); + fs::write( + profile_dir.join("profile.json"), + r#"{"folder_name":"b","account_label":"b@example.com","quota":{"five_hour":{"remaining_percent":41},"weekly":{"remaining_percent":63}},"quota_updated_at_ms":1}"#, + ) + .unwrap(); + write_quota_session(&codex_home.join("sessions").join("session-001.jsonl")); + + let response = load_current_live_quota(Some(&codex_home)).unwrap(); + + assert_eq!(response.profile.as_deref(), Some("b")); + let quota = response.quota.expect("expected quota"); + assert_eq!(quota.five_hour.remaining_percent, Some(89)); + assert_eq!(quota.weekly.remaining_percent, Some(88)); + + let _ = fs::remove_dir_all(&codex_home); + } } diff --git a/src-tauri/shared/runtime/quota_cache.rs b/src-tauri/shared/runtime/quota_cache.rs index 1a215ad..1f0c6fc 100644 --- a/src-tauri/shared/runtime/quota_cache.rs +++ b/src-tauri/shared/runtime/quota_cache.rs @@ -321,13 +321,21 @@ mod tests { }, ); - assert!(cache.lookup(&PathBuf::from("/tmp/x.jsonl"), (100, 200)).is_some()); + assert!(cache + .lookup(&PathBuf::from("/tmp/x.jsonl"), (100, 200)) + .is_some()); // mtime drift: cache miss - assert!(cache.lookup(&PathBuf::from("/tmp/x.jsonl"), (101, 200)).is_none()); + assert!(cache + .lookup(&PathBuf::from("/tmp/x.jsonl"), (101, 200)) + .is_none()); // size drift: cache miss - assert!(cache.lookup(&PathBuf::from("/tmp/x.jsonl"), (100, 201)).is_none()); + assert!(cache + .lookup(&PathBuf::from("/tmp/x.jsonl"), (100, 201)) + .is_none()); // unknown path: cache miss - assert!(cache.lookup(&PathBuf::from("/tmp/y.jsonl"), (100, 200)).is_none()); + assert!(cache + .lookup(&PathBuf::from("/tmp/y.jsonl"), (100, 200)) + .is_none()); } #[test] diff --git a/src-tauri/shared/runtime/quota_routing.rs b/src-tauri/shared/runtime/quota_routing.rs index 1a3d30c..ce971c0 100644 --- a/src-tauri/shared/runtime/quota_routing.rs +++ b/src-tauri/shared/runtime/quota_routing.rs @@ -71,11 +71,23 @@ mod tests { #[test] fn missing_or_unknown_window_minutes_falls_back_to_position() { - assert_eq!(slot_from_window_minutes(None, QuotaSlot::FiveHour), QuotaSlot::FiveHour); - assert_eq!(slot_from_window_minutes(None, QuotaSlot::Weekly), QuotaSlot::Weekly); + assert_eq!( + slot_from_window_minutes(None, QuotaSlot::FiveHour), + QuotaSlot::FiveHour + ); + assert_eq!( + slot_from_window_minutes(None, QuotaSlot::Weekly), + QuotaSlot::Weekly + ); // 60 (1h) is not one of the known windows; trust the position // hint rather than silently dropping the data. - assert_eq!(slot_from_window_minutes(Some(60), QuotaSlot::FiveHour), QuotaSlot::FiveHour); - assert_eq!(slot_from_window_minutes(Some(60), QuotaSlot::Weekly), QuotaSlot::Weekly); + assert_eq!( + slot_from_window_minutes(Some(60), QuotaSlot::FiveHour), + QuotaSlot::FiveHour + ); + assert_eq!( + slot_from_window_minutes(Some(60), QuotaSlot::Weekly), + QuotaSlot::Weekly + ); } } diff --git a/src-tauri/shared/runtime/runtime_isolation.rs b/src-tauri/shared/runtime/runtime_isolation.rs index 44474cb..e6a7ae8 100644 --- a/src-tauri/shared/runtime/runtime_isolation.rs +++ b/src-tauri/shared/runtime/runtime_isolation.rs @@ -136,8 +136,8 @@ mod tests { .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); - let path = std::env::temp_dir() - .join(format!("codex-switch-runtime-isolation-{name}-{unique}")); + let path = + std::env::temp_dir().join(format!("codex-switch-runtime-isolation-{name}-{unique}")); fs::create_dir_all(&path).unwrap(); path } diff --git a/src-tauri/shared/runtime/session_usage.rs b/src-tauri/shared/runtime/session_usage.rs index 1a8e090..0f86e41 100644 --- a/src-tauri/shared/runtime/session_usage.rs +++ b/src-tauri/shared/runtime/session_usage.rs @@ -291,6 +291,12 @@ pub fn load_latest_local_quota_snapshot_since( match next_last_snapshot { Some(snapshot) => cache.set_last_snapshot(snapshot), + // A filtered scan that found nothing newer than the cutoff must + // not erase the unfiltered winner: right after a switch every + // 15s display tick runs filtered and would otherwise clear the + // fast-path anchor on each pass until the new account writes + // its first session. + None if min_source_mtime_ms.is_some() => {} None => cache.clear_last_snapshot(), } cache.save(codex_home); @@ -316,9 +322,7 @@ fn try_fast_path( if signature != (last.mtime_ms, last.size) { return None; } - if min_source_mtime_ms.is_some_and(|min_mtime| { - last.source_mtime_ms.unwrap_or(0) < min_mtime - }) { + if min_source_mtime_ms.is_some_and(|min_mtime| last.source_mtime_ms.unwrap_or(0) < min_mtime) { return None; } Some(LocalQuotaSnapshot { @@ -463,7 +467,11 @@ mod tests { // Add a lex-larger file with a different quota — the fast // path's `newest != last.path` guard must reject the // cached snapshot and pick up the new file. - write_jsonl(&codex_home, "2026/05/10/rollout-Z.jsonl", QUOTA_LINE_DIFFERENT); + write_jsonl( + &codex_home, + "2026/05/10/rollout-Z.jsonl", + QUOTA_LINE_DIFFERENT, + ); let second = load_latest_local_quota_snapshot(Some(&codex_home)) .expect("second call returns quota from the new file"); @@ -510,7 +518,11 @@ mod tests { let codex_home = temp_codex_home("per-file-skip"); // Older file: no token_count event — `load_latest_quota_from_file` // returns `None`. Newer file: has a quota. - write_jsonl(&codex_home, "2026/05/10/rollout-A.jsonl", "{\"type\":\"event_msg\"}\n"); + write_jsonl( + &codex_home, + "2026/05/10/rollout-A.jsonl", + "{\"type\":\"event_msg\"}\n", + ); write_jsonl(&codex_home, "2026/05/10/rollout-Z.jsonl", QUOTA_LINE); let first = load_latest_local_quota_snapshot(Some(&codex_home)) diff --git a/src-tauri/shared/runtime/switch_core.rs b/src-tauri/shared/runtime/switch_core.rs index cee5bb2..490ea47 100644 --- a/src-tauri/shared/runtime/switch_core.rs +++ b/src-tauri/shared/runtime/switch_core.rs @@ -8,12 +8,17 @@ use super::fs_ops::{ autosave_auth, backup_root_state_to_profile, clear_active_markers, overlay_directory_contents, set_active_marker, }; +use super::metadata::{ + load_profile_metadata_strict, sync_profile_quota, write_root_profile_metadata, +}; use super::paths::{get_backup_root, get_codex_home, validate_profile_name}; use super::process_lock::{acquire_process_lock, ProcessLockGuard}; use super::profiles::{ - detect_unmanaged_live_account, resolve_backup_target, resolve_current_profile, + detect_unmanaged_live_account, profile_activated_at_ms, resolve_backup_target, + resolve_current_profile, }; -use super::profiles_index::load_profiles_index; +use super::profiles_index::{load_profiles_index, quota_summary_has_data}; +use super::session_usage::load_latest_local_quota_snapshot_since; fn acquire_switch_lock(codex_home: Option<&Path>) -> AppResult { acquire_process_lock( @@ -23,6 +28,58 @@ fn acquire_switch_lock(codex_home: Option<&Path>) -> AppResult ) } +/// Fold the freshest live-JSONL usage into `profile`'s stored card, then +/// refresh the root `profile.json` copy from the merged result. +/// +/// Must run immediately before every `backup_root_state_to_profile` +/// write-back (switch, launch bootstrap, window close, and the +/// current-profile login in `profile_actions`). The root copy is +/// otherwise only written by the switch-in overlay, i.e. it is frozen at +/// switch-in time — while the profile-side copy keeps moving via the +/// 5-min ticker / bulk plan refresh / manual refresh. A write-back that +/// skips this step copies that frozen snapshot over the profile copy and +/// silently reverts every quota / plan update recorded while the profile +/// was active (permanently so for API-key profiles, which no API refresh +/// path ever repairs). +pub(crate) fn sync_live_quota_and_refresh_root(profile: &str, codex_home: &Path) -> AppResult<()> { + // A present-but-unreadable stored card must abort loudly instead of + // proceeding with a default: the root refresh below would write the + // default and the imminent write-back would copy it over the stored + // card, laundering a transient read failure into permanently + // blanked quota / plan data. + let mut metadata = load_profile_metadata_strict(profile, Some(codex_home))?; + + // Only fold live usage when the profile's own activation marker is + // parseable: sessions in `~/.codex/sessions` survive switches + // un-swapped (the app never places a sessions dir inside profile + // folders), so entries are attributable to this profile only from + // its own activation onward. No marker — an identity-drift target + // whose marker still sits on another profile, or a hand-removed + // file — means the live sessions cannot be safely attributed to + // this card at all: skip the fold, but still refresh the root copy + // so the write-back cannot clobber. + if let Some(activated_at_ms) = + profile_activated_at_ms(profile, &get_backup_root(Some(codex_home))) + { + if let Some(snapshot) = + load_latest_local_quota_snapshot_since(Some(codex_home), Some(activated_at_ms)) + { + let live_is_newer = + snapshot.source_mtime_ms.unwrap_or(0) > metadata.quota_updated_at_ms.unwrap_or(0); + if live_is_newer || !quota_summary_has_data(&metadata.quota) { + metadata = sync_profile_quota( + profile, + snapshot.quota, + snapshot.source_mtime_ms, + Some(codex_home), + )?; + } + } + } + + write_root_profile_metadata(codex_home, &metadata) +} + pub fn switch_profile_with_home( hooks: &H, profile_name: &str, @@ -63,6 +120,11 @@ pub fn switch_profile_with_home( // re-auth) the marker can be stale, and a blind copy would overwrite an // unrelated profile's credentials. `None` = nothing safe to save; skip it. if let Some(backup_target) = resolve_backup_target(&backup_root, &codex_home) { + // Same identity-verified target for the quota fold: the live + // session JSONL was written by the account sitting in ~/.codex, + // so its usage belongs to the profile that owns that account — + // not to whatever the marker names. + sync_live_quota_and_refresh_root(&backup_target, &codex_home)?; backup_root_state_to_profile(&backup_target, &codex_home, &backup_root)?; } @@ -112,10 +174,20 @@ pub fn sync_root_state_to_current_profile_with_home( return Ok(None); }; - // If the live account drifted to a different managed profile than the - // marker claims, heal the marker so the UI and the next switch agree with - // what is really in ~/.codex. - if resolve_current_profile(&backup_root).as_deref() != Some(target.as_str()) { + // Fold + root refresh BEFORE the marker heal: the fold must see the + // pre-heal marker state. A drifted target has no marker of its own + // yet, so the fold is (correctly) skipped — re-stamping first would + // instead filter every session against a just-written "now" and + // silently drop the lot while looking like a successful fold. + sync_live_quota_and_refresh_root(&target, &codex_home)?; + + // Heal the marker when it names a different profile than the live + // account's owner — or when its activated_at is missing/corrupt + // (hand-edited marker), so activation scoping recovers on the next + // launch instead of staying disabled forever. + if resolve_current_profile(&backup_root).as_deref() != Some(target.as_str()) + || profile_activated_at_ms(&target, &backup_root).is_none() + { set_active_marker(&target, &backup_root)?; } @@ -319,7 +391,8 @@ mod tests { let b_fresh = "{\"tokens\":{\"account_id\":\"acct_B\"},\"last_refresh\":\"new\"}"; fs::write(codex_home.join("auth.json"), b_fresh).unwrap(); - let synced = super::sync_root_state_to_current_profile_with_home(Some(&codex_home)).unwrap(); + let synced = + super::sync_root_state_to_current_profile_with_home(Some(&codex_home)).unwrap(); assert_eq!(synced.as_deref(), Some("b")); // Marker healed to the real owner. @@ -356,7 +429,8 @@ mod tests { // Live root drifted to a brand-new, unmanaged account Z. fs::write(codex_home.join("auth.json"), auth_with_account("acct_Z")).unwrap(); - let synced = super::sync_root_state_to_current_profile_with_home(Some(&codex_home)).unwrap(); + let synced = + super::sync_root_state_to_current_profile_with_home(Some(&codex_home)).unwrap(); assert_eq!(synced, None); // No contamination: "a" keeps its own account. @@ -395,7 +469,8 @@ mod tests { let refreshed = "{\"tokens\":{\"account_id\":\"acct_X\"},\"last_refresh\":\"now\"}"; fs::write(codex_home.join("auth.json"), refreshed).unwrap(); - let synced = super::sync_root_state_to_current_profile_with_home(Some(&codex_home)).unwrap(); + let synced = + super::sync_root_state_to_current_profile_with_home(Some(&codex_home)).unwrap(); assert_eq!(synced.as_deref(), Some("a")); assert_eq!( @@ -410,4 +485,230 @@ mod tests { let _ = fs::remove_dir_all(&codex_home); } + + fn write_quota_session(path: &Path) { + let line = r#"{"type":"event_msg","payload":{"type":"token_count","rate_limits":{"limit_id":"codex","primary":{"used_percent":11.0,"resets_at":1730000000,"window_minutes":300},"secondary":{"used_percent":12.0,"resets_at":1730600000,"window_minutes":10080}}}}"#; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, format!("{line}\n")).unwrap(); + } + + fn write_active_marker(profile_dir: &Path, activated_at: &str) { + fs::write( + profile_dir.join(crate::shared::paths::ACTIVE_MARKER_FILE), + format!("activated_at={activated_at}\n"), + ) + .unwrap(); + } + + // Moved from the Windows-only switch wrapper when the quota backfill + // was folded into switch_core: the outgoing profile's card must absorb + // the freshest live-JSONL usage before the write-back. + #[test] + fn switch_profile_syncs_current_live_quota_into_previous_profile_card() { + let codex_home = temp_codex_home("live-quota"); + let backup_root = codex_home.join("account_backup"); + let profile_a_dir = backup_root.join("a"); + let profile_b_dir = backup_root.join("b"); + + fs::create_dir_all(&profile_a_dir).unwrap(); + fs::create_dir_all(&profile_b_dir).unwrap(); + fs::write(codex_home.join("auth.json"), "root-auth-before-switch\n").unwrap(); + fs::write(codex_home.join("profile.json"), r#"{"folder_name":"a"}"#).unwrap(); + fs::write(profile_a_dir.join("auth.json"), "profile-a-auth\n").unwrap(); + fs::write( + profile_a_dir.join("profile.json"), + r#"{"folder_name":"a","quota":{"five_hour":{"remaining_percent":25},"weekly":{"remaining_percent":50}},"quota_updated_at_ms":1}"#, + ) + .unwrap(); + fs::write(profile_b_dir.join("auth.json"), "profile-b-auth\n").unwrap(); + fs::write(profile_b_dir.join("profile.json"), r#"{"folder_name":"b"}"#).unwrap(); + fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); + write_active_marker(&profile_a_dir, "2020-01-01T00:00:00Z"); + write_quota_session(&codex_home.join("sessions").join("session-001.jsonl")); + + let hooks = FakeHooks::new(false); + let response = switch_profile_with_home(&hooks, "b", Some(&codex_home)).unwrap(); + + assert!(response.ok); + let stored = fs::read_to_string(profile_a_dir.join("profile.json")).unwrap(); + assert!(stored.contains(r#""remaining_percent": 89"#)); + assert!(stored.contains(r#""remaining_percent": 88"#)); + assert_eq!( + fs::read_to_string(get_current_profile_file(Some(&codex_home))).unwrap(), + "b\n" + ); + assert!(get_profiles_index_path(Some(&codex_home)).is_file()); + + let _ = fs::remove_dir_all(&codex_home); + } + + // Moved from the Windows-only switch wrapper (see above): a live + // snapshot older than the stored card must not clobber it. + #[test] + fn switch_profile_preserves_existing_stored_quota_when_live_snapshot_is_older() { + let codex_home = temp_codex_home("older-live-quota"); + let backup_root = codex_home.join("account_backup"); + let profile_a_dir = backup_root.join("a"); + let profile_b_dir = backup_root.join("b"); + + fs::create_dir_all(&profile_a_dir).unwrap(); + fs::create_dir_all(&profile_b_dir).unwrap(); + fs::write(codex_home.join("auth.json"), "root-auth-before-switch\n").unwrap(); + fs::write(profile_a_dir.join("auth.json"), "profile-a-auth\n").unwrap(); + fs::write( + profile_a_dir.join("profile.json"), + r#"{"folder_name":"a","quota":{"five_hour":{"remaining_percent":77},"weekly":{"remaining_percent":66}},"quota_updated_at_ms":9999999999999}"#, + ) + .unwrap(); + fs::write(profile_b_dir.join("auth.json"), "profile-b-auth\n").unwrap(); + fs::write(profile_b_dir.join("profile.json"), r#"{"folder_name":"b"}"#).unwrap(); + fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); + write_active_marker(&profile_a_dir, "2020-01-01T00:00:00Z"); + write_quota_session(&codex_home.join("sessions").join("session-001.jsonl")); + + let hooks = FakeHooks::new(false); + let response = switch_profile_with_home(&hooks, "b", Some(&codex_home)).unwrap(); + + assert!(response.ok); + let stored = fs::read_to_string(profile_a_dir.join("profile.json")).unwrap(); + assert!(stored.contains(r#""remaining_percent": 77"#)); + assert!(stored.contains(r#""remaining_percent": 66"#)); + + let _ = fs::remove_dir_all(&codex_home); + } + + // Drift target without its own activation marker: the live sessions + // cannot be attributed to it, so the fold must be skipped — the + // target's card keeps its stored numbers instead of absorbing the + // previous account's usage (while the marker still gets healed). + #[test] + fn bootstrap_sync_skips_live_fold_for_drifted_target_without_marker() { + let codex_home = temp_codex_home("drift-skip-fold"); + let backup_root = codex_home.join("account_backup"); + let profile_a_dir = backup_root.join("a"); + let profile_b_dir = backup_root.join("b"); + + fs::create_dir_all(&profile_a_dir).unwrap(); + fs::create_dir_all(&profile_b_dir).unwrap(); + // Marker still names a (which owns its own activation) … + fs::write(profile_a_dir.join("auth.json"), auth_with_account("acct_A")).unwrap(); + write_active_marker(&profile_a_dir, "2020-01-01T00:00:00Z"); + fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); + // … but the live account belongs to b, whose sessions these are not. + fs::write(profile_b_dir.join("auth.json"), auth_with_account("acct_B")).unwrap(); + fs::write( + profile_b_dir.join("profile.json"), + r#"{"folder_name":"b","quota":{"five_hour":{"remaining_percent":55},"weekly":{"remaining_percent":44}},"quota_updated_at_ms":1}"#, + ) + .unwrap(); + fs::write(codex_home.join("auth.json"), auth_with_account("acct_B")).unwrap(); + write_quota_session(&codex_home.join("sessions").join("session-001.jsonl")); + + let synced = + super::sync_root_state_to_current_profile_with_home(Some(&codex_home)).unwrap(); + + assert_eq!(synced.as_deref(), Some("b")); + let stored = fs::read_to_string(profile_b_dir.join("profile.json")).unwrap(); + assert!( + stored.contains(r#""remaining_percent": 55"#) + && stored.contains(r#""remaining_percent": 44"#), + "foreign sessions must not be folded into the drift target, got: {stored}" + ); + assert!( + profile_b_dir + .join(crate::shared::paths::ACTIVE_MARKER_FILE) + .is_file(), + "marker should be healed to the drift target" + ); + + let _ = fs::remove_dir_all(&codex_home); + } + + // Regression for the stale-root clobber: the root profile.json is + // frozen at switch-in time, while the profile copy keeps moving via + // the API refresh paths. The write-back must refresh the root copy + // first — copying the frozen snapshot over the profile copy would + // revert every quota update recorded while the profile was active. + #[test] + fn switch_write_back_does_not_revert_profile_copy_with_stale_root_metadata() { + let codex_home = temp_codex_home("stale-root-switch"); + let backup_root = codex_home.join("account_backup"); + let profile_a_dir = backup_root.join("a"); + let profile_b_dir = backup_root.join("b"); + + fs::create_dir_all(&profile_a_dir).unwrap(); + fs::create_dir_all(&profile_b_dir).unwrap(); + fs::write(codex_home.join("auth.json"), "root-auth-before-switch\n").unwrap(); + // Root copy frozen at switch-in: old numbers, old timestamp. + fs::write( + codex_home.join("profile.json"), + r#"{"folder_name":"a","quota":{"five_hour":{"remaining_percent":10},"weekly":{"remaining_percent":20}},"quota_updated_at_ms":1000}"#, + ) + .unwrap(); + fs::write(profile_a_dir.join("auth.json"), "profile-a-auth\n").unwrap(); + // Profile copy refreshed while active (e.g. by the 5-min ticker). + fs::write( + profile_a_dir.join("profile.json"), + r#"{"folder_name":"a","quota":{"five_hour":{"remaining_percent":73},"weekly":{"remaining_percent":64}},"quota_updated_at_ms":9999999999999}"#, + ) + .unwrap(); + fs::write(profile_b_dir.join("auth.json"), "profile-b-auth\n").unwrap(); + fs::write(profile_b_dir.join("profile.json"), r#"{"folder_name":"b"}"#).unwrap(); + fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); + + let hooks = FakeHooks::new(false); + let response = switch_profile_with_home(&hooks, "b", Some(&codex_home)).unwrap(); + + assert!(response.ok); + let stored = fs::read_to_string(profile_a_dir.join("profile.json")).unwrap(); + assert!( + stored.contains(r#""remaining_percent": 73"#) + && stored.contains(r#""remaining_percent": 64"#), + "write-back must not revert the refreshed profile copy, got: {stored}" + ); + + let _ = fs::remove_dir_all(&codex_home); + } + + // Same regression for the launch / window-close path, which shares + // the write-back with switch but runs far more often. + #[test] + fn bootstrap_sync_does_not_revert_profile_copy_with_stale_root_metadata() { + let codex_home = temp_codex_home("stale-root-bootstrap"); + let backup_root = codex_home.join("account_backup"); + let profile_a_dir = backup_root.join("a"); + + fs::create_dir_all(&profile_a_dir).unwrap(); + fs::write(codex_home.join("auth.json"), auth_with_account("acct_X")).unwrap(); + fs::write( + codex_home.join("profile.json"), + r#"{"folder_name":"a","quota":{"five_hour":{"remaining_percent":10},"weekly":{"remaining_percent":20}},"quota_updated_at_ms":1000}"#, + ) + .unwrap(); + fs::write(profile_a_dir.join("auth.json"), auth_with_account("acct_X")).unwrap(); + fs::write( + profile_a_dir.join("profile.json"), + r#"{"folder_name":"a","quota":{"five_hour":{"remaining_percent":73},"weekly":{"remaining_percent":64}},"quota_updated_at_ms":9999999999999}"#, + ) + .unwrap(); + fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); + + let synced = + super::sync_root_state_to_current_profile_with_home(Some(&codex_home)).unwrap(); + + assert_eq!(synced.as_deref(), Some("a")); + let stored = fs::read_to_string(profile_a_dir.join("profile.json")).unwrap(); + assert!( + stored.contains(r#""remaining_percent": 73"#) + && stored.contains(r#""remaining_percent": 64"#), + "launch sync must not revert the refreshed profile copy, got: {stored}" + ); + // The root copy itself must now carry the fresh numbers too. + let root = fs::read_to_string(codex_home.join("profile.json")).unwrap(); + assert!(root.contains(r#""remaining_percent": 73"#)); + + let _ = fs::remove_dir_all(&codex_home); + } } diff --git a/src-tauri/win/runtime/mod.rs b/src-tauri/win/runtime/mod.rs index bc691c4..75f8cf3 100644 --- a/src-tauri/win/runtime/mod.rs +++ b/src-tauri/win/runtime/mod.rs @@ -4,13 +4,12 @@ pub mod bootstrap; pub mod install; pub mod process; pub mod profile_actions; -pub mod profiles_index; pub mod refresh_runtime; pub mod switch; pub use crate::shared::{ - config, fs_ops, login_runtime, metadata, paths, profiles, runtime_isolation, session_files, - session_usage, + config, fs_ops, login_runtime, metadata, paths, profiles, profiles_index, runtime_isolation, + session_files, session_usage, }; pub mod actions { diff --git a/src-tauri/win/runtime/process.rs b/src-tauri/win/runtime/process.rs index d5315ae..bf599b8 100644 --- a/src-tauri/win/runtime/process.rs +++ b/src-tauri/win/runtime/process.rs @@ -9,9 +9,9 @@ use std::thread; use std::time::Duration; use crate::errors::{AppError, AppResult}; +use crate::models::CodexCliCandidate; use crate::platform::hooks::PlatformHooks; use crate::shared::codex_app_server::{fetch_account_snapshot, AppServerSnapshot}; -use crate::models::CodexCliCandidate; use crate::shared::codex_cli_path::CodexPathResolver; pub use crate::shared::codex_cli_path::{InstallState, RealCodexPathSource}; use crate::shared::login_cancel::wait_for_login_or_cancel; @@ -463,10 +463,7 @@ pub(super) fn validate_user_codex_cli_path( /// Persist a user override for the real codex CLI path. Returns the /// canonicalized path that was saved. -pub fn set_user_codex_cli_path( - codex_home: Option<&Path>, - raw_input: &str, -) -> AppResult { +pub fn set_user_codex_cli_path(codex_home: Option<&Path>, raw_input: &str) -> AppResult { let resolved = validate_user_codex_cli_path(codex_home, raw_input)?; let mut state = load_install_state(codex_home); let next = Some(resolved.to_string_lossy().into_owned()); @@ -494,10 +491,7 @@ pub struct WindowsCodexPathResolver; pub static WINDOWS_CODEX_PATH_RESOLVER: WindowsCodexPathResolver = WindowsCodexPathResolver; impl CodexPathResolver for WindowsCodexPathResolver { - fn resolve_with_source( - &self, - codex_home: &Path, - ) -> Option<(PathBuf, RealCodexPathSource)> { + fn resolve_with_source(&self, codex_home: &Path) -> Option<(PathBuf, RealCodexPathSource)> { resolve_real_codex_cli_with_source(Some(codex_home)) } @@ -538,7 +532,12 @@ pub fn suggested_codex_cli_paths(codex_home: Option<&Path>) -> Vec { let base = PathBuf::from(local_app_data); push(base.join("Programs").join("codex").join("codex.exe")); push(base.join("Programs").join("codex").join("codex.cmd")); - push(base.join("Programs").join("codex").join("bin").join("codex.cmd")); + push( + base.join("Programs") + .join("codex") + .join("bin") + .join("codex.cmd"), + ); } if let Some(app_data) = env::var_os("APPDATA") { let npm_base = PathBuf::from(app_data).join("npm"); @@ -702,11 +701,7 @@ impl PlatformHooks for WindowsPlatformHooks { reopen_codex_app_if_needed(app_was_running, codex_home) } - fn run_codex_login( - &self, - cli_codex_home: &Path, - runtime_codex_home: &Path, - ) -> AppResult<()> { + fn run_codex_login(&self, cli_codex_home: &Path, runtime_codex_home: &Path) -> AppResult<()> { run_codex_login(cli_codex_home, runtime_codex_home) } @@ -771,7 +766,10 @@ mod tests { fs::create_dir_all(&codex_home).unwrap(); // (a) non-file path → None, never spawned. - assert_eq!(probe_codex_version(&codex_home.join("does-not-exist")), None); + assert_eq!( + probe_codex_version(&codex_home.join("does-not-exist")), + None + ); let set_exec = |path: &std::path::Path| { let mut perm = fs::metadata(path).unwrap().permissions(); diff --git a/src-tauri/win/runtime/profile_actions.rs b/src-tauri/win/runtime/profile_actions.rs index a79220b..174cb69 100644 --- a/src-tauri/win/runtime/profile_actions.rs +++ b/src-tauri/win/runtime/profile_actions.rs @@ -9,11 +9,11 @@ use crate::platform; use super::config::sync_root_openai_base_url_from_profile_metadata; use super::fs_ops::{backup_root_state_to_profile, remove_path}; +use super::login_runtime::login_profile_with_home; use super::metadata::{ load_profile_metadata, save_profile_metadata, sync_profile_metadata_from_auth, sync_profile_openai_base_url, }; -use super::login_runtime::login_profile_with_home; use super::paths::{ get_backup_root, get_codex_home, get_login_runtime_dir, validate_profile_name, ACTIVE_MARKER_FILE, CONTACT_URL, RELEASES_URL, XIAOHONGSHU_URL, @@ -76,6 +76,10 @@ pub fn login_current_profile() -> AppResult { )); } + // Same invariant as every other write-back: refresh the root + // profile.json copy first, or the frozen switch-in snapshot below + // reverts the quota updates recorded while this profile was active. + crate::shared::switch_core::sync_live_quota_and_refresh_root(¤t_profile, &codex_home)?; backup_root_state_to_profile(¤t_profile, &codex_home, &backup_root)?; sync_profile_metadata_from_auth(¤t_profile, None, Some(&codex_home))?; super::profiles_index::load_profiles_index(Some(&codex_home))?; diff --git a/src-tauri/win/runtime/profiles_index.rs b/src-tauri/win/runtime/profiles_index.rs deleted file mode 100644 index eb45560..0000000 --- a/src-tauri/win/runtime/profiles_index.rs +++ /dev/null @@ -1,152 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::errors::AppResult; -use crate::models::{CurrentQuotaResponse, ProfileIndexEntry, ProfilesIndex}; - -use super::paths::get_codex_home; -use super::session_usage::{ - load_latest_local_quota_snapshot, normalize_quota_summary, LocalQuotaSnapshot, -}; - -pub use crate::shared::profiles_index::{load_profiles_index, load_profiles_snapshot}; - -fn select_current_quota( - entry: &ProfileIndexEntry, - live_snapshot: Option<&LocalQuotaSnapshot>, -) -> crate::models::QuotaSummary { - let stored_updated_at_ms = entry.stored_quota_updated_at_ms.unwrap_or(0); - - match live_snapshot { - Some(snapshot) if snapshot.source_mtime_ms.unwrap_or(0) > stored_updated_at_ms => { - snapshot.quota.clone() - } - _ => entry.stored_quota.clone(), - } -} - -pub fn load_current_live_quota(codex_home: Option<&Path>) -> AppResult { - let codex_home = codex_home.map(PathBuf::from).unwrap_or_else(get_codex_home); - let index: ProfilesIndex = load_profiles_index(Some(&codex_home))?; - let Some(current_profile) = index.current_profile.clone() else { - return Ok(CurrentQuotaResponse { - profile: None, - quota: None, - }); - }; - let Some(entry) = index - .profiles - .iter() - .find(|profile| profile.folder_name == current_profile) - else { - return Ok(CurrentQuotaResponse { - profile: Some(current_profile), - quota: None, - }); - }; - - let live_snapshot = load_latest_local_quota_snapshot(Some(&codex_home)); - let quota = normalize_quota_summary( - Some(select_current_quota(entry, live_snapshot.as_ref())), - entry.plan_name.as_deref(), - entry.has_account_identity, - ); - - Ok(CurrentQuotaResponse { - profile: Some(entry.folder_name.clone()), - quota: Some(quota), - }) -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - use super::load_current_live_quota; - use crate::windows::env_guard; - - fn temp_codex_home(name: &str) -> PathBuf { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("codex-switch-win-profiles-index-{name}-{unique}")) - } - - fn write_quota_session(path: PathBuf, primary_used_percent: f64, secondary_used_percent: f64) { - let line = format!( - r#"{{"type":"event_msg","payload":{{"type":"token_count","rate_limits":{{"limit_id":"codex","primary":{{"used_percent":{primary_used_percent},"resets_at":1730000000,"window_minutes":300}},"secondary":{{"used_percent":{secondary_used_percent},"resets_at":1730600000,"window_minutes":10080}}}}}}}}"# - ); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(path, format!("{line}\n")).unwrap(); - } - - #[test] - fn current_live_quota_prefers_stored_target_quota_when_it_was_primed_after_switch() { - let _guard = env_guard(); - let codex_home = temp_codex_home("prefer-primed-stored"); - let profile_dir = codex_home.join("account_backup").join("b"); - fs::create_dir_all(&profile_dir).unwrap(); - fs::write( - codex_home.join("account_backup").join(".current_profile"), - "b\n", - ) - .unwrap(); - fs::write(profile_dir.join("auth.json"), "profile-b-auth\n").unwrap(); - fs::write( - profile_dir.join("profile.json"), - r#"{"folder_name":"b","account_label":"b@example.com","quota":{"five_hour":{},"weekly":{}},"quota_updated_at_ms":9999999999999}"#, - ) - .unwrap(); - write_quota_session( - codex_home.join("sessions").join("session-001.jsonl"), - 11.0, - 12.0, - ); - - let response = load_current_live_quota(Some(&codex_home)).unwrap(); - - assert_eq!(response.profile.as_deref(), Some("b")); - let quota = response.quota.expect("expected quota"); - assert_eq!(quota.five_hour.remaining_percent, None); - assert_eq!(quota.weekly.remaining_percent, None); - - let _ = fs::remove_dir_all(&codex_home); - } - - #[test] - fn current_live_quota_still_uses_live_session_when_it_is_newer_than_stored_quota() { - let _guard = env_guard(); - let codex_home = temp_codex_home("prefer-live-when-newer"); - let profile_dir = codex_home.join("account_backup").join("b"); - fs::create_dir_all(&profile_dir).unwrap(); - fs::write( - codex_home.join("account_backup").join(".current_profile"), - "b\n", - ) - .unwrap(); - fs::write(profile_dir.join("auth.json"), "profile-b-auth\n").unwrap(); - fs::write( - profile_dir.join("profile.json"), - r#"{"folder_name":"b","account_label":"b@example.com","quota":{"five_hour":{"remaining_percent":41},"weekly":{"remaining_percent":63}},"quota_updated_at_ms":1}"#, - ) - .unwrap(); - write_quota_session( - codex_home.join("sessions").join("session-001.jsonl"), - 11.0, - 12.0, - ); - - let response = load_current_live_quota(Some(&codex_home)).unwrap(); - - assert_eq!(response.profile.as_deref(), Some("b")); - let quota = response.quota.expect("expected quota"); - assert_eq!(quota.five_hour.remaining_percent, Some(89)); - assert_eq!(quota.weekly.remaining_percent, Some(88)); - - let _ = fs::remove_dir_all(&codex_home); - } -} diff --git a/src-tauri/win/runtime/refresh_runtime.rs b/src-tauri/win/runtime/refresh_runtime.rs index 3ff6132..7c8aa5d 100644 --- a/src-tauri/win/runtime/refresh_runtime.rs +++ b/src-tauri/win/runtime/refresh_runtime.rs @@ -7,9 +7,7 @@ use crate::models::QuotaSummary; use crate::platform; use super::fs_ops::{copy_entry, remove_path}; -use super::metadata::{ - load_profile_metadata, sync_profile_metadata_from_auth, sync_profile_quota, -}; +use super::metadata::{load_profile_metadata, sync_profile_metadata_from_auth, sync_profile_quota}; use super::paths::{ get_backup_root, get_codex_home, get_refresh_runtime_dir, validate_profile_name, }; @@ -32,8 +30,7 @@ fn should_force_oauth_rotation(profile_name: &str, codex_home: &Path) -> bool { .and_then(|value| u64::try_from(value.as_millis()).ok()); match now_ms { Some(now) => { - now.saturating_sub(last_check) - >= crate::shared::chatgpt_api::PLAN_FRESHNESS_TTL_MS + now.saturating_sub(last_check) >= crate::shared::chatgpt_api::PLAN_FRESHNESS_TTL_MS } None => true, } @@ -342,8 +339,9 @@ mod tests { .unwrap() .as_nanos(); let pid = std::process::id(); - let path = std::env::temp_dir() - .join(format!("codex-switch-win-force-rotate-{name}-{pid}-{unique}")); + let path = std::env::temp_dir().join(format!( + "codex-switch-win-force-rotate-{name}-{pid}-{unique}" + )); fs::create_dir_all(path.join("account_backup").join("a")).unwrap(); path } diff --git a/src-tauri/win/runtime/switch.rs b/src-tauri/win/runtime/switch.rs index 637b390..b2ee261 100644 --- a/src-tauri/win/runtime/switch.rs +++ b/src-tauri/win/runtime/switch.rs @@ -1,308 +1,23 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::errors::{AppError, AppResult}; -use crate::models::{ProfileMetadata, QuotaSummary, SwitchResponse}; -use crate::platform::hooks::PlatformHooks; +use std::path::Path; +use crate::errors::AppResult; +use crate::models::SwitchResponse; use crate::{platform, shared::switch_core}; -use super::metadata::{load_profile_metadata, sync_profile_quota}; -use super::paths::{ - get_backup_root, get_codex_home, validate_profile_name, PROFILE_METADATA_FILENAME, -}; -use super::profiles::resolve_current_profile; -use super::session_usage::load_latest_local_quota_snapshot; - -fn current_time_ms() -> Option { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|value| u64::try_from(value.as_millis()).ok()) -} - -fn quota_summary_has_data(quota: &QuotaSummary) -> bool { - quota.five_hour.remaining_percent.is_some() - || quota.five_hour.refresh_at.is_some() - || quota.weekly.remaining_percent.is_some() - || quota.weekly.refresh_at.is_some() -} - -fn write_root_profile_metadata(codex_home: &Path, metadata: &ProfileMetadata) -> AppResult<()> { - let metadata_path = codex_home.join(PROFILE_METADATA_FILENAME); - let serialized = serde_json::to_string_pretty(metadata).map_err(|error| { - AppError::new( - "PROFILE_METADATA_INVALID", - format!("Failed to serialize metadata: {error}"), - ) - })?; - - fs::write(&metadata_path, format!("{serialized}\n")).map_err(|error| { - AppError::new( - "PROFILE_METADATA_WRITE_FAILED", - format!( - "Failed to write root profile metadata {}: {error}", - metadata_path.display() - ), - ) - }) -} - -fn sync_current_profile_quota_before_switch(codex_home: &Path) -> AppResult<()> { - let backup_root = get_backup_root(Some(codex_home)); - let Some(current_profile) = resolve_current_profile(&backup_root) else { - return Ok(()); - }; - - let mut metadata = load_profile_metadata(¤t_profile, Some(codex_home)); - if let Some(snapshot) = load_latest_local_quota_snapshot(Some(codex_home)) { - let live_is_newer = - snapshot.source_mtime_ms.unwrap_or(0) > metadata.quota_updated_at_ms.unwrap_or(0); - if live_is_newer || !quota_summary_has_data(&metadata.quota) { - metadata = sync_profile_quota( - ¤t_profile, - snapshot.quota, - snapshot.source_mtime_ms, - Some(codex_home), - )?; - } - } - - write_root_profile_metadata(codex_home, &metadata) -} - -fn prime_target_profile_quota_before_switch( - profile_name: &str, - codex_home: &Path, -) -> AppResult<()> { - let metadata = load_profile_metadata(profile_name, Some(codex_home)); - sync_profile_quota( - profile_name, - metadata.quota, - current_time_ms().or(metadata.quota_updated_at_ms), - Some(codex_home), - )?; - Ok(()) -} - -fn switch_profile_with_home_and_hooks( - hooks: &H, - profile_name: &str, - codex_home: Option<&Path>, -) -> AppResult { - let codex_home = codex_home.map(PathBuf::from).unwrap_or_else(get_codex_home); - let profile_name = validate_profile_name(profile_name)?; - sync_current_profile_quota_before_switch(&codex_home)?; - prime_target_profile_quota_before_switch(&profile_name, &codex_home)?; - switch_core::switch_profile_with_home(hooks, &profile_name, Some(&codex_home)) -} +// The Windows-only pre-switch quota steps that used to live here moved +// into shared switch_core (live-quota fold + root metadata refresh); the +// target-prime step was removed outright — activation-scoped session +// filtering closes the cross-account display bleed it guarded against, +// and priming the stored timestamp to "now" only suppressed the +// immediate post-switch API refresh behind the 5-min staleness gate. pub fn switch_profile_with_home( profile_name: &str, codex_home: Option<&Path>, ) -> AppResult { - switch_profile_with_home_and_hooks(platform::current_hooks(), profile_name, codex_home) + switch_core::switch_profile_with_home(platform::current_hooks(), profile_name, codex_home) } pub fn switch_profile(profile_name: &str) -> AppResult { switch_profile_with_home(profile_name, None) } - -#[cfg(test)] -mod tests { - use std::fs; - use std::path::{Path, PathBuf}; - use std::sync::Mutex; - use std::time::{SystemTime, UNIX_EPOCH}; - - use crate::errors::AppResult; - use crate::platform::hooks::PlatformHooks; - use crate::shared::paths::{get_current_profile_file, get_profiles_index_path}; - - use super::switch_profile_with_home_and_hooks; - - struct FakeHooks { - app_was_running: bool, - reopen_calls: Mutex>, - } - - impl FakeHooks { - fn new(app_was_running: bool) -> Self { - Self { - app_was_running, - reopen_calls: Mutex::new(Vec::new()), - } - } - } - - impl PlatformHooks for FakeHooks { - fn open_or_activate_codex_app(&self, _codex_home: Option<&Path>) -> AppResult { - unreachable!("not used in switch tests") - } - - fn quit_codex_app_if_running(&self) -> AppResult { - Ok(self.app_was_running) - } - - fn reopen_codex_app_if_needed( - &self, - app_was_running: bool, - _codex_home: Option<&Path>, - ) -> Vec { - self.reopen_calls.lock().unwrap().push(app_was_running); - Vec::new() - } - - fn run_codex_login( - &self, - _cli_codex_home: &Path, - _runtime_codex_home: &Path, - ) -> AppResult<()> { - unreachable!("not used in switch tests") - } - - fn fetch_account_via_app_server( - &self, - _cli_codex_home: &Path, - _runtime_codex_home: &Path, - ) -> AppResult { - unreachable!("not used in switch tests") - } - - fn sync_on_window_close(&self) -> AppResult<()> { - unreachable!("not used in switch tests") - } - } - - fn temp_codex_home(name: &str) -> PathBuf { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("codex-switch-win-switch-{name}-{unique}")) - } - - fn write_quota_session(path: &Path) { - let line = r#"{"type":"event_msg","payload":{"type":"token_count","rate_limits":{"limit_id":"codex","primary":{"used_percent":11.0,"resets_at":1730000000,"window_minutes":300},"secondary":{"used_percent":12.0,"resets_at":1730600000,"window_minutes":10080}}}}"#; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(path, format!("{line}\n")).unwrap(); - } - - #[test] - fn switch_profile_syncs_current_live_quota_into_previous_profile_card() { - let codex_home = temp_codex_home("live-quota"); - let backup_root = codex_home.join("account_backup"); - let profile_a_dir = backup_root.join("a"); - let profile_b_dir = backup_root.join("b"); - - fs::create_dir_all(&profile_a_dir).unwrap(); - fs::create_dir_all(&profile_b_dir).unwrap(); - fs::write(codex_home.join("auth.json"), "root-auth-before-switch\n").unwrap(); - fs::write(codex_home.join("profile.json"), r#"{"folder_name":"a"}"#).unwrap(); - fs::write(profile_a_dir.join("auth.json"), "profile-a-auth\n").unwrap(); - fs::write( - profile_a_dir.join("profile.json"), - r#"{"folder_name":"a","quota":{"five_hour":{"remaining_percent":25},"weekly":{"remaining_percent":50}},"quota_updated_at_ms":1}"#, - ) - .unwrap(); - fs::write(profile_b_dir.join("auth.json"), "profile-b-auth\n").unwrap(); - fs::write(profile_b_dir.join("profile.json"), r#"{"folder_name":"b"}"#).unwrap(); - fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); - write_quota_session(&codex_home.join("sessions").join("session-001.jsonl")); - - let hooks = FakeHooks::new(false); - let response = switch_profile_with_home_and_hooks(&hooks, "b", Some(&codex_home)).unwrap(); - - assert!(response.ok); - let stored = fs::read_to_string(profile_a_dir.join("profile.json")).unwrap(); - assert!(stored.contains(r#""remaining_percent": 89"#)); - assert!(stored.contains(r#""remaining_percent": 88"#)); - assert_eq!( - fs::read_to_string(get_current_profile_file(Some(&codex_home))).unwrap(), - "b\n" - ); - assert!(get_profiles_index_path(Some(&codex_home)).is_file()); - - let _ = fs::remove_dir_all(&codex_home); - } - - #[test] - fn switch_profile_preserves_existing_stored_quota_when_live_snapshot_is_older() { - let codex_home = temp_codex_home("older-live-quota"); - let backup_root = codex_home.join("account_backup"); - let profile_a_dir = backup_root.join("a"); - let profile_b_dir = backup_root.join("b"); - - fs::create_dir_all(&profile_a_dir).unwrap(); - fs::create_dir_all(&profile_b_dir).unwrap(); - fs::write(codex_home.join("auth.json"), "root-auth-before-switch\n").unwrap(); - fs::write(profile_a_dir.join("auth.json"), "profile-a-auth\n").unwrap(); - fs::write( - profile_a_dir.join("profile.json"), - r#"{"folder_name":"a","quota":{"five_hour":{"remaining_percent":77},"weekly":{"remaining_percent":66}},"quota_updated_at_ms":9999999999999}"#, - ) - .unwrap(); - fs::write(profile_b_dir.join("auth.json"), "profile-b-auth\n").unwrap(); - fs::write(profile_b_dir.join("profile.json"), r#"{"folder_name":"b"}"#).unwrap(); - fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); - write_quota_session(&codex_home.join("sessions").join("session-001.jsonl")); - - let hooks = FakeHooks::new(false); - let response = switch_profile_with_home_and_hooks(&hooks, "b", Some(&codex_home)).unwrap(); - - assert!(response.ok); - let stored = fs::read_to_string(profile_a_dir.join("profile.json")).unwrap(); - assert!(stored.contains(r#""remaining_percent": 77"#)); - assert!(stored.contains(r#""remaining_percent": 66"#)); - - let _ = fs::remove_dir_all(&codex_home); - } - - #[test] - fn switch_profile_primes_target_profile_quota_timestamp_before_switch() { - let codex_home = temp_codex_home("prime-target-quota"); - let backup_root = codex_home.join("account_backup"); - let profile_a_dir = backup_root.join("a"); - let profile_b_dir = backup_root.join("b"); - - fs::create_dir_all(&profile_a_dir).unwrap(); - fs::create_dir_all(&profile_b_dir).unwrap(); - fs::write(codex_home.join("auth.json"), "root-auth-before-switch\n").unwrap(); - fs::write(codex_home.join("profile.json"), r#"{"folder_name":"a"}"#).unwrap(); - fs::write(profile_a_dir.join("auth.json"), "profile-a-auth\n").unwrap(); - fs::write(profile_a_dir.join("profile.json"), r#"{"folder_name":"a"}"#).unwrap(); - fs::write(profile_b_dir.join("auth.json"), "profile-b-auth\n").unwrap(); - fs::write( - profile_b_dir.join("profile.json"), - r#"{"folder_name":"b","quota":{"five_hour":{"remaining_percent":41},"weekly":{"remaining_percent":63}}}"#, - ) - .unwrap(); - fs::write(get_current_profile_file(Some(&codex_home)), "a\n").unwrap(); - write_quota_session(&codex_home.join("sessions").join("session-001.jsonl")); - let live_mtime_ms = fs::metadata(codex_home.join("sessions").join("session-001.jsonl")) - .unwrap() - .modified() - .unwrap() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as u64; - - let hooks = FakeHooks::new(false); - let response = switch_profile_with_home_and_hooks(&hooks, "b", Some(&codex_home)).unwrap(); - - assert!(response.ok); - let stored = fs::read_to_string(profile_b_dir.join("profile.json")).unwrap(); - assert!(stored.contains(r#""remaining_percent": 41"#)); - assert!(stored.contains(r#""remaining_percent": 63"#)); - let parsed = serde_json::from_str::(&stored).unwrap(); - let stored_updated_at_ms = parsed - .get("quota_updated_at_ms") - .and_then(|value| value.as_u64()) - .unwrap(); - assert!(stored_updated_at_ms >= live_mtime_ms); - - let _ = fs::remove_dir_all(&codex_home); - } -} diff --git a/src-tauri/win/runtime/windowing.rs b/src-tauri/win/runtime/windowing.rs index f69fbd9..d0e8c06 100644 --- a/src-tauri/win/runtime/windowing.rs +++ b/src-tauri/win/runtime/windowing.rs @@ -13,7 +13,12 @@ pub fn install(app: &mut App) -> tauri::Result<()> { window.on_window_event(move |event| { if let WindowEvent::CloseRequested { .. } = event { - let _ = crate::platform::sync_on_window_close(); + // Last persistence opportunity before the app goes away — + // a swallowed failure here silently loses the session's + // quota / auth write-back, so leave a trace. + if let Err(error) = crate::platform::sync_on_window_close() { + eprintln!("codex_switch: window-close sync failed: {}", error.message); + } } });