Skip to content

Commit 1d5c46f

Browse files
committed
fix: show balloon notification when OAuth token is fully expired
1 parent a407f69 commit 1d5c46f

9 files changed

Lines changed: 96 additions & 19 deletions

File tree

src/localization/english.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: "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.",
3739
second_suffix: "s",
3840
};

src/localization/french.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: "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.",
3739
second_suffix: "s",
3840
};

src/localization/german.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: "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.",
3739
second_suffix: "s",
3840
};

src/localization/japanese.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: "Claude token expired",
38+
token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.",
3739
second_suffix: "秒",
3840
};

src/localization/korean.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: "Claude token expired",
38+
token_expired_body: "Your session has expired. Run 'claude logout' then 'claude login' in a terminal, then restart this app.",
3739
second_suffix: "초",
3840
};

src/localization/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ pub struct Strings {
127127
pub hour_suffix: &'static str,
128128
pub minute_suffix: &'static str,
129129
pub second_suffix: &'static str,
130+
pub token_expired_title: &'static str,
131+
pub token_expired_body: &'static str,
130132
}
131133

132134
pub fn resolve_language(language_override: Option<LanguageId>) -> LanguageId {

src/localization/spanish.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: "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.",
3739
second_suffix: "s",
3840
};

src/tray_icon.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use windows::Win32::Foundation::*;
22
use windows::Win32::Graphics::Gdi::*;
33
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
44
use windows::Win32::UI::Shell::{
5-
ExtractIconExW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY,
6-
NOTIFYICONDATAW, Shell_NotifyIconW,
5+
ExtractIconExW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY,
6+
NIIF_WARNING, NOTIFYICONDATAW, Shell_NotifyIconW,
77
};
88
use windows::Win32::UI::WindowsAndMessaging::*;
99
use windows::core::PCWSTR;
@@ -249,6 +249,35 @@ fn load_embedded_app_icon() -> HICON {
249249
}
250250
}
251251

252+
/// Show a Windows balloon notification from the tray icon.
253+
/// Used to alert the user when re-authentication is required.
254+
pub fn notify_balloon(hwnd: HWND, title: &str, message: &str) {
255+
unsafe {
256+
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
257+
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
258+
nid.hWnd = hwnd;
259+
nid.uID = TRAY_ICON_ID;
260+
nid.uFlags = NIF_INFO;
261+
nid.dwInfoFlags = NIIF_WARNING;
262+
copy_wide(title, &mut nid.szInfoTitle);
263+
copy_wide_256(message, &mut nid.szInfo);
264+
let _ = Shell_NotifyIconW(NIM_MODIFY, &nid);
265+
}
266+
}
267+
268+
/// Copy a string into a fixed-size wide buffer (truncates to fit).
269+
fn copy_wide<const N: usize>(s: &str, buf: &mut [u16; N]) {
270+
let wide: Vec<u16> = s.encode_utf16().collect();
271+
let len = wide.len().min(N - 1);
272+
buf[..len].copy_from_slice(&wide[..len]);
273+
buf[len] = 0;
274+
}
275+
276+
/// Copy a string into a 256-wide buffer.
277+
fn copy_wide_256(s: &str, buf: &mut [u16; 256]) {
278+
copy_wide(s, buf)
279+
}
280+
252281
/// Register the tray icon with the shell.
253282
pub fn add(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
254283
let hicon = create_icon(percent);

src/window.rs

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,24 +1264,58 @@ fn do_poll(send_hwnd: SendHwnd) {
12641264
let _ = PostMessageW(hwnd, WM_APP_USAGE_UPDATED, WPARAM(0), LPARAM(0));
12651265
}
12661266
}
1267-
Err(_e) => {
1268-
// Show refresh indicator — retry will recover silently
1269-
let mut state = lock_state();
1270-
if let Some(s) = state.as_mut() {
1271-
s.session_text = "...".to_string();
1272-
s.weekly_text = "...".to_string();
1273-
s.last_poll_ok = false;
1274-
1275-
// Exponential backoff retry: 30s, 60s, 120s, ... up to poll_interval
1276-
s.retry_count = s.retry_count.saturating_add(1);
1277-
let backoff = RETRY_BASE_MS
1278-
.saturating_mul(1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX));
1279-
let retry_ms = backoff.min(s.poll_interval_ms);
1267+
Err(e) => {
1268+
// Distinguish token expiry (needs user action) from transient errors (retry helps).
1269+
let notify_expired = {
1270+
let mut state = lock_state();
1271+
let mut should_notify = false;
1272+
if let Some(s) = state.as_mut() {
1273+
s.last_poll_ok = false;
1274+
match e {
1275+
poller::PollError::TokenExpired => {
1276+
// Only show the balloon on the first failure so it doesn't spam.
1277+
if s.retry_count == 0 {
1278+
should_notify = true;
1279+
}
1280+
s.session_text = "auth?".to_string();
1281+
s.weekly_text = "re-login".to_string();
1282+
s.retry_count = s.retry_count.saturating_add(1);
1283+
// Retry every 5 minutes — polling more often won't help until
1284+
// the user re-authenticates via 'claude logout && claude login'.
1285+
unsafe {
1286+
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
1287+
SetTimer(hwnd, TIMER_POLL, 5 * 60 * 1000, None);
1288+
}
1289+
}
1290+
_ => {
1291+
// Transient network / credential-missing errors: exponential backoff.
1292+
s.session_text = "...".to_string();
1293+
s.weekly_text = "...".to_string();
1294+
s.retry_count = s.retry_count.saturating_add(1);
1295+
let backoff = RETRY_BASE_MS
1296+
.saturating_mul(1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX));
1297+
let retry_ms = backoff.min(s.poll_interval_ms);
1298+
unsafe {
1299+
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
1300+
SetTimer(hwnd, TIMER_POLL, retry_ms, None);
1301+
}
1302+
}
1303+
}
1304+
}
1305+
should_notify
1306+
};
12801307

1281-
unsafe {
1282-
// Kill the 5-second reset poll so it doesn't bypass backoff
1283-
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
1284-
SetTimer(hwnd, TIMER_POLL, retry_ms, None);
1308+
if notify_expired {
1309+
let strings = {
1310+
let state = lock_state();
1311+
state.as_ref().map(|s| s.language.strings())
1312+
};
1313+
if let Some(strings) = strings {
1314+
tray_icon::notify_balloon(
1315+
hwnd,
1316+
strings.token_expired_title,
1317+
strings.token_expired_body,
1318+
);
12851319
}
12861320
}
12871321

0 commit comments

Comments
 (0)