Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/localization/english.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
2 changes: 2 additions & 0 deletions src/localization/french.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
2 changes: 2 additions & 0 deletions src/localization/german.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
2 changes: 2 additions & 0 deletions src/localization/japanese.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "秒",
};
2 changes: 2 additions & 0 deletions src/localization/korean.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "초",
};
2 changes: 2 additions & 0 deletions src/localization/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>) -> LanguageId {
Expand Down
2 changes: 2 additions & 0 deletions src/localization/spanish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
33 changes: 31 additions & 2 deletions src/tray_icon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::<NOTIFYICONDATAW>() 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<const N: usize>(s: &str, buf: &mut [u16; N]) {
let wide: Vec<u16> = 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<f64>, tooltip: &str) {
let hicon = create_icon(percent);
Expand Down
68 changes: 51 additions & 17 deletions src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}

Expand Down