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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
use super::invalidate_account_usage;
use crate::state::AppState;
use codexbar::core::ProviderId;
use codexbar::providers::claude::accounts::{self, AccountManager, ClaudeAccount};
use std::sync::Mutex;
use tauri::Emitter;
use tauri::Manager;

static MUTATION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());

#[tauri::command]
pub fn claude_accounts_list() -> Result<Vec<ClaudeAccount>, String> {
AccountManager::new()
.and_then(|m| m.list())
.map_err(|e| e.to_string())
}

fn changed(app: &tauri::AppHandle) {
let _emit = app.emit("claude-accounts-updated", ());
}

#[tauri::command]
pub async fn claude_account_add(app: tauri::AppHandle) -> Result<(), String> {
let _mutation = MUTATION
.try_lock()
.map_err(|_| "A Claude account operation is already in progress.")?;
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())?;
let _credentials = accounts::CREDENTIAL_OPERATION.lock().await;
AccountManager::new()
.and_then(|m| m.import(login))
.map_err(|e| e.to_string())?;
changed(&app);
Ok(())
}

#[tauri::command]
pub fn claude_account_cancel_login() {
accounts::cancel_login();
}

#[tauri::command]
pub async fn claude_account_save_current(app: tauri::AppHandle) -> Result<(), String> {
let _mutation = MUTATION
.try_lock()
.map_err(|_| "A Claude account operation is already in progress.")?;
let _credentials = accounts::CREDENTIAL_OPERATION.lock().await;
AccountManager::new()
.and_then(|m| m.save_current())
.map_err(|e| e.to_string())?;
changed(&app);
Ok(())
}

#[tauri::command]
pub async fn claude_account_remove(app: tauri::AppHandle, id: String) -> Result<(), String> {
let _mutation = MUTATION
.try_lock()
.map_err(|_| "A Claude account operation is already in progress.")?;
let _credentials = accounts::CREDENTIAL_OPERATION.lock().await;
AccountManager::new()
.and_then(|m| m.remove(&id))
.map_err(|e| e.to_string())?;
changed(&app);
Ok(())
}

