From 489d2a869cc4e4c87e5b287ea41eafa019f2212d Mon Sep 17 00:00:00 2001 From: SquarePots <46488165+squarepots@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:40:52 +0800 Subject: [PATCH] fix: restore account sign-in and quota clarity for v1.0.8 --- docs/design.md | 18 ++- docs/security.md | 2 +- docs/workflows.md | 21 ++- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 22 ++- src-tauri/src/intake.rs | 166 +++++++++++++++++++++-- src-tauri/src/quota.rs | 58 +++++++- src-tauri/src/switching.rs | 64 ++++----- src-tauri/src/types.rs | 37 +++++- src-tauri/src/wake.rs | 32 ++++- src-tauri/tauri.conf.json | 2 +- src/App.test.tsx | 78 +++++++++-- src/App.tsx | 265 +++++++++++++++++++++++++------------ src/api.ts | 2 +- src/i18n.ts | 118 ++++++++++++----- src/styles.css | 151 +++++++++++++++------ src/types.ts | 20 ++- 19 files changed, 819 insertions(+), 243 deletions(-) diff --git a/docs/design.md b/docs/design.md index 040e566..a09d3bd 100644 --- a/docs/design.md +++ b/docs/design.md @@ -20,7 +20,7 @@ Focused dialogs may handle add-account, explicit confirmation, progress/results, or recovery; they do not create a second navigation system. The toolbar contains the current Codex account, Refresh, Wake all, Add account, -and a lightweight Settings button. It is a command bar, not a dashboard header +and a compact language menu. It is a command bar, not a dashboard header or a custom window chrome. Its status dot always remains visible; only a long account label may truncate. The status is also written in text so ready, unknown, signed-out, setup, and recovery states do not rely on color. @@ -29,7 +29,7 @@ An account card should show only the state needed for a decision: - label and useful identity; - active, ready, needs-login, unsupported, or busy state; -- five-hour and weekly quota with reset timing when available; +- the Codex quota windows actually supplied for that plan, with reset timing when available; - reset-credit count and nearest expiry when available; - direct actions such as Switch, Wake, refresh, reset, or remove when eligible. @@ -88,8 +88,14 @@ explicit reset preserves GSwitch's damaged file without touching Codex. current identity stays protected. Operation-lock failures remain visible in the confirmation with a retry instruction. - Quota refreshes share a serialized request path across startup and manual - refresh; failed cards identify stale data or show quota as unavailable. - Batch feedback describes quota results only. + refresh. A failed card keeps a visible, compact warning icon beside quota; + its hover/focus explanation gives the safe reason and last successful update. + Old percentages say "Last" and use muted meters; an unavailable result stays + unknown. Actions stay in the account menu, not in the warning tooltip. Batch + feedback describes quota results only. +- A saved email can be copied from its card menu. Offer sign-in again only after + an authentication failure. The focused login dialog explains which saved + account will be updated; a mismatched login changes no account. - Keep card content compact. Truncated account and workspace names expose their full value on hover; do not reserve empty vertical space for the old heading reminder. @@ -108,8 +114,8 @@ filesystem, protocol, or token details do not belong in the primary UI. The application ships English and Simplified Chinese in one frontend resource. On first launch it maps a compatible system/WebView locale to one of those -languages and otherwise falls back to English. Settings offers only System / -Automatic, English, and Simplified Chinese; a manual choice applies immediately +languages and otherwise falls back to English. The toolbar language menu offers +System / Automatic, English, and Simplified Chinese; a manual choice applies immediately and wins over automatic detection. The only persisted WebView preference is that language choice. Dates, reset/expiry timing, numbers, and percentages use the selected locale's platform formatters. diff --git a/docs/security.md b/docs/security.md index 981a163..2b5cd90 100644 --- a/docs/security.md +++ b/docs/security.md @@ -99,7 +99,7 @@ and expected prior state. Before replacing live credentials: -1. the effective store must be confirmed as file-backed and unmanaged; +1. the live profile must explicitly use the file store, and the official App Server in a short-lived GSwitch-owned profile must confirm that machine policy does not override it; do not initialize App Server against the live profile for this check; 2. no external Codex runtime may be active or uninspectable, and that check must happen before target-network validation; 3. the current live identity must be saved or the live profile must be empty; diff --git a/docs/workflows.md b/docs/workflows.md index 6892ca6..5090576 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -13,7 +13,9 @@ the user's live Codex identity. 1. Create an isolated GSwitch-owned `CODEX_HOME`. 2. Ask the official Codex App Server to start ChatGPT login. -3. Open or copy the returned HTTPS authorization URL. +3. Show the returned HTTPS authorization URL for copying or an explicit + **Open browser** action. Do not open it automatically. Use Codex's local + success page so completing login does not launch the ChatGPT desktop app. 4. Wait for the matching completion event, read the complete resulting credential document, and use its fresh access-token snapshot for the read-only account metadata check. Do not request a proactive refresh. @@ -23,6 +25,11 @@ the user's live Codex identity. Cancellation and timeout end the isolated login. OAuth is the default login experience; GSwitch does not implement a parallel OAuth protocol. +For **Sign in again** on a saved account, retain the selected account ID and +compare the newly verified user and workspace identity, plus an available +email, before replacing that account's saved credential. A different login, +removed account, or changed identity leaves the saved account and live Codex +credential unchanged. The WebView receives only a safe failure category. The App Server is stopped before the isolated profile is removed, including after cancellation or validation failure; cleanup failure is reported. @@ -196,7 +203,9 @@ when it is not. GSwitch sends no App Server request and writes no credential for a successful ordinary read. It normalizes the provider's primary, secondary, and additional buckets into five-hour, weekly, and other windows by the durations supplied by the provider; missing or malformed values remain -unknown. +unknown. The card renders whichever Codex windows the provider actually supplies, +including a five-week Free-plan window, with its reset time. It does not +invent a five-hour or weekly window for a plan that lacks one. The supported minimum is Codex 0.144.5. GSwitch sends its rate-limit request with a null parameter payload for that version and retries once with an empty @@ -217,7 +226,9 @@ stale ChatGPT accounts in the background. Startup and manual refreshes share one serial request queue, and requests for the same account join the in-flight request. One failed account does not stop the remaining queue. Its card keeps the last result marked stale, or shows quota as unavailable when no snapshot -exists; the batch notice reports only failed quota refreshes. Adding, importing, +exists. A compact warning on that card explains the safe failure category and +last successful update; old percentages are explicitly labelled as the last +result. The batch notice reports only failed quota refreshes. Adding, importing, or saving an account follows the same refresh path. When a running Codex instance is identified as using the account, GSwitch rereads the live file-backed credential immediately before the request and retries once only when the same @@ -299,6 +310,10 @@ silently relabel another account. Wake is user-triggered; there is no cron, background schedule, automatic rotation, history dashboard, or job-management surface. +When the freshly read quota specifically shows a zero five-hour or weekly +balance in an active window, name that window in the result. Otherwise say only +that Wake has no available quota; do not infer which limit was exhausted. + ## Recovery Interrupted switch and reset-credit actions retain non-secret durable intent diff --git a/package.json b/package.json index 31186a3..f313927 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gswitch", "private": true, - "version": "1.0.7", + "version": "1.0.8", "type": "module", "packageManager": "pnpm@10.17.1", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e77651a..cff8eee 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1968,7 +1968,7 @@ dependencies = [ [[package]] name = "gswitch" -version = "1.0.7" +version = "1.0.8" dependencies = [ "aes-gcm", "atomic-write-file", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cecd297..6d32f22 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gswitch" -version = "1.0.7" +version = "1.0.8" description = "A focused local Codex account switcher" authors = ["Ginbing"] edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4a7f7ce..dea5f43 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7,9 +7,9 @@ use crate::{ codex, intake, migration, quota, switching, types::{ AccountView, AppSnapshot, ExportResult, ImportResult, LiveAccountView, MigrationPreview, - OAuthLoginStart, OAuthLoginStatus, QuotaView, ResetCreditOutcome, RuntimeInfo, - StorageStatus, SwitchFailure, SwitchFailureCode, SwitchOutcome, UpdateDelivery, - WakeOperationView, WakeStart, + OAuthLoginStart, OAuthLoginStatus, QuotaRefreshFailure, QuotaRefreshFailureCode, QuotaView, + ResetCreditOutcome, RuntimeInfo, StorageStatus, SwitchFailure, SwitchFailureCode, + SwitchOutcome, UpdateDelivery, WakeOperationView, WakeStart, }, wake, }; @@ -85,9 +85,12 @@ pub async fn recover_pending_credentials(state: State<'_, AppState>) -> Result) -> Result { +pub async fn start_oauth_login( + state: State<'_, AppState>, + target_id: Option, +) -> Result { let state = state.inner().clone(); - run_blocking(move || intake::start_oauth(state)).await + run_blocking(move || intake::start_oauth(state, target_id)).await } #[tauri::command] @@ -278,9 +281,14 @@ pub async fn get_account_quota( pub async fn refresh_account_quota( state: State<'_, AppState>, id: String, -) -> Result { +) -> Result { let state = state.inner().clone(); - run_blocking(move || quota::refresh_quota(&state, &id)).await + tauri::async_runtime::spawn_blocking(move || quota::refresh_quota(&state, &id)) + .await + .map_err(|_| QuotaRefreshFailure { + code: QuotaRefreshFailureCode::Unavailable, + })? + .map_err(|error| quota::refresh_failure(&error)) } #[tauri::command] diff --git a/src-tauri/src/intake.rs b/src-tauri/src/intake.rs index 915bae7..d4ba037 100644 --- a/src-tauri/src/intake.rs +++ b/src-tauri/src/intake.rs @@ -19,8 +19,8 @@ use crate::{ chatgpt::{self, ChatGptClient, RequestFailure, RequestFailureKind}, identity, storage, types::{ - AccountIdentity, AccountKind, AccountView, ExportResult, ImportResult, OAuthLoginStart, - OAuthLoginStatus, + AccountIdentity, AccountKind, AccountView, ExportResult, ImportResult, OAuthFailureCode, + OAuthLoginStart, OAuthLoginStatus, }, }; @@ -132,8 +132,89 @@ pub(crate) fn complete_accounts_export( }) } -pub fn start_oauth(state: AppState) -> Result { +#[derive(Clone)] +struct OAuthTarget { + id: String, + identity: AccountIdentity, + email: Option, + label: String, +} + +fn oauth_target( + state: &AppState, + target_id: Option, +) -> Result, String> { + let Some(id) = target_id else { return Ok(None) }; + let account = state.account_by_id(&id)?; + if account.kind != AccountKind::ChatGpt { + return Err("Only a saved ChatGPT account can be signed in again".to_string()); + } + let identity = account + .identity + .ok_or_else(|| "The saved account has no stable identity for sign-in".to_string())?; + Ok(Some(OAuthTarget { + id, + identity, + email: account.email, + label: account.label, + })) +} + +fn oauth_failure_code(message: &str) -> OAuthFailureCode { + if message.contains("timed out") || message.contains("before the operation timed out") { + OAuthFailureCode::TimedOut + } else if message.contains("OAuth identity mismatch") { + OAuthFailureCode::IdentityMismatch + } else if message.contains("could not save validated credentials") { + OAuthFailureCode::SaveFailed + } else if message == "OAuth account verification failed" { + OAuthFailureCode::VerificationFailed + } else if message == "OAuth sign-in did not complete" { + OAuthFailureCode::NotCompleted + } else { + OAuthFailureCode::Unavailable + } +} + +fn ensure_oauth_target_identity( + state: &AppState, + operation: &OperationGuard<'_>, + target: &OAuthTarget, + signed_in_identity: &AccountIdentity, +) -> Result<(), String> { + let current = state + .account_by_id_under_operation(operation, &target.id) + .map_err(|_| { + "OAuth identity mismatch: the selected saved account was removed".to_string() + })?; + if current.kind != AccountKind::ChatGpt + || current.identity.as_ref() != Some(&target.identity) + || signed_in_identity != &target.identity + || current.email != target.email + { + return Err("OAuth identity mismatch: the selected saved account changed".to_string()); + } + Ok(()) +} + +fn ensure_oauth_target_email( + target: &OAuthTarget, + signed_in_email: Option<&str>, +) -> Result<(), String> { + if target + .email + .as_deref() + .zip(signed_in_email) + .is_some_and(|(saved, signed_in)| !saved.eq_ignore_ascii_case(signed_in)) + { + return Err("OAuth identity mismatch: this login belongs to another email".to_string()); + } + Ok(()) +} + +pub fn start_oauth(state: AppState, target_id: Option) -> Result { state.ensure_store_ready()?; + let target = oauth_target(&state, target_id)?; let profile = TempCodexHome::create(&state.isolated_profile_root()?)?; let mut server = AppServer::start(&profile.path)?; @@ -142,8 +223,7 @@ pub fn start_oauth(state: AppState) -> Result { "account/login/start", json!({ "type": "chatgpt", - "useHostedLoginSuccessPage": true, - "appBrand": "chatgpt" + "useHostedLoginSuccessPage": false }), Duration::from_secs(30), )?; @@ -173,12 +253,21 @@ pub fn start_oauth(state: AppState) -> Result { let monitor_id = login_id.clone(); thread::spawn(move || { let result = with_oauth_profile(server, profile, |server, profile| { - monitor_oauth(server, profile, &state, &monitor_id, &cancelled) + monitor_oauth( + server, + profile, + &state, + &monitor_id, + &cancelled, + target.as_ref(), + ) }); let status = match result { Ok(Some(account)) => OAuthLoginStatus::Complete { account }, Ok(None) => OAuthLoginStatus::Cancelled, - Err(message) => OAuthLoginStatus::Failed { message }, + Err(message) => OAuthLoginStatus::Failed { + code: oauth_failure_code(&message), + }, }; let _ = state.set_oauth_status(monitor_id, status); }); @@ -209,6 +298,7 @@ fn monitor_oauth( state: &AppState, login_id: &str, cancelled: &AtomicBool, + target: Option<&OAuthTarget>, ) -> Result, String> { let notification = match server.wait_for_notification_cancelled( OAUTH_COMPLETION_TIMEOUT, @@ -243,15 +333,21 @@ fn monitor_oauth( let operation = state.acquire_operation()?; let credential = profile.read_auth()?; let expected_identity = identity::derive_identity(&AccountKind::ChatGpt, &credential)?; + if let Some(target) = target { + ensure_oauth_target_identity(state, &operation, target, &expected_identity)?; + } let metadata = read_chatgpt_metadata(&credential, &expected_identity) - .map_err(SnapshotMetadataFailure::message)?; + .map_err(|_| "OAuth account verification failed".to_string())?; + if let Some(target) = target { + ensure_oauth_target_email(target, metadata.email.as_deref())?; + } persist_validated( state, &operation, profile, metadata, - None, - None, + target.map(|target| target.identity.clone()), + target.map(|target| target.label.clone()), "ChatGPT account".to_string(), ) .map(Some) @@ -1119,6 +1215,56 @@ mod tests { net::TcpListener, }; + #[test] + fn repeat_oauth_login_rejects_a_different_saved_identity_or_email() { + let store_path = test_path("oauth-repeat-identity", "json"); + let credential = official_credential("user", "workspace", "access-token"); + let state = AppState::new(store_path.clone()).expect("state"); + let saved_identity = + identity::derive_identity(&AccountKind::ChatGpt, &credential).expect("saved identity"); + let operation = state.acquire_operation().expect("operation"); + let saved = state + .upsert_under_operation( + &operation, + AccountDraft { + label: Some("Saved".to_string()), + default_label: "Saved".to_string(), + kind: AccountKind::ChatGpt, + email: Some("person@example.com".to_string()), + plan_type: Some("plus".to_string()), + workspace_name: Some("Personal".to_string()), + account_structure: Some("workspace".to_string()), + identity: saved_identity.clone(), + credential, + }, + ) + .expect("saved account"); + drop(operation); + let target = oauth_target(&state, Some(saved.id)) + .expect("target") + .expect("saved target"); + let operation = state.acquire_operation().expect("operation"); + ensure_oauth_target_identity(&state, &operation, &target, &saved_identity) + .expect("same identity"); + let other = identity::derive_identity( + &AccountKind::ChatGpt, + &official_credential("other-user", "workspace", "other-access"), + ) + .expect("other identity"); + assert!( + ensure_oauth_target_identity(&state, &operation, &target, &other) + .expect_err("different user must not replace saved account") + .contains("OAuth identity mismatch") + ); + assert!( + ensure_oauth_target_email(&target, Some("other@example.com")) + .expect_err("different email must not replace saved account") + .contains("OAuth identity mismatch") + ); + drop(operation); + let _ = fs::remove_dir_all(store_path.parent().expect("parent")); + } + #[test] fn oauth_profile_is_removed_only_after_its_user_exits_even_on_failure() { struct ProfileUser(std::path::PathBuf); diff --git a/src-tauri/src/quota.rs b/src-tauri/src/quota.rs index fabfe3e..7782dbf 100644 --- a/src-tauri/src/quota.rs +++ b/src-tauri/src/quota.rs @@ -13,9 +13,10 @@ use crate::{ runtime, types::{ AccountIdentity, AccountKind, PendingResetCredit, QuotaBucket, QuotaBucketKind, - QuotaSnapshot, QuotaStatus, QuotaView, QuotaWindow, QuotaWindowKind, ResetCreditDetailView, - ResetCreditOutcome, ResetCreditOutcomeKind, ResetCreditsView, StoredAccount, - StoredResetCredit, StoredResetCredits, + QuotaRefreshFailure, QuotaRefreshFailureCode, QuotaSnapshot, QuotaStatus, QuotaView, + QuotaWindow, QuotaWindowKind, ResetCreditDetailView, ResetCreditOutcome, + ResetCreditOutcomeKind, ResetCreditsView, StoredAccount, StoredResetCredit, + StoredResetCredits, }, }; @@ -32,6 +33,35 @@ pub(crate) enum ExternalCredentialState { const CACHE_FRESH_FOR_MS: i64 = 5 * 60 * 1000; +/// Only a stable, non-secret reason crosses the Rust/WebView boundary. These +/// messages originate in GSwitch or the sanitized App Server adapter, never +/// in a provider response body. +pub fn refresh_failure(error: &str) -> QuotaRefreshFailure { + let code = if error.contains("Another GSwitch operation") { + QuotaRefreshFailureCode::OperationBusy + } else if error.contains("cannot safely identify its active account") { + QuotaRefreshFailureCode::CodexAccountUnknown + } else if error.contains("rejected the read-only quota request") { + QuotaRefreshFailureCode::Authentication + } else if error.contains("rate-limited the quota request") { + QuotaRefreshFailureCode::RateLimited + } else if error.contains("Unable to reach ChatGPT quota service") { + QuotaRefreshFailureCode::Network + } else if error.contains("quota service returned an error") { + QuotaRefreshFailureCode::Service + } else if error.contains("invalid quota response") + || error.contains("response is missing a result") + { + QuotaRefreshFailureCode::InvalidResponse + } else if error.contains("identity") || error.contains("saved account needs to be added again") + { + QuotaRefreshFailureCode::IdentityMismatch + } else { + QuotaRefreshFailureCode::Unavailable + }; + QuotaRefreshFailure { code } +} + /// Returns the last provider snapshot without initiating a provider request. pub fn cached_quota(state: &AppState, account_id: &str) -> Result { let account = state.account_by_id(account_id)?; @@ -1151,6 +1181,28 @@ mod tests { assert_eq!(snapshot.buckets[1].limit_id, "image"); } + #[test] + fn retains_the_free_five_week_window_and_its_reset_time() { + let snapshot = normalize_rate_limits( + &json!({ + "plan_type": "free", + "rate_limit": { + "primary_window": { + "used_percent": 0, + "limit_window_seconds": 3_024_000, + "reset_at": 1_800_000_000 + } + } + }), + 100, + ); + + let window = &snapshot.buckets[0].windows[0]; + assert_eq!(window.kind, QuotaWindowKind::Other); + assert_eq!(window.window_duration_mins, Some(50_400)); + assert_eq!(window.remaining_percent, Some(100)); + assert_eq!(window.resets_at, Some(1_800_000_000)); + } #[test] fn keeps_missing_or_malformed_values_unknown() { let snapshot = normalize_rate_limits( diff --git a/src-tauri/src/switching.rs b/src-tauri/src/switching.rs index 133f1ed..042c7a2 100644 --- a/src-tauri/src/switching.rs +++ b/src-tauri/src/switching.rs @@ -292,7 +292,7 @@ pub fn switch_account(state: &AppState, target_id: &str) -> Result Result Result<(), String> { }; runtime::ensure_no_external_codex(&[])?; let codex_home = codex::codex_home()?; - drop(start_effective_file_store(&codex_home)?); + check_effective_file_store(state, &codex_home).map_err(|_| { + "Unable to confirm file-backed Codex configuration for recovery".to_string() + })?; let current = codex::read_optional_auth_document(&codex_home)?; if let Some(current) = current { @@ -461,37 +463,35 @@ fn require_file_store(codex_home: &std::path::Path) -> Result<(), String> { /// A user config file alone cannot prove that enterprise or MDM policy did /// not override the credential backend. Consult the supported App Server view /// before any operation reads, saves, or replaces live credentials. -fn start_effective_file_store(codex_home: &std::path::Path) -> Result { - require_file_store(codex_home)?; - let mut server = AppServer::start(codex_home)?; - runtime::ensure_no_external_codex(&[server.pid()])?; - let config = server.config_read(1)?; - reject_managed_store_origin(&config)?; - if config_store_value(&config) != Some("file") { - return Err( - "Codex did not confirm file-backed credential storage for switching".to_string(), - ); - } - Ok(server) -} - -fn confirm_effective_file_store_for_switch( +/// Inspect effective policy in a clean, GSwitch-owned Codex profile. The +/// user profile is checked locally for an explicit file store, while the +/// isolated App Server reveals machine policy without initializing against +/// the live auth/config directory that may be held by another application. +fn check_effective_file_store( + state: &AppState, codex_home: &std::path::Path, -) -> Result<(), SwitchFailure> { - require_file_store(codex_home) - .map_err(|_| switch_failure(SwitchFailureCode::FileStoreRequired))?; - let mut server = AppServer::start(codex_home) - .map_err(|_| switch_failure(SwitchFailureCode::LocalVerificationFailed))?; - runtime::ensure_no_external_codex(&[server.pid()]) - .map_err(|_| switch_failure(SwitchFailureCode::CodexOpen))?; +) -> Result<(), SwitchFailureCode> { + require_file_store(codex_home).map_err(|_| SwitchFailureCode::FileStoreRequired)?; + let profile = TempCodexHome::create( + &state + .isolated_profile_root() + .map_err(|_| SwitchFailureCode::CodexConfigUnavailable)?, + ) + .map_err(|_| SwitchFailureCode::CodexConfigUnavailable)?; + let mut server = + AppServer::start(&profile.path).map_err(|_| SwitchFailureCode::CodexConfigUnavailable)?; + runtime::ensure_no_external_codex(&[server.pid()]).map_err(|_| SwitchFailureCode::CodexOpen)?; let config = server .config_read(1) - .map_err(|_| switch_failure(SwitchFailureCode::LocalVerificationFailed))?; - reject_managed_store_origin(&config) - .map_err(|_| switch_failure(SwitchFailureCode::FileStoreRequired))?; + .map_err(|_| SwitchFailureCode::CodexConfigUnavailable)?; + reject_managed_store_origin(&config).map_err(|_| SwitchFailureCode::FileStoreRequired)?; if config_store_value(&config) != Some("file") { - return Err(switch_failure(SwitchFailureCode::FileStoreRequired)); + return Err(SwitchFailureCode::FileStoreRequired); } + drop(server); + profile + .cleanup() + .map_err(|_| SwitchFailureCode::CodexConfigUnavailable)?; Ok(()) } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index f032f74..d7ce8fb 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -235,6 +235,9 @@ pub enum SwitchFailureCode { CredentialsChanged, RecoveryRequired, LocalVerificationFailed, + CodexConfigUnavailable, + CurrentCredentialUnreadable, + CurrentAccountNotSaved, TargetCheckUnavailable, TargetWorkspaceMismatch, PostWriteVerificationFailed, @@ -369,6 +372,8 @@ pub struct ResetCreditOutcome { pub enum WakeResultKind { Started, AlreadyActive, + FiveHourExhausted, + WeeklyExhausted, NoOrdinaryCapacity, NeedsSignIn, SentNotConfirmed, @@ -483,6 +488,25 @@ pub struct QuotaView { pub message: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum QuotaRefreshFailureCode { + OperationBusy, + CodexAccountUnknown, + Authentication, + RateLimited, + Network, + Service, + InvalidResponse, + IdentityMismatch, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QuotaRefreshFailure { + pub code: QuotaRefreshFailureCode, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OAuthLoginStart { pub login_id: String, @@ -495,7 +519,18 @@ pub enum OAuthLoginStatus { Pending, Complete { account: AccountView }, Cancelled, - Failed { message: String }, + Failed { code: OAuthFailureCode }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OAuthFailureCode { + NotCompleted, + TimedOut, + IdentityMismatch, + VerificationFailed, + SaveFailed, + Unavailable, } #[cfg(test)] diff --git a/src-tauri/src/wake.rs b/src-tauri/src/wake.rs index 21eeee0..ca3e40f 100644 --- a/src-tauri/src/wake.rs +++ b/src-tauri/src/wake.rs @@ -462,9 +462,35 @@ fn quota_eligibility(snapshot: &QuotaSnapshot) -> Option { )); } if ordinary_quota_exhausted(snapshot, now_seconds) { + let exhausted_window = snapshot + .buckets + .iter() + .find(|bucket| bucket.kind == QuotaBucketKind::Codex) + .and_then(|bucket| { + bucket + .windows + .iter() + .find(|window| { + window.kind == QuotaWindowKind::FiveHour + && window.remaining_percent == Some(0) + && window.resets_at.is_some_and(|reset| reset > now_seconds) + }) + .or_else(|| { + bucket.windows.iter().find(|window| { + window.kind == QuotaWindowKind::Weekly + && window.remaining_percent == Some(0) + && window.resets_at.is_some_and(|reset| reset > now_seconds) + }) + }) + }); + let result = match exhausted_window.map(|window| &window.kind) { + Some(QuotaWindowKind::FiveHour) => WakeResultKind::FiveHourExhausted, + Some(QuotaWindowKind::Weekly) => WakeResultKind::WeeklyExhausted, + _ => WakeResultKind::NoOrdinaryCapacity, + }; return Some(WakeAccountOutcome::new( - WakeResultKind::NoOrdinaryCapacity, - "Ordinary Codex quota is exhausted; Wake will not use Reserve or reset credits", + result, + "Codex quota is exhausted; Wake will not use Reserve or reset credits", )); } None @@ -626,7 +652,7 @@ mod tests { let active = quota_eligibility(&snapshot(10, 90, i64::MAX)).expect("active result"); assert_eq!(active.result, WakeResultKind::AlreadyActive); let exhausted = quota_eligibility(&snapshot(100, 0, i64::MAX)).expect("capacity result"); - assert_eq!(exhausted.result, WakeResultKind::NoOrdinaryCapacity); + assert_eq!(exhausted.result, WakeResultKind::FiveHourExhausted); } #[test] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6861362..cfa7965 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "GSwitch", - "version": "1.0.7", + "version": "1.0.8", "identifier": "com.ginbing.gswitch", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/App.test.tsx b/src/App.test.tsx index c5b64c9..0fe5a1b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -216,8 +216,8 @@ describe("GSwitch account workspace", () => { const first = render(); await screen.findByRole("heading", { name: "1 saved account" }); - await userEvent.click(screen.getByRole("button", { name: "Open settings" })); - await userEvent.selectOptions(screen.getByLabelText("Language"), "zh-CN"); + await userEvent.click(screen.getAllByLabelText("Language")[0]); + await userEvent.click(screen.getByRole("button", { name: "简体中文" })); expect(screen.getByRole("button", { name: "添加账户" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "已保存 1 个账户" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "批量选择" })).toHaveAttribute("aria-pressed", "false"); @@ -496,7 +496,7 @@ describe("GSwitch account workspace", () => { expect(await screen.findByText("Personal")).toBeInTheDocument(); expect(screen.getByText("Stale")).toBeInTheDocument(); - expect(screen.getByText("30%")).toBeInTheDocument(); + expect(screen.getByText("Last 30%")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Details" })); expect(await screen.findByRole("dialog", { name: /Reset credits/i })).toBeInTheDocument(); @@ -510,6 +510,31 @@ describe("GSwitch account workspace", () => { ); }); + it("shows the provider-supplied five-week free quota and reset time", async () => { + const freeAccount = { ...chatAccount, plan_type: "free" }; + const resetAt = Math.floor(Date.now() / 1000) + 21 * 24 * 60 * 60; + mocks.listAccounts.mockResolvedValue([freeAccount]); + mocks.accountQuota.mockResolvedValue({ + account_id: freeAccount.id, + status: "fresh", + snapshot: { + fetched_at_unix_ms: Date.now(), + buckets: [{ + limit_id: "codex", + kind: "codex", + windows: [{ kind: "other", window_duration_mins: 50_400, remaining_percent: 100, used_percent: 0, resets_at: resetAt }], + }], + }, + }); + render(); + + const card = (await screen.findByRole("heading", { name: "person@example.com" })).closest(".account-card"); + expect(card).toHaveTextContent("5 weeks"); + expect(card).toHaveTextContent("100%"); + expect(card).toHaveTextContent("Resets"); + expect(card).not.toHaveTextContent("5-hour"); + expect(card).not.toHaveTextContent("Reset time unavailable"); + }); it("uses ChatGPT email as the primary identity and workspace as context", async () => { mocks.listAccounts.mockResolvedValue([chatAccount]); render(); @@ -603,7 +628,7 @@ describe("GSwitch account workspace", () => { expect(await screen.findByText("Personal")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Switch to person@example.com" })).toBeEnabled(); - expect(screen.getAllByText("Not available")).toHaveLength(2); + expect(screen.getAllByText("Not available")).toHaveLength(1); }); it("saves the current account and refreshes its unknown quota in the background", async () => { @@ -646,7 +671,7 @@ describe("GSwitch account workspace", () => { mocks.accountQuota.mockResolvedValue(staleQuota); resolveRefresh?.(staleQuota); - expect(await screen.findByText("30%")).toBeInTheDocument(); + expect(await screen.findByText("Last 30%")).toBeInTheDocument(); }); it("serializes a six-account refresh and continues after one quota failure", async () => { @@ -691,10 +716,37 @@ describe("GSwitch account workspace", () => { expect(await screen.findByText("Could not refresh quota for 1 account(s). See the affected cards for their latest result.")).toBeInTheDocument(); const failedCard = screen.getByRole("heading", { name: "person3@example.com" }).closest(".account-card"); expect(failedCard).toHaveTextContent(/Last result is stale|Not available/); - expect(failedCard).toHaveTextContent("Quota could not be refreshed. The last result is shown when available."); + expect(within(failedCard as HTMLElement).getByRole("button", { name: "Quota update failed" })).toBeInTheDocument(); + expect(failedCard).toHaveTextContent("GSwitch could not update this quota. Try again later."); + expect(failedCard).toHaveTextContent("Last 30%"); expect(mocks.appSnapshot).toHaveBeenCalledOnce(); }); + it("keeps failed quota values visibly historical and offers account recovery only for a rejected sign-in", async () => { + const copyEmail = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: { writeText: copyEmail } }); + mocks.listAccounts.mockResolvedValue([chatAccount]); + mocks.accountQuota.mockResolvedValue(staleQuota); + mocks.refreshAccountQuota.mockRejectedValue({ code: "authentication" }); + render(); + + const alert = await screen.findByRole("button", { name: "Quota update failed" }); + expect(alert).toHaveAttribute("aria-describedby", "quota-error-account-1"); + expect(screen.getByText("Last 30%")).toBeInTheDocument(); + expect(screen.getByText("This saved sign-in was rejected. Sign in to this account again.")).toBeInTheDocument(); + expect(screen.getByText(/Last successful update:/)).toBeInTheDocument(); + expect(screen.queryByText("Quota could not be refreshed. The last result is shown when available.")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText("More actions for person@example.com")); + await userEvent.click(screen.getByRole("button", { name: "Copy email" })); + expect(copyEmail).toHaveBeenCalledWith("person@example.com"); + await userEvent.click(screen.getByLabelText("More actions for person@example.com")); + await userEvent.click(screen.getByRole("button", { name: "Sign in to this account again" })); + await waitFor(() => expect(mocks.startOAuth).toHaveBeenCalledWith("account-1")); + expect(mocks.openOAuth).not.toHaveBeenCalled(); + expect(await screen.findByRole("dialog", { name: "Sign in again · person@example.com" })).toBeInTheDocument(); + }); + it("updates one account quota without reloading the account workspace", async () => { const secondAccount = { ...chatAccount, id: "account-2", email: "other@example.com" }; mocks.listAccounts.mockResolvedValue([chatAccount, secondAccount]); @@ -747,12 +799,10 @@ describe("GSwitch account workspace", () => { it("keeps account actions available when the live Codex account cannot be identified", async () => { mocks.listAccounts.mockResolvedValue([chatAccount]); mocks.accountQuota.mockResolvedValue({ account_id: "account-1", status: "unknown" }); - mocks.refreshAccountQuota.mockRejectedValue( - new Error("Codex is running and GSwitch cannot safely identify its active account"), - ); + mocks.refreshAccountQuota.mockRejectedValue({ code: "codex_account_unknown" }); render(); - expect(await screen.findByText(/Codex is running and its active account could not be identified/)).toBeInTheDocument(); + expect(await screen.findByText(/Codex is running, but its account cannot be identified/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Switch to person@example.com" })).toBeEnabled(); }); @@ -763,7 +813,10 @@ describe("GSwitch account workspace", () => { ["file_store_required", /Enable file-backed Codex credentials/], ["credentials_changed", /Codex credentials changed during the switch/], ["recovery_required", /Complete the protected switch recovery/], - ["local_verification_failed", /could not confirm local Codex state/], + ["codex_config_unavailable", /could not check Codex configuration/], + ["current_credential_unreadable", /could not read the current Codex sign-in/], + ["current_account_not_saved", /Save the current Codex account/], + ["local_verification_failed", /could not read its local account state/], ["target_check_unavailable", /could not check .* with ChatGPT/], ["target_workspace_mismatch", /do not match the selected workspace/], ["post_write_verification_failed", /restored the previous Codex account/], @@ -847,8 +900,9 @@ describe("GSwitch account workspace", () => { await userEvent.click(screen.getByRole("button", { name: /^Add account$/ })); const dialog = await screen.findByRole("dialog", { name: "Add a Codex account" }); - await userEvent.click(within(dialog).getByRole("button", { name: /Sign in with Codex/ })); + await userEvent.click(within(dialog).getByRole("button", { name: /Sign in to add an account/ })); expect(await screen.findByText("Finish sign-in in your browser")).toBeInTheDocument(); + expect(mocks.openOAuth).not.toHaveBeenCalled(); await userEvent.click(screen.getByRole("button", { name: "Cancel sign-in" })); await waitFor(() => expect(mocks.cancelOAuth).toHaveBeenCalledWith("login-1")); diff --git a/src/App.tsx b/src/App.tsx index ca7a7fa..dc43676 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,6 @@ import { MoreHorizontal, Plus, RefreshCw, - Settings, ShieldAlert, Trash2, Upload, @@ -54,9 +53,10 @@ import type { MigrationPreview, OAuthLoginStart, OAuthLoginStatus, + OAuthFailureCode, + QuotaRefreshFailureCode, QuotaView, QuotaWindow, - RuntimeInfo, StorageView, SwitchFailure, SwitchFailureCode, @@ -66,7 +66,6 @@ import { updater, type AvailableUpdate } from "./updater"; type Dialog = | "add" - | "settings" | "enable-switching" | "recover-switch" | "recover-reset-credit" @@ -137,6 +136,9 @@ const switchFailureCodes = new Set([ "credentials_changed", "recovery_required", "local_verification_failed", + "codex_config_unavailable", + "current_credential_unreadable", + "current_account_not_saved", "target_check_unavailable", "target_workspace_mismatch", "post_write_verification_failed", @@ -163,6 +165,9 @@ function switchFailureMessage(t: Translator, error: unknown, account: AccountVie credentials_changed: "switch.error.credentialsChanged", recovery_required: "switch.error.recoveryRequired", local_verification_failed: "switch.error.localVerificationFailed", + codex_config_unavailable: "switch.error.codexConfigUnavailable", + current_credential_unreadable: "switch.error.currentCredentialUnreadable", + current_account_not_saved: "switch.error.currentAccountNotSaved", target_check_unavailable: "switch.error.targetCheckUnavailable", target_workspace_mismatch: "switch.error.targetWorkspaceMismatch", post_write_verification_failed: "switch.error.postWriteVerificationFailed", @@ -172,15 +177,43 @@ function switchFailureMessage(t: Translator, error: unknown, account: AccountVie return t(key, { name: accountPrimaryName(account) }); } -function quotaRefreshMessage(t: Translator, error: unknown) { - const message = String(error); - if (/currently using this account|cannot safely identify its active account/i.test(message)) { - return t("quota.runningCodex"); - } - if (/another gswitch operation is already in progress/i.test(message)) { - return t("quota.operationBusy"); - } - return t("quota.refreshFailed"); +const quotaFailureCodes = new Set([ + "operation_busy", "codex_account_unknown", "authentication", "rate_limited", + "network", "service", "invalid_response", "identity_mismatch", "unavailable", +]); + +function quotaFailureCode(error: unknown): QuotaRefreshFailureCode { + if (typeof error !== "object" || error === null || !("code" in error)) return "unavailable"; + const code = (error as { code?: unknown }).code; + return typeof code === "string" && quotaFailureCodes.has(code as QuotaRefreshFailureCode) + ? code as QuotaRefreshFailureCode : "unavailable"; +} + +function quotaFailureMessage(t: Translator, code: QuotaRefreshFailureCode) { + const keys = { + operation_busy: "quota.failureBusy", + codex_account_unknown: "quota.failureCodexUnknown", + authentication: "quota.failureAuthentication", + rate_limited: "quota.failureRateLimited", + network: "quota.failureNetwork", + service: "quota.failureService", + invalid_response: "quota.failureInvalidResponse", + identity_mismatch: "quota.failureIdentity", + unavailable: "quota.failureUnavailable", + } as const; + return t(keys[code]); +} + +function oauthFailureMessage(t: Translator, code: OAuthFailureCode) { + const keys = { + not_completed: "oauth.failureNotCompleted", + timed_out: "oauth.failureTimedOut", + identity_mismatch: "oauth.failureIdentity", + verification_failed: "oauth.failureVerification", + save_failed: "oauth.failureSave", + unavailable: "oauth.failureUnavailable", + } as const; + return t(keys[code]); } function removeFailureMessage(t: Translator, error: unknown) { @@ -215,11 +248,21 @@ function accountGridHasGlobalMutation(busy: string | null) { ].includes(busy) || busy.startsWith("switch:") || busy.startsWith("reset:") || busy.startsWith("remove:"); } -function primaryWindow(quota: QuotaView | undefined, kind: "five_hour" | "weekly") { +function codexQuotaWindows(quota: QuotaView | undefined): QuotaWindow[] { return quota?.snapshot?.buckets .filter((bucket) => bucket.kind === "codex") - .flatMap((bucket) => bucket.windows) - .find((window) => window.kind === kind); + .flatMap((bucket) => bucket.windows) ?? []; +} + +function quotaWindowLabel(window: QuotaWindow, t: Translator): string { + if (window.kind === "five_hour") return t("quota.fiveHour"); + if (window.kind === "weekly") return t("quota.weekly"); + const minutes = window.window_duration_mins; + if (minutes === 50_400) return t("quota.fiveWeeks"); + if (minutes && minutes % 10_080 === 0) return t("quota.durationWeeks", { count: minutes / 10_080 }); + if (minutes && minutes % 1_440 === 0) return t("quota.durationDays", { count: minutes / 1_440 }); + if (minutes && minutes % 60 === 0) return t("quota.durationHours", { count: minutes / 60 }); + return t("quota.otherWindow"); } function accountPlan(account: AccountView, t: Translator) { @@ -260,17 +303,6 @@ function accountSecondaryName(account: AccountView, primary: string, t: Translat return undefined; } -function credentialStoreLabel(store: RuntimeInfo["credential_store"] | undefined, t: Translator) { - const labels = { - file: "credentialStore.file", - keyring: "credentialStore.keyring", - auto: "credentialStore.auto", - ephemeral: "credentialStore.ephemeral", - unknown: "credentialStore.unknown", - } as const; - return t(labels[store || "unknown"]); -} - function wakeStatusLabel(status: WakeOperationView["status"], t: Translator) { const key = `wake.status${status.slice(0, 1).toUpperCase()}${status.slice(1)}` as | "wake.statusRunning" @@ -284,6 +316,8 @@ function wakeResultLabel(result: WakeOperationView["results"][number]["result"], const labels = { started: "wake.started", already_active: "wake.alreadyActive", + five_hour_exhausted: "wake.fiveHourExhausted", + weekly_exhausted: "wake.weeklyExhausted", no_ordinary_capacity: "wake.noOrdinaryCapacity", needs_sign_in: "wake.needsSignIn", sent_not_confirmed: "wake.sentNotConfirmed", @@ -390,12 +424,18 @@ function QuotaMeter({ label, window, status, + failure, + failureId, + lastSuccess, t, formatLocale, }: { label: string; window?: QuotaWindow; status?: QuotaView["status"]; + failure?: QuotaRefreshFailureCode; + failureId?: string; + lastSuccess?: number; t: Translator; formatLocale: string; }) { @@ -404,18 +444,34 @@ function QuotaMeter({ const value = typeof remaining === "number" ? Math.max(0, Math.min(100, remaining)) : undefined; return ( -
+
- {label} - {value === undefined || !known ? "—" : formatPercent(value, formatLocale)} + {label}{failure ? ( + + + + {t("quota.refreshErrorLabel")} + {quotaFailureMessage(t, failure)} + {lastSuccess ? {t("quota.lastUpdated", { time: formatDateTimeWithRelative(lastSuccess / 1000, formatLocale) })} : null} + + + ) : null} + {value === undefined || !known ? "—" : status === "stale" + ? t("quota.previousValue", { value: formatPercent(value, formatLocale) }) + : formatPercent(value, formatLocale)}
void; resetRecoveryRequired: boolean; onRemove: () => void; + onCopyEmail: () => void; + onReauthenticate: () => void; + reauthenticationAvailable: boolean; + quotaFailure?: QuotaRefreshFailureCode; selectionMode: boolean; selected: boolean; onSelectionChange: () => void; @@ -504,7 +568,16 @@ function AccountCard({
+ {account.email ? : null} + {reauthenticationAvailable ? : null} - +
+ +
+ {(["system", "zh-CN", "en"] as const).map((preference) => ( + + ))} +
+
@@ -1682,6 +1803,8 @@ export default function App() { formatLocale={locale.formatLocale} globalBusy={accountGridBusy} key={account.id} + onCopyEmail={() => void copyAccountEmail(account)} + onReauthenticate={() => void startOAuth(account)} onRefresh={() => void refreshAccount(account)} onRemove={() => { setRemoveAccount(account); @@ -1708,6 +1831,8 @@ export default function App() { ); }} quota={quotas[account.id]} + quotaFailure={quotaFailures[account.id]} + reauthenticationAvailable={account.kind === "chat_gpt" && (quotaFailures[account.id] === "authentication" || accountSignInNeeded[account.id] === true)} selected={selectedAccountIds.includes(account.id)} selectionMode={selectionMode} t={t} @@ -1725,7 +1850,7 @@ export default function App() {
{dialog === "add" ? ( - + {addMethod === "start" ? (
@@ -1861,7 +1986,7 @@ export default function App() { <>
-

{t("oauth.finishTitle")}

{t("oauth.finishBody")}

+

{t("oauth.finishTitle")}

{t(oauthTarget ? "oauth.reauthenticateBody" : "oauth.finishBody")}

@@ -1870,21 +1995,21 @@ export default function App() {
- +
) : oauth.status.status === "complete" ? (
-

{t("oauth.added", { name: oauth.status.account.label })}

+

{t(oauthTarget ? "oauth.reauthenticated" : "oauth.added", { name: oauth.status.account.label })}

) : (

{oauth.status.status === "cancelled" ? t("oauth.cancelled") : t("oauth.incomplete")}

-

{oauth.status.status === "failed" ? t("oauth.retryBody") : t("oauth.unchanged")}

- +

{oauth.status.status === "failed" ? oauthFailureMessage(t, oauth.status.code) : t("oauth.unchanged")}

+
)}
@@ -1976,28 +2101,6 @@ export default function App() {
) : null} - {dialog === "settings" ? ( - setDialog(null)} t={t} title={t("settings.title")}> -
-
- - -
-
{t("settings.credentialStore")}{runtime ? credentialStoreLabel(runtime.credential_store, t) : t("settings.checking")}
-
{t("settings.accountRecords")}{storage?.status === "recovery_required" ? t("settings.recoveryRequired") : t("settings.storedLocally")}
-

{t("settings.secretBoundary")}

-
-
- ) : null} - {dialog === "storage-recovery" ? ( invoke("reset_damaged_account_store"), liveAccount: () => invoke("get_live_account_state"), - startOAuth: () => invoke("start_oauth_login"), + startOAuth: (targetId?: string) => invoke("start_oauth_login", { targetId }), oauthStatus: (loginId: string) => invoke("get_oauth_login_status", { loginId }), cancelOAuth: (loginId: string) => diff --git a/src/i18n.ts b/src/i18n.ts index 134597e..589efd2 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -44,8 +44,15 @@ const en = { "account.savedNoBillableCheck": "Saved without a billable check", "account.apiKeyDescription": "Subscription quota, reset credits, and Wake do not apply to API-key accounts.", "account.refresh": "Refresh {name}", + "account.copyEmail": "Copy email", + "account.signInAgain": "Sign in to this account again", "quota.fiveHour": "5-hour", "quota.weekly": "Weekly", + "quota.fiveWeeks": "5 weeks", + "quota.durationWeeks": "{count} weeks", + "quota.durationDays": "{count} days", + "quota.durationHours": "{count} hours", + "quota.otherWindow": "Codex quota", "quota.notAvailable": "Not available", "quota.lastResultStale": "Last result is stale", "quota.resets": "Resets {time}", @@ -54,6 +61,18 @@ const en = { "quota.runningCodex": "Codex is running and its active account could not be identified. The last quota result is shown; try again when the account is available.", "quota.operationBusy": "Another GSwitch operation is in progress. The last quota result is shown; try again when it finishes.", "quota.refreshFailed": "Quota could not be refreshed. The last result is shown when available.", + "quota.previousValue": "Last {value}", + "quota.refreshErrorLabel": "Quota update failed", + "quota.lastUpdated": "Last successful update: {time}", + "quota.failureBusy": "GSwitch is busy. Retry when the current action finishes.", + "quota.failureCodexUnknown": "Codex is running, but its account cannot be identified. Quit Codex and retry.", + "quota.failureAuthentication": "This saved sign-in was rejected. Sign in to this account again.", + "quota.failureRateLimited": "The quota service is limiting requests. Try again later.", + "quota.failureNetwork": "The quota service could not be reached. Check your connection.", + "quota.failureService": "The quota service returned an error. Try again later.", + "quota.failureInvalidResponse": "The quota service returned a result GSwitch could not read.", + "quota.failureIdentity": "GSwitch could not confirm this saved account's identity.", + "quota.failureUnavailable": "GSwitch could not update this quota. Try again later.", "credit.resetCredits": "Reset credits", "credit.earliest": "Earliest {time}", "firstRun.eyebrow": "GET STARTED", @@ -63,7 +82,7 @@ const en = { "firstRun.addManually": "Add manually", "firstRun.browserSignIn": "Browser sign-in", "firstRun.authJson": "Cockpit Tools export, Codex auth.json, or another supported account export", - "toolbar.openSettings": "Open settings", + "toolbar.language": "Language", "toolbar.wakeAll": "Wake all", "toolbar.currentAccount": "Current Codex account", "toolbar.statusChecking": "Checking", @@ -104,7 +123,10 @@ const en = { "switch.error.fileStoreRequired": "GSwitch did not switch {name}. Enable file-backed Codex credentials, then try again.", "switch.error.credentialsChanged": "GSwitch stopped because Codex credentials changed during the switch. Review the current account, then retry {name}.", "switch.error.recoveryRequired": "GSwitch did not switch {name}. Complete the protected switch recovery before trying again.", - "switch.error.localVerificationFailed": "GSwitch could not confirm local Codex state before switching {name}. Your Codex account was not changed. Check the current account and retry.", + "switch.error.localVerificationFailed": "GSwitch could not read its local account state before switching {name}. Your Codex account was not changed.", + "switch.error.codexConfigUnavailable": "GSwitch could not check Codex configuration before switching {name}. Make sure Codex is installed and try again. Your current account was not changed.", + "switch.error.currentCredentialUnreadable": "GSwitch could not read the current Codex sign-in. Check the current Codex account before switching to {name}; nothing was changed.", + "switch.error.currentAccountNotSaved": "Save the current Codex account in GSwitch before switching to {name}. Nothing was changed.", "switch.error.targetCheckUnavailable": "GSwitch could not check {name} with ChatGPT. Your Codex account was not changed. Retry when the connection is available.", "switch.error.targetWorkspaceMismatch": "The saved credentials for {name} do not match the selected workspace. Your Codex account was not changed. Sign in or import that account again.", "switch.error.postWriteVerificationFailed": "GSwitch could not confirm {name} after writing it and restored the previous Codex account. Check the current account before retrying.", @@ -120,6 +142,9 @@ const en = { "notice.pasteAuth": "Paste a complete auth JSON document before adding it.", "notice.enterApiKey": "Enter an API key before adding it.", "notice.importedAccount": "{name} was imported safely.", + "notice.reauthenticatedAccount": "{name} was signed in again. The current Codex account did not change.", + "notice.emailCopied": "Email copied.", + "notice.emailCopyFailed": "Could not copy the email. Select and copy it from the card.", "notice.savedApiKey": "{name} was saved without a billable check.", "notice.quotaUnavailable": "Could not refresh quota for {count} account(s). See the affected cards for their latest result.", "notice.switched": "{name} is now the active Codex account.", @@ -167,8 +192,8 @@ const en = { "add.importExisting": "Import existing", "add.addNew": "Add new", "add.otherMethods": "Other methods", - "add.browserTitle": "Sign in with Codex", - "add.browserDescription": "Use the official Codex sign-in flow.", + "add.browserTitle": "Sign in to add an account", + "add.browserDescription": "Use your browser to sign in; GSwitch saves the verified account.", "add.jsonTitle": "Paste auth JSON", "add.jsonDescription": "Submit a complete Codex auth document.", "add.fileTitle": "Choose files", @@ -198,6 +223,16 @@ const en = { "migration.workspace": "Workspace: {name}", "oauth.finishTitle": "Finish sign-in in your browser", "oauth.finishBody": "GSwitch adds the verified account when Codex confirms the sign-in.", + "oauth.reauthenticateTitle": "Sign in again · {name}", + "oauth.reauthenticateBody": "Sign in to the same email and workspace. GSwitch will update only this saved account.", + "oauth.reauthenticated": "{name} was signed in again", + "oauth.openFailed": "The browser did not open. Copy the sign-in link and paste it into your browser.", + "oauth.failureNotCompleted": "Browser sign-in did not finish. Try the link again.", + "oauth.failureTimedOut": "Sign-in timed out. Start a new sign-in and finish it in your browser.", + "oauth.failureIdentity": "The browser signed in to a different account or workspace. The saved account was not changed.", + "oauth.failureVerification": "Sign-in finished, but GSwitch could not verify this account. Nothing was saved.", + "oauth.failureSave": "Sign-in was verified, but GSwitch could not save the account. Follow any recovery prompt.", + "oauth.failureUnavailable": "GSwitch could not finish sign-in. Try again; your saved account was not changed.", "oauth.link": "Sign-in link", "oauth.copyLink": "Copy sign-in link", "oauth.cancel": "Cancel sign-in", @@ -214,22 +249,10 @@ const en = { "form.displayName": "Display name", "form.apiKeyTitle": "Add an API-key account", "form.apiKeyBody": "GSwitch does not use the key for an unrequested billable validation call.", - "settings.title": "Settings", "settings.language": "Language", "settings.system": "System / Automatic", "settings.english": "English", "settings.chinese": "简体中文", - "settings.credentialStore": "Credential store", - "settings.accountRecords": "Account records", - "settings.checking": "Checking…", - "settings.recoveryRequired": "Recovery required", - "settings.storedLocally": "Stored locally", - "settings.secretBoundary": "Credentials, provider responses, and reset-credit identifiers never enter this window.", - "credentialStore.file": "File", - "credentialStore.keyring": "Keyring", - "credentialStore.auto": "Automatic", - "credentialStore.ephemeral": "Ephemeral", - "credentialStore.unknown": "Unknown", "recovery.storageTitle": "Recover GSwitch account storage", "recovery.keepCodex": "Keep Codex untouched", "recovery.storageBody": "GSwitch could not safely read its saved account library. It has not changed your live Codex credential.", @@ -285,7 +308,9 @@ const en = { "wake.viewResults": "View Wake results", "wake.alreadyActive": "The five-hour window is already active.", "wake.started": "A new five-hour window was confirmed.", - "wake.noOrdinaryCapacity": "No ordinary Codex capacity is available.", + "wake.fiveHourExhausted": "5-hour quota is used up. Wake did not use reset credits.", + "wake.weeklyExhausted": "Weekly quota is used up. Wake did not use reset credits.", + "wake.noOrdinaryCapacity": "This account has no quota available for Wake. Check its 5-hour and weekly quota.", "wake.needsSignIn": "Sign in again before trying another Wake.", "wake.sentNotConfirmed": "Wake was sent, but the new window was not confirmed.", "wake.quotaUnavailable": "Quota could not be checked, so no Wake was sent. Retry later.", @@ -332,8 +357,15 @@ const zhCN: Record = { "account.savedNoBillableCheck": "已保存,未进行计费验证", "account.apiKeyDescription": "订阅额度、重置额度和唤醒不适用于 API 密钥账户。", "account.refresh": "刷新 {name}", + "account.copyEmail": "复制邮箱", + "account.signInAgain": "重新登录此账户", "quota.fiveHour": "5 小时", "quota.weekly": "每周", + "quota.fiveWeeks": "5 周", + "quota.durationWeeks": "{count} 周", + "quota.durationDays": "{count} 天", + "quota.durationHours": "{count} 小时", + "quota.otherWindow": "Codex 额度", "quota.notAvailable": "不可用", "quota.lastResultStale": "上次结果已过期", "quota.resets": "重置于 {time}", @@ -342,6 +374,18 @@ const zhCN: Record = { "quota.runningCodex": "Codex 正在运行,GSwitch 无法识别其当前账户。现显示上次额度结果;账户可用后请重试。", "quota.operationBusy": "GSwitch 正在执行其他操作。现显示上次额度结果;操作完成后请重试。", "quota.refreshFailed": "额度刷新失败;如有上次结果,仍会显示。", + "quota.previousValue": "上次 {value}", + "quota.refreshErrorLabel": "额度更新失败", + "quota.lastUpdated": "上次成功更新:{time}", + "quota.failureBusy": "GSwitch 正在处理其他操作,请稍后重试。", + "quota.failureCodexUnknown": "Codex 正在运行,但无法识别当前账户。关闭 Codex 后重试。", + "quota.failureAuthentication": "此账户的登录凭据被拒绝,请重新登录此账户。", + "quota.failureRateLimited": "额度服务暂时限制请求,请稍后重试。", + "quota.failureNetwork": "无法连接额度服务,请检查网络。", + "quota.failureService": "额度服务返回错误,请稍后重试。", + "quota.failureInvalidResponse": "额度服务返回了 GSwitch 无法读取的结果。", + "quota.failureIdentity": "GSwitch 无法确认此账户的身份。", + "quota.failureUnavailable": "GSwitch 未能更新此账户额度,请稍后重试。", "credit.resetCredits": "重置额度", "credit.earliest": "最早到期:{time}", "firstRun.eyebrow": "开始使用", @@ -351,7 +395,7 @@ const zhCN: Record = { "firstRun.addManually": "手动添加", "firstRun.browserSignIn": "浏览器登录", "firstRun.authJson": "Cockpit Tools 导出、Codex auth.json 或其他受支持的账户导出文件", - "toolbar.openSettings": "打开设置", + "toolbar.language": "语言", "toolbar.wakeAll": "全部唤醒", "toolbar.currentAccount": "当前 Codex 账户", "toolbar.statusChecking": "检查中", @@ -392,7 +436,10 @@ const zhCN: Record = { "switch.error.fileStoreRequired": "GSwitch 未切换到 {name}。请启用基于文件的 Codex 凭据后重试。", "switch.error.credentialsChanged": "切换期间 Codex 凭据发生变化,GSwitch 已停止。请检查当前账户后重试 {name}。", "switch.error.recoveryRequired": "GSwitch 未切换到 {name}。请先完成受保护的切换恢复步骤。", - "switch.error.localVerificationFailed": "切换到 {name} 前,GSwitch 无法确认本地 Codex 状态。当前账户未改变。请检查当前账户后重试。", + "switch.error.localVerificationFailed": "切换到 {name} 前,GSwitch 无法读取本地账户状态。当前 Codex 账户未改变。", + "switch.error.codexConfigUnavailable": "切换到 {name} 前,GSwitch 无法检查 Codex 配置。请确认 Codex 已安装后重试;当前账户未改变。", + "switch.error.currentCredentialUnreadable": "GSwitch 无法读取当前 Codex 登录状态。请检查当前账户后再切换到 {name};账户未改变。", + "switch.error.currentAccountNotSaved": "请先将当前 Codex 账户保存到 GSwitch,再切换到 {name}。当前账户未改变。", "switch.error.targetCheckUnavailable": "GSwitch 暂时无法向 ChatGPT 验证 {name}。当前 Codex 账户未改变,请在连接恢复后重试。", "switch.error.targetWorkspaceMismatch": "{name} 的已保存凭据与所选工作区不符。当前 Codex 账户未改变,请重新登录或导入该账户。", "switch.error.postWriteVerificationFailed": "写入后无法确认 {name},GSwitch 已恢复之前的 Codex 账户。请检查当前账户后重试。", @@ -408,6 +455,9 @@ const zhCN: Record = { "notice.pasteAuth": "请先粘贴完整的 auth JSON 文档。", "notice.enterApiKey": "请先输入 API 密钥。", "notice.importedAccount": "已安全导入 {name}。", + "notice.reauthenticatedAccount": "已重新登录 {name},当前 Codex 账户未改变。", + "notice.emailCopied": "已复制邮箱。", + "notice.emailCopyFailed": "复制邮箱失败,请从卡片中手动选择并复制。", "notice.savedApiKey": "{name} 已保存,未进行计费验证。", "notice.quotaUnavailable": "有 {count} 个账户的额度未能刷新。请查看对应卡片中的最新结果。", "notice.switched": "{name} 现在是活动的 Codex 账户。", @@ -455,8 +505,8 @@ const zhCN: Record = { "add.importExisting": "导入已有账户", "add.addNew": "添加新账户", "add.otherMethods": "其他方式", - "add.browserTitle": "使用 Codex 登录", - "add.browserDescription": "使用官方 Codex 登录流程。", + "add.browserTitle": "登录并添加账户", + "add.browserDescription": "在浏览器中登录,GSwitch 会保存验证后的账户。", "add.jsonTitle": "粘贴 auth JSON", "add.jsonDescription": "提交完整的 Codex auth 文档。", "add.fileTitle": "选择文件", @@ -486,6 +536,16 @@ const zhCN: Record = { "migration.workspace": "工作区:{name}", "oauth.finishTitle": "请在浏览器中完成登录", "oauth.finishBody": "Codex 确认登录后,GSwitch 会添加已验证的账户。", + "oauth.reauthenticateTitle": "重新登录 · {name}", + "oauth.reauthenticateBody": "请登录同一邮箱和工作区。GSwitch 只会更新这个已保存账户。", + "oauth.reauthenticated": "已重新登录 {name}", + "oauth.openFailed": "浏览器未能打开。请复制登录链接并粘贴到浏览器。", + "oauth.failureNotCompleted": "浏览器登录未完成,请重新打开链接。", + "oauth.failureTimedOut": "登录已超时,请重新开始并在浏览器中完成。", + "oauth.failureIdentity": "浏览器登录了另一个账户或工作区,原账户未改变。", + "oauth.failureVerification": "登录完成,但 GSwitch 无法验证此账户,没有保存任何修改。", + "oauth.failureSave": "登录已验证,但 GSwitch 无法保存账户。若出现恢复提示,请按提示处理。", + "oauth.failureUnavailable": "GSwitch 未能完成登录,请重试;已保存账户未改变。", "oauth.link": "登录链接", "oauth.copyLink": "复制登录链接", "oauth.cancel": "取消登录", @@ -502,22 +562,10 @@ const zhCN: Record = { "form.displayName": "显示名称", "form.apiKeyTitle": "添加 API 密钥账户", "form.apiKeyBody": "GSwitch 不会使用该密钥发起未经请求的计费验证调用。", - "settings.title": "设置", "settings.language": "语言", "settings.system": "系统 / 自动", "settings.english": "English", "settings.chinese": "简体中文", - "settings.credentialStore": "凭据存储", - "settings.accountRecords": "账户记录", - "settings.checking": "正在检查…", - "settings.recoveryRequired": "需要恢复", - "settings.storedLocally": "已本地保存", - "settings.secretBoundary": "凭据、provider 响应和重置额度标识符永远不会进入此窗口。", - "credentialStore.file": "文件", - "credentialStore.keyring": "钥匙串", - "credentialStore.auto": "自动", - "credentialStore.ephemeral": "临时", - "credentialStore.unknown": "未知", "recovery.storageTitle": "恢复 GSwitch 账户存储", "recovery.keepCodex": "保持 Codex 不变", "recovery.storageBody": "GSwitch 无法安全读取已保存的账户库。它没有更改当前 Codex 凭据。", @@ -573,7 +621,9 @@ const zhCN: Record = { "wake.viewResults": "查看唤醒结果", "wake.alreadyActive": "5 小时窗口已激活。", "wake.started": "已确认启动新的 5 小时窗口。", - "wake.noOrdinaryCapacity": "没有可用的常规 Codex 容量。", + "wake.fiveHourExhausted": "5 小时额度已用完,唤醒未使用重置额度。", + "wake.weeklyExhausted": "每周额度已用完,唤醒未使用重置额度。", + "wake.noOrdinaryCapacity": "此账户目前没有可用于唤醒的额度,请查看 5 小时和每周额度。", "wake.needsSignIn": "请重新登录后再尝试唤醒。", "wake.sentNotConfirmed": "唤醒已发送,但未确认新窗口。", "wake.quotaUnavailable": "无法检查额度,因此未发送唤醒请求。请稍后重试。", diff --git a/src/styles.css b/src/styles.css index b491e03..eeb7c7e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -202,6 +202,57 @@ summary:focus-visible { gap: 8px; } +.language-menu { + position: relative; +} + +.language-menu summary { + display: grid; + width: 35px; + height: 35px; + place-items: center; + list-style: none; + border: 1px solid var(--border); + border-radius: 9px; + color: var(--muted); + background: var(--surface); + cursor: pointer; +} + +.language-menu summary::-webkit-details-marker { display: none; } +.language-menu summary:hover { color: var(--text); background: var(--surface-hover); } + +.language-menu-popover { + position: absolute; + z-index: 10; + top: 42px; + right: 0; + width: 155px; + padding: 5px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.language-menu-popover button { + display: flex; + width: 100%; + min-height: 32px; + align-items: center; + justify-content: space-between; + padding: 0 8px; + border: 0; + border-radius: 6px; + color: var(--text); + background: transparent; + font-size: 12px; + text-align: left; +} + +.language-menu-popover button:hover { background: var(--surface-hover); } +.language-menu-popover button[aria-pressed="true"] { color: var(--accent); font-weight: 700; } + .button, .icon-button { display: inline-flex; @@ -591,6 +642,9 @@ summary:focus-visible { transform: translateY(-1px); } +.account-card:hover, +.account-card:focus-within { position: relative; z-index: 4; } + .account-active { border-color: color-mix(in srgb, var(--accent) 68%, var(--border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent), var(--shadow); @@ -696,7 +750,7 @@ summary:focus-visible { z-index: 3; top: 33px; right: 0; - width: 154px; + width: 205px; padding: 5px; border: 1px solid var(--border); border-radius: 9px; @@ -713,7 +767,7 @@ summary:focus-visible { padding: 0 8px; border: 0; border-radius: 6px; - color: var(--danger); + color: var(--text); background: transparent; font-size: 12px; font-weight: 650; @@ -721,9 +775,12 @@ summary:focus-visible { } .card-menu-popover button:hover:not(:disabled) { - background: var(--danger-soft); + background: var(--surface-hover); } +.card-menu-popover .card-menu-danger { color: var(--danger); } +.card-menu-popover .card-menu-danger:hover:not(:disabled) { background: var(--danger-soft); } + .account-badges { flex-wrap: wrap; gap: 6px; @@ -762,6 +819,8 @@ summary:focus-visible { gap: 14px; } +.quota-pair-single { grid-template-columns: minmax(0, 1fr); } + .quota-meter { min-width: 0; } @@ -780,6 +839,49 @@ summary:focus-visible { font-variant-numeric: tabular-nums; } +.quota-label { display: inline-flex; align-items: center; gap: 4px; } +.quota-alert { position: relative; display: inline-flex; align-items: center; } +.quota-alert-button { + display: grid; + width: 22px; + height: 22px; + place-items: center; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--warning); + background: var(--warning-soft); + cursor: pointer; +} + +.quota-alert-tooltip { + position: absolute; + z-index: 8; + top: 26px; + left: -4px; + display: grid; + width: min(250px, 70vw); + gap: 5px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 9px; + color: var(--text); + background: var(--surface); + box-shadow: var(--shadow); + font-size: 12px; + font-weight: 450; + line-height: 1.4; + opacity: 0; + pointer-events: none; + visibility: hidden; +} + +.quota-alert-tooltip strong { font-size: 12px; font-weight: 700; } +.quota-alert:hover .quota-alert-tooltip, +.quota-alert:focus-within .quota-alert-tooltip { opacity: 1; visibility: visible; } +.quota-meter-stale .quota-heading > strong { color: var(--muted); font-size: 11px; } +.quota-meter-stale .quota-track span { opacity: .45; } + .quota-track { height: 5px; margin-top: 8px; @@ -810,13 +912,6 @@ summary:focus-visible { line-height: 1.35; } -.quota-status-message { - margin: 9px 0 0; - color: var(--soft-muted); - font-size: 10.5px; - line-height: 1.35; -} - .credit-row { min-height: 37px; justify-content: space-between; @@ -1047,7 +1142,6 @@ summary:focus-visible { .oauth-flow, .credential-form, .migration-flow, -.settings-list, .confirm-panel, .reset-details, .wake-panel { @@ -1238,7 +1332,6 @@ summary:focus-visible { .credential-form > p, .confirm-panel > p, .outcome-panel p, -.settings-list p, .reset-details > p { margin: 5px 0 0; color: var(--muted); @@ -1341,38 +1434,6 @@ textarea { margin-top: 8px; } -.settings-list { - display: grid; - gap: 0; -} - -.settings-list > div { - display: flex; - min-height: 48px; - align-items: center; - justify-content: space-between; - gap: 18px; - border-bottom: 1px solid var(--border); - color: var(--muted); - font-size: 12.5px; -} - -.settings-list .settings-language label { - color: var(--muted); - font-size: 12.5px; -} - -.settings-list .settings-language select { - width: auto; - max-width: 60%; -} - -.settings-list strong { - color: var(--text); - font-size: 12px; - text-transform: capitalize; -} - .confirm-panel { display: grid; justify-items: start; @@ -1519,6 +1580,8 @@ textarea { background: var(--success); } +.wake-five_hour_exhausted, +.wake-weekly_exhausted, .wake-no_ordinary_capacity, .wake-needs_sign_in, .wake-cancelled, diff --git a/src/types.ts b/src/types.ts index 7dd7c2e..fd50c01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,9 @@ export type SwitchFailureCode = | "credentials_changed" | "recovery_required" | "local_verification_failed" + | "codex_config_unavailable" + | "current_credential_unreadable" + | "current_account_not_saved" | "target_check_unavailable" | "target_workspace_mismatch" | "post_write_verification_failed" @@ -82,7 +85,11 @@ export type OAuthLoginStatus = | { status: "pending" } | { status: "complete"; account: AccountView } | { status: "cancelled" } - | { status: "failed"; message: string }; + | { status: "failed"; code: OAuthFailureCode }; + +export type OAuthFailureCode = + | "not_completed" | "timed_out" | "identity_mismatch" + | "verification_failed" | "save_failed" | "unavailable"; export interface ImportResult { imported: AccountView[]; @@ -114,6 +121,15 @@ export interface MigrationPreview { export type QuotaStatus = "fresh" | "stale" | "unknown" | "not_applicable"; +export type QuotaRefreshFailureCode = + | "operation_busy" | "codex_account_unknown" | "authentication" + | "rate_limited" | "network" | "service" | "invalid_response" + | "identity_mismatch" | "unavailable"; + +export interface QuotaRefreshFailure { + code: QuotaRefreshFailureCode; +} + export interface QuotaWindow { kind: "five_hour" | "weekly" | "other"; used_percent?: number; @@ -173,6 +189,8 @@ export interface ResetCreditOutcome { export type WakeResultKind = | "started" | "already_active" + | "five_hour_exhausted" + | "weekly_exhausted" | "no_ordinary_capacity" | "needs_sign_in" | "sent_not_confirmed"