mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-20 02:17:11 +00:00
Every Rust module under src/ that previously contained upstream-derivative
code has been replaced by a from-scratch implementation:
diag/ log + simplelog file appender (was: diagnose.rs)
os/ color, dpi, registry, string, theme (was: theme.rs, native_interop.rs)
net/ WinHTTP-based HTTP client (was: ureq + native-tls)
i18n/ TOML-embedded locale tables (was: localization/*.rs)
usage/ trait UsageProvider + ClaudeProvider + ChatGptProvider + refresh
orchestrator + registry (was: poller.rs, models.rs)
creds/ trait CredentialSource + local/WSL/Codex impls (was: poller.rs)
tray/ stateless tray manager + tiny-skia anti-aliased badge renderer
(was: tray_icon.rs)
update/ release fetch + inline cmd /c handoff installer
(was: updater.rs's helper-exe pattern)
Application files (app.rs, bubble.rs, panel.rs, settings.rs) migrated to
the new modules. main.rs declares only the new modules.
NOTICE deleted; LICENSE is plain Apache-2.0; README updated to credit
inspiration rather than claim derivation. Cargo.toml drops ureq + native-tls
+ winres in favour of log + simplelog + thiserror + toml + tiny-skia +
embed-resource. Build script swapped to embed-resource via res/icon.rc.
External contracts preserved unchanged: Anthropic + ChatGPT endpoints and
headers, ~/.claude/.credentials.json + Codex auth.json paths, WSL bridging
via wsl.exe, CLI-driven token refresh, GitHub Releases JSON shape, Windows
registry path for startup, single-instance mutex name.
Phase docs: plans/260516-0707-cleanroom-rewrite/.
8.7 KiB
8.7 KiB
phase, status, estimated_hours
| phase | status | estimated_hours |
|---|---|---|
| 3 | pending | 3 |
Phase 3 — creds/ module (credential discovery)
Context links
- Brainstorm: axis 4 (credential discovery) + 5 (refresh — only the discovery part lives here; orchestrator lives in Phase 4)
- Source file to be REPLACED: parts of
src/poller.rs(credential reading/discovery),src/native_interop.rs(WSL command execution)
Overview
- Priority: Medium — Phase 4 providers depend on this.
- Status: pending
- Brief: Introduce a
trait CredentialSourcewith three impls (local Claude, WSL Claude, local Codex). Replace the source'senum CredentialSource { Windows, Wsl }+ serial fallback with a registry-pattern + iterator.
Key insights from brainstorm
- Trait-based discovery is structurally different from source's enum + match dispatch.
Vec<Box<dyn CredentialSource>>ordered by priority lets future additions (e.g. an environment-variable-based source) drop in with no changes to the locator.- Change-detection signatures (used by app's "watch for re-auth" loop) become a trait method.
Requirements
Functional
creds::Token { access_token: String, expires_at_unix_ms: Option<i64>, account_id: Option<String> }.trait creds::CredentialSource: Send + Sync:fn id(&self) -> &str— stable identifier ("local-claude", "wsl:Ubuntu-22", "codex").fn read(&self) -> Result<Token, Error>.fn signature(&self) -> Option<String>— opaque hash/key for change detection.fn refresh_hint(&self) -> RefreshHint— what command to spawn for refresh.
creds::CredentialLocator::default_claude()builds a locator with local Windows path first, then all installed WSL distros.creds::CredentialLocator::default_codex()builds a locator with the local Codex path.locator.first_available() -> Option<&dyn CredentialSource>.locator.signatures() -> Vec<String>.
Non-functional
- WSL probe (which spawns
wsl.exe -l -q) must complete in ≤ 5s or be timed out. - WSL token-read must complete in ≤ 5s or be timed out.
- No blocking work in
signature()(it's called frequently from the poll loop) — only stat/file-size, not file-read.
Architecture
src/creds/mod.rs
use std::time::Duration;
pub mod local_fs;
pub mod wsl_bridge;
pub mod codex_auth;
#[derive(Debug, Clone)]
pub struct Token {
pub access_token: String,
pub expires_at_unix_ms: Option<i64>,
pub account_id: Option<String>,
}
#[derive(Debug, Clone)]
pub enum RefreshHint {
LocalCliCommand { exe: &'static str },
WslCliCommand { distro: String },
Codex,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("credential file not found at {path}")]
NotFound { path: String },
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("invalid JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("missing field in credential JSON: {0}")]
MissingField(&'static str),
#[error("WSL command failed: {0}")]
WslCommand(String),
#[error("timeout waiting for WSL command")]
WslTimeout,
}
pub trait CredentialSource: Send + Sync {
fn id(&self) -> &str;
fn read(&self) -> Result<Token, Error>;
fn signature(&self) -> Option<String>;
fn refresh_hint(&self) -> RefreshHint;
}
pub struct CredentialLocator {
sources: Vec<Box<dyn CredentialSource>>,
}
impl CredentialLocator {
pub fn new(sources: Vec<Box<dyn CredentialSource>>) -> Self {
Self { sources }
}
pub fn default_claude() -> Self {
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
if let Some(local) = local_fs::LocalClaudeCreds::detect() {
sources.push(Box::new(local));
}
for distro in wsl_bridge::list_distros() {
sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro)));
}
Self { sources }
}
pub fn default_codex() -> Self {
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
if let Some(codex) = codex_auth::LocalCodexCreds::detect() {
sources.push(Box::new(codex));
}
Self { sources }
}
pub fn first_available(&self) -> Option<&dyn CredentialSource> {
self.sources.iter().find(|s| s.signature().is_some()).map(Box::as_ref)
}
pub fn signatures(&self) -> Vec<String> {
self.sources.iter().filter_map(|s| s.signature()).collect()
}
pub fn iter(&self) -> impl Iterator<Item = &dyn CredentialSource> {
self.sources.iter().map(Box::as_ref)
}
}
src/creds/local_fs.rs
use std::path::PathBuf;
pub struct LocalClaudeCreds {
path: PathBuf,
id: String,
}
impl LocalClaudeCreds {
pub fn detect() -> Option<Self> {
let home = dirs::home_dir()?;
let path = home.join(".claude").join(".credentials.json");
Some(Self { id: format!("local:{}", path.display()), path })
}
}
impl super::CredentialSource for LocalClaudeCreds {
fn id(&self) -> &str { &self.id }
fn read(&self) -> Result<super::Token, super::Error> {
let content = std::fs::read_to_string(&self.path)?;
parse_claude_json(&content)
}
fn signature(&self) -> Option<String> {
let meta = std::fs::metadata(&self.path).ok()?;
let modified = meta.modified().ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs()).unwrap_or(0);
Some(format!("{}|{}|{}", self.id, meta.len(), modified))
}
fn refresh_hint(&self) -> super::RefreshHint {
super::RefreshHint::LocalCliCommand { exe: "claude" }
}
}
pub fn parse_claude_json(content: &str) -> Result<super::Token, super::Error> {
let json: serde_json::Value = serde_json::from_str(content)?;
let oauth = json.get("claudeAiOauth")
.ok_or(super::Error::MissingField("claudeAiOauth"))?;
let access_token = oauth.get("accessToken")
.and_then(|v| v.as_str())
.ok_or(super::Error::MissingField("accessToken"))?
.to_string();
let expires_at_unix_ms = oauth.get("expiresAt").and_then(|v| v.as_i64());
Ok(super::Token { access_token, expires_at_unix_ms, account_id: None })
}
src/creds/wsl_bridge.rs
Spawns wsl.exe -l -q to enumerate distros, then per-distro spawns wsl.exe -d <distro> -- sh -lc 'cat ~/.claude/.credentials.json'. Includes UTF-16LE-aware text decoder for wsl.exe -l -q output. Uses CREATE_NO_WINDOW flag. 5s timeout per command via a run_with_timeout helper.
Key public types:
pub fn list_distros() -> Vec<String>— empty Vec if WSL not installed.pub struct WslClaudeCreds { distro: String, id: String }implementingCredentialSource.
src/creds/codex_auth.rs
Reads $CODEX_HOME/auth.json or ~/.codex/auth.json. Token includes account_id from tokens.account_id. Mirrors local_fs.rs pattern.
Related code files
To create:
src/creds/mod.rssrc/creds/local_fs.rssrc/creds/wsl_bridge.rssrc/creds/codex_auth.rs
To modify:
src/main.rs—mod creds;
To delete: nothing (Phase 4 deletes src/poller.rs once usage::* providers go live).
Implementation steps
- Create
src/creds/mod.rswith trait +Token+Error+RefreshHint+CredentialLocator. - Create
local_fs.rswithLocalClaudeCreds. - Create
wsl_bridge.rswith distro enumeration + per-distro creds source. Notedecode_wsl_texthandles the UTF-16LE encoding quirk onwsl.exe -l -q. - Create
codex_auth.rswithLocalCodexCreds. - Wire
mod creds;intomain.rs. cargo build --releaseclean.
Todo checklist
creds/mod.rscreds/local_fs.rscreds/wsl_bridge.rscreds/codex_auth.rsmain.rsdeclares modulecargo build --releaseclean- Manual test (Windows):
CredentialLocator::default_claude().first_available()finds a real credential file
Success criteria
- Trait dispatch works; locator returns the right source based on priority.
- WSL probe doesn't hang the process when no WSL is installed.
signature()is fast (<1 ms for local, <100 ms for WSL).
Risks + mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| WSL probe blocks for full 5s when WSL is uninstalled | Low | Test on a WSL-free VM; verify timeout works |
| UTF-16LE detection heuristic produces false positives | Low | Source has same heuristic and ships in production |
wsl.exe not on PATH |
Negligible on Win10+ | Return empty list silently |
dirs::home_dir() returns None |
Negligible on Windows | Return None from detect() and let locator skip |
Security considerations
- Tokens stored as
Stringin memory; not logged. - WSL
sh -lcarg is a constant — no user-controlled input → no shell injection. - Don't
log::debug!the token; log onlytoken len=N.
Next steps
→ Phase 4: providers + refresh orchestrator that USES this locator.