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
46 changes: 24 additions & 22 deletions windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,38 @@ documented behaviour and the wire formats.

## Claude accounts

Settings → Accounts can add, name, reconnect and pause Claude accounts. Each account
uses the official Claude Code CLI sign-in in its own `~/.claude-<slug>` directory;
the standalone CLI must be installed. Existing profiles in that layout are detected.
Pausing monitoring retains the tool's sign-in. Authentication, cached readings and
retry deadlines stay separate, including after rate limiting or reconnection.
Settings → Accounts lists every Claude account as a provider row of its own, as the Mac
does: `~/.claude` and each `~/.claude-<slug>` Claude Code has signed in to. A row is named
from its sign-in (Claude Gmail, Claude Acme), from its folder until it has one
(Claude (work)), and is plain Claude when it is the only account. A folder Claude Code
has not signed in to is not an account. Add a Claude account creates the next
`~/.claude-<n>` folder and opens the official Claude Code CLI sign-in in it, counting
down five minutes; a sign-in that does not finish removes the folder again. The
standalone CLI must be installed. Switching a row off stops reading that
account and keeps its sign-in. Authentication, cached readings and retry deadlines stay
separate, including after rate limiting or reconnection.

## Taskbar display

Settings → Appearance → Taskbar can place compact usage indicators in free space
on the left side of the primary taskbar or before the system tray. This is opt-in;
Settings → Appearance → Taskbar can place the notch's rings in free space on the
left side of the primary taskbar or before the system tray. This is opt-in;
Original notch restores the screen-edge display. Hover shows account limits and
reset times, left-click requests a refresh of that account, and right-click opens
Settings. The width control adjusts the space used by the indicators.
Settings.

Claude and Codex display the weekly percentage. Available 5-hour and weekly quotas
have independent arcs; a weekly-only Codex plan has one outer arc, even when the
provider calls that bucket `primary`. Claude Enterprise accounts publishing only a
spend limit display that limit. Activity is assigned only to an identified
account; unknown ownership never animates a different account's indicator.
Each cell is a notch cell turned on its side: the same ring, reading, weekly ring,
activity arc and glyph, drawn in the notch's 56-unit box, with the notch's figure
beside the ring instead of under it. Every provider the notch shows is there, in the
notch's order, reading the same window. The strip is as wide as its cells; when the
free space is narrower, the whole row shrinks rather than dropping an account.

Size, theme, warning thresholds, colour transition, remaining/used display and weekly-ring position/style
reuse the existing appearance settings. The weekly option controls an additional
ring; an account with only a weekly quota keeps its one primary ring. Floating-notch
visibility, adaptive pill, edge and screen controls are disabled in taskbar mode.
Changes apply while the app runs. Large indicators are bounded by the taskbar height
and available width. Use Outside to keep the weekly arc around the 5-hour arc.
Remaining mode reverses meter fill and numbers; warning colours still reflect used
quota. Claude indicators hide stale or expired quota arcs; details retain dated
historical readings and the last reported reset. Clicks respect the existing
five-minute attempt floor, reset-boundary exception and each account's backoff.
reuse the existing appearance settings. Floating-notch visibility, adaptive pill,
edge and screen controls are disabled in taskbar mode. Changes apply while the app
runs. Large indicators are bounded by the taskbar height. Claude indicators hide
stale or expired quota arcs, as the notch does; details retain dated historical
readings and the last reported reset. Clicks respect the existing five-minute
attempt floor, reset-boundary exception and each account's backoff.

The strip uses an owned native child window and DirectComposition. It does not
inject code, subclass Explorer or alter its controls. UI Automation finds free
Expand Down
220 changes: 164 additions & 56 deletions windows/codenotch/src/claude_accounts.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
//! Named Claude profiles. Credentials stay in the official CLI's own folders.
//! Claude profiles. Credentials stay in the official CLI's own folders.
use crate::{config::{ClaudeProfileConfig, Config}, AppState};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
use tauri::{AppHandle, Emitter, Manager};

#[derive(Clone)]
Expand All @@ -10,6 +13,7 @@ pub struct Profile {
pub name: String,
pub dir: PathBuf,
pub enabled: bool,
pub address: Option<String>,
}

