Skip to content

Commit 17c6351

Browse files
committed
v1.3.4
fix: pause usage polling on auth errors - Show the same auth warning state for expired, invalid, or missing credentials - Replace the widget auth text with a warning symbol and updated the toast notification - Stop usage polling and countdown timers while auth is required - Reuse the configured poll interval to watch credential file metadata instead of polling the API - Auto-recover when credentials change, while still supporting manual refresh and restart flows Updated translations
1 parent b5ae662 commit 17c6351

11 files changed

Lines changed: 251 additions & 53 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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.3.3"
3+
version = "1.3.4"
44
edition = "2021"
55
license = "MIT"
66
description = "Claude Code Usage Monitor"

src/localization/english.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ pub(super) const STRINGS: Strings = Strings {
3434
day_suffix: "d",
3535
hour_suffix: "h",
3636
minute_suffix: "m",
37-
token_expired_title: "Claude token expired",
38-
token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.",
37+
token_expired_title: "Claude Code Auth Error",
38+
token_expired_body: "Run 'claude' in a terminal, then use '/login' and follow the prompts. After that, refresh or restart this app.",
3939
second_suffix: "s",
4040
};

src/localization/french.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ pub(super) const STRINGS: Strings = Strings {
3434
day_suffix: "j",
3535
hour_suffix: "h",
3636
minute_suffix: "m",
37-
token_expired_title: "Claude token expired",
38-
token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.",
37+
token_expired_title: "Erreur d'authentification",
38+
token_expired_body: "Exécutez 'claude' dans un terminal, puis utilisez '/login' et suivez les instructions. Ensuite, actualisez ou redémarrez cette application.",
3939
second_suffix: "s",
4040
};

src/localization/german.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ pub(super) const STRINGS: Strings = Strings {
3434
day_suffix: "T",
3535
hour_suffix: "h",
3636
minute_suffix: "m",
37-
token_expired_title: "Claude token expired",
38-
token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.",
37+
token_expired_title: "Authentifizierungsfehler",
38+
token_expired_body: "Führen Sie 'claude' in einem Terminal aus, verwenden Sie dann '/login' und folgen Sie den Anweisungen. Aktualisieren oder starten Sie diese App anschließend neu.",
3939
second_suffix: "s",
4040
};

src/localization/japanese.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ pub(super) const STRINGS: Strings = Strings {
3434
day_suffix: "日",
3535
hour_suffix: "時間",
3636
minute_suffix: "分",
37-
token_expired_title: "Claude token expired",
38-
token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.",
37+
token_expired_title: "認証エラー",
38+
token_expired_body: "ターミナルで 'claude' を実行し、'/login' を使って案内に従ってください。その後、このアプリを更新するか再起動してください。",
3939
second_suffix: "秒",
4040
};

src/localization/korean.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ pub(super) const STRINGS: Strings = Strings {
3434
day_suffix: "일",
3535
hour_suffix: "시간",
3636
minute_suffix: "분",
37-
token_expired_title: "Claude token expired",
38-
token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.",
37+
token_expired_title: "인증 오류",
38+
token_expired_body: "터미널에서 'claude'를 실행한 다음 '/login'을 사용하고 안내에 따라 진행하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.",
3939
second_suffix: "초",
4040
};

src/localization/spanish.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ pub(super) const STRINGS: Strings = Strings {
3434
day_suffix: "d",
3535
hour_suffix: "h",
3636
minute_suffix: "m",
37-
token_expired_title: "Claude token expired",
38-
token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.",
37+
token_expired_title: "Error de autenticación",
38+
token_expired_body: "Ejecuta 'claude' en una terminal, luego usa '/login' y sigue las indicaciones. Después, actualiza o reinicia esta aplicación.",
3939
second_suffix: "s",
4040
};

src/localization/traditional_chinese.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,7 @@ pub(super) const STRINGS: Strings = Strings {
3434
day_suffix: "天",
3535
hour_suffix: "時",
3636
minute_suffix: "分",
37+
token_expired_title: "驗證錯誤",
38+
token_expired_body: "請在終端機中執行 'claude',然後使用 '/login' 並依照提示操作。完成後,請重新整理或重新啟動此應用程式。",
3739
second_suffix: "秒",
3840
};

