Skip to content

Commit 9941c9a

Browse files
committed
v1.0.6
- Added new function to find the claude CLI - Using `claude -p .` to force OAuth refresh as `claude auth status` did not work - Fixed handling of 'Start with Windows' - Widened text display to fit values like '80% . 44m' (thank you @wangsu)
1 parent fcad7fb commit 9941c9a

File tree

2 files changed

+130
-23
lines changed

2 files changed

+130
-23
lines changed

src/poller.rs

Lines changed: 86 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,20 @@ pub enum PollError {
1919
}
2020

2121
pub fn poll() -> Result<UsageData, PollError> {
22-
let mut creds = read_credentials().ok_or(PollError::NoCredentials)?;
22+
let mut creds = match read_credentials() {
23+
Some(c) => c,
24+
None => return Err(PollError::NoCredentials),
25+
};
2326

2427
if is_token_expired(creds.expires_at) {
25-
// Token expired — ask the Claude CLI to refresh its own credentials
2628
cli_refresh_token();
27-
// Re-read the credentials file after CLI refresh
28-
creds = read_credentials().ok_or(PollError::NoCredentials)?;
29+
30+
// Re-read credentials in case the CLI refreshed them
31+
match read_credentials() {
32+
Some(refreshed) => creds = refreshed,
33+
None => return Err(PollError::NoCredentials),
34+
}
35+
2936
if is_token_expired(creds.expires_at) {
3037
return Err(PollError::TokenExpired);
3138
}
@@ -34,15 +41,70 @@ pub fn poll() -> Result<UsageData, PollError> {
3441
fetch_usage_with_fallback(&creds.access_token)
3542
}
3643

37-
/// Invoke the Claude CLI to trigger its internal token refresh.
38-
/// `claude auth status` checks auth state, which causes the CLI to
39-
/// refresh expired tokens and write updated credentials to disk.
44+
/// Invoke the Claude CLI with a minimal prompt to force its internal
45+
/// OAuth token refresh. `claude -p "."` makes the CLI
46+
/// authenticate (refreshing the access token if expired), perform a
47+
/// tiny API call, and exit — updating the credentials file on disk.
4048
fn cli_refresh_token() {
41-
let _ = Command::new("claude")
42-
.args(["auth", "status"])
49+
let claude_path = resolve_claude_path();
50+
let is_cmd = claude_path.to_lowercase().ends_with(".cmd");
51+
52+
let args: &[&str] = &["-p", "."];
53+
54+
// Clear env vars that prevent nested Claude Code sessions
55+
let mut cmd = if is_cmd {
56+
let mut c = Command::new("cmd.exe");
57+
c.arg("/c").arg(&claude_path).args(args);
58+
c
59+
} else {
60+
let mut c = Command::new(&claude_path);
61+
c.args(args);
62+
c
63+
};
64+
cmd.env_remove("CLAUDECODE")
65+
.env_remove("CLAUDE_CODE_ENTRYPOINT")
4366
.stdout(std::process::Stdio::null())
44-
.stderr(std::process::Stdio::null())
45-
.status();
67+
.stderr(std::process::Stdio::null());
68+
69+
let _ = cmd.status();
70+
}
71+
72+
/// Resolve the full path to the `claude` CLI executable.
73+
/// First tries the bare command name (works if on PATH), then falls back
74+
/// to `where.exe claude` which searches the system/user PATH from the
75+
/// registry — important for processes started via the Windows Run key
76+
/// that may not inherit the full shell PATH.
77+
fn resolve_claude_path() -> String {
78+
// Quick check: try claude.cmd first (Windows npm wrapper), then bare "claude"
79+
for name in &["claude.cmd", "claude"] {
80+
if Command::new(name)
81+
.arg("--version")
82+
.stdout(std::process::Stdio::null())
83+
.stderr(std::process::Stdio::null())
84+
.status()
85+
.is_ok()
86+
{
87+
return name.to_string();
88+
}
89+
}
90+
91+
// Use where.exe to search the system/user PATH from the registry.
92+
// Try claude.cmd first (the Windows batch wrapper npm creates).
93+
for name in &["claude.cmd", "claude"] {
94+
if let Ok(output) = Command::new("where.exe").arg(name).output() {
95+
if output.status.success() {
96+
let stdout = String::from_utf8_lossy(&output.stdout);
97+
if let Some(first_line) = stdout.lines().next() {
98+
let path = first_line.trim().to_string();
99+
if !path.is_empty() {
100+
return path;
101+
}
102+
}
103+
}
104+
}
105+
}
106+
107+
"claude.cmd".to_string()
46108
}
47109

48110
fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
@@ -68,9 +130,12 @@ fn read_credentials() -> Option<Credentials> {
68130
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
69131

70132
let oauth = json.get("claudeAiOauth")?;
133+
let access_token = oauth.get("accessToken").and_then(|v| v.as_str())?.to_string();
134+
let expires_at = oauth.get("expiresAt").and_then(|v| v.as_i64());
135+
71136
Some(Credentials {
72-
access_token: oauth.get("accessToken")?.as_str()?.to_string(),
73-
expires_at: oauth.get("expiresAt").and_then(|v| v.as_i64()),
137+
access_token,
138+
expires_at,
74139
})
75140
}
76141

@@ -84,12 +149,10 @@ fn is_token_expired(expires_at: Option<i64>) -> bool {
84149
}
85150

86151
fn try_model(token: &str, model: &str) -> Option<UsageData> {
87-
let tls = std::sync::Arc::new(
88-
native_tls::TlsConnector::new().ok()?
89-
);
152+
let tls = native_tls::TlsConnector::new().ok()?;
90153
let agent = ureq::AgentBuilder::new()
91154
.timeout(Duration::from_secs(30))
92-
.tls_connector(tls)
155+
.tls_connector(std::sync::Arc::new(tls))
93156
.build();
94157

95158
let body = serde_json::json!({
@@ -106,13 +169,15 @@ fn try_model(token: &str, model: &str) -> Option<UsageData> {
106169
.send_json(&body)
107170
{
108171
Ok(resp) => resp,
109-
Err(ureq::Error::Status(_, resp)) => resp,
172+
Err(ureq::Error::Status(_code, resp)) => resp,
110173
Err(_) => return None,
111174
};
112175

113-
let has_rate_limit_headers = response.header("anthropic-ratelimit-unified-5h-utilization").is_some()
114-
|| response.header("anthropic-ratelimit-unified-7d-utilization").is_some()
115-
|| response.header("anthropic-ratelimit-unified-status").is_some();
176+
let h5 = response.header("anthropic-ratelimit-unified-5h-utilization");
177+
let h7 = response.header("anthropic-ratelimit-unified-7d-utilization");
178+
let hs = response.header("anthropic-ratelimit-unified-status");
179+
180+
let has_rate_limit_headers = h5.is_some() || h7.is_some() || hs.is_some();
116181

117182
if has_rate_limit_headers {
118183
Some(parse_headers(&response))

src/window.rs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ fn lock_state() -> MutexGuard<'static, Option<AppState>> {
7878
const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
7979
const STARTUP_REGISTRY_KEY: &str = "ClaudeCodeUsageMonitor";
8080

81+
/// Returns true only if the startup registry value points to this executable.
8182
fn is_startup_enabled() -> bool {
8283
unsafe {
8384
let path = native_interop::wide_str(STARTUP_REGISTRY_PATH);
@@ -95,6 +96,7 @@ fn is_startup_enabled() -> bool {
9596
return false;
9697
}
9798

99+
// Query the size of the value
98100
let mut data_size: u32 = 0;
99101
let result = RegQueryValueExW(
100102
hkey,
@@ -104,8 +106,45 @@ fn is_startup_enabled() -> bool {
104106
None,
105107
Some(&mut data_size),
106108
);
109+
if result.is_err() || data_size == 0 {
110+
let _ = RegCloseKey(hkey);
111+
return false;
112+
}
113+
114+
// Read the value
115+
let mut buf = vec![0u8; data_size as usize];
116+
let result = RegQueryValueExW(
117+
hkey,
118+
PCWSTR::from_raw(key_name.as_ptr()),
119+
None,
120+
None,
121+
Some(buf.as_mut_ptr()),
122+
Some(&mut data_size),
123+
);
107124
let _ = RegCloseKey(hkey);
108-
result.is_ok()
125+
if result.is_err() {
126+
return false;
127+
}
128+
129+
// Convert the registry value (UTF-16) to a string
130+
let wide_slice = std::slice::from_raw_parts(
131+
buf.as_ptr() as *const u16,
132+
data_size as usize / 2,
133+
);
134+
let reg_value = String::from_utf16_lossy(wide_slice)
135+
.trim_end_matches('\0')
136+
.to_string();
137+
138+
// Get the current executable path
139+
let mut exe_buf = [0u16; 260];
140+
let len = GetModuleFileNameW(None, &mut exe_buf) as usize;
141+
if len == 0 {
142+
return false;
143+
}
144+
let current_exe = String::from_utf16_lossy(&exe_buf[..len]);
145+
146+
// Case-insensitive comparison (Windows paths are case-insensitive)
147+
reg_value.eq_ignore_ascii_case(&current_exe)
109148
}
110149
}
111150

@@ -164,7 +203,7 @@ const DIVIDER_RIGHT_MARGIN: i32 = 10;
164203
const LABEL_WIDTH: i32 = 18;
165204
const LABEL_RIGHT_MARGIN: i32 = 10;
166205
const BAR_RIGHT_MARGIN: i32 = 4;
167-
const TEXT_WIDTH: i32 = 52;
206+
const TEXT_WIDTH: i32 = 62;
168207
const RIGHT_MARGIN: i32 = 1;
169208
const WIDGET_HEIGHT: i32 = 46;
170209

@@ -597,7 +636,10 @@ fn do_poll(send_hwnd: SendHwnd) {
597636
1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX),
598637
);
599638
let retry_ms = backoff.min(s.poll_interval_ms);
639+
600640
unsafe {
641+
// Kill the 5-second reset poll so it doesn't bypass backoff
642+
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
601643
SetTimer(hwnd, TIMER_POLL, retry_ms, None);
602644
}
603645
}

0 commit comments

Comments
 (0)