#[tauri::command]
pub async fn claude_account_switch(app: tauri::AppHandle, id: String) -> Result<(), String> {
let _mutation = MUTATION
.try_lock()
.map_err(|_| "A Claude account operation is already in progress.")?;
let _credentials = accounts::CREDENTIAL_OPERATION.lock().await;
tauri::async_runtime::spawn_blocking(move || {
accounts::require_cli_closed()?;
AccountManager::new()?.switch(&id)
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let pending = {
let state = app.state::<Mutex<AppState>>();
let mut state = state.lock().map_err(|e| e.to_string())?;
invalidate_account_usage(&mut state, ProviderId::Claude)
};
crate::events::emit_provider_updated(&app, &pending);
drop(_credentials);
changed(&app);
tauri::async_runtime::spawn(async move {
let _refresh = super::refresh_providers(app).await;
});
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn switching_invalidates_old_identity_usage_and_inflight_results() {
let mut state = AppState::new();
let mut old = invalidate_account_usage(&mut state, ProviderId::Claude);
old.account_email = Some("old@example.com".into());
old.plan_name = Some("old-plan".into());
old.error = None;
old.primary.used_percent = 80.0;
state.provider_cache = vec![old];
state.is_refreshing = true;
state
.transient_provider_failure_counts
.insert(ProviderId::Claude, 1);
let generation = state.provider_refresh_generation;
let pending = invalidate_account_usage(&mut state, ProviderId::Claude);
assert!(pending.account_email.is_none());
assert!(pending.plan_name.is_none());
assert!(pending.error.is_some());
assert_eq!(pending.primary.used_percent, 0.0);
assert_eq!(state.provider_cache.len(), 1);
assert!(state.provider_cache[0].error.is_some());
assert_ne!(state.provider_refresh_generation, generation);
assert!(!state.is_refreshing);
assert!(
!state
.transient_provider_failure_counts
.contains_key(&ProviderId::Claude)
);
}
}
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ mod usage_spend;
mod agent_sessions;
mod bridge;
mod browser_import;
mod claude_accounts;
mod codex_accounts;
mod codex_workspaces;
mod credential_detection;
Expand All @@ -49,6 +50,7 @@ mod system;
pub use agent_sessions::*;
pub(crate) use bridge::*;
pub use browser_import::*;
pub use claude_accounts::*;
pub use codex_accounts::*;
pub use codex_workspaces::*;
pub use credential_detection::*;
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ use std::sync::Arc;

const MAX_CONCURRENT_PROVIDER_FETCHES: usize = 8;

/// Account changes supersede the old identity's cache and any in-flight batch.
pub(crate) fn invalidate_account_usage(
state: &mut AppState,
id: ProviderId,
) -> ProviderUsageSnapshot {
state.provider_refresh_generation = state.provider_refresh_generation.wrapping_add(1);
state.is_refreshing = false;
state.provider_refresh_started_at = None;
state.transient_provider_failure_counts.remove(&id);
state
.provider_cache
.retain(|snapshot| snapshot.provider_id != id.cli_name());
let pending = ProviderUsageSnapshot::from_error(
id,
instantiate_provider(id).metadata(),
format!("Account changed. Refreshing {} usage…", id.display_name()),
codexbar::core::ProviderStateKind::Unknown,
);
state.provider_cache.push(pending.clone());
pending
}

// ── Provider refresh commands ────────────────────────────────────────

/// Build a `FetchContext` for a provider using persisted cookies/keys.
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ fn main() {
commands::get_cached_providers,
commands::get_deepseek_pricing_status,
commands::codex_accounts_list,
commands::claude_accounts_list,
commands::claude_account_add,
commands::claude_account_cancel_login,
commands::claude_account_save_current,
commands::claude_account_remove,
commands::claude_account_switch,
commands::codex_account_add,
commands::codex_account_remove,
commands::codex_account_switch,
Expand Down Expand Up @@ -254,6 +260,9 @@ fn main() {
floatbar::set_float_bar_orientation,
])
.setup(move |app| {
if let Err(error) = codexbar::providers::claude::accounts::cleanup_abandoned_logins() {
tracing::warn!("failed to clean abandoned Claude sign-in directories: {error}");
}
if let Some(window) = app.get_webview_window("main") {
shell::dwm::force_dark_caption(&window);
window.hide()?;
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,14 @@ export const ALL_LOCALE_KEYS = [
"ProviderCodexSparkUsage",
"ProviderCodexSparkUsageHelp",
"CodexAccountsTitle",
"ClaudeAccountsTitle",
"ClaudeAccountsHint",
"ClaudeAccountsEmpty",
"ClaudeAccountsSaveCurrent",
"ClaudeAccountsCancelLogin",
"ClaudeAccountsSigningIn",
"ClaudeAccountsSwitched",
"ClaudeAccountsAdded",
"CodexAccountsHint",
"CodexAccountsAddButton",
"CodexAccountsSwitchButton",
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop-tauri/src/lib/tauri.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import type {
ClaudeAccount,
ApiKeyInfoBridge,
ApiKeyProviderInfoBridge,
AppInfoBridge,
Expand Down Expand Up @@ -42,6 +43,13 @@ import type {
DeepSeekPricingStatus,
} from "../types/bridge";

export const claudeAccountsList = () => invoke<ClaudeAccount[]>("claude_accounts_list");
export const claudeAccountAdd = () => invoke<void>("claude_account_add");
export const claudeAccountCancelLogin = () => invoke<void>("claude_account_cancel_login");
export const claudeAccountSaveCurrent = () => invoke<void>("claude_account_save_current");
export const claudeAccountRemove = (id: string) => invoke<void>("claude_account_remove", { id });
export const claudeAccountSwitch = (id: string) => invoke<void>("claude_account_switch", { id });

export function getBootstrapState(): Promise<BootstrapState> {
return invoke<BootstrapState>("get_bootstrap_state");
}
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop-tauri/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -6028,3 +6028,16 @@ html:has(.menu-surface--tray) {
margin-top: 4px;
font-weight: 500;
}

.codex-accounts .credential-card__header {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.codex-accounts .credential-card__actions {
justify-content: flex-end;
flex-wrap: wrap;
}
.codex-accounts .credential-card__badge {
align-self: flex-start;
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { UsageSourceSection } from "./sections/UsageSourceSection";
import { shouldShowCookieSource } from "./sections/usageSourcePolicy";
import { RegionSection } from "./sections/RegionSection";
import { CodexUsageOptions } from "./sections/credentials/CodexUsageOptions";
import { ClaudeAccountsSection } from "./sections/credentials/ClaudeAccountsSection";
import { CodexAccountsSection } from "./sections/credentials/CodexAccountsSection";
import { TokenAccountsPanel } from "../tokens/TokenAccountsPanel";
import { ApiKeySection } from "./ApiKeySection";
Expand Down Expand Up @@ -336,6 +337,7 @@ export function ProviderDetailPane({
<CredentialsDispatcher providerId={detail.id} t={t} />
{detail.id === "codex" && <CodexUsageOptions t={t} />}
{detail.id === "codex" && <CodexAccountsSection t={t} />}
{detail.id === "claude" && <ClaudeAccountsSection t={t} />}
<CredentialStorageSection
status={credentialStatus}
busy={busy}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClaudeAccount } from "../../../../../types/bridge";

const mocks = vi.hoisted(() => ({
claudeAccountsList: vi.fn(), claudeAccountAdd: vi.fn(), claudeAccountCancelLogin: vi.fn(),
claudeAccountSaveCurrent: vi.fn(), claudeAccountRemove: vi.fn(), claudeAccountSwitch: 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);
import { ClaudeAccountsSection } from "./ClaudeAccountsSection";

const t = (key: string) => key;
const current: ClaudeAccount = { id: "one:org", email: "one@example.com", organization: "Work", plan: "max", isActive: true, isSaved: false };
const other: ClaudeAccount = { ...current, id: "two:org", email: "two@example.com", isActive: false, isSaved: true };

describe("ClaudeAccountsSection", () => {
beforeEach(() => {
vi.resetAllMocks();
events.listen.mockResolvedValue(() => {});
mocks.claudeAccountsList.mockResolvedValue([current, other]);
});

it("offers to save the discovered account and switches only saved inactive accounts", async () => {
render(<ClaudeAccountsSection t={t} />);
await screen.findByText(current.email);
expect(screen.getAllByText("CodexAccountsSwitchButton")).toHaveLength(1);
await act(async () => fireEvent.click(screen.getByText("ClaudeAccountsSaveCurrent")));
expect(mocks.claudeAccountSaveCurrent).toHaveBeenCalledOnce();
await act(async () => fireEvent.click(screen.getByText("CodexAccountsSwitchButton")));
expect(mocks.claudeAccountSwitch).toHaveBeenCalledWith(other.id);
expect(screen.getByRole("status").textContent).toBe("ClaudeAccountsSwitched");
});

it("keeps mutations disabled across an account update during browser login and supports cancel", async () => {
let finish!: () => void;
mocks.claudeAccountAdd.mockImplementation(() => new Promise<void>(resolve => { finish = resolve; }));
mocks.claudeAccountCancelLogin.mockResolvedValue(undefined);
render(<ClaudeAccountsSection t={t} />);
await screen.findByText(current.email);
fireEvent.click(screen.getByText("CodexAccountsAddButton"));
const eventCallback = events.listen.mock.calls[0][1] as unknown as () => void;
await act(async () => eventCallback());
expect((screen.getByText("CodexAccountsSwitchButton") as HTMLButtonElement).disabled).toBe(true);
await act(async () => fireEvent.click(screen.getByText("ClaudeAccountsCancelLogin")));
expect(mocks.claudeAccountCancelLogin).toHaveBeenCalledOnce();
await act(async () => finish());
expect(screen.queryByText("ClaudeAccountsCancelLogin")).toBeNull();
expect((screen.getByText("CodexAccountsAddButton") as HTMLButtonElement).disabled).toBe(false);
});

it("shows a switch error without claiming success and allows retry", async () => {
mocks.claudeAccountSwitch.mockRejectedValue("Close Claude Code first.");
render(<ClaudeAccountsSection t={t} />);
await screen.findByText(other.email);
await act(async () => fireEvent.click(screen.getByText("CodexAccountsSwitchButton")));
expect(screen.getByRole("alert").textContent).toContain("Close Claude Code first.");
expect(screen.queryByText("ClaudeAccountsSwitched")).toBeNull();
expect((screen.getByText("CodexAccountsSwitchButton") as HTMLButtonElement).disabled).toBe(false);
});

it("shows initial loading errors while keeping sign-in accessible", async () => {
mocks.claudeAccountsList.mockRejectedValue("Could not read saved accounts.");
render(<ClaudeAccountsSection t={t} />);
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Could not read"));
expect((screen.getByText("CodexAccountsAddButton") as HTMLButtonElement).disabled).toBe(false);
});
});
Loading