diff --git a/Cargo.lock b/Cargo.lock index 030fe1e..d89af12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "claude-code-usage-monitor" -version = "1.3.1" +version = "1.3.4" dependencies = [ "dirs", "native-tls", diff --git a/Cargo.toml b/Cargo.toml index 0648989..a452c78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claude-code-usage-monitor" -version = "1.3.1" +version = "1.3.4" edition = "2021" license = "MIT" description = "Claude Code Usage Monitor" diff --git a/src/localization/english.rs b/src/localization/english.rs index f8e72d3..f14b0da 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "d", hour_suffix: "h", minute_suffix: "m", + token_expired_title: "Claude Code Auth Error", + token_expired_body: "Run 'claude' in a terminal, then use '/login' and follow the prompts. After that, refresh or restart this app.", second_suffix: "s", quiet_hours: "Quiet Hours", quiet_start: "Start", diff --git a/src/localization/french.rs b/src/localization/french.rs index c72a4b0..dfc1516 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "j", hour_suffix: "h", minute_suffix: "m", + token_expired_title: "Erreur d'authentification", + token_expired_body: "Exécutez 'claude' dans un terminal, puis utilisez '/login' et suivez les instructions. Ensuite, actualisez ou redémarrez cette application.", second_suffix: "s", quiet_hours: "Quiet Hours", quiet_start: "Start", diff --git a/src/localization/german.rs b/src/localization/german.rs index a78e796..07f13c7 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "T", hour_suffix: "h", minute_suffix: "m", + token_expired_title: "Authentifizierungsfehler", + 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.", second_suffix: "s", quiet_hours: "Quiet Hours", quiet_start: "Start", diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index e185144..54850f0 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "日", hour_suffix: "時間", minute_suffix: "分", + token_expired_title: "認証エラー", + token_expired_body: "ターミナルで 'claude' を実行し、'/login' を使って案内に従ってください。その後、このアプリを更新するか再起動してください。", second_suffix: "秒", quiet_hours: "サイレント時間", quiet_start: "開始", diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 2291574..cded6c1 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "일", hour_suffix: "시간", minute_suffix: "분", + token_expired_title: "인증 오류", + token_expired_body: "터미널에서 'claude'를 실행한 다음 '/login'을 사용하고 안내에 따라 진행하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", second_suffix: "초", quiet_hours: "조용한 시간", quiet_start: "시작", diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 031f53e..933bd3d 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -147,6 +147,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 quiet_hours: &'static str, pub quiet_start: &'static str, pub quiet_end: &'static str, diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index d802661..fbb82cc 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "d", hour_suffix: "h", minute_suffix: "m", + token_expired_title: "Error de autenticación", + token_expired_body: "Ejecuta 'claude' en una terminal, luego usa '/login' y sigue las indicaciones. Después, actualiza o reinicia esta aplicación.", second_suffix: "s", quiet_hours: "Quiet Hours", quiet_start: "Start", diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index f078a70..eafe34e 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -34,6 +34,8 @@ pub(super) const STRINGS: Strings = Strings { day_suffix: "天", hour_suffix: "時", minute_suffix: "分", + token_expired_title: "驗證錯誤", + token_expired_body: "請在終端機中執行 'claude',然後使用 '/login' 並依照提示操作。完成後,請重新整理或重新啟動此應用程式。", second_suffix: "秒", quiet_hours: "安靜時刻", quiet_start: "開始時間", diff --git a/src/poller.rs b/src/poller.rs index 938075c..5bd4bfd 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -17,11 +17,20 @@ const MODEL_FALLBACK_CHAIN: &[&str] = &["claude-3-haiku-20240307", "claude-haiku #[derive(Debug)] pub enum PollError { + AuthRequired, NoCredentials, TokenExpired, RequestFailed, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CredentialWatchMode { + ActiveSource, + AllSources, +} + +pub type CredentialWatchSnapshot = Vec; + #[derive(Deserialize)] struct UsageResponse { five_hour: Option, @@ -234,23 +243,112 @@ fn build_agent() -> Result { .build()) } +pub fn credential_watch_snapshot(mode: CredentialWatchMode) -> CredentialWatchSnapshot { + let sources = match mode { + CredentialWatchMode::ActiveSource => read_credentials() + .map(|creds| vec![creds.source]) + .unwrap_or_else(all_known_credential_sources), + CredentialWatchMode::AllSources => all_known_credential_sources(), + }; + + let mut snapshot: CredentialWatchSnapshot = sources + .into_iter() + .filter_map(|source| credential_watch_signature(&source)) + .collect(); + snapshot.sort(); + snapshot.dedup(); + snapshot +} + +fn all_known_credential_sources() -> Vec { + let mut sources = Vec::new(); + if let Some(source) = windows_credential_source() { + sources.push(source); + } + for distro in list_wsl_distros() { + sources.push(CredentialSource::Wsl { distro }); + } + sources +} + +fn windows_credential_source() -> Option { + let home = dirs::home_dir()?; + Some(CredentialSource::Windows( + home.join(".claude").join(".credentials.json"), + )) +} + +fn credential_watch_signature(source: &CredentialSource) -> Option { + match source { + CredentialSource::Windows(path) => Some(windows_credential_watch_signature(path)), + CredentialSource::Wsl { distro } => wsl_credential_watch_signature(distro), + } +} + +fn windows_credential_watch_signature(path: &PathBuf) -> String { + let key = format!("win:{}", path.display()); + match std::fs::metadata(path) { + Ok(metadata) => { + let modified = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_secs()) + .unwrap_or(0); + format!("{key}|present|{}|{modified}", metadata.len()) + } + Err(_) => format!("{key}|missing"), + } +} + +fn wsl_credential_watch_signature(distro: &str) -> Option { + let output = run_with_timeout( + Command::new("wsl.exe") + .arg("-d") + .arg(distro) + .arg("--") + .arg("sh") + .arg("-lc") + .arg( + "if [ -f ~/.claude/.credentials.json ]; then \ + stat -c 'present|%s|%Y' ~/.claude/.credentials.json; \ + else echo missing; fi", + ) + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()), + Duration::from_secs(5), + )?; + + let state = if output.status.success() { + decode_wsl_text(&output.stdout).trim().to_string() + } else { + format!("status-{}", output.status) + }; + + Some(format!("wsl:{distro}|{state}")) +} + fn fetch_usage_with_fallback(token: &str) -> Result { // Try the dedicated usage endpoint first - if let Some(data) = try_usage_endpoint(token) { + match try_usage_endpoint(token)? { + Some(data) => { // If reset timers are missing, fill them in from the Messages API - if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() { - if let Ok(fallback) = fetch_usage_via_messages(token) { - let mut merged = data; - if merged.session.resets_at.is_none() { - merged.session.resets_at = fallback.session.resets_at; + if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() { + if let Ok(fallback) = fetch_usage_via_messages(token) { + let mut merged = data; + if merged.session.resets_at.is_none() { + merged.session.resets_at = fallback.session.resets_at; + } + if merged.weekly.resets_at.is_none() { + merged.weekly.resets_at = fallback.weekly.resets_at; + } + return Ok(merged); } - if merged.weekly.resets_at.is_none() { - merged.weekly.resets_at = fallback.weekly.resets_at; - } - return Ok(merged); } + return Ok(data); } - return Ok(data); + None => {} } // Fall back to Messages API with rate limit headers @@ -261,8 +359,8 @@ fn fetch_usage_with_fallback(token: &str) -> Result { result } -fn try_usage_endpoint(token: &str) -> Option { - let agent = build_agent().ok()?; +fn try_usage_endpoint(token: &str) -> Result, PollError> { + let agent = build_agent()?; let resp = match agent .get(USAGE_URL) @@ -271,10 +369,19 @@ fn try_usage_endpoint(token: &str) -> Option { .call() { Ok(resp) => resp, - _ => return None, + Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { + diagnose::log(format!( + "usage endpoint returned auth error status {code}; re-login required" + )); + return Err(PollError::AuthRequired); + } + Err(_) => return Ok(None), }; - let response: UsageResponse = resp.into_json().ok()?; + let response: UsageResponse = match resp.into_json() { + Ok(response) => response, + Err(_) => return Ok(None), + }; let mut data = UsageData::default(); if let Some(bucket) = &response.five_hour { @@ -287,7 +394,7 @@ fn try_usage_endpoint(token: &str) -> Option { data.weekly.resets_at = parse_iso8601(bucket.resets_at.as_deref()); } - Some(data) + Ok(Some(data)) } fn fetch_usage_via_messages(token: &str) -> Result { @@ -308,6 +415,12 @@ fn fetch_usage_via_messages(token: &str) -> Result { .send_json(&body) { Ok(resp) => resp, + Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { + diagnose::log(format!( + "messages endpoint returned auth error status {code}; re-login required" + )); + return Err(PollError::AuthRequired); + } Err(ureq::Error::Status(_code, resp)) => resp, Err(_) => continue, }; @@ -410,8 +523,9 @@ fn read_credentials() -> Option { } fn read_windows_credentials() -> Option { - let home = dirs::home_dir()?; - let cred_path = home.join(".claude").join(".credentials.json"); + let CredentialSource::Windows(cred_path) = windows_credential_source()? else { + return None; + }; let content = match std::fs::read_to_string(&cred_path) { Ok(content) => content, Err(error) => { 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 f5fd002..9504318 100644 --- a/src/window.rs +++ b/src/window.rs @@ -65,6 +65,10 @@ struct AppState { poll_interval_ms: u32, retry_count: u32, + force_notify_auth_error: bool, + auth_error_paused_polling: bool, + auth_watch_mode: poller::CredentialWatchMode, + auth_watch_snapshot: poller::CredentialWatchSnapshot, last_poll_ok: bool, update_status: UpdateStatus, last_update_check_unix: Option, @@ -1348,6 +1352,10 @@ pub fn run() { data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, + force_notify_auth_error: false, + auth_error_paused_polling: false, + auth_watch_mode: poller::CredentialWatchMode::ActiveSource, + auth_watch_snapshot: Vec::new(), last_poll_ok: false, update_status: UpdateStatus::Idle, last_update_check_unix: settings.last_update_check_unix, @@ -1766,30 +1774,87 @@ fn do_poll(send_hwnd: SendHwnd) { SetTimer(hwnd, TIMER_POLL, interval, None); } } + s.force_notify_auth_error = false; + s.auth_error_paused_polling = false; + s.auth_watch_mode = poller::CredentialWatchMode::ActiveSource; + s.auth_watch_snapshot.clear(); } unsafe { 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; + Err(e) => { + let auth_watch = match e { + poller::PollError::AuthRequired | poller::PollError::TokenExpired => Some(( + poller::CredentialWatchMode::ActiveSource, + poller::credential_watch_snapshot(poller::CredentialWatchMode::ActiveSource), + )), + poller::PollError::NoCredentials => Some(( + poller::CredentialWatchMode::AllSources, + poller::credential_watch_snapshot(poller::CredentialWatchMode::AllSources), + )), + poller::PollError::RequestFailed => None, + }; + // Distinguish auth-required errors from transient errors. + let notify_auth_error = { + let mut state = lock_state(); + let mut should_notify = false; + if let Some(s) = state.as_mut() { + s.last_poll_ok = false; + match auth_watch { + Some((watch_mode, watch_snapshot)) => { + // Only show the balloon on the first failure so it doesn't spam. + if s.retry_count == 0 || s.force_notify_auth_error { + should_notify = true; + } + s.force_notify_auth_error = false; + s.auth_error_paused_polling = true; + s.auth_watch_mode = watch_mode; + s.auth_watch_snapshot = watch_snapshot; + s.session_text = "⚠".to_string(); + s.weekly_text = "⚠".to_string(); + s.retry_count = s.retry_count.saturating_add(1); + unsafe { + let _ = KillTimer(hwnd, TIMER_POLL); + let _ = KillTimer(hwnd, TIMER_RESET_POLL); + let _ = KillTimer(hwnd, TIMER_COUNTDOWN); + SetTimer(hwnd, TIMER_POLL, s.poll_interval_ms, None); + } + } + _ => { + // Transient network / credential-missing errors: exponential backoff. + s.force_notify_auth_error = false; + s.auth_error_paused_polling = false; + s.auth_watch_mode = poller::CredentialWatchMode::ActiveSource; + s.auth_watch_snapshot.clear(); + 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 + }; - // 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); - - 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_auth_error { + 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, + ); } } @@ -1807,13 +1872,20 @@ fn schedule_countdown_timer() { None => return, }; + let hwnd = s.hwnd.to_hwnd(); + if !s.last_poll_ok { + unsafe { + let _ = KillTimer(hwnd, TIMER_COUNTDOWN); + let _ = KillTimer(hwnd, TIMER_RESET_POLL); + } + return; + } + let data = match &s.data { Some(d) => d, None => return, }; - let hwnd = s.hwnd.to_hwnd(); - // If a reset time has passed, poll every 5s to pick up fresh data if poller::is_past_reset(data) { unsafe { @@ -1884,20 +1956,20 @@ fn update_display() { fn position_at_taskbar() { refresh_dpi(); - - // Extract everything we need from state, then DROP the lock before making - // any Win32 calls. MoveWindow dispatches WM_PAINT synchronously and our - // wnd_proc also calls lock_state() — Rust's Mutex is not reentrant, - // so holding it across Win32 calls causes a deadlock. + // Drop the app-state lock before any Win32 call that may synchronously + // re-enter our window procedure. let (hwnd, embedded, tray_offset, taskbar_hwnd) = { let state = lock_state(); let s = match state.as_ref() { Some(s) => s, None => return, }; + + // Don't fight the user's drag if s.dragging { return; } + let taskbar_hwnd = match s.taskbar_hwnd { Some(h) => h, None => { @@ -1905,6 +1977,7 @@ fn position_at_taskbar() { return; } }; + (s.hwnd.to_hwnd(), s.embedded, s.tray_offset, taskbar_hwnd) }; @@ -2043,10 +2116,44 @@ unsafe extern "system" fn wnd_proc( match timer_id { TIMER_POLL => { if !is_quiet_time() { - let sh = SendHwnd::from_hwnd(hwnd); - std::thread::spawn(move || { - do_poll(sh); - }); + let auth_watch = { + let state = lock_state(); + state + .as_ref() + .map(|s| { + ( + s.auth_error_paused_polling, + s.auth_watch_mode, + s.auth_watch_snapshot.clone(), + ) + }) + }; + match auth_watch { + Some((true, watch_mode, previous_snapshot)) => { + let current_snapshot = poller::credential_watch_snapshot(watch_mode); + if current_snapshot != previous_snapshot { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + if s.auth_error_paused_polling && s.auth_watch_mode == watch_mode + { + s.auth_watch_snapshot = current_snapshot; + } + } + drop(state); + let sh = SendHwnd::from_hwnd(hwnd); + std::thread::spawn(move || { + do_poll(sh); + }); + } + } + Some((false, _, _)) => { + let sh = SendHwnd::from_hwnd(hwnd); + std::thread::spawn(move || { + do_poll(sh); + }); + } + None => {} + } } } TIMER_COUNTDOWN => { @@ -2055,7 +2162,14 @@ unsafe extern "system" fn wnd_proc( schedule_countdown_timer(); } TIMER_RESET_POLL => { - if !is_quiet_time() { + let should_poll = { + let state = lock_state(); + state + .as_ref() + .map(|s| !s.auth_error_paused_polling) + .unwrap_or(false) + }; + if should_poll && !is_quiet_time() { let sh = SendHwnd::from_hwnd(hwnd); std::thread::spawn(move || { do_poll(sh); @@ -2063,7 +2177,6 @@ unsafe extern "system" fn wnd_proc( } } TIMER_QUIET_BOUNDARY => { - // 安靜時刻邊界到了,立即重繪並排程下一個邊界 render_layered(); schedule_quiet_boundary_timer(hwnd); } @@ -2135,86 +2248,88 @@ unsafe extern "system" fn wnd_proc( if is_dragging { let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); + let move_target = { + let mut state = lock_state(); + let s = match state.as_mut() { + Some(s) => s, + None => return LRESULT(0), + }; - let mut state = lock_state(); - let s = match state.as_mut() { - Some(s) => s, - None => return LRESULT(0), - }; + // Moving mouse left = positive delta = larger offset (further left) + let delta = s.drag_start_mouse_x - pt.x; + let mut new_offset = s.drag_start_offset + delta; - // Moving mouse left = positive delta = larger offset (further left) - let delta = s.drag_start_mouse_x - pt.x; - let mut new_offset = s.drag_start_offset + delta; - - // Clamp: offset >= 0 (can't go right of default) - if new_offset < 0 { - new_offset = 0; - } - - // Clamp: don't go past left edge of taskbar - if let Some(taskbar_hwnd) = s.taskbar_hwnd { - if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { - let mut tray_left = taskbar_rect.right; - if let Some(tray_hwnd) = - native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd") - { - if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd) - { - tray_left = tray_rect.left; - } - } - let widget_width = total_widget_width(); - let max_offset = if s.embedded { - tray_left - taskbar_rect.left - widget_width - } else { - tray_left - taskbar_rect.left - widget_width - }; - if new_offset > max_offset { - new_offset = max_offset; - } + // Clamp: offset >= 0 (can't go right of default) + if new_offset < 0 { + new_offset = 0; } - } - s.tray_offset = new_offset; + let taskbar_hwnd = s.taskbar_hwnd; + let embedded = s.embedded; + let hwnd_val = s.hwnd.to_hwnd(); - // Move window directly - let hwnd_val = s.hwnd.to_hwnd(); - if let Some(taskbar_hwnd) = s.taskbar_hwnd { - if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { - let taskbar_height = taskbar_rect.bottom - taskbar_rect.top; - let mut tray_left = taskbar_rect.right; - let anchor_top = taskbar_rect.top; - let anchor_height = taskbar_height; - if let Some(tray_hwnd) = - native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd") - { - if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd) + // Clamp: don't go past left edge of taskbar + if let Some(taskbar_hwnd) = taskbar_hwnd { + if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { + let mut tray_left = taskbar_rect.right; + if let Some(tray_hwnd) = + native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd") { - tray_left = tray_rect.left; + if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd) + { + tray_left = tray_rect.left; + } } - } - let widget_width = total_widget_width(); - let widget_height = sc(WIDGET_HEIGHT); - let y = compute_anchor_y(anchor_top, anchor_height, widget_height); - if s.embedded { - let x = tray_left - taskbar_rect.left - widget_width - new_offset; - native_interop::move_window( - hwnd_val, - x, - y - taskbar_rect.top, - widget_width, - widget_height, - ); - } else { - let x = tray_left - widget_width - new_offset; - native_interop::move_window( + let widget_width = total_widget_width(); + let max_offset = tray_left - taskbar_rect.left - widget_width; + if new_offset > max_offset { + new_offset = max_offset; + } + + s.tray_offset = new_offset; + + let taskbar_height = taskbar_rect.bottom - taskbar_rect.top; + let anchor_top = taskbar_rect.top; + let anchor_height = taskbar_height; + let widget_height = sc(WIDGET_HEIGHT); + let y = compute_anchor_y(anchor_top, anchor_height, widget_height); + let x = if embedded { + tray_left - taskbar_rect.left - widget_width - new_offset + } else { + tray_left - widget_width - new_offset + }; + Some(( hwnd_val, + embedded, x, y, + taskbar_rect.top, widget_width, widget_height, - ); + )) + } else { + s.tray_offset = new_offset; + None } + } else { + s.tray_offset = new_offset; + None + } + }; + + if let Some((hwnd_val, embedded, x, y, taskbar_top, widget_width, widget_height)) = + move_target + { + if embedded { + native_interop::move_window( + hwnd_val, + x, + y - taskbar_top, + widget_width, + widget_height, + ); + } else { + native_interop::move_window(hwnd_val, x, y, widget_width, widget_height); } } } @@ -2254,6 +2369,7 @@ unsafe extern "system" fn wnd_proc( if let Some(s) = state.as_mut() { s.session_text = "...".to_string(); s.weekly_text = "...".to_string(); + s.force_notify_auth_error = true; } } render_layered();