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
165 changes: 143 additions & 22 deletions apps/desktop-tauri/src-tauri/src/commands/claude_usage.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::io;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use codexbar::core::{ProviderError, RateWindow, UsageSnapshot};
use codexbar::providers::claude::accounts::{self, ClaudeAccount};
use codexbar::{atomic_file, secure_file};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};

Expand Down Expand Up @@ -138,6 +141,74 @@ fn publish_results(
true
}

const ACCOUNT_USAGE_STORE_VERSION: u32 = 1;
const ACCOUNT_USAGE_STORE_RELATIVE_PATH: &str = "claude/account-usage-v1.json";

/// Holds only rendered percentages, reset times and sanitized error text.
/// Credentials stay in the account store and are never written here.
#[derive(Serialize, Deserialize)]
struct ClaudeAccountUsageStore {
version: u32,
#[serde(default)]
accounts: BTreeMap<String, ClaudeAccountUsageState>,
}

fn encode_account_usage(
usage: &HashMap<String, ClaudeAccountUsageState>,
) -> serde_json::Result<String> {
serde_json::to_string_pretty(&ClaudeAccountUsageStore {
version: ACCOUNT_USAGE_STORE_VERSION,
accounts: usage
.iter()
.map(|(id, state)| (id.clone(), state.clone()))
.collect(),
})
}

/// An unreadable or unsupported store is ignored so it never blocks startup.
fn decode_account_usage(raw: &str) -> Option<HashMap<String, ClaudeAccountUsageState>> {
let store: ClaudeAccountUsageStore = serde_json::from_str(raw).ok()?;
(store.version == ACCOUNT_USAGE_STORE_VERSION).then(|| store.accounts.into_iter().collect())
}

fn account_usage_store_path() -> Option<PathBuf> {
codexbar::logging::config_root().map(|root| root.join(ACCOUNT_USAGE_STORE_RELATIVE_PATH))
}

/// Last-known per-account usage, restored at launch so the tray can show it
/// before the first refresh completes.
pub(crate) fn load_account_usage_cache() -> HashMap<String, ClaudeAccountUsageState> {
let Some(path) = account_usage_store_path() else {
return HashMap::new();
};
match secure_file::read_string(&path) {
Ok(raw) => decode_account_usage(&raw).unwrap_or_else(|| {
tracing::warn!("ignoring unreadable Claude account usage cache");
HashMap::new()
}),
Err(error) => {
if error.kind() != io::ErrorKind::NotFound {
tracing::warn!("failed to read Claude account usage cache: {error}");
}
HashMap::new()
}
}
}

fn save_account_usage_cache(usage: &HashMap<String, ClaudeAccountUsageState>) -> io::Result<()> {
let Some(path) = account_usage_store_path() else {
return Ok(());
};
let raw = encode_account_usage(usage).map_err(io::Error::other)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
// One fixed staging name is overwritten on each save, so no temp files accumulate.
let staged = path.with_extension("json.tmp");
secure_file::write_string(&staged, &raw)?;
atomic_file::replace_staged(&staged, &path)
}

/// Share the provider refresh cadence and permits; each request uses only the
/// selected account's OAuth credentials and leaves the active CLI login intact.
pub(crate) async fn refresh_claude_account_lanes(
Expand All @@ -151,37 +222,41 @@ pub(crate) async fn refresh_claude_account_lanes(
let Ok(accounts) = super::claude_accounts_list() else {
return;
};
let mut workers = Vec::new();
for account in accounts {
let permits = Arc::clone(&permits);
workers.push(tokio::spawn(async move {
let Ok(_permit) = permits.acquire_owned().await else {
return None;
// One task walks the accounts in turn. Concurrent lanes burst the usage
// endpoint and one account's 429 used to block every other account (#787).
// The task owns each credential renewal and persistence, so a UI that stops
// waiting never cancels a refresh-token rotation half way.
let worker = tokio::spawn(async move {
let mut results = Vec::new();
for account in accounts {
let Ok(_permit) = permits.acquire().await else {
break;
};
// The worker owns the credential operation until renewal and
// persistence finish. Refresh rotation is never cancelled by a UI.
let result = accounts::fetch_usage(&account.id)
.await
.map(|result| result.usage);
Some((account.id, result))
}));
}
let mut results = Vec::new();
for worker in workers {
if let Ok(Some(result)) = worker.await {
results.push(result);
results.push((account.id, result));
}
}
results
});
let results = worker.await.unwrap_or_default();
let Ok(accounts) = super::claude_accounts_list() else {
return;
};
let state = app.state::<Mutex<AppState>>();
let published = state
.lock()
.is_ok_and(|mut state| publish_results(&mut state, generation, &accounts, results));
if published {
let _ = app.emit("claude-accounts-updated", ());
let usage = state.lock().ok().and_then(|mut state| {
publish_results(&mut state, generation, &accounts, results)
.then(|| state.claude_account_usage.clone())
});
let Some(usage) = usage else {
return;
};
if let Err(error) = save_account_usage_cache(&usage) {
tracing::warn!("failed to persist Claude account usage: {error}");
}
let _ = app.emit("claude-accounts-updated", ());
let handle = app.clone();
let _ = app.run_on_main_thread(move || crate::tray_bridge::rebuild_tray_menu(&handle));
}

#[cfg(test)]
Expand Down Expand Up @@ -249,4 +324,50 @@ mod tests {
fn missing_session_is_unavailable_instead_of_zero_usage() {
assert!(quota_window(&RateWindow::no_active_session()).is_none());
}

#[test]
fn account_usage_cache_round_trips_per_account_and_rejects_other_versions() {
let mut usage = HashMap::new();
usage.insert(
"acct-a".to_string(),
ClaudeAccountUsageState {
usage: Some(ClaudeAccountUsage {
five_hour: Some(ClaudeAccountWindow {
used_percent: 42.0,
resets_at: None,
}),
seven_day: None,
updated_at: "2026-10-18T00:00:00+00:00".into(),
}),
usage_error: None,
needs_authentication: false,
},
);
usage.insert(
"acct-b".to_string(),
ClaudeAccountUsageState {
usage_error: Some("Sign in again to check this Claude account.".into()),
needs_authentication: true,
..Default::default()
},
);
let raw = encode_account_usage(&usage).unwrap();
let decoded = decode_account_usage(&raw).expect("current version decodes");
assert_eq!(decoded.len(), 2);
assert_eq!(
decoded["acct-a"]
.usage
.as_ref()
.unwrap()
.five_hour
.as_ref()
.unwrap()
.used_percent,
42.0
);
assert!(decoded["acct-b"].needs_authentication);
let other_version = raw.replace("\"version\": 1", "\"version\": 2");
assert!(decode_account_usage(&other_version).is_none());
assert!(decode_account_usage("not json").is_none());
}
}
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ fn main() {

let mut initial_state = AppState::new();
initial_state.proof_config = proof_config;
initial_state.claude_account_usage = commands::load_account_usage_cache();
// Validate the complete proof seed before installing any snapshots, so an
// invalid multi-provider fixture cannot leave a partial cache behind.
if let Some(snapshots) = proof_harness::seed_usage_snapshots_from_env(
Expand Down
Loading