diff --git a/src/localization/english.rs b/src/localization/english.rs index d730842..7e2dd72 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -34,5 +34,7 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "d", hour_suffix: "h", minute_suffix: "m", + token_expired_title: "Claude token expired", + token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index e1d51b8..650d423 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -34,5 +34,7 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "j", hour_suffix: "h", minute_suffix: "m", + token_expired_title: "Claude token expired", + token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 70de6fb..85ff8b0 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -34,5 +34,7 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "T", hour_suffix: "h", minute_suffix: "m", + token_expired_title: "Claude token expired", + token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 5078a21..453df01 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -34,5 +34,7 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "日", hour_suffix: "時間", minute_suffix: "分", + token_expired_title: "Claude token expired", + token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index ca2a88e..c1f7e1c 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -34,5 +34,7 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "일", hour_suffix: "시간", minute_suffix: "분", + token_expired_title: "Claude token expired", + token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 2b741f6..337f9d2 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -127,6 +127,8 @@ pub struct Strings { pub hour_suffix: &'static str, pub minute_suffix: &'static str, pub second_suffix: &'static str, + pub token_expired_title: &'static str, + pub token_expired_body: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index f3adace..4a1cd6c 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -34,5 +34,7 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "d", hour_suffix: "h", minute_suffix: "m", + token_expired_title: "Claude token expired", + token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.", second_suffix: "s", }; diff --git a/src/tray_icon.rs b/src/tray_icon.rs index 1fc2593..2bd837c 100644 --- a/src/tray_icon.rs +++ b/src/tray_icon.rs @@ -2,8 +2,8 @@ use windows::Win32::Foundation::*; use windows::Win32::Graphics::Gdi::*; use windows::Win32::System::LibraryLoader::GetModuleFileNameW; use windows::Win32::UI::Shell::{ - ExtractIconExW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY, - NOTIFYICONDATAW, Shell_NotifyIconW, + ExtractIconExW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY, + NIIF_WARNING, NOTIFYICONDATAW, Shell_NotifyIconW, }; use windows::Win32::UI::WindowsAndMessaging::*; use windows::core::PCWSTR; @@ -249,6 +249,35 @@ fn load_embedded_app_icon() -> HICON { } } +/// Show a Windows balloon notification from the tray icon. +/// Used to alert the user when re-authentication is required. +pub fn notify_balloon(hwnd: HWND, title: &str, message: &str) { + unsafe { + let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); + nid.cbSize = std::mem::size_of::() as u32; + nid.hWnd = hwnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_INFO; + nid.dwInfoFlags = NIIF_WARNING; + copy_wide(title, &mut nid.szInfoTitle); + copy_wide_256(message, &mut nid.szInfo); + let _ = Shell_NotifyIconW(NIM_MODIFY, &nid); + } +} + +/// Copy a string into a fixed-size wide buffer (truncates to fit). +fn copy_wide(s: &str, buf: &mut [u16; N]) { + let wide: Vec = s.encode_utf16().collect(); + let len = wide.len().min(N - 1); + buf[..len].copy_from_slice(&wide[..len]); + buf[len] = 0; +} + +/// Copy a string into a 256-wide buffer. +fn copy_wide_256(s: &str, buf: &mut [u16; 256]) { + copy_wide(s, buf) +} + /// Register the tray icon with the shell. pub fn add(hwnd: HWND, percent: Option, tooltip: &str) { let hicon = create_icon(percent); diff --git a/src/window.rs b/src/window.rs index 43024c7..dd71739 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1264,24 +1264,58 @@ fn do_poll(send_hwnd: SendHwnd) { let _ = PostMessageW(hwnd, WM_APP_USAGE_UPDATED, WPARAM(0), LPARAM(0)); } } - Err(_e) => { - // Show refresh indicator — retry will recover silently - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.session_text = "...".to_string(); - s.weekly_text = "...".to_string(); - s.last_poll_ok = false; - - // Exponential backoff retry: 30s, 60s, 120s, ... up to poll_interval - s.retry_count = s.retry_count.saturating_add(1); - let backoff = RETRY_BASE_MS - .saturating_mul(1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX)); - let retry_ms = backoff.min(s.poll_interval_ms); + Err(e) => { + // Distinguish token expiry (needs user action) from transient errors (retry helps). + let notify_expired = { + let mut state = lock_state(); + let mut should_notify = false; + if let Some(s) = state.as_mut() { + s.last_poll_ok = false; + match e { + poller::PollError::TokenExpired => { + // Only show the balloon on the first failure so it doesn't spam. + if s.retry_count == 0 { + should_notify = true; + } + s.session_text = "auth?".to_string(); + s.weekly_text = "re-login".to_string(); + s.retry_count = s.retry_count.saturating_add(1); + // Retry every 5 minutes — polling more often won't help until + // the user re-authenticates via 'claude logout && claude login'. + unsafe { + let _ = KillTimer(hwnd, TIMER_RESET_POLL); + SetTimer(hwnd, TIMER_POLL, 5 * 60 * 1000, None); + } + } + _ => { + // Transient network / credential-missing errors: exponential backoff. + s.session_text = "...".to_string(); + s.weekly_text = "...".to_string(); + s.retry_count = s.retry_count.saturating_add(1); + let backoff = RETRY_BASE_MS + .saturating_mul(1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX)); + let retry_ms = backoff.min(s.poll_interval_ms); + unsafe { + let _ = KillTimer(hwnd, TIMER_RESET_POLL); + SetTimer(hwnd, TIMER_POLL, retry_ms, None); + } + } + } + } + should_notify + }; - unsafe { - // Kill the 5-second reset poll so it doesn't bypass backoff - let _ = KillTimer(hwnd, TIMER_RESET_POLL); - SetTimer(hwnd, TIMER_POLL, retry_ms, None); + if notify_expired { + let strings = { + let state = lock_state(); + state.as_ref().map(|s| s.language.strings()) + }; + if let Some(strings) = strings { + tray_icon::notify_balloon( + hwnd, + strings.token_expired_title, + strings.token_expired_body, + ); } }