src/poller.rs

Lines changed: 132 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,20 @@ const MODEL_FALLBACK_CHAIN: &[&str] = &["claude-3-haiku-20240307", "claude-haiku
1717

1818
#[derive(Debug)]
1919
pub enum PollError {
20+
AuthRequired,
2021
NoCredentials,
2122
TokenExpired,
2223
RequestFailed,
2324
}
2425

26+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27+
pub enum CredentialWatchMode {
28+
ActiveSource,
29+
AllSources,
30+
}
31+
32+
pub type CredentialWatchSnapshot = Vec<String>;
33+
2534
#[derive(Deserialize)]
2635
struct UsageResponse {
2736
five_hour: Option<UsageBucket>,
@@ -234,23 +243,112 @@ fn build_agent() -> Result<ureq::Agent, PollError> {
234243
.build())
235244
}
236245

246+
pub fn credential_watch_snapshot(mode: CredentialWatchMode) -> CredentialWatchSnapshot {
247+
let sources = match mode {
248+
CredentialWatchMode::ActiveSource => read_credentials()
249+
.map(|creds| vec![creds.source])
250+
.unwrap_or_else(all_known_credential_sources),
251+
CredentialWatchMode::AllSources => all_known_credential_sources(),
252+
};
253+
254+
let mut snapshot: CredentialWatchSnapshot = sources
255+
.into_iter()
256+
.filter_map(|source| credential_watch_signature(&source))
257+
.collect();
258+
snapshot.sort();
259+
snapshot.dedup();
260+
snapshot
261+
}
262+
263+
fn all_known_credential_sources() -> Vec<CredentialSource> {
264+
let mut sources = Vec::new();
265+
if let Some(source) = windows_credential_source() {
266+
sources.push(source);
267+
}
268+
for distro in list_wsl_distros() {
269+
sources.push(CredentialSource::Wsl { distro });
270+
}
271+
sources
272+
}
273+
274+
fn windows_credential_source() -> Option<CredentialSource> {
275+
let home = dirs::home_dir()?;
276+
Some(CredentialSource::Windows(
277+
home.join(".claude").join(".credentials.json"),
278+
))
279+
}
280+
281+
fn credential_watch_signature(source: &CredentialSource) -> Option<String> {
282+
match source {
283+
CredentialSource::Windows(path) => Some(windows_credential_watch_signature(path)),
284+
CredentialSource::Wsl { distro } => wsl_credential_watch_signature(distro),
285+
}
286+
}
287+
288+
fn windows_credential_watch_signature(path: &PathBuf) -> String {
289+
let key = format!("win:{}", path.display());
290+
match std::fs::metadata(path) {
291+
Ok(metadata) => {
292+
let modified = metadata
293+
.modified()
294+
.ok()
295+
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
296+
.map(|value| value.as_secs())
297+
.unwrap_or(0);
298+
format!("{key}|present|{}|{modified}", metadata.len())
299+
}
300+
Err(_) => format!("{key}|missing"),
301+
}
302+
}
303+
304+
fn wsl_credential_watch_signature(distro: &str) -> Option<String> {
305+
let output = run_with_timeout(
306+
Command::new("wsl.exe")
307+
.arg("-d")
308+
.arg(distro)
309+
.arg("--")
310+
.arg("sh")
311+
.arg("-lc")
312+
.arg(
313+
"if [ -f ~/.claude/.credentials.json ]; then \
314+
stat -c 'present|%s|%Y' ~/.claude/.credentials.json; \
315+
else echo missing; fi",
316+
)
317+
.creation_flags(CREATE_NO_WINDOW)
318+
.stdout(std::process::Stdio::piped())
319+
.stderr(std::process::Stdio::null()),
320+
Duration::from_secs(5),
321+
)?;
322+
323+
let state = if output.status.success() {
324+
decode_wsl_text(&output.stdout).trim().to_string()
325+
} else {
326+
format!("status-{}", output.status)
327+
};
328+
329+
Some(format!("wsl:{distro}|{state}"))
330+
}
331+
237332
fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
238333
// Try the dedicated usage endpoint first
239-
if let Some(data) = try_usage_endpoint(token) {
334+
match try_usage_endpoint(token)? {
335+
Some(data) => {
240336
// If reset timers are missing, fill them in from the Messages API
241-
if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() {
242-
if let Ok(fallback) = fetch_usage_via_messages(token) {
243-
let mut merged = data;
244-
if merged.session.resets_at.is_none() {
245-
merged.session.resets_at = fallback.session.resets_at;
246-
}
247-
if merged.weekly.resets_at.is_none() {
248-
merged.weekly.resets_at = fallback.weekly.resets_at;
337+
if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() {
338+
if let Ok(fallback) = fetch_usage_via_messages(token) {
339+
let mut merged = data;
340+
if merged.session.resets_at.is_none() {
341+
merged.session.resets_at = fallback.session.resets_at;
342+
}
343+
if merged.weekly.resets_at.is_none() {
344+
merged.weekly.resets_at = fallback.weekly.resets_at;
345+
}
346+
return Ok(merged);
249347
}
250-
return Ok(merged);
251348
}
349+
return Ok(data);
252350
}
253-
return Ok(data);
351+
None => {}
254352
}
255353

256354
// Fall back to Messages API with rate limit headers
@@ -261,8 +359,8 @@ fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
261359
result
262360
}
263361

264-
fn try_usage_endpoint(token: &str) -> Option<UsageData> {
265-
let agent = build_agent().ok()?;
362+
fn try_usage_endpoint(token: &str) -> Result<Option<UsageData>, PollError> {
363+
let agent = build_agent()?;
266364

267365
let resp = match agent
268366
.get(USAGE_URL)
@@ -271,10 +369,19 @@ fn try_usage_endpoint(token: &str) -> Option<UsageData> {
271369
.call()
272370
{
273371
Ok(resp) => resp,
274-
_ => return None,
372+
Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => {
373+
diagnose::log(format!(
374+
"usage endpoint returned auth error status {code}; re-login required"
375+
));
376+
return Err(PollError::AuthRequired);
377+
}
378+
Err(_) => return Ok(None),
275379
};
276380

277-
let response: UsageResponse = resp.into_json().ok()?;
381+
let response: UsageResponse = match resp.into_json() {
382+
Ok(response) => response,
383+
Err(_) => return Ok(None),
384+
};
278385
let mut data = UsageData::default();
279386

280387
if let Some(bucket) = &response.five_hour {
@@ -287,7 +394,7 @@ fn try_usage_endpoint(token: &str) -> Option<UsageData> {
287394
data.weekly.resets_at = parse_iso8601(bucket.resets_at.as_deref());
288395
}
289396

290-
Some(data)
397+
Ok(Some(data))
291398
}
292399

293400
fn fetch_usage_via_messages(token: &str) -> Result<UsageData, PollError> {
@@ -308,6 +415,12 @@ fn fetch_usage_via_messages(token: &str) -> Result<UsageData, PollError> {
308415
.send_json(&body)
309416
{
310417
Ok(resp) => resp,
418+
Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => {
419+
diagnose::log(format!(
420+
"messages endpoint returned auth error status {code}; re-login required"
421+
));
422+
return Err(PollError::AuthRequired);
423+
}
311424
Err(ureq::Error::Status(_code, resp)) => resp,
312425
Err(_) => continue,
313426
};
@@ -410,8 +523,9 @@ fn read_credentials() -> Option<Credentials> {
410523
}
411524

412525
fn read_windows_credentials() -> Option<Credentials> {
413-
let home = dirs::home_dir()?;
414-
let cred_path = home.join(".claude").join(".credentials.json");
526+
let CredentialSource::Windows(cred_path) = windows_credential_source()? else {
527+
return None;
528+
};
415529
let content = match std::fs::read_to_string(&cred_path) {
416530
Ok(content) => content,
417531
Err(error) => {

0 commit comments

Comments
 (0)