From 448db4d0e6a6886e7dbe4d3e8f736f28a18ff4be Mon Sep 17 00:00:00 2001 From: PyramidPeak <81540280+PyramidPeak@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:57:50 +0300 Subject: [PATCH 1/2] Improve Grok account reliability and optional Orca switching --- CHANGELOG.md | 8 + .../src-tauri/src/commands/grok_accounts.rs | 58 +++- apps/desktop-tauri/src-tauri/src/main.rs | 9 +- .../src-tauri/src/shell/flyout_window.rs | 68 ++++- apps/desktop-tauri/src-tauri/src/state.rs | 3 + .../src-tauri/src/window_positioner.rs | 72 ++++- .../src/components/CodexAccountsMenu.test.tsx | 44 +++ .../src/components/CodexAccountsMenu.tsx | 94 ++++--- .../components/GrokAccountUsageDetails.tsx | 36 +++ .../src/components/GrokAccountsMenu.test.tsx | 137 ++++++++- .../src/components/GrokAccountsMenu.tsx | 178 ++++++------ .../desktop-tauri/src/components/MenuCard.tsx | 59 ++-- .../src/hooks/useGrokAccounts.ts | 28 +- apps/desktop-tauri/src/i18n/keys.ts | 10 + apps/desktop-tauri/src/lib/tauri.ts | 1 + apps/desktop-tauri/src/styles.css | 10 + .../src/surfaces/TrayPanel.test.tsx | 13 +- apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 33 +-- .../credentials/CodexAccountsSection.test.tsx | 26 +- .../credentials/CodexAccountsSection.tsx | 18 +- .../credentials/GrokAccountsSection.test.tsx | 2 + .../credentials/GrokAccountsSection.tsx | 40 +-- apps/desktop-tauri/src/types/bridge.ts | 1 + rust/src/locale.rs | 10 + rust/src/locale/en-US.ftl | 12 +- rust/src/providers/grok/accounts.rs | 164 ++++++++++- rust/src/providers/grok/accounts/orca.rs | 212 ++++++++++++++ .../providers/grok/accounts/orca_runtime.rs | 208 ++++++++++++++ rust/src/providers/grok/accounts/refresh.rs | 261 ++++++++++++++++++ rust/src/providers/grok/mod.rs | 52 ++-- rust/src/providers/grok/tests.rs | 13 + 31 files changed, 1626 insertions(+), 254 deletions(-) create mode 100644 apps/desktop-tauri/src/components/GrokAccountUsageDetails.tsx create mode 100644 rust/src/providers/grok/accounts/orca.rs create mode 100644 rust/src/providers/grok/accounts/orca_runtime.rs create mode 100644 rust/src/providers/grok/accounts/refresh.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f28c2434f..35a860ee55 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [Unreleased] + +### Improved +- Keep saved Grok account usage tied to its login, renew expiring credentials, and coordinate optional account selection and reconnects with Orca. +- Show both Codex account quota windows, expand account lists by default, and place Grok refresh in its card header. +- Open desktop launches near the cursor while retaining the current foreground activation policy. + # Changelog ## [Windows] 0.70.0 - 2026-10-03 @@ -114,6 +121,7 @@ allowances and per-model spending, clearer account switching, and steadier menu-bar layout. ### Added +- Codex accounts: show both five-hour and weekly usage windows with independent reset times in the tray, and include both limits in Settings. - Cost history: add provider-scoped, display-only Codex and Claude quota-window projection primitives with exact reset boundaries, independent token/cost completeness, and backward-compatible local evidence handling. - Provider charts: show recent Codex and Claude quota-window history with explicit account scope and estimated-boundary markers; historical data remains display-only. - Grok: Settings and tray **Add account** flow matching Codex/Claude — isolated `grok login --oauth`, save current CLI login, switch, and remove without logging out the active session. diff --git a/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs index c2f3b338c2..d672fef8df 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs @@ -1,8 +1,10 @@ use super::invalidate_account_usage; use crate::state::AppState; -use codexbar::core::ProviderId; +use codexbar::core::{ProviderError, ProviderId}; use codexbar::providers::grok::GrokProvider; -use codexbar::providers::grok::accounts::{self, AccountManager, GrokAccount, GrokAccountUsage}; +use codexbar::providers::grok::accounts::{ + self, AccountManager, GrokAccount, GrokAccountUsage, GrokUsageStatus, +}; use std::sync::Mutex; use tauri::Emitter; use tauri::Manager; @@ -91,13 +93,45 @@ pub async fn grok_account_remove(app: tauri::AppHandle, id: String) -> Result<() #[tauri::command] pub async fn grok_account_fetch(id: String) -> Result { - let text = AccountManager::new() - .and_then(|m| m.auth_text_for(&id)) - .map_err(|e| e.to_string())?; - GrokProvider::new() - .fetch_usage_from_auth_json(&text) + let manager = AccountManager::new().map_err(|error| error.to_string())?; + let usage = match manager.auth_text_for_usage(&id, false).await { + Ok(text) => match GrokProvider::new().fetch_usage_from_auth_json(&text).await { + Err(ProviderError::AuthRequired) => { + match manager.auth_text_for_usage(&id, true).await { + Ok(text) => GrokProvider::new().fetch_usage_from_auth_json(&text).await, + Err(error) => Err(error), + } + } + result => result, + }, + Err(error) => Err(error), + }; + Ok(match usage { + Ok(usage) => usage, + Err(ProviderError::AuthRequired) => { + GrokAccountUsage::unavailable(GrokUsageStatus::SignInRequired) + } + Err(_) => GrokAccountUsage::unavailable(GrokUsageStatus::Failed), + }) +} + +#[tauri::command] +pub async fn grok_account_reauthenticate(app: tauri::AppHandle, id: String) -> Result<(), String> { + let _mutation = MUTATION + .try_lock() + .map_err(|_| "A Grok account operation is already in progress.")?; + let _ = accounts::cleanup_abandoned_logins(); + accounts::begin_login(); + let login = tauri::async_runtime::spawn_blocking(accounts::login) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let _credentials = accounts::CREDENTIAL_OPERATION.lock().await; + let result = AccountManager::new().and_then(|manager| manager.reauthenticate(&id, login)); + drop(_credentials); + // Even a partial persistence failure can have refreshed the saved login. + refresh_after_grok_change(app)?; + result.map_err(|e| e.to_string()) } #[tauri::command] @@ -105,6 +139,14 @@ pub async fn grok_account_switch(app: tauri::AppHandle, id: String) -> Result<() let _mutation = MUTATION .try_lock() .map_err(|_| "A Grok account operation is already in progress.")?; + AccountManager::new() + .map_err(|error| error.to_string())? + .auth_text_for_usage(&id, false) + .await + .map_err(|error| error.to_string())?; + accounts::select_orca_account(&id) + .await + .map_err(|error| error.to_string())?; let _credentials = accounts::CREDENTIAL_OPERATION.lock().await; tauri::async_runtime::spawn_blocking(move || AccountManager::new()?.switch(&id)) .await diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 2a2caf309e..288c4aac47 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -49,7 +49,7 @@ fn should_hide_close_request(mode: SurfaceMode) -> bool { /// Open the primary window: the tray-panel flyout, the only dashboard /// layout. The legacy PopOut layout on `main` is retired, so launches and -/// relaunches land on the same panel as a tray left-click. +/// relaunches use that same panel near the cursor, while tray clicks anchor it to the tray. /// /// Spawned because building the flyout window synchronously can deadlock on /// Windows (see `shell::flyout_window::open_or_focus`). @@ -58,14 +58,16 @@ fn should_hide_close_request(mode: SurfaceMode) -> bool { /// Windows for the foreground (`Activation::IfAllowed`): a launch from Start /// or Explorer gets focus, a login-time or background launch does not. fn open_primary_window(app: &tauri::AppHandle, delay: Duration) { + // Capture the launch location before startup work or a delayed reveal moves the cursor. + let cursor = app.cursor_position().ok().map(|point| (point.x, point.y)); let app = app.clone(); tauri::async_runtime::spawn(async move { if !delay.is_zero() { tokio::time::sleep(delay).await; } - if let Err(error) = shell::flyout_window::open_or_focus( + if let Err(error) = shell::flyout_window::open_near_cursor( &app, - None, + cursor, shell::activation::Activation::IfAllowed, ) { tracing::warn!(%error, "failed to open the tray panel window"); @@ -227,6 +229,7 @@ fn main() { commands::claude_account_switch, commands::grok_accounts_list, commands::grok_account_add, + commands::grok_account_reauthenticate, commands::grok_account_cancel_login, commands::grok_account_save_current, commands::grok_account_remove, diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs index 0a2f1dae7a..7f3a2bec4e 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs @@ -28,7 +28,7 @@ use crate::surface::SurfaceMode; pub const FLYOUT_LABEL: &str = "flyout"; /// Geometry-store key for the flyout's remembered SIZE (position is never -/// stored — the flyout always re-anchors above the tray on open). Kept as +/// stored — each open chooses a tray or captured cursor anchor). Kept as /// its own key (distinct from the legacy `SurfaceMode::TrayPanel::as_str()` /// `"trayPanel"` key) — `geometry_store::load_size` migrates a pre-existing /// `"trayPanel"` entry into this key on first read, so upgrading users keep @@ -88,6 +88,29 @@ pub fn open_or_focus( position: Option<(i32, i32)>, activation: Activation, ) -> Result<(), String> { + open_with_anchor(app, position, None, activation) +} + +/// Open a desktop launch near its captured cursor location, retaining that +/// anchor through subsequent content-driven resizes. +pub fn open_near_cursor( + app: &AppHandle, + cursor: Option<(f64, f64)>, + activation: Activation, +) -> Result<(), String> { + open_with_anchor(app, None, cursor, activation) +} + +fn open_with_anchor( + app: &AppHandle, + position: Option<(i32, i32)>, + cursor: Option<(f64, f64)>, + activation: Activation, +) -> Result<(), String> { + app.state::>() + .lock() + .map_err(|e| e.to_string())? + .flyout_cursor_anchor = cursor; let settings = Settings::load(); if let Some(window) = app.get_webview_window(FLYOUT_LABEL) { apply_window_always_on_top(&window, settings.tray_panel_always_on_top)?; @@ -150,9 +173,11 @@ pub fn open_or_focus( // Settings window. super::dwm::force_dark_caption_resizable(&win); - let target_position = - position.or_else(|| super::position::default_surface_position(app, SurfaceMode::TrayPanel)); - if let Some((x, y)) = target_position { + if cursor.is_some() { + reanchor(app)?; + } else if let Some((x, y)) = + position.or_else(|| super::position::default_surface_position(app, SurfaceMode::TrayPanel)) + { // Best-effort initial placement; the frontend re-reveals the // window, so a failed set_position here is non-fatal. let _set_initial = win.set_position(PhysicalPosition::new(x, y)); @@ -322,7 +347,7 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - // drag-resizes call `set_flyout_size`, auto-fit resizes never do) — // mirrors `shell::position::remember_current_geometry_if_eligible` // skipping TrayPanel for the same reason on the old shared window. - // Position is never persisted (always re-anchored above the tray). + // Position is never persisted; resizing follows this open's selected anchor. tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_) => true, tauri::WindowEvent::CloseRequested { api, .. } => { // Hide-not-close on a native close request (Alt+F4-equivalent from @@ -337,8 +362,8 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - } } -/// Reposition the flyout so its bottom-right corner stays anchored to the -/// system-tray area, using the window's CURRENT logical size (after a +/// Reposition the flyout at its captured cursor or system-tray anchor, +/// using the window's CURRENT logical size (after a /// frontend-driven resize). Canonical anchor-math implementation for the /// flyout window; the `reanchor_tray_panel` Tauri command /// (`commands/system.rs`) is a thin retarget onto this function. @@ -357,18 +382,28 @@ pub fn reanchor(app: &AppHandle) -> Result<(), String> { height: (outer.height as f64 / scale).round() as u32, }; - let anchor = app + let (anchor, cursor) = app .try_state::>() - .and_then(|state| state.lock().ok()?.tray_anchor); + .and_then(|state| { + let state = state.lock().ok()?; + Some((state.tray_anchor, state.flyout_cursor_anchor)) + }) + .unwrap_or_default(); let monitors = window.available_monitors().unwrap_or_default(); - let monitor = anchor - .and_then(|anchor| crate::shell::geometry::monitor_for_anchor(&monitors, anchor)) - .cloned() + let monitor = cursor + .and_then(|(x, y)| app.monitor_from_point(x, y).ok().flatten()) + .or_else(|| { + anchor + .and_then(|anchor| crate::shell::geometry::monitor_for_anchor(&monitors, anchor)) + .cloned() + }) .or_else(|| window.current_monitor().ok().flatten()) .or_else(|| window.primary_monitor().ok().flatten()) .ok_or_else(|| "no monitor".to_string())?; let work_area = crate::shell::geometry::monitor_work_area_rect(&monitor); + // The target monitor can differ from the window's previous monitor. + let scale = monitor.scale_factor().max(1.0); let monitor_bounds = Rect { x: monitor.position().x, y: monitor.position().y, @@ -377,7 +412,14 @@ pub fn reanchor(app: &AppHandle) -> Result<(), String> { }; let (x, y) = { - if let Some(a) = anchor { + if let Some(cursor) = cursor { + crate::window_positioner::calculate_cursor_position( + cursor, + &work_area, + &panel_size, + scale, + ) + } else if let Some(a) = anchor { crate::window_positioner::calculate_panel_position( &Rect { x: a.x, diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 4d9fa3c1bb..246dff8d81 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -120,6 +120,8 @@ pub struct AppState { pub surface_machine: SurfaceStateMachine, pub current_target: SurfaceTarget, pub tray_anchor: Option, + /// Cursor location captured for a desktop launch; cleared by tray/other opens. + pub flyout_cursor_anchor: Option<(f64, f64)>, pub provider_cache: Vec, pub transient_provider_failure_counts: HashMap, /// Live session behind each provider's cached good snapshot, for @@ -198,6 +200,7 @@ impl AppState { surface_machine: SurfaceStateMachine::new(), current_target: SurfaceTarget::Summary, tray_anchor: None, + flyout_cursor_anchor: None, provider_cache: Vec::new(), transient_provider_failure_counts: HashMap::new(), last_good_owners: HashMap::new(), diff --git a/apps/desktop-tauri/src-tauri/src/window_positioner.rs b/apps/desktop-tauri/src-tauri/src/window_positioner.rs index e5e6eef7ba..407074ed48 100644 --- a/apps/desktop-tauri/src-tauri/src/window_positioner.rs +++ b/apps/desktop-tauri/src-tauri/src/window_positioner.rs @@ -156,7 +156,27 @@ pub fn calculate_panel_position( } } -/// Position for shortcut-triggered opening: 22 % from left, vertically centred. +/// Position beside a desktop launch's physical cursor point, kept in the work area. +pub fn calculate_cursor_position( + cursor: (f64, f64), + work_area: &Rect, + panel_size: &PanelSize, + scale_factor: f64, +) -> (i32, i32) { + #[expect( + clippy::cast_possible_truncation, + reason = "physical cursor coordinates fit i32" + )] + let x = (cursor.0.round() as i32).saturating_add(GAP); + #[expect( + clippy::cast_possible_truncation, + reason = "physical cursor coordinates fit i32" + )] + let y = (cursor.1.round() as i32).saturating_add(GAP); + clamp_to_work_area(x, y, work_area, panel_size, scale_factor) +} + +/// Legacy shortcut placement: 22 % from left, vertically centred. pub fn calculate_shortcut_position( monitor_rect: &Rect, panel_size: &PanelSize, @@ -477,6 +497,56 @@ mod tests { // --- shortcut-anchor tests --- + #[test] + fn desktop_open_stays_beside_cursor_after_content_grows() { + let monitor = hd_monitor(); + let cursor = (50.0, 100.0); + assert_eq!( + calculate_cursor_position(cursor, &monitor, &panel(), 1.0), + (58, 108) + ); + let taller = PanelSize { + width: 360, + height: 800, + }; + assert_eq!( + calculate_cursor_position(cursor, &monitor, &taller, 1.0), + (58, 108) + ); + } + + #[test] + fn cursor_open_clamps_on_high_dpi_monitor_with_negative_origin() { + let work_area = Rect { + x: -2560, + y: 0, + width: 2560, + height: 1400, + }; + let panel = PanelSize { + width: 400, + height: 600, + }; + assert_eq!( + calculate_cursor_position((-10.0, 1390.0), &work_area, &panel, 2.0), + (-808, 192) + ); + } + + #[test] + fn oversized_cursor_panel_keeps_its_top_left_visible() { + let work_area = Rect { + x: 1920, + y: -100, + width: 500, + height: 400, + }; + assert_eq!( + calculate_cursor_position((2200.0, 250.0), &work_area, &panel(), 2.0), + (1928, -92) + ); + } + #[test] fn shortcut_position_22_pct_from_left() { let monitor = hd_monitor(); diff --git a/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx index a6e9edeb03..b399158cfe 100644 --- a/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx +++ b/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx @@ -124,6 +124,49 @@ describe("CodexAccountsMenu", () => { expect((fills[1] as HTMLElement).style.width).toBe("70%"); }); + it("renders both five-hour and weekly usage bars when both windows exist", async () => { + const both: CodexAccountUsageSnapshot = { + email: "both@example.com", + providerAccountId: null, + plan: "plus", + allowed: true, + limitReached: false, + primaryWindow: { + usedPercent: 14, + resetAt: "2030-01-02T03:04:00Z", + limitWindowSeconds: 18_000, + }, + secondaryWindow: { + usedPercent: 93, + resetAt: "2030-01-08T03:04:00Z", + limitWindowSeconds: 604_800, + }, + credits: null, + updatedAt: "2024-01-01T00:00:00Z", + }; + const { container } = renderMenu(false, { + accounts: [account("1", { source: "ambient" }), account("2")], + accountOrdinals: { "1": 1, "2": 2 }, + snapshots: { "1": both }, + }, false); + await screen.findByText("user-1@example.com"); + expect(screen.getByText("14% PanelUsedSuffix")).toBeInTheDocument(); + expect(screen.getByText("93% PanelUsedSuffix")).toBeInTheDocument(); + const fills = container.querySelectorAll(".codex-menu-accounts__bar-fill"); + expect(fills.length).toBe(2); + expect((fills[0] as HTMLElement).style.width).toBe("14%"); + expect((fills[1] as HTMLElement).style.width).toBe("93%"); + expect((fills[1] as HTMLElement).dataset.level).toBe("critical"); + expect(screen.getByText("5h")).toBeInTheDocument(); + expect(screen.getByText("7d")).toBeInTheDocument(); + for (const resetAt of [both.primaryWindow!.resetAt!, both.secondaryWindow!.resetAt!]) { + const formatted = new Intl.DateTimeFormat(undefined, { + month: "short", day: "numeric", hour: "numeric", minute: "2-digit", + }).format(new Date(resetAt)); + expect(screen.getByText(`MetricResetsIn ${formatted}`)).toBeInTheDocument(); + } + }); + it("renders a usage bar from a weekly-only snapshot (primaryWindow: null)", async () => { const weeklyOnly: CodexAccountUsageSnapshot = { email: "weekly@example.com", @@ -201,6 +244,7 @@ describe("CodexAccountsMenu", () => { }); expect(tauriMocks.codexAccountSwitch).toHaveBeenCalledWith("2"); expect(tauriMocks.refreshProviders).toHaveBeenCalledTimes(1); + expect(screen.getByText("CodexAccountsSwitchedHint")).toBeInTheDocument(); }); it("uses an opaque ordinal for every account while hideEmail is on", async () => { const { container: hidden } = renderMenu(true, { diff --git a/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx b/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx index 228c7692aa..fe3bdf0f21 100644 --- a/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx +++ b/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx @@ -4,6 +4,7 @@ import type { CodexAccount, CodexAccountsStateBridge, CodexAccountUsageSnapshot, + CodexUsageWindow, } from "../types/bridge"; import { useLocale } from "../hooks/useLocale"; import { useFormattedResetTime } from "../hooks/useFormattedResetTime"; @@ -41,6 +42,7 @@ export default function CodexAccountsMenu({ const [accountOrdinals, setAccountOrdinals] = useState>({}); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [switched, setSwitched] = useState(false); const load = useCallback(async () => { setBusy(true); @@ -64,7 +66,7 @@ export default function CodexAccountsMenu({ useEffect(() => { onLayoutChange?.(); - }, [accounts.length, error, onLayoutChange]); + }, [accounts.length, snapshots, error, switched, onLayoutChange]); useEffect(() => { let cancelled = false; @@ -78,10 +80,12 @@ export default function CodexAccountsMenu({ }, [load]); const handleSwitch = async (id: string) => { + setSwitched(false); setBusy(true); setError(null); try { await codexAccountSwitch(id); + setSwitched(true); await load(); // Make the tray icon/menu reflect the newly active ambient identity. void refreshProviders().catch(() => {}); @@ -105,11 +109,12 @@ export default function CodexAccountsMenu({ ); return ( -
+
{t("CodexAccountsTitle")} {accounts.length} + {switched &&

{t("CodexAccountsSwitchedHint")}

} {error && (
{error} @@ -154,23 +159,9 @@ function CodexAccountRow({ onSwitch: (id: string) => Promise; }) { const { t } = useLocale(); - // Prefer the primary (normally five-hour) window. Accounts whose backend - // only returns a weekly window have primaryWindow: null, so keep the - // existing secondary-window fallback for their bar and reset detail. - const usageWindow = - snapshot?.primaryWindow ?? snapshot?.secondaryWindow ?? null; - const pct = usageWindow ? Math.round(usageWindow.usedPercent) : null; - const resetText = useFormattedResetTime( - usageWindow?.resetAt ?? null, - null, - resetTimeRelative, + const windows = [snapshot?.primaryWindow, snapshot?.secondaryWindow].filter( + (window): window is NonNullable => window != null, ); - const resetLabel = resetText - ? resetTimeRelative - ? resetText - : `${t("MetricResetsIn")} ${resetText}` - : null; - const windowLabel = formatWindowLabel(usageWindow?.limitWindowSeconds); const isAmbient = account.source === "ambient"; return ( @@ -187,23 +178,13 @@ function CodexAccountRow({ )} - {(pct !== null || resetLabel) && ( - - {windowLabel && {windowLabel}} - {pct !== null && ( - {pct}% {t("PanelUsedSuffix")} - )} - {resetLabel && {resetLabel}} - - )} - {pct !== null && ( - - - - )} + {windows.map((usageWindow, index) => ( + + ))}
); - if (accounts.length <= 1 && !hasSwitchableAccount && !error) return null; return ( -
- - {t("GrokAccountsTitle")} - {accounts.length} - - {error && ( -
- {error} -
- )} - {switched &&

{t("GrokAccountsSwitched")}

} -
    - {accounts.map((account) => ( - - ))} -
-
+ <> + {renderHeader ? renderHeader(refreshAction) : refreshAction} +
+ + {t("GrokAccountsTitle")} + {accounts.length} + + {error && ( +
+ {error} +
+ )} + {switched &&

{t("GrokAccountsSwitched")}

} + {reauthenticated &&

{t("GrokAccountsReauthenticated")}

} +

{t("GrokAccountsSourceHint")}

+ {loggingIn &&

{t("GrokAccountsSigningIn")}

} + {loggingIn && } +
    + {accounts.map((account) => ( + + ))} +
+
+ ); } +function accountUsage( + account: GrokAccount, + snapshot: GrokAccountUsage | undefined, + card: GrokAccountsCardSnapshot | undefined, +): GrokAccountUsage | undefined { + if (snapshot?.usageAvailable && snapshot.usedPercent != null) return snapshot; + // The card may still belong to the previous CLI or browser account after a + // switch. Unknown identity and informational windows cannot prove usage. + const email = card?.accountEmail?.trim().toLowerCase(); + if (!account.isActive || !card || card.error || !email + || email !== account.email.trim().toLowerCase() + || (card.accountOrganization ?? null) !== account.organization + || card.primary.isInformational + || !Number.isFinite(card.primary.usedPercent)) return snapshot; + return { + status: "ready", + usageAvailable: true, + usedPercent: card.primary.usedPercent, + plan: card.planName, + windowMinutes: card.primary.windowMinutes, + resetsAt: card.primary.resetsAt, + }; +} + +type GrokAccountsCardSnapshot = Pick; + function GrokAccountRow({ account, snapshot, + status, hideEmail, resetTimeRelative, busy, onSwitch, + onSignInAgain, }: { account: GrokAccount; snapshot: GrokAccountUsage | undefined; + status: GrokAccountUsage["status"]; hideEmail: boolean; resetTimeRelative: boolean; busy: boolean; onSwitch: (id: string) => Promise; + onSignInAgain: (id: string) => Promise; }) { const { t } = useLocale(); const email = hideEmail ? maskEmail(account.email) : account.email; - const pct = - snapshot?.usageAvailable && snapshot.usedPercent != null - ? Math.round(snapshot.usedPercent) - : null; - const resetText = useFormattedResetTime( - snapshot?.resetsAt ?? null, - null, - resetTimeRelative, - ); - const resetLabel = resetText - ? resetTimeRelative - ? resetText - : `${t("MetricResetsIn")} ${resetText}` - : null; - const windowLabel = formatWindowLabel(snapshot?.windowMinutes); - const barLevel = - pct != null && pct >= 100 ? "exhausted" : pct != null && pct >= 90 ? "critical" : undefined; return (
  • @@ -109,39 +154,18 @@ function GrokAccountRow({ )} - {(pct !== null || resetLabel) && ( - - {windowLabel && {windowLabel}} - {pct !== null && {pct}% {t("PanelUsedSuffix")}} - {resetLabel && {resetLabel}} - - )} - {pct !== null && ( - - - - )} + - + } + {status === "signInRequired" && }
  • ); } - -function formatWindowLabel(windowMinutes: number | null | undefined): string | null { - if (!windowMinutes || windowMinutes <= 0) return null; - if (windowMinutes % 1_440 === 0) return `${windowMinutes / 1_440}d`; - if (windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; - return null; -} diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 516a6e7571..a821e549ae 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -1,4 +1,4 @@ -import { type CSSProperties, useCallback, useEffect, useState } from "react"; +import { type CSSProperties, type ReactNode, useCallback, useEffect, useState } from "react"; import type { CostSummaryDisplayStyle, ProviderChartData, @@ -275,37 +275,42 @@ export default function MenuCard({ .filter(Boolean) .join(" "); + const renderHeader = (refreshAction?: ReactNode) => ( +
    +
    +
    + {provider.displayName} + {!provider.error && email && {email}} +
    + {refreshAction} +
    + {provider.error ? ( +
    +
    {provider.error}
    + +
    + ) : ( +
    + + {Number.isNaN(Date.parse(provider.updatedAt)) + ? provider.updatedAt + : formatRelativeUpdated(Date.parse(provider.updatedAt), t)} + + {displayedPlanName && ( + {displayedPlanName} + )} +
    + )} +
    + ); + return (
    -
    -
    -
    - {provider.displayName} - {!provider.error && email && {email}} -
    -
    - {provider.error ? ( -
    -
    {provider.error}
    - -
    - ) : ( -
    - - {Number.isNaN(Date.parse(provider.updatedAt)) - ? provider.updatedAt - : formatRelativeUpdated(Date.parse(provider.updatedAt), t)} - - {displayedPlanName && ( - {displayedPlanName} - )} -
    - )} -
    + {provider.providerId !== "grok" && renderHeader()} {provider.providerId === "codex" && ( )} diff --git a/apps/desktop-tauri/src/hooks/useGrokAccounts.ts b/apps/desktop-tauri/src/hooks/useGrokAccounts.ts index 36d8bcb9a7..bf44b2cbf2 100644 --- a/apps/desktop-tauri/src/hooks/useGrokAccounts.ts +++ b/apps/desktop-tauri/src/hooks/useGrokAccounts.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; -import type { GrokAccount, GrokAccountUsage } from "../types/bridge"; +import type { GrokAccount, GrokAccountUsage, ProviderUsageSnapshot } from "../types/bridge"; import { grokAccountFetch, grokAccountsList, @@ -13,6 +13,7 @@ export function useGrokAccounts({ reloadOnFocus = false }: { reloadOnFocus?: boo const [error, setError] = useState(null); const mounted = useRef(false); const reloadSequence = useRef(0); + const mutationInFlight = useRef(false); const reportError = useCallback((value: unknown, sequence = reloadSequence.current) => { if (mounted.current && sequence === reloadSequence.current) setError(String(value)); @@ -22,20 +23,26 @@ export function useGrokAccounts({ reloadOnFocus = false }: { reloadOnFocus?: boo const sequence = ++reloadSequence.current; try { const next = await grokAccountsList(); - const snapshots: Record = {}; + if (!mounted.current || sequence !== reloadSequence.current) return false; + setAccounts(next); + setError(null); + setUsage((previous) => Object.fromEntries(next.map((account) => [account.id, { + ...(previous[account.id] ?? { usageAvailable: false, usedPercent: null, plan: null, windowMinutes: null, resetsAt: null }), status: "loading", + }]))); await Promise.all( next.map(async (account) => { + let snapshot: GrokAccountUsage; try { - snapshots[account.id] = await grokAccountFetch(account.id); + snapshot = await grokAccountFetch(account.id); } catch { - // Keep the account row even if that login's usage fetch fails. + snapshot = { status: "failed", usageAvailable: false, usedPercent: null, plan: null, windowMinutes: null, resetsAt: null }; + } + if (mounted.current && sequence === reloadSequence.current) { + setUsage((previous) => ({ ...previous, [account.id]: snapshot })); } }), ); if (!mounted.current || sequence !== reloadSequence.current) return false; - setAccounts(next); - setUsage(snapshots); - setError(null); return true; } catch (value) { reportError(value, sequence); @@ -48,11 +55,15 @@ export function useGrokAccounts({ reloadOnFocus = false }: { reloadOnFocus?: boo const refresh = () => void reload(); refresh(); const unlisten = listen("grok-accounts-updated", refresh); + const unlistenProvider = listen("provider-updated", ({ payload }) => { + if (payload.providerId === "grok") refresh(); + }); if (reloadOnFocus) window.addEventListener("focus", refresh); return () => { mounted.current = false; if (reloadOnFocus) window.removeEventListener("focus", refresh); void unlisten.then((dispose) => dispose()).catch(() => {}); + void unlistenProvider.then((dispose) => dispose()).catch(() => {}); }; }, [reload, reloadOnFocus, reportError]); @@ -62,6 +73,8 @@ export function useGrokAccounts({ reloadOnFocus = false }: { reloadOnFocus?: boo onSuccess?: () => void, onFinally?: () => void, ) => { + if (mutationInFlight.current) return false; + mutationInFlight.current = true; setBusy(true); setError(null); try { @@ -73,6 +86,7 @@ export function useGrokAccounts({ reloadOnFocus = false }: { reloadOnFocus?: boo reportError(value); return false; } finally { + mutationInFlight.current = false; if (mounted.current) { setBusy(false); onFinally?.(); diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index e6968770f3..33c59299cb 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -348,6 +348,16 @@ export const ALL_LOCALE_KEYS = [ "GrokAccountsCancelLogin", "GrokAccountsSigningIn", "GrokAccountsSwitched", + "GrokUsageLoading", + "GrokUsageSignInRequired", + "GrokUsageUnavailable", + "GrokUsageFailed", + "GrokUsageResetUnavailable", + "GrokUsageRetry", + "GrokAccountsSignInAgain", + "GrokAccountsReauthenticated", + "GrokAccountsSourceHint", + "CodexAccountsSwitchedHint", "GrokAccountsAdded", "ClaudeSwapTitle", "ClaudeSwapHint", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index b32003c367..4c0e734d2a 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -59,6 +59,7 @@ export const claudeAccountSwitch = (id: string) => invoke("claude_account_switch", { id }); export const grokAccountsList = () => invoke("grok_accounts_list"); export const grokAccountAdd = () => invoke("grok_account_add"); +export const grokAccountReauthenticate = (id: string) => invoke("grok_account_reauthenticate", { id }); export const grokAccountCancelLogin = () => invoke("grok_account_cancel_login"); export const grokAccountSaveCurrent = () => invoke("grok_account_save_current"); export const grokAccountRemove = (id: string) => invoke("grok_account_remove", { id }); diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 937a9c0157..75f9e68019 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -3961,6 +3961,11 @@ html:has(.menu-surface--tray) { opacity: 0.65; } +.codex-menu-accounts__usage-status { + white-space: normal; + overflow-wrap: anywhere; +} + .codex-menu-accounts__bar { display: block; height: 4px; @@ -4022,6 +4027,11 @@ html:has(.menu-surface--tray) { gap: var(--menu-card-header-column-gap); } +.menu-card__refresh-btn { + flex-shrink: 0; + white-space: nowrap; +} + /* Provider brand badge + display name cluster. Tauri-only addition that gives each card a recognizable provider mark before the name. */ .menu-card__name-group { diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index b39b3a36cd..5b9f3ca100 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -320,7 +320,7 @@ describe("TrayPanel provider grid", () => { }); }); - it("offers an Overview share snapshot using only included spend rows", async () => { + it("shows only included overview spend rows without an export button", async () => { tauriMocks.getUsageSpendSummary.mockResolvedValue({ contract: {}, reportingPeriod: "rolling:30", @@ -370,9 +370,16 @@ describe("TrayPanel provider grid", () => { renderTrayPanel([provider("codex", "Codex", 35)]); - expect(await screen.findByRole("button", { name: "UsageSpendShare" })).toBeInTheDocument(); - expect(screen.getByText("~$2.00")).toBeInTheDocument(); + const expectedTotal = `~${new Intl.NumberFormat(undefined, { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, + }).format(2)}`; + expect(await screen.findByText((_, element) => + element?.tagName === "STRONG" && element.textContent === expectedTotal, + )).toBeInTheDocument(); expect(screen.getByText(/1 of 2 OverviewSpendProviderCoverage/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "UsageSpendShare" })).not.toBeInTheDocument(); }); it("dismisses the tray panel on unmodified Escape", async () => { diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 1de3c573a3..5f2f41e9af 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -2,7 +2,7 @@ import { Fragment, useEffect, useState, type CSSProperties } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import type { BootstrapState, ProviderUsageSnapshot, UsageSpendSummary } from "../types/bridge"; import type { LocaleKey } from "../i18n/keys"; -import { costPeriodLabel, costPeriodShortLabel } from "../lib/costPeriod"; +import { costPeriodShortLabel } from "../lib/costPeriod"; import { useCurrency } from "../hooks/CurrencyProvider"; import { sumDisplayCurrencyAmounts } from "../lib/currency"; import { @@ -25,10 +25,7 @@ import UpdateBanner from "../components/UpdateBanner"; import ProviderGrid from "../components/ProviderGrid"; import AgentSessions from "../components/AgentSessions"; import { hasSuccessfulClaudeCliQuota } from "../lib/claudeAccountActions"; -import { - filterUsageSpendSummaryForOverview, - shareUsageSpendPng, -} from "../lib/usageSpendSharing"; +import { filterUsageSpendSummaryForOverview } from "../lib/usageSpendSharing"; /** Provider IDs that have a dashboard URL in the backend */ const HAS_DASHBOARD = new Set([ @@ -352,9 +349,8 @@ function OverviewSpendSummary({ period?: string; t: (key: LocaleKey) => string; }) { - const { preferredCode, rates, format } = useCurrency(); + const { preferredCode, rates } = useCurrency(); const [summary, setSummary] = useState(null); - const [shareError, setShareError] = useState(null); useEffect(() => { let cancelled = false; @@ -373,16 +369,6 @@ function OverviewSpendSummary({ // The summary names the History window its period columns cover. const summaryPeriod = overviewSummary.reportingPeriod; const title = t("OverviewSpendPeriodTitle").replace("{}", costPeriodShortLabel(summaryPeriod, t)); - const onShare = () => { - setShareError(null); - const error = shareUsageSpendPng( - overviewSummary, - title, - `codexbar-overview-usage-${overviewSummary.reportingDay}.png`, - costPeriodLabel(summaryPeriod, t), - ); - if (error) setShareError(t(error as LocaleKey)); - }; const rows = overviewSummary.rows; const target = preferredCode.trim().toUpperCase() || "AUTO"; @@ -407,19 +393,6 @@ function OverviewSpendSummary({
    {aggregate.included} of {aggregate.considered} {t("OverviewSpendProviderCoverage")} · {t("OverviewSpendEstimate")}
    - - {shareError && ( -
    - {shareError} -
    - )} ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.test.tsx index 002254f091..48451dfba2 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.test.tsx @@ -153,10 +153,32 @@ describe("CodexAccountsSection", () => { ); render(); await waitFor(() => { - expect(screen.getByText("free · 38%")).toBeDefined(); + expect(screen.getByText("free · 1h 38%")).toBeDefined(); }); }); + it("shows both five-hour and weekly limits in the settings pill", async () => { + const usage = snapshot(14, "plus"); + usage.primaryWindow!.limitWindowSeconds = 18_000; + usage.secondaryWindow = { usedPercent: 93, resetAt: null, limitWindowSeconds: 604_800 }; + tauriMocks.getCodexAccountsState.mockResolvedValue({ + accounts: [account("1")], accountOrdinals: { "1": 1 }, snapshots: { "1": usage }, + }); + render(); + expect(await screen.findByText("plus · 5h 14% · 7d 93%")).toBeInTheDocument(); + }); + + it("shows weekly-only limits in the settings pill", async () => { + const usage = snapshot(42, "pro"); + usage.secondaryWindow = { ...usage.primaryWindow!, limitWindowSeconds: 604_800 }; + usage.primaryWindow = null; + tauriMocks.getCodexAccountsState.mockResolvedValue({ + accounts: [account("1")], accountOrdinals: { "1": 1 }, snapshots: { "1": usage }, + }); + render(); + expect(await screen.findByText("pro · 7d 42%")).toBeInTheDocument(); + }); + it("offers ambient reauthentication and reloads the account state", async () => { const ambient = account("ambient", { source: "ambient" }); tauriMocks.getCodexAccountsState @@ -173,7 +195,7 @@ describe("CodexAccountsSection", () => { expect(tauriMocks.codexAccountReauthenticate).toHaveBeenCalledTimes(1); await waitFor(() => { - expect(screen.getByText("free · 12%")).toBeDefined(); + expect(screen.getByText("free · 1h 12%")).toBeDefined(); }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx index c60332826d..789c31efb7 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/CodexAccountsSection.tsx @@ -317,13 +317,21 @@ function CodexUsagePill({ snapshot: CodexAccountUsageSnapshot; t: (key: LocaleKey) => string; }) { - const window = snapshot.primaryWindow; - const percent = window ? Math.round(window.usedPercent) : null; const plan = snapshot.plan ?? ""; const blocked = snapshot.allowed === false || snapshot.limitReached === true; - const label = [plan, percent !== null ? `${percent}%` : null] - .filter(Boolean) - .join(" · "); + const windows = [snapshot.primaryWindow, snapshot.secondaryWindow] + .filter((window): window is NonNullable => window != null) + .map((window) => { + const pct = `${Math.round(window.usedPercent)}%`; + if (window.limitWindowSeconds % 86_400 === 0) { + return `${window.limitWindowSeconds / 86_400}d ${pct}`; + } + if (window.limitWindowSeconds % 3_600 === 0) { + return `${window.limitWindowSeconds / 3_600}h ${pct}`; + } + return pct; + }); + const label = [plan, ...windows].filter(Boolean).join(" · "); return ( {label || t("CodexAccountsUsageUnavailable")} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.test.tsx index 6b8a659142..208cebd40d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.test.tsx @@ -10,12 +10,14 @@ const mocks = vi.hoisted(() => ({ grokAccountRemove: vi.fn(), grokAccountSwitch: vi.fn(), grokAccountFetch: vi.fn(), + grokAccountReauthenticate: vi.fn(), })); const events = vi.hoisted(() => ({ listen: vi.fn<(event: string, listener: () => void) => Promise<() => void>>(), })); vi.mock("../../../../../lib/tauri", () => mocks); vi.mock("@tauri-apps/api/event", () => events); +vi.mock("../../../../../hooks/useLocale", () => ({ useLocale: () => ({ t: (key: string) => key }) })); import { GrokAccountsSection } from "./GrokAccountsSection"; const t = (key: string) => key; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.tsx index cf4ec30042..a1d44941c7 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/GrokAccountsSection.tsx @@ -1,5 +1,4 @@ import { useState } from "react"; -import type { GrokAccount, GrokAccountUsage } from "../../../../../types/bridge"; import type { LocaleKey } from "../../../../../i18n/keys"; import { grokAccountAdd, @@ -7,23 +6,28 @@ import { grokAccountSaveCurrent, grokAccountRemove, grokAccountSwitch, + grokAccountReauthenticate, } from "../../../../../lib/tauri"; import { useGrokAccounts } from "../../../../../hooks/useGrokAccounts"; +import GrokAccountUsageDetails from "../../../../../components/GrokAccountUsageDetails"; export function GrokAccountsSection({ t }: { t: (key: LocaleKey) => string }) { const [loggingIn, setLoggingIn] = useState(false); const [message, setMessage] = useState(null); - const { accounts, usage, busy, error, reportError, run } = useGrokAccounts(); - const runOperation = (operation: () => Promise, success?: LocaleKey) => + const { accounts, usage, busy, error, reportError, run, reload } = useGrokAccounts(); + const runOperation = (operation: () => Promise, success?: LocaleKey) => { + setMessage(null); void run( operation, success ? () => setMessage(t(success)) : undefined, () => setLoggingIn(false), ); + }; return (

    {t("GrokAccountsTitle")}

    {t("GrokAccountsHint")}

    +

    {t("GrokAccountsSourceHint")}

    {error && (
    {error} @@ -43,8 +47,9 @@ export function GrokAccountsSection({ t }: { t: (key: LocaleKey) => string }) {
    {account.email} - {usageLabel(account, usage[account.id])} + {usage[account.id]?.plan || account.plan} + {account.isActive && ( {t("TokenAccountActive")} @@ -52,20 +57,29 @@ export function GrokAccountsSection({ t }: { t: (key: LocaleKey) => string }) { )}
    - {!account.isActive && account.isSaved && ( + {!account.isActive && account.isSaved && usage[account.id]?.status !== "signInRequired" && ( )} + {usage[account.id]?.status === "signInRequired" && } {!account.isSaved && (
    ); } - -function usageLabel(account: GrokAccount, snapshot?: GrokAccountUsage): string { - const plan = snapshot?.plan || account.plan || ""; - const percent = - snapshot?.usageAvailable && snapshot.usedPercent != null - ? `${Math.round(snapshot.usedPercent)}%` - : null; - return [plan, percent].filter(Boolean).join(" · "); -} diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index d26ebbe551..6eb13473a4 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -1257,6 +1257,7 @@ export interface GrokAccount { } export interface GrokAccountUsage { + status?: "loading" | "ready" | "signInRequired" | "unavailable" | "failed"; usageAvailable: boolean; usedPercent: number | null; plan: string | null; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 39abd95834..d4dc41c844 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -594,6 +594,16 @@ locale_keys! { GrokAccountsCancelLogin, GrokAccountsSigningIn, GrokAccountsSwitched, + GrokUsageLoading, + GrokUsageSignInRequired, + GrokUsageUnavailable, + GrokUsageFailed, + GrokUsageResetUnavailable, + GrokUsageRetry, + GrokAccountsSignInAgain, + GrokAccountsReauthenticated, + GrokAccountsSourceHint, + CodexAccountsSwitchedHint, GrokAccountsAdded, ClaudeSwapTitle, ClaudeSwapHint, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index eaf416a01e..a332cdb550 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -940,7 +940,17 @@ GrokAccountsEmpty = No Grok accounts saved yet. GrokAccountsSaveCurrent = Save current account GrokAccountsCancelLogin = Cancel sign-in GrokAccountsSigningIn = Complete Grok sign-in in your browser. Use the Firefox profile for the account you want to add. Your current CLI account stays active until you switch. -GrokAccountsSwitched = Account switched. Restart Grok CLI sessions to use it. +GrokAccountsSwitched = Grok account switched for new sessions. Existing sessions keep their account. +GrokUsageLoading = Updating usage… +GrokUsageSignInRequired = Sign-in expired or unavailable. Sign in again. +GrokUsageUnavailable = Usage unavailable for this account. +GrokUsageFailed = Could not fetch usage. Try again. +GrokUsageResetUnavailable = Reset time unavailable +GrokUsageRetry = Refresh usage +GrokAccountsSignInAgain = Sign in again +GrokAccountsReauthenticated = Account sign-in refreshed. Its usage will update now. +GrokAccountsSourceHint = Switch selects the local Grok CLI account and the linked account in Orca. +CodexAccountsSwitchedHint = Codex account switched. Restart running Codex Desktop or terminal sessions to use it. Existing chats are preserved. GrokAccountsAdded = Account saved. Select Switch to use it. ClaudeSwapTitle = claude-swap accounts ClaudeSwapHint = Read external Claude subscription accounts from the claude-swap (cswap) executable. CodexBar never reads or stores its credentials; switching runs only when you click Switch. diff --git a/rust/src/providers/grok/accounts.rs b/rust/src/providers/grok/accounts.rs index dc6a0785c5..acdb13db6f 100644 --- a/rust/src/providers/grok/accounts.rs +++ b/rust/src/providers/grok/accounts.rs @@ -2,6 +2,9 @@ //! skills, and the rest of ~/.grok stay in the ambient home. mod login; +mod orca; +mod orca_runtime; +mod refresh; use std::io; use std::path::{Path, PathBuf}; @@ -14,6 +17,7 @@ use crate::atomic_file::replace_staged; use crate::secure_file; pub use login::{begin_login, cancel_login, cleanup_abandoned_logins, login}; +pub use orca::select_account as select_orca_account; pub static CREDENTIAL_OPERATION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -31,6 +35,7 @@ pub struct GrokAccount { #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct GrokAccountUsage { + pub status: GrokUsageStatus, pub usage_available: bool, pub used_percent: Option, pub plan: Option, @@ -38,6 +43,28 @@ pub struct GrokAccountUsage { pub resets_at: Option>, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum GrokUsageStatus { + Ready, + SignInRequired, + Unavailable, + Failed, +} + +impl GrokAccountUsage { + pub fn unavailable(status: GrokUsageStatus) -> Self { + Self { + status, + usage_available: false, + used_percent: None, + plan: None, + window_minutes: None, + resets_at: None, + } + } +} + #[derive(Debug, thiserror::Error)] pub(crate) enum GrokAuthFileError { #[error("Failed to decode Grok auth.json: {0}")] @@ -277,6 +304,31 @@ impl AccountManager { self.save(&store) } + /// Refresh only the requested identity, preserving the currently selected account. + pub fn reauthenticate(&self, id: &str, login: SavedLogin) -> io::Result<()> { + login.validate()?; + if login.id()? != id { + return Err(io::Error::other( + "Signed in to a different Grok account. Sign in to the selected account instead.", + )); + } + if !self.list()?.iter().any(|account| account.id == id) { + return Err(io::Error::other("Grok account no longer exists.")); + } + let active = read_login(&self.ambient_auth)? + .is_some_and(|current| current.id().ok().as_deref() == Some(id)); + self.import(login.clone())?; + orca::update_login(id, &login)?; + if active { + let staged = stage_json(&self.ambient_auth, &login.auth)?; + if let Err(error) = replace_staged(&staged, &self.ambient_auth) { + let _cleanup = std::fs::remove_file(staged); + return Err(error); + } + } + Ok(()) + } + pub fn switch(&self, id: &str) -> io::Result<()> { let mut store = self.load()?; let target = store @@ -285,6 +337,7 @@ impl AccountManager { .find(|a| a.id().ok().as_deref() == Some(id)) .cloned() .ok_or_else(|| io::Error::other("Saved Grok account not found."))?; + let target = orca::saved_login(id)?.unwrap_or(target); target.validate()?; if let Some(current) = read_login(&self.ambient_auth)? { if current.id()? == id { @@ -305,6 +358,20 @@ impl AccountManager { } pub fn auth_text_for(&self, id: &str) -> io::Result { + if let Some(login) = orca::saved_login(id)? { + return serde_json::to_string(&login.auth).map_err(io::Error::other); + } + // Switch writes the live login to ~/.grok/auth.json. Prefer that file + // when the id matches so the Active row uses the same credentials as + // the Weekly card, not a stale saved copy. + let current = read_login(&self.ambient_auth); + if let Ok(Some(login)) = ¤t + && login.id()? == id + { + // Serialize the same snapshot whose identity was checked. A second + // file read could race with a switch to another account. + return serde_json::to_string(&login.auth).map_err(io::Error::other); + } let store = self.load()?; if let Some(login) = store .accounts @@ -313,12 +380,10 @@ impl AccountManager { { return serde_json::to_string(&login.auth).map_err(io::Error::other); } - let current = read_login(&self.ambient_auth)? - .ok_or_else(|| io::Error::other("Grok account not found."))?; - if current.id()? != id { - return Err(io::Error::other("Grok account not found.")); - } - std::fs::read_to_string(&self.ambient_auth) + // A damaged ambient file must not prevent usage reads for a saved + // account. If no saved identity matched, retain the ambient diagnostic. + current.map(|_| ())?; + Err(io::Error::other("Grok account not found.")) } } @@ -450,6 +515,58 @@ mod tests { .unwrap(); } + #[test] + fn reauthentication_updates_only_the_selected_saved_identity() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + let first = login("user-a", "a@example.com", "original"); + activate(&manager, &first); + manager.save_current().unwrap(); + manager + .import(login("user-b", "b@example.com", "other")) + .unwrap(); + let ambient = std::fs::read(&manager.ambient_auth).unwrap(); + manager + .reauthenticate("user-b", login("user-b", "b@example.com", "renewed")) + .unwrap(); + assert_eq!(manager.list().unwrap().len(), 2); + assert!(manager.auth_text_for("user-b").unwrap().contains("renewed")); + assert_eq!(std::fs::read(&manager.ambient_auth).unwrap(), ambient); + manager + .reauthenticate("user-a", login("user-a", "a@example.com", "active-renewed")) + .unwrap(); + assert!( + manager + .auth_text_for("user-a") + .unwrap() + .contains("active-renewed") + ); + assert!( + std::fs::read_to_string(&manager.ambient_auth) + .unwrap() + .contains("active-renewed") + ); + assert_eq!(manager.list().unwrap().len(), 2); + } + + #[test] + fn reauthentication_rejects_a_different_identity_without_changing_any_login() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + activate(&manager, &login("user-a", "a@example.com", "original")); + manager.save_current().unwrap(); + let ambient = std::fs::read(&manager.ambient_auth).unwrap(); + let saved = manager.auth_text_for("user-a").unwrap(); + assert!( + manager + .reauthenticate("user-a", login("user-b", "b@example.com", "wrong")) + .is_err() + ); + assert_eq!(manager.list().unwrap().len(), 1); + assert_eq!(manager.auth_text_for("user-a").unwrap(), saved); + assert_eq!(std::fs::read(&manager.ambient_auth).unwrap(), ambient); + } + #[test] fn add_and_switch_preserves_outgoing_account() { let dir = tempfile::tempdir().unwrap(); @@ -547,6 +664,41 @@ mod tests { assert!(manager.auth_text_for("missing").is_err()); } + #[test] + fn auth_text_for_prefers_live_login_over_saved_copy() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + manager + .import(login("user-a", "a@example.com", "token-old")) + .unwrap(); + activate(&manager, &login("user-a", "a@example.com", "token-live")); + let text = manager.auth_text_for("user-a").unwrap(); + assert!(text.contains("token-live")); + assert!(!text.contains("token-old")); + } + + #[test] + fn auth_text_for_saved_account_survives_invalid_ambient_login() { + let dir = tempfile::tempdir().unwrap(); + let manager = manager(dir.path()); + manager + .import(login("user-a", "a@example.com", "token-saved")) + .unwrap(); + std::fs::create_dir_all(manager.ambient_auth.parent().unwrap()).unwrap(); + std::fs::write(&manager.ambient_auth, "invalid-json").unwrap(); + assert!( + manager + .auth_text_for("user-a") + .unwrap() + .contains("token-saved") + ); + assert!(manager.auth_text_for("missing").is_err()); + assert_eq!( + std::fs::read_to_string(&manager.ambient_auth).unwrap(), + "invalid-json" + ); + } + #[test] fn same_user_upserts_instead_of_duplicating() { let dir = tempfile::tempdir().unwrap(); diff --git a/rust/src/providers/grok/accounts/orca.rs b/rust/src/providers/grok/accounts/orca.rs new file mode 100644 index 0000000000..69ef1f370b --- /dev/null +++ b/rust/src/providers/grok/accounts/orca.rs @@ -0,0 +1,212 @@ +use super::{SavedLogin, read_login}; +use crate::core::ProviderError; +use serde_json::Value; +use std::io; +use std::path::{Path, PathBuf}; + +struct LinkedAccount { + id: String, + home: PathBuf, +} + +fn resolve_account(root: &Path, user_id: &str) -> io::Result> { + let index = root.join("accounts.json"); + if !index.exists() { + return Ok(None); + } + if std::fs::symlink_metadata(root)?.file_type().is_symlink() + || std::fs::symlink_metadata(&index)?.file_type().is_symlink() + { + return Err(io::Error::other( + "Orca account storage is not a regular folder.", + )); + } + let value: Value = serde_json::from_slice(&std::fs::read(index)?).map_err(io::Error::other)?; + if value.get("version").and_then(Value::as_u64) != Some(1) { + return Err(io::Error::other("Unsupported Orca account storage.")); + } + let matches: Vec<_> = value + .get("accounts") + .and_then(Value::as_array) + .ok_or_else(|| io::Error::other("Invalid Orca accounts."))? + .iter() + .filter(|account| account.get("userId").and_then(Value::as_str) == Some(user_id)) + .collect(); + if matches.is_empty() { + return Ok(None); + } + if matches.len() != 1 { + return Err(io::Error::other( + "Choose this Grok account directly in Orca.", + )); + } + let id = matches[0] + .get("id") + .and_then(Value::as_str) + .filter(|id| { + !id.is_empty() + && id.len() <= 128 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + }) + .ok_or_else(|| io::Error::other("Invalid Orca account folder."))?; + let home = root.join(id); + for path in [ + &home, + &home.join(".orca-grok-account"), + &home.join("auth.json"), + ] { + if std::fs::symlink_metadata(path)?.file_type().is_symlink() { + return Err(io::Error::other("Orca account folder contains a link.")); + } + } + if std::fs::read_to_string(home.join(".orca-grok-account"))?.trim() != id + || home.canonicalize()? != root.canonicalize()?.join(id) + { + return Err(io::Error::other( + "Orca account folder is not owned by Orca.", + )); + } + let login = read_login(&home.join("auth.json"))? + .ok_or_else(|| io::Error::other("Reconnect this Grok account in Orca."))?; + if login.id()? != user_id { + return Err(io::Error::other("Orca login identity changed.")); + } + Ok(Some(LinkedAccount { + id: id.to_owned(), + home, + })) +} + +fn linked_account(user_id: &str) -> io::Result> { + if !cfg!(windows) { + return Ok(None); + } + let Some(root) = dirs::config_dir() else { + return Ok(None); + }; + resolve_account(&root.join("orca/grok-accounts"), user_id) +} + +pub(super) fn saved_login(user_id: &str) -> io::Result> { + linked_account(user_id)? + .map(|account| read_login(&account.home.join("auth.json"))) + .transpose() + .map(Option::flatten) +} + +fn write_login(account: LinkedAccount, user_id: &str, login: &SavedLogin) -> io::Result<()> { + login.validate()?; + if login.id()? != user_id { + return Err(io::Error::other("Orca login identity changed.")); + } + let destination = account.home.join("auth.json"); + let staged = super::stage_json(&destination, &login.auth)?; + crate::atomic_file::replace_staged(&staged, &destination) +} + +pub(super) fn update_login(user_id: &str, login: &SavedLogin) -> io::Result<()> { + if let Some(account) = linked_account(user_id)? { + write_login(account, user_id, login)?; + } + Ok(()) +} + +async fn run_orca(args: &[&str]) -> Result<(), ProviderError> { + let binary = dirs::data_local_dir() + .ok_or(ProviderError::AuthRequired)? + .join("Programs/orca/resources/bin/orca.exe"); + super::orca_runtime::run(&binary, args).await +} + +pub(super) async fn refresh_login(user_id: &str) -> Result, ProviderError> { + let Some(account) = linked_account(user_id).map_err(|_| ProviderError::AuthRequired)? else { + return Ok(None); + }; + let mut login = read_login(&account.home.join("auth.json")) + .map_err(|_| ProviderError::AuthRequired)? + .ok_or(ProviderError::AuthRequired)?; + if needs_renewal(&login) { + run_orca(&["account", "usage", "--agent", "grok"]).await?; + login = read_login(&account.home.join("auth.json")) + .map_err(|_| ProviderError::AuthRequired)? + .ok_or(ProviderError::AuthRequired)?; + } + if login.id().map_err(|_| ProviderError::AuthRequired)? != user_id { + return Err(ProviderError::AuthRequired); + } + serde_json::to_string(&login.auth) + .map(Some) + .map_err(|_| ProviderError::AuthRequired) +} + +fn needs_renewal(login: &SavedLogin) -> bool { + super::auth_file(&login.auth) + .ok() + .and_then(|file| file.select_account().ok()) + .and_then(|entry| super::text_field(entry.value(), "expires_at")) + .and_then(|value| chrono::DateTime::parse_from_rfc3339(&value).ok()) + .is_some_and(|expires| expires <= chrono::Utc::now() + chrono::Duration::minutes(5)) +} + +pub async fn select_account(user_id: &str) -> Result<(), ProviderError> { + let Some(account) = linked_account(user_id).map_err(|_| ProviderError::AuthRequired)? else { + return Ok(()); + }; + run_orca(&[ + "account", + "select", + "--agent", + "grok", + "--account-id", + &account.id, + ]) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn fresh_login_reads_do_not_require_the_orca_runtime() { + let login = |expires: &str| SavedLogin { + auth: serde_json::json!({"https://auth.x.ai::client":{"key":"fixture","user_id":"user-one","email":"one@example.com","expires_at":expires}}), + }; + assert!(!needs_renewal(&login("2099-01-01T00:00:00Z"))); + assert!(needs_renewal(&login("2020-01-01T00:00:00Z"))); + } + #[test] + fn resolves_only_marked_matching_accounts_and_rejects_traversal() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let home = root.join("saved-one"); + std::fs::create_dir(&home).unwrap(); + std::fs::write(home.join(".orca-grok-account"), "saved-one").unwrap(); + std::fs::write(home.join("auth.json"), r#"{"https://auth.x.ai::client":{"key":"fixture","user_id":"user-one","email":"one@example.com"}}"#).unwrap(); + let index = |id: &str| { + serde_json::json!({"version":1,"accounts":[{"id":id,"userId":"user-one"}]}).to_string() + }; + std::fs::write(root.join("accounts.json"), index("saved-one")).unwrap(); + assert_eq!( + resolve_account(root, "user-one").unwrap().unwrap().home, + home + ); + assert!(resolve_account(root, "user-two").unwrap().is_none()); + let renewed = SavedLogin { + auth: serde_json::json!({"https://auth.x.ai::client":{"key":"renewed-fixture","user_id":"user-one","email":"one@example.com"}}), + }; + write_login( + resolve_account(root, "user-one").unwrap().unwrap(), + "user-one", + &renewed, + ) + .unwrap(); + assert_eq!( + read_login(&home.join("auth.json")).unwrap().unwrap().auth, + renewed.auth + ); + std::fs::write(root.join("accounts.json"), index("../outside")).unwrap(); + assert!(resolve_account(root, "user-one").is_err()); + } +} diff --git a/rust/src/providers/grok/accounts/orca_runtime.rs b/rust/src/providers/grok/accounts/orca_runtime.rs new file mode 100644 index 0000000000..dcfccb3615 --- /dev/null +++ b/rust/src/providers/grok/accounts/orca_runtime.rs @@ -0,0 +1,208 @@ +use crate::core::ProviderError; +use serde_json::Value; +use std::future::Future; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +#[derive(Clone, Copy)] +enum Outcome { + Complete, + RuntimeUnavailable, + Rejected, +} + +pub(super) async fn run(binary: &Path, args: &[&str]) -> Result<(), ProviderError> { + let operation = run_with( + args, + |args| invoke(binary, args), + Duration::from_millis(500), + ); + tokio::time::timeout(Duration::from_secs(25), operation) + .await + .map_err(|_| { + ProviderError::Other("Orca did not respond. Open Orca and try again.".into()) + })? +} + +async fn invoke(binary: &Path, args: Vec) -> Result { + let mut command = tokio::process::Command::new(binary); + command + .args(&args) + .arg("--json") + .env("ORCA_BACKGROUND_LAUNCH", "1") + .kill_on_drop(true); + #[cfg(windows)] + command.creation_flags(0x0800_0000); + if args.as_slice() == ["open"] { + // The GUI inherits open's handles; pipes would wait for the GUI to exit. + let status = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map_err(|_| { + ProviderError::Other("Could not start Orca. Open Orca and try again.".into()) + })?; + return Ok(if status.success() { + Outcome::Complete + } else { + Outcome::Rejected + }); + } + let output = command.output().await.map_err(|_| { + ProviderError::Other("Could not reach Orca. Open Orca and try again.".into()) + })?; + let reply: Value = serde_json::from_slice(&output.stdout) + .map_err(|_| ProviderError::Other("Orca returned an unreadable account result.".into()))?; + Ok(classify_reply(output.status.success(), &reply)) +} + +fn classify_reply(success: bool, reply: &Value) -> Outcome { + if success && reply.get("ok").and_then(Value::as_bool) == Some(true) { + Outcome::Complete + } else if reply.get("ok").and_then(Value::as_bool) == Some(false) + && reply.pointer("/error/code").and_then(Value::as_str) == Some("runtime_unavailable") + { + Outcome::RuntimeUnavailable + } else { + Outcome::Rejected + } +} + +async fn run_with(args: &[&str], mut invoke: F, delay: Duration) -> Result<(), ProviderError> +where + F: FnMut(Vec) -> R, + R: Future>, +{ + let arguments = || args.iter().map(|arg| (*arg).to_owned()).collect(); + match invoke(arguments()).await? { + Outcome::Complete => return Ok(()), + Outcome::Rejected => return Err(rejected()), + Outcome::RuntimeUnavailable => {} + } + // Account commands do not start Orca; only its explicit open command does. + if !matches!(invoke(vec!["open".into()]).await?, Outcome::Complete) { + return Err(rejected()); + } + for attempt in 0..10 { + match invoke(arguments()).await? { + Outcome::Complete => return Ok(()), + Outcome::Rejected => return Err(rejected()), + Outcome::RuntimeUnavailable => {} + } + if attempt < 9 { + tokio::time::sleep(delay).await; + } + } + Err(rejected()) +} + +fn rejected() -> ProviderError { + ProviderError::Other( + "Orca could not complete the account change. Check the account in Orca.".into(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + + #[cfg(windows)] + #[tokio::test] + async fn startup_finishes_while_its_background_child_is_still_running() { + let directory = tempfile::tempdir().unwrap(); + let binary = directory.path().join("startup.cmd"); + std::fs::write( + &binary, + "@echo off\r\nstart \"\" /b cmd.exe /d /c \"ping -n 6 127.0.0.1 >nul\"\r\necho {\"ok\":true}\r\nexit /b 0\r\n", + ) + .unwrap(); + let result = + tokio::time::timeout(Duration::from_secs(3), invoke(&binary, vec!["open".into()])) + .await + .expect("Startup waited for its background child") + .unwrap(); + assert!(matches!(result, Outcome::Complete)); + } + + async fn replay(outcomes: &[Outcome]) -> (bool, Vec>) { + let mut replies: VecDeque<_> = outcomes.iter().copied().collect(); + let mut calls = Vec::new(); + let result = run_with( + &["account", "usage", "--agent", "grok"], + |args| { + calls.push(args); + std::future::ready(Ok(replies.pop_front().expect("Unexpected command"))) + }, + Duration::ZERO, + ) + .await; + (result.is_ok(), calls) + } + + #[tokio::test] + async fn closed_runtime_is_opened_once_then_the_original_command_is_retried() { + let (ok, calls) = replay(&[ + Outcome::RuntimeUnavailable, + Outcome::Complete, + Outcome::RuntimeUnavailable, + Outcome::Complete, + ]) + .await; + assert!(ok); + assert_eq!(calls.len(), 4); + assert_eq!(calls[1], ["open"]); + assert_eq!(calls[0], calls[2]); + assert_eq!(calls[0], calls[3]); + } + + #[tokio::test] + async fn running_runtime_and_rejected_account_changes_are_never_reopened() { + for outcome in [Outcome::Complete, Outcome::Rejected] { + let (ok, calls) = replay(&[outcome]).await; + assert_eq!(ok, matches!(outcome, Outcome::Complete)); + assert_eq!(calls.len(), 1); + } + let (ok, calls) = replay(&[Outcome::RuntimeUnavailable, Outcome::Rejected]).await; + assert!(!ok); + assert_eq!(calls.len(), 2); + } + + #[tokio::test] + async fn unavailable_runtime_has_bounded_retries() { + let mut outcomes = vec![Outcome::RuntimeUnavailable, Outcome::Complete]; + outcomes.extend([Outcome::RuntimeUnavailable; 10]); + let (ok, calls) = replay(&outcomes).await; + assert!(!ok); + assert_eq!(calls.len(), 12); + assert_eq!( + calls + .iter() + .filter(|args| args.as_slice() == ["open"]) + .count(), + 1 + ); + } + + #[test] + fn only_an_explicit_runtime_unavailable_error_permits_recovery() { + for reply in [ + serde_json::json!({"ok":false,"error":{"code":"permission_denied"}}), + serde_json::json!({"ok":false,"error":{"code":"invalid_argument"}}), + serde_json::json!({"ok":false,"error":{"message":"runtime_unavailable"}}), + serde_json::json!({"ok":true,"error":{"code":"runtime_unavailable"}}), + ] { + assert!(matches!(classify_reply(false, &reply), Outcome::Rejected)); + } + assert!(matches!( + classify_reply( + false, + &serde_json::json!({"ok":false,"error":{"code":"runtime_unavailable"}}) + ), + Outcome::RuntimeUnavailable + )); + } +} diff --git a/rust/src/providers/grok/accounts/refresh.rs b/rust/src/providers/grok/accounts/refresh.rs new file mode 100644 index 0000000000..28fa3ea58f --- /dev/null +++ b/rust/src/providers/grok/accounts/refresh.rs @@ -0,0 +1,261 @@ +use super::*; +use crate::core::ProviderError; +use base64::Engine; +use chrono::{DateTime, Utc}; + +const TOKEN_ENDPOINT: &str = "https://auth.x.ai/oauth2/token"; + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: i64, +} + +impl AccountManager { + /// Renew a saved login before usage reads, persisting rotated tokens before reuse. + pub async fn auth_text_for_usage( + &self, + id: &str, + force: bool, + ) -> Result { + let _credentials = CREDENTIAL_OPERATION.lock().await; + self.refresh_locked(id, force, TOKEN_ENDPOINT).await + } + + pub async fn ambient_auth_text_for_usage(&self) -> Result { + let _credentials = CREDENTIAL_OPERATION.lock().await; + let login = read_login(&self.ambient_auth) + .map_err(storage_error)? + .ok_or(ProviderError::AuthRequired)?; + self.refresh_locked(&login.id().map_err(storage_error)?, false, TOKEN_ENDPOINT) + .await + } + + async fn refresh_locked( + &self, + id: &str, + force: bool, + endpoint: &str, + ) -> Result { + if let Some(auth) = super::orca::refresh_login(id).await? { + return Ok(auth); + } + let text = self.auth_text_for(id).map_err(storage_error)?; + let mut login: SavedLogin = SavedLogin { + auth: serde_json::from_str(&text) + .map_err(|_| ProviderError::Parse("Invalid Grok login.".into()))?, + }; + let entry = auth_file(&login.auth) + .map_err(storage_error)? + .select_account() + .map_err(|_| ProviderError::AuthRequired)?; + let Some(client_id) = entry + .scope + .strip_prefix("https://auth.x.ai::") + .filter(|id| !id.is_empty()) + else { + return Ok(text); + }; + let expires = text_field(entry.value(), "expires_at") + .and_then(|value| DateTime::parse_from_rfc3339(&value).ok()); + if !force && !expires.is_some_and(|date| date <= Utc::now() + chrono::Duration::seconds(60)) + { + return Ok(text); + } + let refresh = text_field(entry.value(), "refresh_token") + .filter(|token| !token.is_empty()) + .ok_or(ProviderError::AuthRequired)?; + let scope = entry.scope.to_owned(); + let client = crate::core::credentialed_http_client_builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(15)) + .build()?; + let response = client + .post(endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", client_id), + ("refresh_token", refresh.as_str()), + ]) + .send() + .await?; + if matches!(response.status().as_u16(), 400 | 401 | 403) { + return Err(ProviderError::AuthRequired); + } + let response = response + .error_for_status()? + .json::() + .await + .map_err(|_| ProviderError::Parse("Invalid Grok token refresh response.".into()))?; + // These claims come from the fixed, TLS-authenticated token endpoint. + // Use them only to reject an identity mix-up, never to authenticate a request. + let claims = response + .access_token + .split('.') + .nth(1) + .and_then(|part| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(part) + .ok() + }) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .ok_or_else(|| { + ProviderError::Parse("Grok refreshed token has no account identity.".into()) + })?; + if claims.get("sub").and_then(Value::as_str) != Some(id) + || claims.get("iss").and_then(Value::as_str) != Some("https://auth.x.ai") + { + return Err(ProviderError::Other( + "Grok refreshed a different account; saved login was preserved.".into(), + )); + } + let expires_at = chrono::Duration::try_seconds(response.expires_in) + .filter(|duration| *duration > chrono::Duration::zero()) + .and_then(|duration| Utc::now().checked_add_signed(duration)) + .ok_or_else(|| ProviderError::Parse("Invalid Grok token lifetime.".into()))?; + let entry = login + .auth + .get_mut(&scope) + .and_then(Value::as_object_mut) + .ok_or(ProviderError::AuthRequired)?; + entry.insert("key".into(), Value::String(response.access_token)); + entry.insert("expires_at".into(), Value::String(expires_at.to_rfc3339())); + if let Some(refresh) = response.refresh_token.filter(|token| !token.is_empty()) { + entry.insert("refresh_token".into(), Value::String(refresh)); + } + self.reauthenticate(id, login).map_err(storage_error)?; + self.auth_text_for(id).map_err(storage_error) + } +} + +fn storage_error(_: io::Error) -> ProviderError { + ProviderError::Other("Could not read or save the selected Grok login.".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn setup() -> (tempfile::TempDir, AccountManager) { + let dir = tempfile::tempdir().unwrap(); + let manager = AccountManager { + root: dir.path().join("store"), + ambient_auth: dir.path().join("home/auth.json"), + }; + manager.import(SavedLogin { auth: json!({"https://auth.x.ai::client": { + "key": "expired", "refresh_token": "old-refresh", "user_id": "user-a", + "email": "a@example.com", "auth_mode": "oidc", "expires_at": "2000-01-01T00:00:00Z" + }}) }).unwrap(); + (dir, manager) + } + + fn token(subject: &str) -> String { + let claims = json!({"sub":subject,"iss":"https://auth.x.ai"}); + format!( + "header.{}.signature", + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&claims).unwrap()) + ) + } + + #[tokio::test] + async fn renewal_persists_rotation_and_survives_a_new_manager() { + let (_dir, manager) = setup(); + let mut server = mockito::Server::new_async().await; + let request = server + .mock("POST", "/token") + .match_body("grant_type=refresh_token&client_id=client&refresh_token=old-refresh") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({"access_token":token("user-a"),"refresh_token":"rotated","expires_in":3600}) + .to_string(), + ) + .expect(1) + .create_async() + .await; + let endpoint = format!("{}/token", server.url()); + let text = manager + .refresh_locked("user-a", false, &endpoint) + .await + .unwrap(); + let restored = AccountManager { + root: manager.root.clone(), + ambient_auth: manager.ambient_auth.clone(), + }; + assert_eq!( + restored + .refresh_locked("user-a", false, &endpoint) + .await + .unwrap(), + text + ); + assert!(text.contains("rotated")); + assert_eq!(restored.list().unwrap().len(), 1); + assert!(!restored.ambient_auth.exists()); + request.assert_async().await; + } + + #[tokio::test] + async fn renewal_updates_the_active_login_without_switching_accounts() { + let (_dir, manager) = setup(); + let old = manager.auth_text_for("user-a").unwrap(); + std::fs::create_dir_all(manager.ambient_auth.parent().unwrap()).unwrap(); + std::fs::write(&manager.ambient_auth, &old).unwrap(); + let mut server = mockito::Server::new_async().await; + let request = server + .mock("POST", "/token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"access_token":token("user-a"),"expires_in":3600}).to_string()) + .create_async() + .await; + manager + .refresh_locked("user-a", false, &format!("{}/token", server.url())) + .await + .unwrap(); + let ambient = std::fs::read_to_string(&manager.ambient_auth).unwrap(); + assert!(ambient.contains(&token("user-a"))); + assert!(ambient.contains("old-refresh")); + assert!(manager.list().unwrap()[0].is_active); + request.assert_async().await; + } + + #[tokio::test] + async fn wrong_identity_and_revoked_refresh_preserve_the_saved_login() { + for (status, body) in [ + ( + 200, + json!({"access_token":token("user-b"),"refresh_token":"wrong","expires_in":3600}) + .to_string(), + ), + (400, "{\"error\":\"invalid_grant\"}".into()), + (503, "service unavailable".into()), + ] { + let (_dir, manager) = setup(); + let original = manager.auth_text_for("user-a").unwrap(); + let mut server = mockito::Server::new_async().await; + let request = server + .mock("POST", "/token") + .with_status(status) + .with_header("content-type", "application/json") + .with_body(body) + .create_async() + .await; + let result = manager + .refresh_locked("user-a", false, &format!("{}/token", server.url())) + .await; + assert!(result.is_err()); + if status == 400 { + assert!(matches!(result, Err(ProviderError::AuthRequired))); + } + if status == 503 { + assert!(matches!(result, Err(ProviderError::Network(_)))); + } + assert_eq!(manager.auth_text_for("user-a").unwrap(), original); + request.assert_async().await; + } + } +} diff --git a/rust/src/providers/grok/mod.rs b/rust/src/providers/grok/mod.rs index f3e2bc0666..abe5fb8d1c 100644 --- a/rust/src/providers/grok/mod.rs +++ b/rust/src/providers/grok/mod.rs @@ -95,7 +95,14 @@ impl GrokProvider { self } - fn load_credentials(kind: GrokAuthKind) -> Result { + async fn load_credentials(kind: GrokAuthKind) -> Result { + if kind == GrokAuthKind::OAuth { + let text = accounts::AccountManager::new() + .map_err(|_| ProviderError::AuthRequired)? + .ambient_auth_text_for_usage() + .await?; + return GrokCredentials::parse_for_kind(&text, kind); + } let path = Self::auth_file_path() .ok_or_else(|| ProviderError::NotInstalled("Grok auth path not found".to_string()))?; let text = std::fs::read_to_string(&path).map_err(|_| { @@ -108,15 +115,21 @@ impl GrokProvider { &self, text: &str, ) -> Result { - let (credentials, kind) = - if let Ok(credentials) = GrokCredentials::parse_for_kind(text, GrokAuthKind::OAuth) { - (credentials, GrokAuthKind::OAuth) - } else { - ( - GrokCredentials::parse_for_kind(text, GrokAuthKind::Cli)?, - GrokAuthKind::Cli, - ) - }; + // A saved row identifies its OAuth entry. An expired OAuth login must + // not fall back to a different CLI identity in the same auth file. + let parsed = + ParsedGrokAuthFile::parse(text).map_err(|e| ProviderError::Parse(e.to_string()))?; + let kind = if parsed + .view() + .map_err(|e| ProviderError::Parse(e.to_string()))? + .select(GrokAuthKind::OAuth) + .is_some() + { + GrokAuthKind::OAuth + } else { + GrokAuthKind::Cli + }; + let credentials = GrokCredentials::parse_for_kind(text, kind)?; let result = self .fetch_with_auth(&credentials, kind, &FetchContext::default()) .await?; @@ -267,8 +280,7 @@ impl GrokProvider { .is_some_and(|token| !token.trim().is_empty()), ctx.manual_cookie_header .as_deref() - .is_some_and(|cookie| !cookie.trim().is_empty()) - && allow_browser_cookie_fallback, + .is_some_and(|cookie| !cookie.trim().is_empty()), allow_browser_cookie_fallback, ) { match step { @@ -308,7 +320,7 @@ impl GrokProvider { kind: GrokAuthKind, ctx: &FetchContext, ) -> Option> { - let credentials = Self::load_credentials(kind).ok()?; + let credentials = Self::load_credentials(kind).await.ok()?; match self.fetch_with_auth(&credentials, kind, ctx).await { Ok(result) => Some(Ok(result)), Err(ProviderError::AuthRequired) => None, @@ -567,14 +579,14 @@ impl Provider for GrokProvider { self.fetch_with_cookie_refresh(ctx).await } SourceMode::Cli => { - let credentials = Self::load_credentials(GrokAuthKind::Cli)?; + let credentials = Self::load_credentials(GrokAuthKind::Cli).await?; self.fetch_with_auth(&credentials, GrokAuthKind::Cli, ctx) .await } SourceMode::OAuth => { // Prefer the switched ~/.grok/auth.json over a leftover token // account so Weekly/notifications follow Grok account Switch. - let credentials = match Self::load_credentials(GrokAuthKind::OAuth) { + let credentials = match Self::load_credentials(GrokAuthKind::OAuth).await { Ok(credentials) => credentials, Err(error) => { let Some(token) = ctx.api_key.as_deref() else { @@ -631,7 +643,8 @@ fn grok_auto_steps( if has_api_key { steps.push(GrokAutoStep::ApiKey); } - if has_manual_cookie && allow_browser_cookie_fallback { + // Explicit cookies remain available even when automatic browser discovery is disabled. + if has_manual_cookie { steps.push(GrokAutoStep::ManualCookie); } if allow_browser_cookie_fallback { @@ -754,8 +767,13 @@ fn account_usage_from_result( result: &ProviderFetchResult, ) -> crate::providers::grok::accounts::GrokAccountUsage { let primary = &result.usage.primary; - let usage_available = !primary.is_informational; + let usage_available = !primary.is_informational && primary.used_percent.is_finite(); crate::providers::grok::accounts::GrokAccountUsage { + status: if usage_available { + accounts::GrokUsageStatus::Ready + } else { + accounts::GrokUsageStatus::Unavailable + }, usage_available, used_percent: usage_available.then_some(primary.used_percent), plan: result diff --git a/rust/src/providers/grok/tests.rs b/rust/src/providers/grok/tests.rs index ee12ae6bc5..c0188d72f6 100644 --- a/rust/src/providers/grok/tests.rs +++ b/rust/src/providers/grok/tests.rs @@ -166,6 +166,7 @@ fn auto_tries_switched_login_before_cookies() { GrokAutoStep::AmbientOAuth, GrokAutoStep::AmbientCli, GrokAutoStep::ApiKey, + GrokAutoStep::ManualCookie, ] ); } @@ -178,6 +179,18 @@ fn cookie_refresh_uses_cache_when_present() { ); } +#[test] +fn auto_uses_explicit_cookie_without_importing_browser_cookies() { + assert_eq!( + grok_auto_steps(false, true, false), + vec![ + GrokAutoStep::AmbientOAuth, + GrokAutoStep::AmbientCli, + GrokAutoStep::ManualCookie, + ] + ); +} + #[test] fn cookie_refresh_reimports_on_auth_failure() { assert_eq!( From 39e6a79b5704f51a49af9e6e0d86843206502237 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:14:03 +0700 Subject: [PATCH 2/2] Fix Grok token rotation loss, Orca switch order and superseded reloads - Save a renewed Grok login without reading the ambient auth file, and check the store is readable before the token grant, so a rotated refresh token is never discarded. - Select the account in Orca only after the local switch succeeds. - Treat a reload superseded by a newer one as success, so switch and re-auth confirmations still show. --- .../src-tauri/src/commands/grok_accounts.rs | 15 ++++-- .../src/hooks/useGrokAccounts.test.tsx | 49 +++++++++++++++++ .../src/hooks/useGrokAccounts.ts | 6 ++- rust/src/providers/grok/accounts/refresh.rs | 52 ++++++++++++++++++- 4 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 apps/desktop-tauri/src/hooks/useGrokAccounts.test.tsx diff --git a/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs index d672fef8df..e71696d5ae 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs @@ -144,14 +144,19 @@ pub async fn grok_account_switch(app: tauri::AppHandle, id: String) -> Result<() .auth_text_for_usage(&id, false) .await .map_err(|error| error.to_string())?; - accounts::select_orca_account(&id) - .await - .map_err(|error| error.to_string())?; let _credentials = accounts::CREDENTIAL_OPERATION.lock().await; - tauri::async_runtime::spawn_blocking(move || AccountManager::new()?.switch(&id)) + let local_id = id.clone(); + tauri::async_runtime::spawn_blocking(move || AccountManager::new()?.switch(&local_id)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; drop(_credentials); - refresh_after_grok_change(app) + // Select in Orca only after the local switch succeeded, so a failed switch + // never leaves Orca and the Grok CLI on different accounts. The local switch + // stands even if Orca fails, so refresh before reporting that error. + let orca = accounts::select_orca_account(&id) + .await + .map_err(|error| error.to_string()); + refresh_after_grok_change(app)?; + orca } diff --git a/apps/desktop-tauri/src/hooks/useGrokAccounts.test.tsx b/apps/desktop-tauri/src/hooks/useGrokAccounts.test.tsx new file mode 100644 index 0000000000..8686486177 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useGrokAccounts.test.tsx @@ -0,0 +1,49 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const tauriMocks = vi.hoisted(() => ({ + grokAccountsList: vi.fn(), + grokAccountFetch: vi.fn(), +})); + +const eventMocks = vi.hoisted(() => ({ + listeners: new Map void>(), + listen: vi.fn(), +})); + +vi.mock("../lib/tauri", () => tauriMocks); +vi.mock("@tauri-apps/api/event", () => eventMocks); + +import { useGrokAccounts } from "./useGrokAccounts"; + +describe("useGrokAccounts", () => { + it("treats a reload superseded by provider-updated as success", async () => { + eventMocks.listen.mockImplementation( + (name: string, handler: (event: { payload: unknown }) => void) => { + eventMocks.listeners.set(name, handler); + return Promise.resolve(() => {}); + }, + ); + tauriMocks.grokAccountsList.mockResolvedValue([]); + const { result } = renderHook(() => useGrokAccounts()); + await waitFor(() => expect(tauriMocks.grokAccountsList).toHaveBeenCalled()); + + // Hold the mutation's reload open until a provider-updated reload starts. + let releaseList: (value: []) => void = () => {}; + tauriMocks.grokAccountsList.mockImplementationOnce( + () => new Promise<[]>((resolve) => (releaseList = resolve)), + ); + const onSuccess = vi.fn(); + let outcome: Promise = Promise.resolve(false); + await act(async () => { + outcome = result.current.run(() => Promise.resolve(), onSuccess); + }); + await act(async () => { + eventMocks.listeners.get("provider-updated")?.({ payload: { providerId: "grok" } }); + releaseList([]); + }); + + await expect(outcome).resolves.toBe(true); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop-tauri/src/hooks/useGrokAccounts.ts b/apps/desktop-tauri/src/hooks/useGrokAccounts.ts index bf44b2cbf2..dfc578125b 100644 --- a/apps/desktop-tauri/src/hooks/useGrokAccounts.ts +++ b/apps/desktop-tauri/src/hooks/useGrokAccounts.ts @@ -19,11 +19,13 @@ export function useGrokAccounts({ reloadOnFocus = false }: { reloadOnFocus?: boo if (mounted.current && sequence === reloadSequence.current) setError(String(value)); }, []); + // Resolves false only when the reload failed. A reload superseded by a newer + // one (e.g. a provider-updated event) is not a failure; the newer one applies. const reload = useCallback(async () => { const sequence = ++reloadSequence.current; try { const next = await grokAccountsList(); - if (!mounted.current || sequence !== reloadSequence.current) return false; + if (!mounted.current || sequence !== reloadSequence.current) return true; setAccounts(next); setError(null); setUsage((previous) => Object.fromEntries(next.map((account) => [account.id, { @@ -42,9 +44,9 @@ export function useGrokAccounts({ reloadOnFocus = false }: { reloadOnFocus?: boo } }), ); - if (!mounted.current || sequence !== reloadSequence.current) return false; return true; } catch (value) { + if (sequence !== reloadSequence.current) return true; reportError(value, sequence); return false; } diff --git a/rust/src/providers/grok/accounts/refresh.rs b/rust/src/providers/grok/accounts/refresh.rs index 28fa3ea58f..8982c6ea81 100644 --- a/rust/src/providers/grok/accounts/refresh.rs +++ b/rust/src/providers/grok/accounts/refresh.rs @@ -67,6 +67,8 @@ impl AccountManager { .filter(|token| !token.is_empty()) .ok_or(ProviderError::AuthRequired)?; let scope = entry.scope.to_owned(); + // An unreadable store would discard the rotated token after the grant. + self.load().map_err(storage_error)?; let client = crate::core::credentialed_http_client_builder() .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(15)) @@ -124,9 +126,27 @@ impl AccountManager { if let Some(refresh) = response.refresh_token.filter(|token| !token.is_empty()) { entry.insert("refresh_token".into(), Value::String(refresh)); } - self.reauthenticate(id, login).map_err(storage_error)?; + self.persist_renewed(id, login).map_err(storage_error)?; self.auth_text_for(id).map_err(storage_error) } + + /// Save a renewed login. Unlike `reauthenticate`, this never requires the + /// ambient file to be readable: the old refresh token is already spent. + fn persist_renewed(&self, id: &str, login: SavedLogin) -> io::Result<()> { + self.import(login.clone())?; + super::orca::update_login(id, &login)?; + // Replace the ambient login only when it readably holds this identity. + if let Ok(Some(current)) = read_login(&self.ambient_auth) + && current.id().ok().as_deref() == Some(id) + { + let staged = stage_json(&self.ambient_auth, &login.auth)?; + if let Err(error) = replace_staged(&staged, &self.ambient_auth) { + let _cleanup = std::fs::remove_file(staged); + return Err(error); + } + } + Ok(()) + } } fn storage_error(_: io::Error) -> ProviderError { @@ -223,6 +243,36 @@ mod tests { request.assert_async().await; } + #[tokio::test] + async fn renewal_is_saved_when_the_ambient_login_is_damaged() { + let (_dir, manager) = setup(); + std::fs::create_dir_all(manager.ambient_auth.parent().unwrap()).unwrap(); + std::fs::write(&manager.ambient_auth, "{not json").unwrap(); + let mut server = mockito::Server::new_async().await; + let request = server + .mock("POST", "/token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({"access_token":token("user-a"),"refresh_token":"rotated","expires_in":3600}) + .to_string(), + ) + .expect(1) + .create_async() + .await; + let text = manager + .refresh_locked("user-a", false, &format!("{}/token", server.url())) + .await + .unwrap(); + assert!(text.contains("rotated")); + assert!(manager.auth_text_for("user-a").unwrap().contains("rotated")); + assert_eq!( + std::fs::read_to_string(&manager.ambient_auth).unwrap(), + "{not json" + ); + request.assert_async().await; + } + #[tokio::test] async fn wrong_identity_and_revoked_refresh_preserve_the_saved_login() { for (status, body) in [