Skip to content

Commit ec4177f

Browse files
committed
v1.0.15
- Added support for WSL2
1 parent 7960ae9 commit ec4177f

File tree

3 files changed

+202
-10
lines changed

3 files changed

+202
-10
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "claude-code-usage-monitor"
3-
version = "1.0.14"
3+
version = "1.0.15"
44
edition = "2021"
55
license = "MIT"
66
description = "Windows taskbar widget for monitoring Claude Code usage and rate limits"

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Each bar shows the current utilization percentage and a countdown until the rate
1919

2020
## How it works
2121

22-
1. Reads your Claude OAuth token from `~/.claude/.credentials.json` (automatically refreshes expired tokens via the Claude CLI)
22+
1. Reads your Claude OAuth token from `~/.claude/.credentials.json`, or from `~/.claude/.credentials.json` inside an installed WSL distro if the Windows file is missing or expired (automatically refreshes expired tokens via the matching Claude CLI)
2323
2. Queries the dedicated Anthropic OAuth usage endpoint (`/api/oauth/usage`) for utilization data
2424
3. Falls back to the Messages API with rate limit header parsing (`anthropic-ratelimit-unified-*`) if the usage endpoint is unavailable
2525
4. Renders the widget using Win32 GDI, embedded as a child window of the taskbar
@@ -33,6 +33,8 @@ The widget automatically detects dark/light mode from Windows system settings. Y
3333
- [Rust toolchain](https://rustup.rs/) (MSVC target)
3434
- An active Claude Pro/Team subscription with OAuth credentials stored by [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
3535

36+
If you use Claude Code inside WSL2, keep `claude` installed and authenticated in that distro. The monitor will scan installed WSL distros and use the first accessible non-expired credential set it finds.
37+
3638
## Building
3739

3840
```bash

src/poller.rs

Lines changed: 198 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ pub fn poll() -> Result<UsageData, PollError> {
4242
};
4343

4444
if is_token_expired(creds.expires_at) {
45-
cli_refresh_token();
45+
cli_refresh_token(&creds.source);
4646

47-
match read_credentials() {
47+
match read_credentials_from_source(&creds.source) {
4848
Some(refreshed) => creds = refreshed,
4949
None => return Err(PollError::NoCredentials),
5050
}
@@ -59,8 +59,15 @@ pub fn poll() -> Result<UsageData, PollError> {
5959

6060
/// Invoke the Claude CLI with a minimal prompt to force its internal
6161
/// OAuth token refresh.
62-
fn cli_refresh_token() {
63-
let claude_path = resolve_claude_path();
62+
fn cli_refresh_token(source: &CredentialSource) {
63+
match source {
64+
CredentialSource::Windows(_) => cli_refresh_windows_token(),
65+
CredentialSource::Wsl { distro } => cli_refresh_wsl_token(distro),
66+
}
67+
}
68+
69+
fn cli_refresh_windows_token() {
70+
let claude_path = resolve_windows_claude_path();
6471
let is_cmd = claude_path.to_lowercase().ends_with(".cmd");
6572

6673
let args: &[&str] = &["-p", "."];
@@ -103,8 +110,49 @@ fn cli_refresh_token() {
103110
}
104111
}
105112

113+
fn cli_refresh_wsl_token(distro: &str) {
114+
let mut cmd = Command::new("wsl.exe");
115+
cmd.arg("-d")
116+
.arg(distro)
117+
.arg("--")
118+
.arg("bash")
119+
.arg("-lic")
120+
.arg("if command -v claude >/dev/null 2>&1; then claude -p .; elif [ -x \"$HOME/.local/bin/claude\" ]; then \"$HOME/.local/bin/claude\" -p .; else exit 127; fi")
121+
.env_remove("CLAUDECODE")
122+
.env_remove("CLAUDE_CODE_ENTRYPOINT")
123+
.creation_flags(CREATE_NO_WINDOW)
124+
.stdin(std::process::Stdio::null())
125+
.stdout(std::process::Stdio::null())
126+
.stderr(std::process::Stdio::null());
127+
128+
let mut child = match cmd.spawn() {
129+
Ok(c) => c,
130+
Err(_) => return,
131+
};
132+
133+
wait_for_refresh(&mut child);
134+
}
135+
136+
fn wait_for_refresh(child: &mut std::process::Child) {
137+
// Wait up to 30 seconds; don't block the poll thread forever.
138+
let start = std::time::Instant::now();
139+
loop {
140+
match child.try_wait() {
141+
Ok(Some(_)) => break,
142+
Ok(None) => {
143+
if start.elapsed() > Duration::from_secs(30) {
144+
let _ = child.kill();
145+
break;
146+
}
147+
std::thread::sleep(Duration::from_millis(500));
148+
}
149+
Err(_) => break,
150+
}
151+
}
152+
}
153+
106154
/// Resolve the full path to the `claude` CLI executable.
107-
fn resolve_claude_path() -> String {
155+
fn resolve_windows_claude_path() -> String {
108156
for name in &["claude.cmd", "claude"] {
109157
if Command::new(name)
110158
.arg("--version")
@@ -283,14 +331,77 @@ fn unix_to_system_time(unix_secs: Option<i64>) -> Option<SystemTime> {
283331
struct Credentials {
284332
access_token: String,
285333
expires_at: Option<i64>,
334+
source: CredentialSource,
335+
}
336+
337+
#[derive(Clone, Debug)]
338+
enum CredentialSource {
339+
Windows(PathBuf),
340+
Wsl { distro: String },
286341
}
287342

288343
fn read_credentials() -> Option<Credentials> {
289-
let home = dirs::home_dir()?;
290-
let cred_path: PathBuf = home.join(".claude").join(".credentials.json");
344+
let mut candidates = Vec::new();
345+
346+
if let Some(creds) = read_windows_credentials() {
347+
candidates.push(creds);
348+
}
291349

350+
for distro in list_wsl_distros() {
351+
if let Some(creds) = read_wsl_credentials(&distro) {
352+
candidates.push(creds);
353+
}
354+
}
355+
356+
choose_best_credentials(candidates)
357+
}
358+
359+
fn read_windows_credentials() -> Option<Credentials> {
360+
let home = dirs::home_dir()?;
361+
let cred_path = home.join(".claude").join(".credentials.json");
292362
let content = std::fs::read_to_string(&cred_path).ok()?;
293-
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
363+
parse_credentials(&content, CredentialSource::Windows(cred_path))
364+
}
365+
366+
fn read_credentials_from_source(source: &CredentialSource) -> Option<Credentials> {
367+
match source {
368+
CredentialSource::Windows(path) => {
369+
let content = std::fs::read_to_string(path).ok()?;
370+
parse_credentials(&content, source.clone())
371+
}
372+
CredentialSource::Wsl { distro } => read_wsl_credentials(distro),
373+
}
374+
}
375+
376+
fn read_wsl_credentials(distro: &str) -> Option<Credentials> {
377+
let output = Command::new("wsl.exe")
378+
.arg("-d")
379+
.arg(distro)
380+
.arg("--")
381+
.arg("sh")
382+
.arg("-lc")
383+
.arg("cat ~/.claude/.credentials.json")
384+
.creation_flags(CREATE_NO_WINDOW)
385+
.stdout(std::process::Stdio::piped())
386+
.stderr(std::process::Stdio::null())
387+
.output()
388+
.ok()?;
389+
390+
if !output.status.success() {
391+
return None;
392+
}
393+
394+
let content = String::from_utf8(output.stdout).ok()?;
395+
parse_credentials(
396+
&content,
397+
CredentialSource::Wsl {
398+
distro: distro.to_string(),
399+
},
400+
)
401+
}
402+
403+
fn parse_credentials(content: &str, source: CredentialSource) -> Option<Credentials> {
404+
let json: serde_json::Value = serde_json::from_str(content).ok()?;
294405

295406
let oauth = json.get("claudeAiOauth")?;
296407
let access_token = oauth.get("accessToken").and_then(|v| v.as_str())?.to_string();
@@ -299,9 +410,88 @@ fn read_credentials() -> Option<Credentials> {
299410
Some(Credentials {
300411
access_token,
301412
expires_at,
413+
source,
302414
})
303415
}
304416

417+
fn choose_best_credentials(mut candidates: Vec<Credentials>) -> Option<Credentials> {
418+
if candidates.is_empty() {
419+
return None;
420+
}
421+
422+
candidates.sort_by_key(|creds| is_token_expired(creds.expires_at));
423+
candidates.into_iter().next()
424+
}
425+
426+
fn list_wsl_distros() -> Vec<String> {
427+
let output = match Command::new("wsl.exe")
428+
.args(["-l", "-q"])
429+
.creation_flags(CREATE_NO_WINDOW)
430+
.stdout(std::process::Stdio::piped())
431+
.stderr(std::process::Stdio::null())
432+
.output()
433+
{
434+
Ok(output) if output.status.success() => output,
435+
_ => return Vec::new(),
436+
};
437+
438+
let stdout = decode_wsl_text(&output.stdout);
439+
stdout
440+
.lines()
441+
.map(str::trim)
442+
.filter(|line| !line.is_empty())
443+
.map(ToOwned::to_owned)
444+
.collect()
445+
}
446+
447+
fn decode_wsl_text(bytes: &[u8]) -> String {
448+
if bytes.is_empty() {
449+
return String::new();
450+
}
451+
452+
if let Some(decoded) = decode_utf16le(bytes) {
453+
return decoded;
454+
}
455+
456+
String::from_utf8_lossy(bytes).into_owned()
457+
}
458+
459+
fn decode_utf16le(bytes: &[u8]) -> Option<String> {
460+
if bytes.len() < 2 || bytes.len() % 2 != 0 {
461+
return None;
462+
}
463+
464+
let body = if bytes.starts_with(&[0xFF, 0xFE]) {
465+
&bytes[2..]
466+
} else if looks_like_utf16le(bytes) {
467+
bytes
468+
} else {
469+
return None;
470+
};
471+
472+
let units: Vec<u16> = body
473+
.chunks_exact(2)
474+
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
475+
.collect();
476+
477+
Some(String::from_utf16_lossy(&units))
478+
}
479+
480+
fn looks_like_utf16le(bytes: &[u8]) -> bool {
481+
let sample_len = bytes.len().min(128);
482+
let units = sample_len / 2;
483+
if units == 0 {
484+
return false;
485+
}
486+
487+
let nul_high_bytes = bytes[..sample_len]
488+
.chunks_exact(2)
489+
.filter(|chunk| chunk[1] == 0)
490+
.count();
491+
492+
nul_high_bytes * 2 >= units
493+
}
494+
305495
fn is_token_expired(expires_at: Option<i64>) -> bool {
306496
let Some(exp) = expires_at else { return false };
307497
let now = SystemTime::now()

0 commit comments

Comments
 (0)