diff --git a/Cargo.lock b/Cargo.lock index b5bfb4c..e1e02ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "claude-code-usage-monitor" -version = "1.1.0" +version = "1.2.0" dependencies = [ "dirs", "native-tls", diff --git a/Cargo.toml b/Cargo.toml index 5953c52..9d8f4d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claude-code-usage-monitor" -version = "1.1.0" +version = "1.2.0" edition = "2021" license = "MIT" description = "Windows taskbar widget for monitoring Claude Code usage and rate limits" diff --git a/src/localization.rs b/src/localization.rs index 2dff8ab..c8ce10b 100644 --- a/src/localization.rs +++ b/src/localization.rs @@ -83,6 +83,15 @@ pub struct Strings { pub reset_position: &'static str, pub language: &'static str, pub system_default: &'static str, + pub check_for_updates: &'static str, + pub checking_for_updates: &'static str, + pub updates: &'static str, + pub update_in_progress: &'static str, + pub up_to_date: &'static str, + pub up_to_date_short: &'static str, + pub update_failed: &'static str, + pub applying_update: &'static str, + pub update_to: &'static str, pub exit: &'static str, pub session_window: &'static str, pub weekly_window: &'static str, @@ -106,6 +115,15 @@ const ENGLISH: Strings = Strings { reset_position: "Reset Position", language: "Language", system_default: "System Default", + check_for_updates: "Check for Updates", + checking_for_updates: "Checking for Updates...", + updates: "Updates", + update_in_progress: "An update check is already in progress.", + up_to_date: "You already have the latest version.", + up_to_date_short: "Up to date", + update_failed: "Unable to update automatically", + applying_update: "Applying update...", + update_to: "Update to", exit: "Exit", session_window: "5h", weekly_window: "7d", @@ -129,6 +147,15 @@ const SPANISH: Strings = Strings { reset_position: "Restablecer posicion", language: "Idioma", system_default: "Predeterminado del sistema", + check_for_updates: "Buscar actualizaciones", + checking_for_updates: "Buscando actualizaciones...", + updates: "Actualizaciones", + update_in_progress: "Ya hay una comprobacion de actualizacion en curso.", + up_to_date: "Ya tienes la version mas reciente.", + up_to_date_short: "Actualizado", + update_failed: "No se pudo actualizar automaticamente", + applying_update: "Aplicando actualizacion...", + update_to: "Actualizar a", exit: "Salir", session_window: "5h", weekly_window: "7d", @@ -152,6 +179,15 @@ const FRENCH: Strings = Strings { reset_position: "Reinitialiser la position", language: "Langue", system_default: "Par defaut du systeme", + check_for_updates: "Verifier les mises a jour", + checking_for_updates: "Verification des mises a jour...", + updates: "Mises a jour", + update_in_progress: "Une verification de mise a jour est deja en cours.", + up_to_date: "Vous utilisez deja la version la plus recente.", + up_to_date_short: "A jour", + update_failed: "Impossible d'effectuer la mise a jour automatiquement", + applying_update: "Application de la mise a jour...", + update_to: "Mettre a jour vers", exit: "Quitter", session_window: "5h", weekly_window: "7d", @@ -175,6 +211,15 @@ const GERMAN: Strings = Strings { reset_position: "Position zurucksetzen", language: "Sprache", system_default: "Systemstandard", + check_for_updates: "Nach Updates suchen", + checking_for_updates: "Suche nach Updates...", + updates: "Updates", + update_in_progress: "Eine Update-Prufung lauft bereits.", + up_to_date: "Sie verwenden bereits die neueste Version.", + up_to_date_short: "Aktuell", + update_failed: "Automatisches Update war nicht moglich", + applying_update: "Update wird installiert...", + update_to: "Aktualisieren auf", exit: "Beenden", session_window: "5h", weekly_window: "7d", @@ -198,6 +243,15 @@ const JAPANESE: Strings = Strings { reset_position: "位置をリセット", language: "言語", system_default: "システム既定", + check_for_updates: "更新を確認", + checking_for_updates: "更新を確認しています...", + updates: "更新", + update_in_progress: "更新確認は既に実行中です。", + up_to_date: "既に最新バージョンです。", + up_to_date_short: "最新です", + update_failed: "自動更新を完了できませんでした", + applying_update: "更新を適用しています...", + update_to: "更新先", exit: "終了", session_window: "5h", weekly_window: "7d", diff --git a/src/main.rs b/src/main.rs index 5fef428..f5729ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,8 +5,13 @@ mod models; mod native_interop; mod poller; mod theme; +mod updater; mod window; fn main() { + let args: Vec = std::env::args().collect(); + if let Some(exit_code) = updater::handle_cli_mode(&args) { + std::process::exit(exit_code); + } window::run(); } diff --git a/src/updater.rs b/src/updater.rs new file mode 100644 index 0000000..9686682 --- /dev/null +++ b/src/updater.rs @@ -0,0 +1,383 @@ +use std::fs::File; +use std::io::{self, Write}; +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use serde::Deserialize; +use windows::core::PCWSTR; +use windows::Win32::Foundation::{HWND, WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE}; +use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK}; + +const GITHUB_API_ACCEPT: &str = "application/vnd.github+json"; +const GITHUB_API_VERSION: &str = "2022-11-28"; +const RELEASE_ASSET_NAME: &str = "claude-code-usage-monitor.exe"; +const HELPER_EXE_NAME: &str = "updater-helper.exe"; +const DOWNLOAD_EXE_NAME: &str = "update-download.exe"; +const CREATE_NO_WINDOW: u32 = 0x08000000; + +#[derive(Clone, Debug)] +pub struct ReleaseDescriptor { + pub latest_version: String, + asset_url: String, +} + +#[derive(Debug)] +pub enum UpdateCheckResult { + UpToDate, + Available(ReleaseDescriptor), +} + +#[derive(Deserialize)] +struct GitHubRelease { + tag_name: String, + assets: Vec, +} + +#[derive(Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +pub fn handle_cli_mode(args: &[String]) -> Option { + if args.len() == 5 && args[1] == "--apply-update" { + let target = PathBuf::from(&args[2]); + let source = PathBuf::from(&args[3]); + let pid = args[4].parse::().unwrap_or(0); + + return Some(match apply_update(target, source, pid) { + Ok(()) => 0, + Err(error) => { + show_error_message("Update failed", &error); + 1 + } + }); + } + + None +} + +pub fn check_for_updates() -> Result { + match fetch_latest_release()? { + Some(release) => Ok(UpdateCheckResult::Available(release)), + None => Ok(UpdateCheckResult::UpToDate), + } +} + +pub fn begin_self_update(release: &ReleaseDescriptor) -> Result<(), String> { + let current_exe = + std::env::current_exe().map_err(|e| format!("Unable to locate current executable: {e}"))?; + ensure_target_location_writable(¤t_exe)?; + + let stage_dir = updates_dir()?; + std::fs::create_dir_all(&stage_dir) + .map_err(|e| format!("Unable to create updater working directory: {e}"))?; + + let helper_path = stage_dir.join(HELPER_EXE_NAME); + let download_path = stage_dir.join(DOWNLOAD_EXE_NAME); + let partial_download_path = stage_dir.join(format!("{DOWNLOAD_EXE_NAME}.part")); + + if helper_path.exists() { + let _ = std::fs::remove_file(&helper_path); + } + if download_path.exists() { + let _ = std::fs::remove_file(&download_path); + } + if partial_download_path.exists() { + let _ = std::fs::remove_file(&partial_download_path); + } + + download_release_asset(&release.asset_url, &partial_download_path, &download_path)?; + std::fs::copy(¤t_exe, &helper_path) + .map_err(|e| format!("Unable to prepare updater helper: {e}"))?; + + let pid = std::process::id().to_string(); + let target = current_exe.to_string_lossy().to_string(); + let source = download_path.to_string_lossy().to_string(); + + Command::new(&helper_path) + .arg("--apply-update") + .arg(target) + .arg(source) + .arg(pid) + .creation_flags(CREATE_NO_WINDOW) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("Unable to launch updater helper: {e}"))?; + + Ok(()) +} + +fn apply_update(target: PathBuf, source: PathBuf, pid: u32) -> Result<(), String> { + if !source.exists() { + return Err(format!( + "Downloaded update not found at {}", + source.display() + )); + } + + let _ = wait_for_process_exit(pid, Duration::from_secs(30)); + replace_target_binary(&target, &source)?; + relaunch_target(&target)?; + let _ = std::fs::remove_file(&source); + + Ok(()) +} + +fn fetch_latest_release() -> Result, String> { + let (owner, repo) = github_repo()?; + let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/latest"); + let agent = build_agent()?; + + let response = agent + .get(&url) + .set("Accept", GITHUB_API_ACCEPT) + .set("User-Agent", user_agent()) + .set("X-GitHub-Api-Version", GITHUB_API_VERSION) + .call() + .map_err(|e| format!("Unable to check GitHub releases: {e}"))?; + + let release: GitHubRelease = response + .into_json() + .map_err(|e| format!("Unable to parse GitHub release data: {e}"))?; + + let latest_version = release.tag_name.trim_start_matches('v').to_string(); + if !is_version_newer(&latest_version, env!("CARGO_PKG_VERSION")) { + return Ok(None); + } + + let asset = release + .assets + .iter() + .find(|asset| asset.name.eq_ignore_ascii_case(RELEASE_ASSET_NAME)) + .or_else(|| { + release + .assets + .iter() + .find(|asset| asset.name.to_ascii_lowercase().ends_with(".exe")) + }) + .ok_or_else(|| { + "No Windows executable asset was found in the latest release.".to_string() + })?; + + Ok(Some(ReleaseDescriptor { + latest_version, + asset_url: asset.browser_download_url.clone(), + })) +} + +fn build_agent() -> Result { + let tls = native_tls::TlsConnector::new() + .map_err(|e| format!("Unable to initialize TLS support for update checks: {e}"))?; + Ok(ureq::AgentBuilder::new() + .timeout(Duration::from_secs(30)) + .tls_connector(std::sync::Arc::new(tls)) + .build()) +} + +fn download_release_asset(url: &str, partial_path: &Path, final_path: &Path) -> Result<(), String> { + let agent = build_agent()?; + let response = agent + .get(url) + .set("User-Agent", user_agent()) + .call() + .map_err(|e| format!("Unable to download the latest release: {e}"))?; + + let mut reader = response.into_reader(); + let mut file = File::create(partial_path) + .map_err(|e| format!("Unable to create temporary download file: {e}"))?; + + io::copy(&mut reader, &mut file) + .map_err(|e| format!("Unable to write the downloaded update: {e}"))?; + file.flush() + .map_err(|e| format!("Unable to finalize the downloaded update: {e}"))?; + + std::fs::rename(partial_path, final_path) + .map_err(|e| format!("Unable to finalize the downloaded update file: {e}"))?; + + Ok(()) +} + +fn replace_target_binary(target: &Path, source: &Path) -> Result<(), String> { + let backup_path = backup_path_for(target); + let mut last_error = None; + + for _ in 0..60 { + let _ = std::fs::remove_file(&backup_path); + + let renamed_existing = match std::fs::rename(target, &backup_path) { + Ok(()) => true, + Err(error) if error.kind() == io::ErrorKind::NotFound => false, + Err(error) => { + last_error = Some(error); + std::thread::sleep(Duration::from_millis(500)); + continue; + } + }; + + match std::fs::copy(source, target) { + Ok(_) => { + let _ = std::fs::remove_file(&backup_path); + return Ok(()); + } + Err(error) => { + last_error = Some(error); + let _ = std::fs::remove_file(target); + if renamed_existing { + let _ = std::fs::rename(&backup_path, target); + } + } + } + + std::thread::sleep(Duration::from_millis(500)); + } + + Err(format!( + "Unable to replace {}. {}", + target.display(), + last_error + .map(|error| error.to_string()) + .unwrap_or_else(|| { + "The file may still be locked or the install directory may not be writable." + .to_string() + }) + )) +} + +fn relaunch_target(target: &Path) -> Result<(), String> { + let mut command = Command::new(target); + if let Some(parent) = target.parent() { + command.current_dir(parent); + } + + command + .creation_flags(CREATE_NO_WINDOW) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| { + format!( + "The update was installed, but the app could not be restarted automatically: {e}" + ) + })?; + + Ok(()) +} + +fn wait_for_process_exit(pid: u32, timeout: Duration) -> Result<(), String> { + if pid == 0 { + return Ok(()); + } + + unsafe { + let handle = OpenProcess(PROCESS_SYNCHRONIZE, false, pid) + .map_err(|e| format!("Unable to monitor the running app process: {e}"))?; + + let result = WaitForSingleObject(handle, timeout.as_millis().min(u32::MAX as u128) as u32); + let _ = windows::Win32::Foundation::CloseHandle(handle); + + if result == WAIT_OBJECT_0 { + Ok(()) + } else if result == WAIT_TIMEOUT { + Err("Timed out waiting for the running app to exit.".to_string()) + } else { + Err("Unable to confirm that the running app has exited.".to_string()) + } + } +} + +fn updates_dir() -> Result { + dirs::data_local_dir() + .map(|dir| dir.join("ClaudeCodeUsageMonitor").join("updates")) + .or_else(|| { + Some( + std::env::temp_dir() + .join("ClaudeCodeUsageMonitor") + .join("updates"), + ) + }) + .ok_or_else(|| "Unable to resolve a writable local updates directory.".to_string()) +} + +fn backup_path_for(target: &Path) -> PathBuf { + let file_name = target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("app.exe"); + target.with_file_name(format!("{file_name}.old")) +} + +fn ensure_target_location_writable(target: &Path) -> Result<(), String> { + let parent = target.parent().ok_or_else(|| { + "Unable to determine the install directory for the current executable.".to_string() + })?; + + let probe_path = parent.join(".__ccum_update_probe"); + match File::create(&probe_path) { + Ok(_) => { + let _ = std::fs::remove_file(&probe_path); + Ok(()) + } + Err(error) => Err(format!( + "The current install location is not writable. Move the app to a user-writable folder or install it somewhere outside Program Files. {error}" + )), + } +} + +fn github_repo() -> Result<(&'static str, &'static str), String> { + let repository = env!("CARGO_PKG_REPOSITORY").trim_end_matches('/'); + let parts: Vec<&str> = repository.split('/').collect(); + if parts.len() < 2 { + return Err("Package repository URL is not configured for GitHub releases.".to_string()); + } + + let owner = parts[parts.len() - 2]; + let repo = parts[parts.len() - 1]; + if owner.is_empty() || repo.is_empty() { + return Err("Package repository URL is not configured for GitHub releases.".to_string()); + } + + Ok((owner, repo)) +} + +fn user_agent() -> &'static str { + concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) +} + +fn is_version_newer(candidate: &str, current: &str) -> bool { + parse_version(candidate) > parse_version(current) +} + +fn parse_version(version: &str) -> (u32, u32, u32) { + let core = version.split('-').next().unwrap_or(version); + let mut parts = core.split('.').map(|part| part.parse::().unwrap_or(0)); + + ( + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + ) +} + +fn show_error_message(title: &str, message: &str) { + unsafe { + let title_wide = wide_str(title); + let message_wide = wide_str(message); + let _ = MessageBoxW( + HWND::default(), + PCWSTR::from_raw(message_wide.as_ptr()), + PCWSTR::from_raw(title_wide.as_ptr()), + MB_OK | MB_ICONERROR, + ); + } +} + +fn wide_str(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} diff --git a/src/window.rs b/src/window.rs index 2804027..d64d313 100644 --- a/src/window.rs +++ b/src/window.rs @@ -22,6 +22,7 @@ use crate::native_interop::{ }; use crate::poller; use crate::theme; +use crate::updater::{self, ReleaseDescriptor, UpdateCheckResult}; /// Wrapper to make HWND sendable across threads (safe for PostMessage usage) #[derive(Clone, Copy)] @@ -59,6 +60,7 @@ struct AppState { poll_interval_ms: u32, retry_count: u32, last_poll_ok: bool, + update_status: UpdateStatus, tray_offset: i32, dragging: bool, @@ -66,6 +68,15 @@ struct AppState { drag_start_offset: i32, } +#[derive(Clone, Debug)] +enum UpdateStatus { + Idle, + Checking, + Applying, + UpToDate, + Available(ReleaseDescriptor), +} + const RETRY_BASE_MS: u32 = 30_000; // 30 seconds const POLL_1_MIN: u32 = 60_000; @@ -80,6 +91,7 @@ const IDM_FREQ_15MIN: u16 = 12; const IDM_FREQ_1HOUR: u16 = 13; const IDM_START_WITH_WINDOWS: u16 = 20; const IDM_RESET_POSITION: u16 = 30; +const IDM_VERSION_ACTION: u16 = 31; const IDM_LANG_SYSTEM: u16 = 40; const IDM_LANG_ENGLISH: u16 = 41; const IDM_LANG_SPANISH: u16 = 42; @@ -90,6 +102,7 @@ const IDM_LANG_JAPANESE: u16 = 45; const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN const WM_DPICHANGED_MSG: u32 = 0x02E0; +const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; /// Current system DPI (96 = 100% scaling, 144 = 150%, 192 = 200%, etc.) static CURRENT_DPI: AtomicU32 = AtomicU32::new(96); @@ -213,6 +226,32 @@ fn set_window_title(hwnd: HWND, strings: Strings) { } } +fn show_info_message(hwnd: HWND, title: &str, message: &str) { + unsafe { + let title_wide = native_interop::wide_str(title); + let message_wide = native_interop::wide_str(message); + let _ = MessageBoxW( + hwnd, + PCWSTR::from_raw(message_wide.as_ptr()), + PCWSTR::from_raw(title_wide.as_ptr()), + MB_OK | MB_ICONINFORMATION, + ); + } +} + +fn show_error_message(hwnd: HWND, title: &str, message: &str) { + unsafe { + let title_wide = native_interop::wide_str(title); + let message_wide = native_interop::wide_str(message); + let _ = MessageBoxW( + hwnd, + PCWSTR::from_raw(message_wide.as_ptr()), + PCWSTR::from_raw(title_wide.as_ptr()), + MB_OK | MB_ICONERROR, + ); + } +} + fn apply_language_to_state(state: &mut AppState, language_override: Option) { state.language_override = language_override; state.language = localization::resolve_language(language_override); @@ -239,6 +278,136 @@ fn update_language_change() -> bool { true } +fn version_action_label(strings: Strings, status: &UpdateStatus) -> String { + let current = env!("CARGO_PKG_VERSION"); + 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!( + "v{current} - {} v{}", + strings.update_to, release.latest_version + ) + } + } +} + +fn begin_update_check(hwnd: HWND) { + let send_hwnd = SendHwnd::from_hwnd(hwnd); + let strings = { + let mut state = lock_state(); + let Some(app_state) = state.as_mut() else { + return; + }; + + if matches!( + app_state.update_status, + UpdateStatus::Checking | UpdateStatus::Applying + ) { + show_info_message( + hwnd, + app_state.language.strings().updates, + app_state.language.strings().update_in_progress, + ); + return; + } + + app_state.update_status = UpdateStatus::Applying; + app_state.language.strings() + }; + + std::thread::spawn(move || { + let hwnd = send_hwnd.to_hwnd(); + 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; + } + } + show_info_message(hwnd, strings.updates, strings.up_to_date); + unsafe { + let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0)); + } + } + Ok(UpdateCheckResult::Available(release)) => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.update_status = UpdateStatus::Available(release); + } + } + unsafe { + let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0)); + } + } + Err(error) => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.update_status = UpdateStatus::Idle; + } + } + 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)); + } + } + } + }); +} + +fn begin_update_apply(hwnd: HWND, release: ReleaseDescriptor) { + let send_hwnd = SendHwnd::from_hwnd(hwnd); + let strings = { + let mut state = lock_state(); + let Some(app_state) = state.as_mut() else { + return; + }; + + if matches!( + app_state.update_status, + UpdateStatus::Checking | UpdateStatus::Applying + ) { + 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() + }; + + std::thread::spawn(move || { + let hwnd = send_hwnd.to_hwnd(); + match updater::begin_self_update(&release) { + Ok(()) => unsafe { + let _ = PostMessageW(hwnd, WM_CLOSE, WPARAM(0), LPARAM(0)); + }, + Err(error) => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.update_status = UpdateStatus::Available(release); + } + } + 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)); + } + } + } + }); +} + const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; const STARTUP_REGISTRY_KEY: &str = "ClaudeCodeUsageMonitor"; @@ -465,6 +634,7 @@ pub fn run() { poll_interval_ms: settings.poll_interval_ms, retry_count: 0, last_poll_ok: false, + update_status: UpdateStatus::Idle, tray_offset: settings.tray_offset, dragging: false, drag_start_mouse_x: 0, @@ -1115,6 +1285,7 @@ unsafe extern "system" fn wnd_proc( schedule_countdown_timer(); LRESULT(0) } + WM_APP_UPDATE_CHECK_COMPLETE => LRESULT(0), WM_SETCURSOR => { let is_dragging = { let state = lock_state(); @@ -1289,6 +1460,21 @@ unsafe extern "system" fn wnd_proc( do_poll(sh); }); } + IDM_VERSION_ACTION => { + let release = { + let state = lock_state(); + match state.as_ref().map(|s| &s.update_status) { + Some(UpdateStatus::Available(release)) => Some(release.clone()), + _ => None, + } + }; + + if let Some(release) = release { + begin_update_apply(hwnd, release); + } else { + begin_update_check(hwnd); + } + } 2 => { let hook = { let state = lock_state(); @@ -1371,15 +1557,21 @@ unsafe extern "system" fn wnd_proc( fn show_context_menu(hwnd: HWND) { unsafe { - let (current_interval, strings, language_override) = { + let (current_interval, strings, language_override, update_status) = { let state = lock_state(); match state.as_ref() { Some(s) => ( s.poll_interval_ms, s.language.strings(), s.language_override, + s.update_status.clone(), + ), + None => ( + POLL_15_MIN, + LanguageId::English.strings(), + None, + UpdateStatus::Idle, ), - None => (POLL_15_MIN, LanguageId::English.strings(), None), } }; @@ -1494,11 +1686,20 @@ fn show_context_menu(hwnd: HWND) { let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null()); - let version_str = native_interop::wide_str(&format!("v{}", env!("CARGO_PKG_VERSION"))); + let version_label = version_action_label(strings, &update_status); + let version_str = native_interop::wide_str(&version_label); + let version_flags = if matches!( + update_status, + UpdateStatus::Checking | UpdateStatus::Applying + ) { + MF_GRAYED + } else { + MENU_ITEM_FLAGS(0) + }; let _ = AppendMenuW( settings_menu, - MF_GRAYED, - 0, + version_flags, + IDM_VERSION_ACTION as usize, PCWSTR::from_raw(version_str.as_ptr()), );