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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,8 @@ pub struct SettingsSnapshot {
tray_scale_percent: u16,
powertoys_status_pipe_enabled: bool,
claude_avoid_keychain_prompts: bool,
claude_swap_enabled: bool,
claude_swap_executable_path: String,
codex_spark_usage_visible: bool,
disable_keychain_access: bool,
wayfinder_gateway_url: String,
Expand Down Expand Up @@ -670,6 +672,8 @@ pub fn get_settings_snapshot() -> SettingsSnapshot {
impl From<Settings> for SettingsSnapshot {
fn from(settings: Settings) -> Self {
let avoid_keychain_prompts = settings.claude_avoid_keychain_prompts();
let claude_swap_enabled = settings.claude_swap_enabled();
let claude_swap_executable_path = settings.claude_swap_executable_path().to_string();
let codex_spark_usage_visible = settings.codex_spark_usage_visible();
let wayfinder_gateway_url = settings.gateway_url(ProviderId::Wayfinder).to_string();

Expand Down Expand Up @@ -735,6 +739,8 @@ impl From<Settings> for SettingsSnapshot {
tray_scale_percent: settings.tray_scale_percent,
powertoys_status_pipe_enabled: settings.powertoys_status_pipe_enabled,
claude_avoid_keychain_prompts: avoid_keychain_prompts,
claude_swap_enabled,
claude_swap_executable_path,
codex_spark_usage_visible,
disable_keychain_access: settings.disable_keychain_access,
wayfinder_gateway_url,
Expand Down
86 changes: 86 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use super::invalidate_account_usage;
use crate::state::AppState;
use codexbar::core::ProviderId;
use codexbar::providers::claude::accounts::{self, AccountManager, ClaudeAccount};
use codexbar::providers::claude::claude_swap::{self, ClaudeSwapAccount};
use serde::Serialize;
use std::sync::Mutex;
use tauri::Emitter;
use tauri::Manager;
Expand All @@ -15,6 +17,90 @@ pub fn claude_accounts_list() -> Result<Vec<ClaudeAccount>, String> {
.map_err(|e| e.to_string())
}

/// External claude-swap accounts plus adapter status for the settings UI.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeSwapAccountsState {
pub enabled: bool,
pub executable_configured: bool,
pub accounts: Vec<ClaudeSwapAccount>,
pub error: Option<String>,
}

fn claude_swap_accounts_state() -> ClaudeSwapAccountsState {
let settings = codexbar::settings::Settings::load();
let enabled = settings.claude_swap_enabled();
let executable_path = settings.claude_swap_executable_path().to_string();
let executable_configured = !executable_path.trim().is_empty();
if !enabled || !executable_configured {
return ClaudeSwapAccountsState {
enabled,
executable_configured,
accounts: Vec::new(),
error: None,
};
}
match claude_swap::read_account_list(&executable_path) {
Ok(list) => ClaudeSwapAccountsState {
enabled,
executable_configured,
accounts: claude_swap::project_accounts(&list, settings.hide_personal_info),
error: None,
},
// Adapter failures are isolated from ambient Claude usage: the last
// built-in account list still renders and the error is surfaced inline.
Err(error) => ClaudeSwapAccountsState {
enabled,
executable_configured,
accounts: Vec::new(),
error: Some(error.to_string()),
},
}
}

#[tauri::command]
pub async fn claude_swap_accounts_list() -> Result<ClaudeSwapAccountsState, String> {
tauri::async_runtime::spawn_blocking(claude_swap_accounts_state)
.await
.map_err(|e| e.to_string())
}

#[tauri::command]
pub async fn claude_swap_account_switch(app: tauri::AppHandle, slot: u32) -> Result<(), String> {
let _mutation = MUTATION
.try_lock()
.map_err(|_| "A Claude account operation is already in progress.")?;
let settings = codexbar::settings::Settings::load();
if !settings.claude_swap_enabled() {
return Err("claude-swap integration is disabled.".to_string());
}
let executable_path = settings.claude_swap_executable_path().to_string();
if executable_path.trim().is_empty() {
return Err("No claude-swap executable path is configured.".to_string());
}
// Serialize with our own Claude OAuth/account mutations: cswap owns the
// credential transaction, so the two paths must never overlap.
let _credentials = accounts::CREDENTIAL_OPERATION.lock().await;
tauri::async_runtime::spawn_blocking(move || {
claude_swap::switch_account(&executable_path, slot)
})
.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(())
}

fn changed(app: &tauri::AppHandle) {
let _emit = app.emit("claude-accounts-updated", ());
let handle = app.clone();
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub struct SettingsUpdate {
pub powertoys_status_pipe_enabled: Option<bool>,
pub claude_avoid_keychain_prompts: Option<bool>,
pub claude_allow_reading_claude_code_credentials: Option<bool>,
pub claude_swap_enabled: Option<bool>,
pub claude_swap_executable_path: Option<String>,
pub codex_spark_usage_visible: Option<bool>,
pub disable_keychain_access: Option<bool>,
/// Map of provider CLI name → metric preference label.
Expand Down Expand Up @@ -333,6 +335,12 @@ impl SettingsUpdate {
if let Some(v) = self.claude_allow_reading_claude_code_credentials {
settings.claude_allow_reading_claude_code_credentials = v;
}
if let Some(v) = self.claude_swap_enabled {
settings.set_claude_swap_enabled(v);
}
if let Some(v) = self.claude_swap_executable_path.clone() {
settings.set_claude_swap_executable_path(v);
}
if let Some(v) = self.codex_spark_usage_visible {
settings.set_codex_spark_usage_visible(v);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ fn main() {
commands::claude_account_save_current,
commands::claude_account_remove,
commands::claude_account_switch,
commands::claude_swap_accounts_list,
commands::claude_swap_account_switch,
commands::codex_account_add,
commands::codex_account_remove,
commands::codex_account_switch,
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,19 @@ export const ALL_LOCALE_KEYS = [
"ClaudeAccountsSigningIn",
"ClaudeAccountsSwitched",
"ClaudeAccountsAdded",
"ClaudeSwapTitle",
"ClaudeSwapHint",
"ClaudeSwapEnable",
"ClaudeSwapEnableHelp",
"ClaudeSwapExecutablePath",
"ClaudeSwapExecutablePathPlaceholder",
"ClaudeSwapStatusDisabled",
"ClaudeSwapStatusNoExecutable",
"ClaudeSwapStatusReady",
"ClaudeSwapEmpty",
"ClaudeSwapSwitchButton",
"ClaudeSwapSwitched",
"ClaudeSwapUsageUnavailable",
"CodexAccountsHint",
"CodexAccountsAddButton",
"CodexAccountsSwitchButton",
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop-tauri/src/lib/tauri.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import type {
ClaudeAccount,
ClaudeSwapAccountsState,
ApiKeyInfoBridge,
ApiKeyProviderInfoBridge,
AppInfoBridge,
Expand Down Expand Up @@ -49,6 +50,10 @@ export const claudeAccountCancelLogin = () => invoke<void>("claude_account_cance
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 const claudeSwapAccountsList = () =>
invoke<ClaudeSwapAccountsState>("claude_swap_accounts_list");
export const claudeSwapAccountSwitch = (slot: number) =>
invoke<void>("claude_swap_account_switch", { slot });

export function getBootstrapState(): Promise<BootstrapState> {
return invoke<BootstrapState>("get_bootstrap_state");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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(),
claudeSwapAccountsList: vi.fn(), claudeSwapAccountSwitch: vi.fn(),
getSettingsSnapshot: vi.fn(), updateSettings: vi.fn(),
}));
const events = vi.hoisted(() => ({ listen: vi.fn<(event: string, listener: () => void) => Promise<() => void>>() }));
vi.mock("../../../../../lib/tauri", () => mocks);
Expand All @@ -20,6 +22,16 @@ describe("ClaudeAccountsSection", () => {
vi.resetAllMocks();
events.listen.mockResolvedValue(() => {});
mocks.claudeAccountsList.mockResolvedValue([current, other]);
mocks.claudeSwapAccountsList.mockResolvedValue({
enabled: false,
executableConfigured: false,
accounts: [],
error: null,
});
mocks.getSettingsSnapshot.mockResolvedValue({
claudeSwapEnabled: false,
claudeSwapExecutablePath: "",
});
});

it("offers to save the discovered account and switches only saved inactive accounts", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
claudeAccountRemove,
claudeAccountSwitch,
} from "../../../../../lib/tauri";
import { ClaudeSwapAccountsSection } from "./ClaudeSwapAccountsSection";

export function ClaudeAccountsSection({ t }: { t: (key: LocaleKey) => string }) {
const [accounts, setAccounts] = useState<ClaudeAccount[]>([]);
Expand Down Expand Up @@ -54,82 +55,85 @@ export function ClaudeAccountsSection({ t }: { t: (key: LocaleKey) => string })
}
};
return (
<section className="provider-detail-section codex-accounts">
<h4>{t("ClaudeAccountsTitle")}</h4>
<p className="settings-section__hint">{t("ClaudeAccountsHint")}</p>
{error && <div className="provider-detail-error" role="alert">{error}</div>}
{message && <div className="provider-detail-note" role="status">{message}</div>}
{loggingIn && <p role="status">{t("ClaudeAccountsSigningIn")}</p>}
{accounts.length === 0 && <p>{t("ClaudeAccountsEmpty")}</p>}
<ul className="credential-list">
{accounts.map(account => (
<li className="credential-card" key={account.id}>
<div className="credential-card__header">
<div className="credential-card__info">
<strong>{account.email}</strong>
<span className="credential-card__meta">
{[
account.organization?.includes(account.email) ? null : account.organization,
account.plan,
].filter(Boolean).join(" · ")}
</span>
{account.isActive && (
<span className="credential-card__badge credential-card__badge--set">
{t("TokenAccountActive")}
<>
<section className="provider-detail-section codex-accounts">
<h4>{t("ClaudeAccountsTitle")}</h4>
<p className="settings-section__hint">{t("ClaudeAccountsHint")}</p>
{error && <div className="provider-detail-error" role="alert">{error}</div>}
{message && <div className="provider-detail-note" role="status">{message}</div>}
{loggingIn && <p role="status">{t("ClaudeAccountsSigningIn")}</p>}
{accounts.length === 0 && <p>{t("ClaudeAccountsEmpty")}</p>}
<ul className="credential-list">
{accounts.map(account => (
<li className="credential-card" key={account.id}>
<div className="credential-card__header">
<div className="credential-card__info">
<strong>{account.email}</strong>
<span className="credential-card__meta">
{[
account.organization?.includes(account.email) ? null : account.organization,
account.plan,
].filter(Boolean).join(" · ")}
</span>
)}
{account.isActive && (
<span className="credential-card__badge credential-card__badge--set">
{t("TokenAccountActive")}
</span>
)}
</div>
<div className="credential-card__actions">
{!account.isActive && account.isSaved && (
<button
className="credential-btn credential-btn--primary"
disabled={busy}
onClick={() => void run(() => claudeAccountSwitch(account.id), "ClaudeAccountsSwitched")}
>
{t("CodexAccountsSwitchButton")}
</button>
)}
{!account.isSaved && (
<button
className="credential-btn credential-btn--secondary"
disabled={busy}
onClick={() => void run(claudeAccountSaveCurrent)}
>
{t("ClaudeAccountsSaveCurrent")}
</button>
)}
{account.isSaved && (
<button
className="credential-btn credential-btn--danger"
disabled={busy}
onClick={() => void run(() => claudeAccountRemove(account.id))}
>
{t("CodexAccountsRemoveButton")}
</button>
)}
</div>
</div>
<div className="credential-card__actions">
{!account.isActive && account.isSaved && (
<button
className="credential-btn credential-btn--primary"
disabled={busy}
onClick={() => void run(() => claudeAccountSwitch(account.id), "ClaudeAccountsSwitched")}
>
{t("CodexAccountsSwitchButton")}
</button>
)}
{!account.isSaved && (
<button
className="credential-btn credential-btn--secondary"
disabled={busy}
onClick={() => void run(claudeAccountSaveCurrent)}
>
{t("ClaudeAccountsSaveCurrent")}
</button>
)}
{account.isSaved && (
<button
className="credential-btn credential-btn--danger"
disabled={busy}
onClick={() => void run(() => claudeAccountRemove(account.id))}
>
{t("CodexAccountsRemoveButton")}
</button>
)}
</div>
</div>
</li>
))}
</ul>
<button
className="credential-btn credential-btn--primary"
disabled={busy}
onClick={() => {
setLoggingIn(true);
void run(claudeAccountAdd, "ClaudeAccountsAdded");
}}
>
{t("CodexAccountsAddButton")}
</button>
{loggingIn && (
</li>
))}
</ul>
<button
className="credential-btn credential-btn--secondary"
onClick={() => void claudeAccountCancelLogin().catch(e => setError(String(e)))}
className="credential-btn credential-btn--primary"
disabled={busy}
onClick={() => {
setLoggingIn(true);
void run(claudeAccountAdd, "ClaudeAccountsAdded");
}}
>
{t("ClaudeAccountsCancelLogin")}
{t("CodexAccountsAddButton")}
</button>
)}
</section>
{loggingIn && (
<button
className="credential-btn credential-btn--secondary"
onClick={() => void claudeAccountCancelLogin().catch(e => setError(String(e)))}
>
{t("ClaudeAccountsCancelLogin")}
</button>
)}
</section>
<ClaudeSwapAccountsSection t={t} />
</>
);
}
Loading