diff --git a/Cargo.lock b/Cargo.lock index fc0bbf2..dfcb1b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "claude-code-usage-monitor" -version = "1.2.7" +version = "1.2.8" dependencies = [ "dirs", "native-tls", diff --git a/Cargo.toml b/Cargo.toml index 4030d9e..b80b2fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claude-code-usage-monitor" -version = "1.2.7" +version = "1.2.8" edition = "2021" license = "MIT" description = "Windows taskbar widget for monitoring Claude Code usage and rate limits" diff --git a/README.md b/README.md index 22bc841..4490432 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ What the app stores locally: - Widget position - Polling frequency - Language preference +- Last update check time What it does **not** do: diff --git a/src/native_interop.rs b/src/native_interop.rs index 3f8f25e..8e5c29f 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -17,6 +17,7 @@ pub const WINEVENT_OUTOFCONTEXT: u32 = 0x0000; pub const TIMER_POLL: usize = 1; pub const TIMER_COUNTDOWN: usize = 2; pub const TIMER_RESET_POLL: usize = 3; +pub const TIMER_UPDATE_CHECK: usize = 4; // Custom messages pub const WM_APP: u32 = 0x8000; diff --git a/src/updater.rs b/src/updater.rs index 2234cac..d4872c5 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -84,10 +84,19 @@ pub fn check_for_updates() -> Result { } pub fn begin_winget_update() -> Result<(), String> { - let command = winget_upgrade_command(); + let current_exe = + std::env::current_exe().map_err(|e| format!("Unable to locate current executable: {e}"))?; + let current_dir = current_exe + .parent() + .ok_or_else(|| "Unable to determine the app directory for restart.".to_string())?; + let command = winget_upgrade_command( + std::process::id(), + ¤t_exe.to_string_lossy(), + ¤t_dir.to_string_lossy(), + ); + Command::new("powershell.exe") .arg("-NoLogo") - .arg("-NoExit") .arg("-Command") .arg(&command) .creation_flags(CREATE_NEW_CONSOLE) @@ -335,8 +344,39 @@ fn updates_dir() -> Result { .ok_or_else(|| "Unable to resolve a writable local updates directory.".to_string()) } -fn winget_upgrade_command() -> String { - format!("winget upgrade --id {WINGET_PACKAGE_ID} --exact") +fn winget_upgrade_command(pid: u32, target: &str, working_dir: &str) -> String { + let target = powershell_single_quoted(target); + let working_dir = powershell_single_quoted(working_dir); + let package_id = WINGET_PACKAGE_ID; + + format!( + concat!( + "$ErrorActionPreference = 'Stop'; ", + "$pidToWait = {pid}; ", + "$target = '{target}'; ", + "$workingDir = '{working_dir}'; ", + "try {{ Wait-Process -Id $pidToWait -Timeout 30 -ErrorAction Stop }} catch {{ }}; ", + "winget upgrade --id {package_id} --exact; ", + "$exitCode = $LASTEXITCODE; ", + "if ($exitCode -eq 0) {{ ", + "Start-Sleep -Seconds 2; ", + "Start-Process -FilePath $target -WorkingDirectory $workingDir; ", + "exit 0 ", + "}}; ", + "Write-Host ''; ", + "Write-Host 'WinGet update failed with exit code' $exitCode; ", + "Read-Host 'Press Enter to close'; ", + "exit $exitCode" + ), + pid = pid, + target = target, + working_dir = working_dir, + package_id = package_id, + ) +} + +fn powershell_single_quoted(value: &str) -> String { + value.replace('\'', "''") } fn backup_path_for(target: &Path) -> PathBuf { @@ -424,10 +464,17 @@ fn winget_install_roots() -> Vec { } fn normalize_path(path: &Path) -> String { - path.to_string_lossy() + let normalized = path + .to_string_lossy() .replace('/', "\\") .trim_end_matches('\\') - .to_ascii_lowercase() + .to_ascii_lowercase(); + + normalized + .strip_prefix("\\\\?\\unc\\") + .map(|rest| format!("\\\\{rest}")) + .or_else(|| normalized.strip_prefix("\\\\?\\").map(str::to_owned)) + .unwrap_or(normalized) } fn is_version_newer(candidate: &str, current: &str) -> bool { diff --git a/src/window.rs b/src/window.rs index 5a76e83..7993327 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Mutex, MutexGuard}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use windows::core::PCWSTR; @@ -19,7 +19,8 @@ use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; use crate::models::UsageData; use crate::native_interop::{ - self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, WM_APP_USAGE_UPDATED, + self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, + WM_APP_USAGE_UPDATED, }; use crate::poller; use crate::theme; @@ -63,6 +64,7 @@ struct AppState { retry_count: u32, last_poll_ok: bool, update_status: UpdateStatus, + last_update_check_unix: Option, tray_offset: i32, dragging: bool, @@ -155,6 +157,8 @@ struct SettingsFile { poll_interval_ms: u32, #[serde(default, skip_serializing_if = "Option::is_none")] language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + last_update_check_unix: Option, } impl Default for SettingsFile { @@ -163,6 +167,7 @@ impl Default for SettingsFile { tray_offset: 0, poll_interval_ms: default_poll_interval(), language: None, + last_update_check_unix: None, } } } @@ -198,10 +203,54 @@ fn save_state_settings() { language: s .language_override .map(|language| language.code().to_string()), + last_update_check_unix: s.last_update_check_unix, }); } } +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn update_check_interval() -> Duration { + Duration::from_secs(24 * 60 * 60) +} + +fn auto_update_check_due(last_update_check_unix: Option) -> bool { + let Some(last_update_check_unix) = last_update_check_unix else { + return true; + }; + + now_unix_secs().saturating_sub(last_update_check_unix) >= update_check_interval().as_secs() +} + +fn schedule_auto_update_check(hwnd: HWND) { + let delay_ms = { + let state = lock_state(); + let Some(s) = state.as_ref() else { + return; + }; + + if auto_update_check_due(s.last_update_check_unix) { + None + } else { + let elapsed = now_unix_secs().saturating_sub(s.last_update_check_unix.unwrap_or(0)); + let remaining_secs = update_check_interval().as_secs().saturating_sub(elapsed); + Some((remaining_secs.saturating_mul(1000)).min(u32::MAX as u64) as u32) + } + }; + + unsafe { + let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK); + if let Some(delay_ms) = delay_ms { + SetTimer(hwnd, TIMER_UPDATE_CHECK, delay_ms.max(1), None); + } + } +} + fn refresh_usage_texts(state: &mut AppState) { if !state.last_poll_ok { return; @@ -304,27 +353,30 @@ fn version_action_label( status: &UpdateStatus, ) -> String { let current = env!("CARGO_PKG_VERSION"); - if install_channel == InstallChannel::Winget { - return format!("v{current} - {}", localization::update_via_winget(language)); - } - match status { UpdateStatus::Idle => format!("v{current} - {}", strings.check_for_updates), UpdateStatus::Checking => format!("v{current} - {}", strings.checking_for_updates), UpdateStatus::Applying => format!("v{current} - {}", strings.applying_update), UpdateStatus::UpToDate => format!("v{current} - {}", strings.up_to_date_short), - UpdateStatus::Available(release) => { - format!( + UpdateStatus::Available(release) => match install_channel { + InstallChannel::Portable => { + format!( + "v{current} - {} v{}", + strings.update_to, release.latest_version + ) + } + InstallChannel::Winget => format!( "v{current} - {} v{}", - strings.update_to, release.latest_version - ) - } + localization::update_via_winget(language), + release.latest_version + ), + }, } } -fn begin_update_check(hwnd: HWND) { +fn begin_update_check(hwnd: HWND, interactive: bool) { let send_hwnd = SendHwnd::from_hwnd(hwnd); - let strings = { + let (strings, install_channel) = { let mut state = lock_state(); let Some(app_state) = state.as_mut() else { return; @@ -334,29 +386,36 @@ fn begin_update_check(hwnd: HWND) { app_state.update_status, UpdateStatus::Checking | UpdateStatus::Applying ) { - show_info_message( - hwnd, - app_state.language.strings().updates, - app_state.language.strings().update_in_progress, - ); + if interactive { + show_info_message( + hwnd, + app_state.language.strings().updates, + app_state.language.strings().update_in_progress, + ); + } return; } app_state.update_status = UpdateStatus::Checking; - app_state.language.strings() + (app_state.language.strings(), app_state.install_channel) }; std::thread::spawn(move || { let hwnd = send_hwnd.to_hwnd(); + let checked_at = now_unix_secs(); match updater::check_for_updates() { Ok(UpdateCheckResult::UpToDate) => { { let mut state = lock_state(); if let Some(s) = state.as_mut() { s.update_status = UpdateStatus::UpToDate; + s.last_update_check_unix = Some(checked_at); } } - show_info_message(hwnd, strings.updates, strings.up_to_date); + save_state_settings(); + if interactive { + show_info_message(hwnd, strings.updates, strings.up_to_date); + } unsafe { let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0)); } @@ -366,10 +425,15 @@ fn begin_update_check(hwnd: HWND) { let mut state = lock_state(); if let Some(s) = state.as_mut() { s.update_status = UpdateStatus::Available(release.clone()); + s.last_update_check_unix = Some(checked_at); } } - if show_update_prompt(hwnd, strings, &release) { - begin_update_apply(hwnd, release); + save_state_settings(); + if interactive && show_update_prompt(hwnd, strings, &release) { + match install_channel { + InstallChannel::Portable => begin_update_apply(hwnd, release), + InstallChannel::Winget => begin_winget_update(hwnd), + } } unsafe { let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0)); @@ -380,10 +444,14 @@ fn begin_update_check(hwnd: HWND) { let mut state = lock_state(); if let Some(s) = state.as_mut() { s.update_status = UpdateStatus::Idle; + s.last_update_check_unix = Some(checked_at); } } - let message = format!("{}.\n\n{}", strings.update_failed, error); - show_error_message(hwnd, strings.updates, &message); + save_state_settings(); + if interactive { + let message = format!("{}.\n\n{}", strings.update_failed, error); + show_error_message(hwnd, strings.updates, &message); + } unsafe { let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0)); } @@ -446,9 +514,14 @@ fn begin_winget_update(hwnd: HWND) { } .unwrap_or(LanguageId::English.strings()); - if let Err(error) = updater::begin_winget_update() { - let message = format!("{}.\n\n{}", strings.update_failed, error); - show_error_message(hwnd, strings.updates, &message); + match updater::begin_winget_update() { + Ok(()) => unsafe { + let _ = PostMessageW(hwnd, WM_CLOSE, WPARAM(0), LPARAM(0)); + }, + Err(error) => { + let message = format!("{}.\n\n{}", strings.update_failed, error); + show_error_message(hwnd, strings.updates, &message); + } } } @@ -690,6 +763,7 @@ pub fn run() { retry_count: 0, last_poll_ok: false, update_status: UpdateStatus::Idle, + last_update_check_unix: settings.last_update_check_unix, tray_offset: settings.tray_offset, dragging: false, drag_start_mouse_x: 0, @@ -769,6 +843,18 @@ pub fn run() { do_poll(send_hwnd); }); + schedule_auto_update_check(hwnd); + let should_check_updates = { + let state = lock_state(); + state + .as_ref() + .map(|s| auto_update_check_due(s.last_update_check_unix)) + .unwrap_or(false) + }; + if should_check_updates { + begin_update_check(hwnd, false); + } + // Initial theme check check_theme_change(); @@ -1356,6 +1442,9 @@ unsafe extern "system" fn wnd_proc( do_poll(sh); }); } + TIMER_UPDATE_CHECK => { + begin_update_check(hwnd, false); + } _ => {} } LRESULT(0) @@ -1367,7 +1456,10 @@ unsafe extern "system" fn wnd_proc( schedule_countdown_timer(); LRESULT(0) } - WM_APP_UPDATE_CHECK_COMPLETE => LRESULT(0), + WM_APP_UPDATE_CHECK_COMPLETE => { + schedule_auto_update_check(hwnd); + LRESULT(0) + } WM_SETCURSOR => { let is_dragging = { let state = lock_state(); @@ -1558,12 +1650,18 @@ unsafe extern "system" fn wnd_proc( }; match install_channel { - InstallChannel::Winget => begin_winget_update(hwnd), + InstallChannel::Winget => { + if release.is_some() { + begin_winget_update(hwnd); + } else { + begin_update_check(hwnd, true); + } + } InstallChannel::Portable => { if let Some(release) = release { begin_update_apply(hwnd, release); } else { - begin_update_check(hwnd); + begin_update_check(hwnd, true); } } } @@ -1793,11 +1891,8 @@ fn show_context_menu(hwnd: HWND) { let version_label = version_action_label(strings, language, install_channel, &update_status); let version_str = native_interop::wide_str(&version_label); - let version_flags = if install_channel == InstallChannel::Portable - && matches!( - update_status, - UpdateStatus::Checking | UpdateStatus::Applying - ) { + let version_flags = if matches!(update_status, UpdateStatus::Checking | UpdateStatus::Applying) + { MF_GRAYED } else { MENU_ITEM_FLAGS(0)