fn valid_id(id: &str) -> bool {
Expand All @@ -22,6 +26,82 @@ fn directory(home: &Path, id: &str) -> Option<PathBuf> {
else { None }
}

/// Claude Code's own record of the account. The default profile keeps it beside its folder, at
/// `~/.claude.json`; a `CLAUDE_CONFIG_DIR` profile keeps it inside. The Mac's `accountFileURL`.
fn account_file(home: &Path, id: &str, dir: &Path) -> PathBuf {
if id == "default" { home.join(".claude.json") } else { dir.join(".claude.json") }
}

#[derive(Deserialize)]
struct AccountFile {
#[serde(rename = "oauthAccount")]
oauth_account: Option<OauthAccount>,
}

#[derive(Deserialize)]
struct OauthAccount {
#[serde(rename = "emailAddress")]
email_address: Option<String>,
}

type Stamp = (Option<SystemTime>, u64);

/// The file also holds Claude Code's project history and is rewritten often, while the taskbar and
/// the poller ask for profiles every few hundred milliseconds: parse it only when it changes.
static ADDRESSES: Mutex<BTreeMap<PathBuf, (Stamp, Option<String>)>> = Mutex::new(BTreeMap::new());

fn signed_in_address(path: &Path) -> Option<String> {
let meta = std::fs::metadata(path).ok()?;
let stamp = (meta.modified().ok(), meta.len());
let mut cache = ADDRESSES.lock().unwrap_or_else(|e| e.into_inner());
if let Some((seen, address)) = cache.get(path) {
if *seen == stamp { return address.clone(); }
}
let address = std::fs::read(path).ok()
.and_then(|bytes| serde_json::from_slice::<AccountFile>(&bytes).ok())
.and_then(|file| file.oauth_account?.email_address)
.filter(|address| !address.is_empty());
cache.insert(path.to_path_buf(), (stamp, address.clone()));
address
}

/// `Gmail` for a gmail.com address, `Acme` for someone@acme.co.uk: the domain, because that is
/// what tells a personal login from a work one. The Mac's `ClaudeProfile.accountLabel`.
fn account_label(address: &str) -> Option<String> {
let (_, domain) = address.rsplit_once('@')?;
let first = domain.split('.').next()?;
if !first.chars().any(char::is_alphabetic) { return None; }
let mut chars = first.chars();
let head = chars.next()?;
Some(head.to_uppercase().chain(chars).collect())
}

/// The Mac's `ClaudeProfile.displayName`: the account's own name once it has signed in, the folder
/// before that.
fn display_name(id: &str, address: Option<&str>) -> String {
match address.and_then(account_label) {
Some(label) => format!("Claude {label}"),
None if id == "default" => "Claude".into(),
None => format!("Claude ({id})"),
}
}

/// The Mac's `ClaudeProfile.displayNames`: a lone account is just Claude, and only accounts whose
/// names would collide pay for the whole address.
fn display_names(accounts: &[(String, Option<String>)]) -> Vec<String> {
if accounts.len() == 1 { return vec!["Claude".into()]; }
let names: Vec<String> = accounts.iter().map(|(id, address)| display_name(id, address.as_deref())).collect();
names.iter().zip(accounts).map(|(name, (_, address))| {
match address {
Some(address) if names.iter().filter(|other| *other == name).count() > 1 => format!("Claude {address}"),
_ => name.clone(),
}
}).collect()
}

/// `~/.claude` and every `~/.claude-<slug>` Claude Code has signed in to, as the Mac's
/// `ClaudeProfile.discover`: no credential, no account, so a folder whose sign-in never finished
/// is not one.
pub fn profiles_at(home: &Path, cfg: &Config) -> Vec<Profile> {
let mut ids = vec!["default".to_string()];
if let Ok(entries) = std::fs::read_dir(home) {
Expand All @@ -34,14 +114,15 @@ pub fn profiles_at(home: &Path, cfg: &Config) -> Vec<Profile> {
found.sort();
ids.extend(found);
}
for p in &cfg.claude_profiles {
if directory(home, &p.id).is_some() && !ids.contains(&p.id) { ids.push(p.id.clone()); }
}
ids.into_iter().filter_map(|id| {
let accounts: Vec<(String, PathBuf, Option<String>)> = ids.into_iter().filter_map(|id| {
let dir = directory(home, &id)?;
let saved = cfg.claude_profiles.iter().find(|p| p.id == id);
let name = saved.map(|p| p.name.clone()).unwrap_or_else(|| if id == "default" { "Claude".into() } else { id.clone() });
Some(Profile { id, dir, name, enabled: saved.map(|p| p.enabled).unwrap_or(true) })
let address = signed_in_address(&account_file(home, &id, &dir));
Some((id, dir, address))
}).collect();
let names = display_names(&accounts.iter().map(|(id, _, address)| (id.clone(), address.clone())).collect::<Vec<_>>());
accounts.into_iter().zip(names).map(|((id, dir, address), name)| {
let enabled = cfg.claude_profiles.iter().find(|p| p.id == id).map(|p| p.enabled).unwrap_or(true);
Profile { id, name, dir, enabled, address }
}).collect()
}

Expand All @@ -57,78 +138,77 @@ fn has_credential(dir: &Path) -> bool {
pub struct AccountView {
id: String,
name: String,
address: Option<String>,
enabled: bool,
connected: bool,
status: String,
fetched_at: u64,
backoff_until: u64,
note: String,
path: String,
}

#[tauri::command]
pub fn get_claude_accounts(app: AppHandle) -> Vec<AccountView> {
let st = app.state::<AppState>();
let profiles = profiles(&st.cfg.lock().unwrap());
let usage = st.usage.lock().unwrap();
profiles.into_iter().map(|p| {
let mut views: Vec<AccountView> = profiles.into_iter().map(|p| {
let snap = usage.accounts.iter().find(|s| s.id == p.id);
AccountView { connected: has_credential(&p.dir), status: snap.map(|s| s.status.clone()).unwrap_or_else(|| "needsAuth".into()),
fetched_at: snap.map(|s| s.fetched_at).unwrap_or(0), path: p.dir.to_string_lossy().to_string(),
fetched_at: snap.map(|s| s.fetched_at).unwrap_or(0),
backoff_until: snap.map(|s| s.backoff_until).unwrap_or(0), note: snap.map(|s| s.note.clone()).unwrap_or_default(),
id: p.id, name: p.name, enabled: p.enabled }
}).collect()
}

fn checked_name(name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() || name.chars().count() > 40 || name.chars().any(char::is_control) {
return Err("Use a name between 1 and 40 characters".into());
id: p.id, name: p.name, address: p.address, enabled: p.enabled }
}).collect();
// An account being added has no credential yet, so it is listed only while its sign-in runs.
let auth = crate::claude_auth::state();
if let Some(id) = auth.profile_id.filter(|id| auth.busy && !views.iter().any(|v| &v.id == id)) {
views.push(AccountView { name: display_name(&id, None), id, address: None, enabled: true, connected: false,
status: "needsAuth".into(), fetched_at: 0, backoff_until: 0, note: String::new() });
}
Ok(name.to_string())
views
}

fn slug(name: &str) -> String {
let text: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
let text = text.split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-");
if text.is_empty() { "account".into() } else { text }
/// A new account's folder, numbered from 2 so it reads as the second account until its sign-in
/// gives it a name.
fn next_id(home: &Path, cfg: &Config) -> String {
(2u32..).map(|n| n.to_string())
.find(|id| !cfg.claude_profiles.iter().any(|p| &p.id == id) && directory(home, id).is_some_and(|dir| !dir.exists()))
.unwrap_or_default()
}

/// Long enough to sign in through a browser, short enough that an abandoned account goes away.
const NEW_ACCOUNT_SIGN_IN: Duration = Duration::from_secs(5 * 60);

/// Makes the next account's folder and opens Claude Code's sign-in in it. A sign-in that does not
/// finish, in time or at all, takes the folder with it.
#[tauri::command]
pub fn add_claude_account(app: AppHandle, name: String) -> Result<String, String> {
let name = checked_name(&name)?;
pub fn add_claude_account(app: AppHandle) -> Result<String, String> {
let home = dirs::home_dir().ok_or("Home directory unavailable")?;
let st = app.state::<AppState>();
let mut cfg = st.cfg.lock().unwrap();
let mut next = cfg.clone();
let base = slug(&name);
let mut id = base.clone();
let mut number = 2;
while id == "default" || next.claude_profiles.iter().any(|p| p.id == id) || directory(&home, &id).ok_or("Invalid account name")?.exists() {
id = format!("{base}-{number}"); number += 1;
}
let dir = directory(&home, &id).ok_or("Invalid account name")?;
let id = next_id(&home, &app.state::<AppState>().cfg.lock().unwrap());
let dir = directory(&home, &id).ok_or("Unable to create the account folder")?;
std::fs::create_dir(&dir).map_err(|_| "Unable to create the account folder")?;
next.claude_profiles.push(ClaudeProfileConfig { id: id.clone(), name, enabled: true });
if let Err(e) = crate::config::save_checked(&next) {
let handle = app.clone();
let folder = dir.clone();
let started = crate::claude_auth::start_profile_login_within(&dir, &id, NEW_ACCOUNT_SIGN_IN, move |_| {
if !has_credential(&folder) { let _ = std::fs::remove_dir_all(&folder); }
changed(&handle);
});
if let Err(e) = started {
let _ = std::fs::remove_dir(&dir); // only the newly-created, still-empty directory
return Err(e);
}
*cfg = next;
drop(cfg);
changed(&app);
Ok(id)
}

#[tauri::command]
pub fn update_claude_account(app: AppHandle, id: String, name: String, enabled: bool) -> Result<(), String> {
let name = checked_name(&name)?;
pub fn set_claude_account_enabled(app: AppHandle, id: String, enabled: bool) -> Result<(), String> {
let st = app.state::<AppState>();
let mut cfg = st.cfg.lock().unwrap();
if !profiles(&cfg).iter().any(|p| p.id == id) { return Err("Unknown Claude account".into()); }
let profile = profiles(&cfg).into_iter().find(|p| p.id == id).ok_or("Unknown Claude account")?;
let mut next = cfg.clone();
if let Some(p) = next.claude_profiles.iter_mut().find(|p| p.id == id) { p.name = name; p.enabled = enabled; }
else { next.claude_profiles.push(ClaudeProfileConfig { id, name, enabled }); }
if let Some(p) = next.claude_profiles.iter_mut().find(|p| p.id == id) { p.enabled = enabled; }
else { next.claude_profiles.push(ClaudeProfileConfig { id, name: profile.name, enabled }); }
crate::config::save_checked(&next)?;
*cfg = next;
drop(cfg);
Expand Down Expand Up @@ -158,19 +238,47 @@ mod tests {
assert_eq!(directory(home, "work-2").unwrap(), home.join(".claude-work-2"));
}
#[test]
fn registered_accounts_remain_visible_before_first_login() {
fn only_folders_claude_code_has_signed_in_to_are_accounts() {
let home = std::env::temp_dir().join(format!("codenotch-profiles-{}", std::process::id()));
for (folder, signed_in) in [(".claude-work", true), (".claude-2", false), (".claude-mem", false)] {
std::fs::create_dir_all(home.join(folder)).unwrap();
if signed_in { std::fs::write(home.join(folder).join(".credentials.json"), "{}").unwrap(); }
}
let mut cfg = Config::default();
cfg.claude_profiles.push(ClaudeProfileConfig { id:"work".into(), name:"Trabalho".into(), enabled:false });
let items = profiles_at(Path::new("/codenotch-nonexistent-fixture"), &cfg);
assert_eq!(items.len(), 2);
assert_eq!(items[1].name, "Trabalho");
cfg.claude_profiles.push(ClaudeProfileConfig { id:"2".into(), name:String::new(), enabled:true });
cfg.claude_profiles.push(ClaudeProfileConfig { id:"work".into(), name:String::new(), enabled:false });
let items = profiles_at(&home, &cfg);
let _ = std::fs::remove_dir_all(&home);
assert_eq!(items.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(), ["default", "work"]);
assert_eq!((items[0].name.as_str(), items[1].name.as_str()), ("Claude", "Claude (work)"));
assert!(!items[1].enabled);
assert_eq!(profiles_at(Path::new("/codenotch-nonexistent-fixture"), &cfg)[0].name, "Claude");
}
#[test]
fn names_are_labels_and_never_shell_source() {
assert!(checked_name(" ").is_err());
assert!(checked_name("bad\nname").is_err());
assert_eq!(slug("Client A $(run)"), "client-a-run");
assert_eq!(slug("日本"), "account");
fn the_default_account_file_sits_beside_its_folder_and_the_others_inside() {
let home = Path::new("/fixture");
assert_eq!(account_file(home, "default", &home.join(".claude")), home.join(".claude.json"));
assert_eq!(account_file(home, "work", &home.join(".claude-work")), home.join(".claude-work").join(".claude.json"));
}
#[test]
fn names_come_from_the_signed_in_domain_as_on_the_mac() {
assert_eq!(account_label("me@gmail.com").as_deref(), Some("Gmail"));
assert_eq!(account_label("me@acme.co.uk").as_deref(), Some("Acme"));
for unusable in ["no-at-sign", "me@", "me@.com", "me@1.com", "me@-.com"] { assert_eq!(account_label(unusable), None, "{unusable}"); }
let named = |accounts: &[(&str, Option<&str>)]| display_names(&accounts.iter()
.map(|(id, address)| (id.to_string(), address.map(Into::into))).collect::<Vec<_>>());
assert_eq!(named(&[("default", Some("me@gmail.com"))]), ["Claude"]);
assert_eq!(named(&[("default", Some("me@gmail.com")), ("work", Some("me@acme.com"))]), ["Claude Gmail", "Claude Acme"]);
assert_eq!(named(&[("default", None), ("2", None)]), ["Claude", "Claude (2)"]);
assert_eq!(named(&[("default", Some("a@gmail.com")), ("2", Some("b@gmail.com")), ("3", None)]),
["Claude a@gmail.com", "Claude b@gmail.com", "Claude (3)"]);
}
#[test]
fn a_new_account_takes_the_next_free_number() {
let mut cfg = Config::default();
let home = Path::new("/codenotch-nonexistent-fixture");
assert_eq!(next_id(home, &cfg), "2");
cfg.claude_profiles.push(ClaudeProfileConfig { id:"2".into(), name:String::new(), enabled:true });
assert_eq!(next_id(home, &cfg), "3");
}
}
Loading
Loading