From 02b458e1b18716aa8f81080774c937f0838de673 Mon Sep 17 00:00:00 2001 From: Stephan Date: Fri, 25 Sep 2026 17:01:07 +0200 Subject: [PATCH 1/2] fix pending winget --- src/localization/locales/de.toml | 1 + src/localization/locales/en.toml | 1 + src/localization/locales/es.toml | 1 + src/localization/locales/fr.toml | 1 + src/localization/locales/ja.toml | 1 + src/localization/locales/ko.toml | 1 + src/localization/locales/nl.toml | 1 + src/localization/locales/pl.toml | 1 + src/localization/locales/pt-BR.toml | 1 + src/localization/locales/ru.toml | 1 + src/localization/locales/th.toml | 1 + src/localization/locales/tr.toml | 1 + src/localization/locales/zh-CN.toml | 1 + src/localization/locales/zh-TW.toml | 1 + src/localization/mod.rs | 1 + src/updater.rs | 73 +++++++++++++++++++++++++++-- src/updater/tests.rs | 30 ++++++++++++ src/window.rs | 12 +++-- 18 files changed, 123 insertions(+), 7 deletions(-) diff --git a/src/localization/locales/de.toml b/src/localization/locales/de.toml index 3c18af66..2d825fa4 100644 --- a/src/localization/locales/de.toml +++ b/src/localization/locales/de.toml @@ -34,6 +34,7 @@ applying_update = "Update wird installiert..." update_to = "Aktualisieren auf" update_available = "Update verfügbar" update_prompt_now = "Version {version} ist verfügbar. Möchten Sie jetzt aktualisieren?" +update_pending_winget = "Version {version} wurde veröffentlicht, ist aber noch nicht über WinGet verfügbar. Bitte versuchen Sie es später erneut." exit = "Beenden" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/en.toml b/src/localization/locales/en.toml index b809ef30..35e58a3a 100644 --- a/src/localization/locales/en.toml +++ b/src/localization/locales/en.toml @@ -34,6 +34,7 @@ applying_update = "Applying update..." update_to = "Update to" update_available = "Update available" update_prompt_now = "Version {version} is available. Do you want to update now?" +update_pending_winget = "Version {version} has been released but is not yet available on WinGet. Please try again later." exit = "Exit" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/es.toml b/src/localization/locales/es.toml index 2153ca01..881f4c47 100644 --- a/src/localization/locales/es.toml +++ b/src/localization/locales/es.toml @@ -34,6 +34,7 @@ applying_update = "Aplicando actualización..." update_to = "Actualizar a" update_available = "Actualización disponible" update_prompt_now = "La versión {version} está disponible. ¿Quieres actualizar ahora?" +update_pending_winget = "La versión {version} se ha publicado, pero aún no está disponible en WinGet. Inténtalo de nuevo más tarde." exit = "Salir" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/fr.toml b/src/localization/locales/fr.toml index a060ff1e..43769ebc 100644 --- a/src/localization/locales/fr.toml +++ b/src/localization/locales/fr.toml @@ -34,6 +34,7 @@ applying_update = "Application de la mise à jour..." update_to = "Mettre à jour vers" update_available = "Mise à jour disponible" update_prompt_now = "La version {version} est disponible. Voulez-vous mettre à jour maintenant ?" +update_pending_winget = "La version {version} a été publiée mais n’est pas encore disponible sur WinGet. Veuillez réessayer plus tard." exit = "Quitter" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/ja.toml b/src/localization/locales/ja.toml index 343c55ec..2914ea99 100644 --- a/src/localization/locales/ja.toml +++ b/src/localization/locales/ja.toml @@ -36,6 +36,7 @@ applying_update = "更新を適用しています..." update_to = "更新先" update_available = "更新が利用可能です" update_prompt_now = "バージョン {version} が利用可能です。今すぐ更新しますか?" +update_pending_winget = "バージョン {version} はリリースされましたが、WinGet ではまだ利用できません。しばらくしてから再度お試しください。" exit = "終了" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/ko.toml b/src/localization/locales/ko.toml index a40d431d..9fdd1d57 100644 --- a/src/localization/locales/ko.toml +++ b/src/localization/locales/ko.toml @@ -36,6 +36,7 @@ applying_update = "업데이트 적용 중..." update_to = "업데이트 대상" update_available = "업데이트 사용 가능" update_prompt_now = "버전 {version}을 사용할 수 있습니다. 지금 업데이트하시겠습니까?" +update_pending_winget = "버전 {version}이 출시되었지만 아직 WinGet에서 사용할 수 없습니다. 나중에 다시 시도하세요." exit = "종료" session_window = "5시간" weekly_window = "7일" diff --git a/src/localization/locales/nl.toml b/src/localization/locales/nl.toml index c4275a3a..10f10249 100644 --- a/src/localization/locales/nl.toml +++ b/src/localization/locales/nl.toml @@ -34,6 +34,7 @@ applying_update = "Update wordt toegepast..." update_to = "Bijwerken naar" update_available = "Update beschikbaar" update_prompt_now = "Versie {version} is beschikbaar. Wil je nu bijwerken?" +update_pending_winget = "Versie {version} is uitgebracht, maar is nog niet beschikbaar via WinGet. Probeer het later opnieuw." exit = "Afsluiten" session_window = "5u" weekly_window = "7d" diff --git a/src/localization/locales/pl.toml b/src/localization/locales/pl.toml index 3148ac61..f79c4e54 100644 --- a/src/localization/locales/pl.toml +++ b/src/localization/locales/pl.toml @@ -34,6 +34,7 @@ applying_update = "Instalowanie aktualizacji..." update_to = "Zaktualizuj do" update_available = "Dostępna aktualizacja" update_prompt_now = "Wersja {version} jest dostępna. Czy chcesz ją zainstalować teraz?" +update_pending_winget = "Wersja {version} została wydana, ale nie jest jeszcze dostępna w WinGet. Spróbuj ponownie później." exit = "Zakończ" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/pt-BR.toml b/src/localization/locales/pt-BR.toml index bb2b6dd5..53ff188c 100644 --- a/src/localization/locales/pt-BR.toml +++ b/src/localization/locales/pt-BR.toml @@ -34,6 +34,7 @@ applying_update = "Aplicando atualização..." update_to = "Atualizar para" update_available = "Atualização disponível" update_prompt_now = "Versão {version} está disponível. Deseja atualizar agora?" +update_pending_winget = "A versão {version} foi lançada, mas ainda não está disponível no WinGet. Tente novamente mais tarde." exit = "Sair" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/ru.toml b/src/localization/locales/ru.toml index 6c89dee7..cd4a538e 100644 --- a/src/localization/locales/ru.toml +++ b/src/localization/locales/ru.toml @@ -34,6 +34,7 @@ applying_update = "Применение обновления..." update_to = "Обновить до" update_available = "Доступно обновление" update_prompt_now = "Доступна версия {version}. Обновить сейчас?" +update_pending_winget = "Версия {version} выпущена, но пока недоступна в WinGet. Повторите попытку позже." exit = "Выход" session_window = "5ч" weekly_window = "7д" diff --git a/src/localization/locales/th.toml b/src/localization/locales/th.toml index 9c49a4b9..45c28f50 100644 --- a/src/localization/locales/th.toml +++ b/src/localization/locales/th.toml @@ -36,6 +36,7 @@ applying_update = "กำลังติดตั้งการอัปเด update_to = "อัปเดตเป็น" update_available = "มีการอัปเดตใหม่" update_prompt_now = "มีเวอร์ชัน {version} ให้ใช้งานแล้ว ต้องการอัปเดตตอนนี้หรือไม่?" +update_pending_winget = "เวอร์ชัน {version} เผยแพร่แล้ว แต่ยังไม่พร้อมใช้งานใน WinGet โปรดลองอีกครั้งในภายหลัง" exit = "ออกจากโปรแกรม" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/tr.toml b/src/localization/locales/tr.toml index 53fe411c..25f7ea13 100644 --- a/src/localization/locales/tr.toml +++ b/src/localization/locales/tr.toml @@ -34,6 +34,7 @@ applying_update = "Güncelleme uygulanıyor..." update_to = "Şu sürüme güncelle" update_available = "Güncelleme mevcut" update_prompt_now = "{version} sürümü mevcut. Şimdi güncellemek ister misiniz?" +update_pending_winget = "{version} sürümü yayımlandı ancak henüz WinGet üzerinde mevcut değil. Lütfen daha sonra tekrar deneyin." exit = "Çıkış" session_window = "5 sa" weekly_window = "7 g" diff --git a/src/localization/locales/zh-CN.toml b/src/localization/locales/zh-CN.toml index b8b27351..77018006 100644 --- a/src/localization/locales/zh-CN.toml +++ b/src/localization/locales/zh-CN.toml @@ -36,6 +36,7 @@ applying_update = "正在应用更新..." update_to = "更新至" update_available = "有可用更新" update_prompt_now = "版本 {version} 已可用。是否立即更新?" +update_pending_winget = "版本 {version} 已发布,但尚未在 WinGet 上提供。请稍后再试。" exit = "退出" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/zh-TW.toml b/src/localization/locales/zh-TW.toml index 01b36265..28d879dd 100644 --- a/src/localization/locales/zh-TW.toml +++ b/src/localization/locales/zh-TW.toml @@ -36,6 +36,7 @@ applying_update = "正在套用更新..." update_to = "更新至" update_available = "有可用更新" update_prompt_now = "版本 {version} 已可用。是否立即更新?" +update_pending_winget = "版本 {version} 已發布,但尚未在 WinGet 上提供。請稍後再試。" exit = "結束" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 58fb1b1a..4d4a2252 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -159,6 +159,7 @@ pub struct Strings { pub update_to: &'static str, pub update_available: &'static str, pub update_prompt_now: &'static str, + pub update_pending_winget: &'static str, pub exit: &'static str, pub session_window: &'static str, pub weekly_window: &'static str, diff --git a/src/updater.rs b/src/updater.rs index 1d54236a..bfde3e1d 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -18,6 +18,10 @@ const CREATE_NO_WINDOW: u32 = 0x08000000; const CREATE_NEW_CONSOLE: u32 = 0x00000010; // Keep this aligned with the package identifier used in winget-pkgs. const WINGET_PACKAGE_ID: &str = "CodeZeno.ClaudeCodeUsageMonitor"; +// `winget show` exit codes for a missing version or package. +const WINGET_NO_MANIFEST_FOUND: u32 = 0x8A15_0017; +const WINGET_NO_APPLICATIONS_FOUND: u32 = 0x8A15_0014; +const WINGET_SHOW_TIMEOUT: Duration = Duration::from_secs(60); mod download; mod release; @@ -36,6 +40,8 @@ pub enum InstallChannel { pub enum UpdateCheckResult { UpToDate, Available(ReleaseDescriptor), + /// Released on GitHub, but the WinGet source does not list it yet. + Pending(String), } pub fn handle_cli_mode(args: &[String]) -> Option { @@ -89,11 +95,16 @@ pub fn current_install_channel() -> InstallChannel { } } -pub fn check_for_updates() -> Result { - match fetch_latest_release()? { - Some(release) => Ok(UpdateCheckResult::Available(release)), - None => Ok(UpdateCheckResult::UpToDate), +pub fn check_for_updates(channel: InstallChannel) -> Result { + let Some(release) = fetch_latest_release()? else { + return Ok(UpdateCheckResult::UpToDate); + }; + // GitHub releases publish before the winget-pkgs manifest PR merges, and + // `winget upgrade` finds nothing to install until then. + if channel == InstallChannel::Winget && !winget_has_version(&release.latest_version)? { + return Ok(UpdateCheckResult::Pending(release.latest_version)); } + Ok(UpdateCheckResult::Available(release)) } pub fn begin_winget_update() -> Result<(), String> { @@ -320,6 +331,60 @@ fn updates_dir() -> Result { .ok_or_else(|| "Unable to resolve a writable local updates directory.".to_string()) } +fn winget_has_version(version: &str) -> Result { + let mut child = Command::new("winget.exe") + .args(winget_show_args(version)) + .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 run WinGet: {e}"))?; + + let started = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => return winget_show_outcome(status.code()), + Ok(None) if started.elapsed() < WINGET_SHOW_TIMEOUT => { + std::thread::sleep(Duration::from_millis(250)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("Timed out waiting for WinGet to check the available version.".into()); + } + Err(error) => return Err(format!("Unable to wait for WinGet: {error}")), + } + } +} + +fn winget_show_args(version: &str) -> [&str; 10] { + [ + "show", + "--id", + WINGET_PACKAGE_ID, + "--exact", + "--version", + version, + "--source", + "winget", + "--accept-source-agreements", + "--disable-interactivity", + ] +} + +fn winget_show_outcome(exit_code: Option) -> Result { + // Windows exit codes are HRESULTs; Rust reports them as signed values. + match exit_code.map(|code| code as u32) { + Some(0) => Ok(true), + Some(WINGET_NO_MANIFEST_FOUND | WINGET_NO_APPLICATIONS_FOUND) => Ok(false), + Some(code) => Err(format!( + "WinGet could not check the available version (exit code 0x{code:08X})." + )), + None => Err("WinGet exited without a status code.".into()), + } +} + 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); diff --git a/src/updater/tests.rs b/src/updater/tests.rs index 73d6941e..33ffb6f2 100644 --- a/src/updater/tests.rs +++ b/src/updater/tests.rs @@ -198,3 +198,33 @@ fn winget_upgrade_command_quotes_each_path_as_a_powershell_literal() { ); } } + +#[test] +fn winget_show_args_query_one_exact_version_non_interactively() { + assert_eq!( + winget_show_args("2.15.17").join(" "), + "show --id CodeZeno.ClaudeCodeUsageMonitor --exact --version 2.15.17 \ + --source winget --accept-source-agreements --disable-interactivity" + ); +} + +#[test] +fn winget_show_outcome_distinguishes_missing_versions_from_failures() { + assert_eq!(winget_show_outcome(Some(0)), Ok(true)); + for missing in [WINGET_NO_MANIFEST_FOUND, WINGET_NO_APPLICATIONS_FOUND] { + assert_eq!(winget_show_outcome(Some(missing as i32)), Ok(false)); + } + assert_eq!( + winget_show_outcome(Some(0x8A15_0001_u32 as i32)), + Err("WinGet could not check the available version (exit code 0x8A150001).".into()) + ); + assert!(winget_show_outcome(Some(1)).is_err()); + assert!(winget_show_outcome(None).is_err()); +} + +#[test] +#[ignore = "runs winget.exe against the live WinGet source"] +fn winget_has_version_queries_the_live_source() { + assert_eq!(winget_has_version("2.15.0"), Ok(true)); + assert_eq!(winget_has_version("99.0.0"), Ok(false)); +} diff --git a/src/window.rs b/src/window.rs index f85c6d68..cda11ef0 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1197,8 +1197,8 @@ fn begin_update_check(hwnd: HWND, interactive: bool) { std::thread::spawn(move || { let hwnd = send_hwnd.to_hwnd(); let checked_at = now_unix_secs(); - match updater::check_for_updates() { - Ok(UpdateCheckResult::UpToDate) => { + match updater::check_for_updates(install_channel) { + Ok(result @ (UpdateCheckResult::UpToDate | UpdateCheckResult::Pending(_))) => { { let mut state = lock_state(); if let Some(s) = state.as_mut() { @@ -1209,7 +1209,13 @@ fn begin_update_check(hwnd: HWND, interactive: bool) { } save_state_settings(); if interactive { - show_info_message(hwnd, strings.updates, strings.up_to_date); + let message = match result { + UpdateCheckResult::Pending(version) => { + strings.update_pending_winget.replace("{version}", &version) + } + _ => strings.up_to_date.to_string(), + }; + show_info_message(hwnd, strings.updates, &message); } unsafe { let _ = PostMessageW( From 753638f7080adb81511c92737420ead8fc05fbdd Mon Sep 17 00:00:00 2001 From: Stephan Date: Fri, 25 Sep 2026 17:21:05 +0200 Subject: [PATCH 2/2] do better --- src/localization/locales/de.toml | 1 + src/localization/locales/en.toml | 1 + src/localization/locales/es.toml | 1 + src/localization/locales/fr.toml | 1 + src/localization/locales/ja.toml | 1 + src/localization/locales/ko.toml | 1 + src/localization/locales/nl.toml | 1 + src/localization/locales/pl.toml | 1 + src/localization/locales/pt-BR.toml | 1 + src/localization/locales/ru.toml | 1 + src/localization/locales/th.toml | 1 + src/localization/locales/tr.toml | 1 + src/localization/locales/zh-CN.toml | 1 + src/localization/locales/zh-TW.toml | 1 + src/localization/mod.rs | 1 + src/updater.rs | 138 ++++++++++++++++++++-------- src/updater/release.rs | 2 +- src/updater/tests.rs | 90 +++++++++++++++--- src/window.rs | 64 +++++++------ 19 files changed, 227 insertions(+), 82 deletions(-) diff --git a/src/localization/locales/de.toml b/src/localization/locales/de.toml index 2d825fa4..5583a39e 100644 --- a/src/localization/locales/de.toml +++ b/src/localization/locales/de.toml @@ -35,6 +35,7 @@ update_to = "Aktualisieren auf" update_available = "Update verfügbar" update_prompt_now = "Version {version} ist verfügbar. Möchten Sie jetzt aktualisieren?" update_pending_winget = "Version {version} wurde veröffentlicht, ist aber noch nicht über WinGet verfügbar. Bitte versuchen Sie es später erneut." +update_prompt_winget_behind = "Version {release} wurde veröffentlicht, ist aber noch nicht über WinGet verfügbar. Version {version} ist verfügbar. Möchten Sie jetzt aktualisieren?" exit = "Beenden" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/en.toml b/src/localization/locales/en.toml index 35e58a3a..8dddb461 100644 --- a/src/localization/locales/en.toml +++ b/src/localization/locales/en.toml @@ -35,6 +35,7 @@ update_to = "Update to" update_available = "Update available" update_prompt_now = "Version {version} is available. Do you want to update now?" update_pending_winget = "Version {version} has been released but is not yet available on WinGet. Please try again later." +update_prompt_winget_behind = "Version {release} has been released but is not yet available on WinGet. Version {version} is available. Do you want to update now?" exit = "Exit" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/es.toml b/src/localization/locales/es.toml index 881f4c47..238e97de 100644 --- a/src/localization/locales/es.toml +++ b/src/localization/locales/es.toml @@ -35,6 +35,7 @@ update_to = "Actualizar a" update_available = "Actualización disponible" update_prompt_now = "La versión {version} está disponible. ¿Quieres actualizar ahora?" update_pending_winget = "La versión {version} se ha publicado, pero aún no está disponible en WinGet. Inténtalo de nuevo más tarde." +update_prompt_winget_behind = "La versión {release} se ha publicado, pero aún no está disponible en WinGet. La versión {version} está disponible. ¿Quieres actualizar ahora?" exit = "Salir" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/fr.toml b/src/localization/locales/fr.toml index 43769ebc..ff7bfd31 100644 --- a/src/localization/locales/fr.toml +++ b/src/localization/locales/fr.toml @@ -35,6 +35,7 @@ update_to = "Mettre à jour vers" update_available = "Mise à jour disponible" update_prompt_now = "La version {version} est disponible. Voulez-vous mettre à jour maintenant ?" update_pending_winget = "La version {version} a été publiée mais n’est pas encore disponible sur WinGet. Veuillez réessayer plus tard." +update_prompt_winget_behind = "La version {release} a été publiée mais n’est pas encore disponible sur WinGet. La version {version} est disponible. Voulez-vous mettre à jour maintenant ?" exit = "Quitter" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/ja.toml b/src/localization/locales/ja.toml index 2914ea99..a6e5d969 100644 --- a/src/localization/locales/ja.toml +++ b/src/localization/locales/ja.toml @@ -37,6 +37,7 @@ update_to = "更新先" update_available = "更新が利用可能です" update_prompt_now = "バージョン {version} が利用可能です。今すぐ更新しますか?" update_pending_winget = "バージョン {version} はリリースされましたが、WinGet ではまだ利用できません。しばらくしてから再度お試しください。" +update_prompt_winget_behind = "バージョン {release} はリリースされましたが、WinGet ではまだ利用できません。バージョン {version} が利用可能です。今すぐ更新しますか?" exit = "終了" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/ko.toml b/src/localization/locales/ko.toml index 9fdd1d57..cee44f4e 100644 --- a/src/localization/locales/ko.toml +++ b/src/localization/locales/ko.toml @@ -37,6 +37,7 @@ update_to = "업데이트 대상" update_available = "업데이트 사용 가능" update_prompt_now = "버전 {version}을 사용할 수 있습니다. 지금 업데이트하시겠습니까?" update_pending_winget = "버전 {version}이 출시되었지만 아직 WinGet에서 사용할 수 없습니다. 나중에 다시 시도하세요." +update_prompt_winget_behind = "버전 {release}이 출시되었지만 아직 WinGet에서 사용할 수 없습니다. 버전 {version}을 사용할 수 있습니다. 지금 업데이트하시겠습니까?" exit = "종료" session_window = "5시간" weekly_window = "7일" diff --git a/src/localization/locales/nl.toml b/src/localization/locales/nl.toml index 10f10249..cf3a8cfd 100644 --- a/src/localization/locales/nl.toml +++ b/src/localization/locales/nl.toml @@ -35,6 +35,7 @@ update_to = "Bijwerken naar" update_available = "Update beschikbaar" update_prompt_now = "Versie {version} is beschikbaar. Wil je nu bijwerken?" update_pending_winget = "Versie {version} is uitgebracht, maar is nog niet beschikbaar via WinGet. Probeer het later opnieuw." +update_prompt_winget_behind = "Versie {release} is uitgebracht, maar is nog niet beschikbaar via WinGet. Versie {version} is beschikbaar. Wil je nu bijwerken?" exit = "Afsluiten" session_window = "5u" weekly_window = "7d" diff --git a/src/localization/locales/pl.toml b/src/localization/locales/pl.toml index f79c4e54..e043b46a 100644 --- a/src/localization/locales/pl.toml +++ b/src/localization/locales/pl.toml @@ -35,6 +35,7 @@ update_to = "Zaktualizuj do" update_available = "Dostępna aktualizacja" update_prompt_now = "Wersja {version} jest dostępna. Czy chcesz ją zainstalować teraz?" update_pending_winget = "Wersja {version} została wydana, ale nie jest jeszcze dostępna w WinGet. Spróbuj ponownie później." +update_prompt_winget_behind = "Wersja {release} została wydana, ale nie jest jeszcze dostępna w WinGet. Wersja {version} jest dostępna. Czy chcesz ją zainstalować teraz?" exit = "Zakończ" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/pt-BR.toml b/src/localization/locales/pt-BR.toml index 53ff188c..8af21274 100644 --- a/src/localization/locales/pt-BR.toml +++ b/src/localization/locales/pt-BR.toml @@ -35,6 +35,7 @@ update_to = "Atualizar para" update_available = "Atualização disponível" update_prompt_now = "Versão {version} está disponível. Deseja atualizar agora?" update_pending_winget = "A versão {version} foi lançada, mas ainda não está disponível no WinGet. Tente novamente mais tarde." +update_prompt_winget_behind = "A versão {release} foi lançada, mas ainda não está disponível no WinGet. A versão {version} está disponível. Deseja atualizar agora?" exit = "Sair" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/ru.toml b/src/localization/locales/ru.toml index cd4a538e..848a7673 100644 --- a/src/localization/locales/ru.toml +++ b/src/localization/locales/ru.toml @@ -35,6 +35,7 @@ update_to = "Обновить до" update_available = "Доступно обновление" update_prompt_now = "Доступна версия {version}. Обновить сейчас?" update_pending_winget = "Версия {version} выпущена, но пока недоступна в WinGet. Повторите попытку позже." +update_prompt_winget_behind = "Версия {release} выпущена, но пока недоступна в WinGet. Доступна версия {version}. Обновить сейчас?" exit = "Выход" session_window = "5ч" weekly_window = "7д" diff --git a/src/localization/locales/th.toml b/src/localization/locales/th.toml index 45c28f50..589641f1 100644 --- a/src/localization/locales/th.toml +++ b/src/localization/locales/th.toml @@ -37,6 +37,7 @@ update_to = "อัปเดตเป็น" update_available = "มีการอัปเดตใหม่" update_prompt_now = "มีเวอร์ชัน {version} ให้ใช้งานแล้ว ต้องการอัปเดตตอนนี้หรือไม่?" update_pending_winget = "เวอร์ชัน {version} เผยแพร่แล้ว แต่ยังไม่พร้อมใช้งานใน WinGet โปรดลองอีกครั้งในภายหลัง" +update_prompt_winget_behind = "เวอร์ชัน {release} เผยแพร่แล้ว แต่ยังไม่พร้อมใช้งานใน WinGet มีเวอร์ชัน {version} ให้ใช้งานแล้ว ต้องการอัปเดตตอนนี้หรือไม่?" exit = "ออกจากโปรแกรม" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/tr.toml b/src/localization/locales/tr.toml index 25f7ea13..c4ae8c94 100644 --- a/src/localization/locales/tr.toml +++ b/src/localization/locales/tr.toml @@ -35,6 +35,7 @@ update_to = "Şu sürüme güncelle" update_available = "Güncelleme mevcut" update_prompt_now = "{version} sürümü mevcut. Şimdi güncellemek ister misiniz?" update_pending_winget = "{version} sürümü yayımlandı ancak henüz WinGet üzerinde mevcut değil. Lütfen daha sonra tekrar deneyin." +update_prompt_winget_behind = "{release} sürümü yayımlandı ancak henüz WinGet üzerinde mevcut değil. {version} sürümü mevcut. Şimdi güncellemek ister misiniz?" exit = "Çıkış" session_window = "5 sa" weekly_window = "7 g" diff --git a/src/localization/locales/zh-CN.toml b/src/localization/locales/zh-CN.toml index 77018006..de64f186 100644 --- a/src/localization/locales/zh-CN.toml +++ b/src/localization/locales/zh-CN.toml @@ -37,6 +37,7 @@ update_to = "更新至" update_available = "有可用更新" update_prompt_now = "版本 {version} 已可用。是否立即更新?" update_pending_winget = "版本 {version} 已发布,但尚未在 WinGet 上提供。请稍后再试。" +update_prompt_winget_behind = "版本 {release} 已发布,但尚未在 WinGet 上提供。版本 {version} 已可用。是否立即更新?" exit = "退出" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/locales/zh-TW.toml b/src/localization/locales/zh-TW.toml index 28d879dd..098d9b2e 100644 --- a/src/localization/locales/zh-TW.toml +++ b/src/localization/locales/zh-TW.toml @@ -37,6 +37,7 @@ update_to = "更新至" update_available = "有可用更新" update_prompt_now = "版本 {version} 已可用。是否立即更新?" update_pending_winget = "版本 {version} 已發布,但尚未在 WinGet 上提供。請稍後再試。" +update_prompt_winget_behind = "版本 {release} 已發布,但尚未在 WinGet 上提供。版本 {version} 已可用。是否立即更新?" exit = "結束" session_window = "5h" weekly_window = "7d" diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 4d4a2252..136c3554 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -160,6 +160,7 @@ pub struct Strings { pub update_available: &'static str, pub update_prompt_now: &'static str, pub update_pending_winget: &'static str, + pub update_prompt_winget_behind: &'static str, pub exit: &'static str, pub session_window: &'static str, pub weekly_window: &'static str, diff --git a/src/updater.rs b/src/updater.rs index bfde3e1d..4fa28b11 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,5 +1,5 @@ use std::fs::File; -use std::io; +use std::io::{self, Read}; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; @@ -18,17 +18,28 @@ const CREATE_NO_WINDOW: u32 = 0x08000000; const CREATE_NEW_CONSOLE: u32 = 0x00000010; // Keep this aligned with the package identifier used in winget-pkgs. const WINGET_PACKAGE_ID: &str = "CodeZeno.ClaudeCodeUsageMonitor"; -// `winget show` exit codes for a missing version or package. -const WINGET_NO_MANIFEST_FOUND: u32 = 0x8A15_0017; +// `winget show` exit code when the package is not in the source. const WINGET_NO_APPLICATIONS_FOUND: u32 = 0x8A15_0014; const WINGET_SHOW_TIMEOUT: Duration = Duration::from_secs(60); +const WINGET_SHOW_VERSIONS_ARGS: [&str; 9] = [ + "show", + "--id", + WINGET_PACKAGE_ID, + "--exact", + "--versions", + "--source", + "winget", + "--accept-source-agreements", + "--disable-interactivity", +]; mod download; mod release; use download::{download_release_asset, open_verified_source, AssetIntegrity}; pub use release::ReleaseDescriptor; -use release::{release_descriptor, GitHubRelease}; +use release::{parse_version, release_descriptor, GitHubRelease}; +use semver::Version; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum InstallChannel { @@ -39,11 +50,34 @@ pub enum InstallChannel { #[derive(Debug)] pub enum UpdateCheckResult { UpToDate, - Available(ReleaseDescriptor), - /// Released on GitHub, but the WinGet source does not list it yet. + Available(AvailableUpdate), + /// Released on GitHub, but the WinGet source has nothing newer than the current version. Pending(String), } +#[derive(Clone, Debug)] +pub enum AvailableUpdate { + /// Portable installs download this GitHub release asset. + Release(ReleaseDescriptor), + /// WinGet installs upgrade to the newest version in the WinGet source, + /// which can trail GitHub. `unlisted_release` names the newer GitHub + /// release that WinGet does not list yet. + Winget { + version: String, + unlisted_release: Option, + }, +} + +impl AvailableUpdate { + /// The version this update installs. + pub fn version(&self) -> &str { + match self { + Self::Release(release) => &release.latest_version, + Self::Winget { version, .. } => version, + } + } +} + pub fn handle_cli_mode(args: &[String]) -> Option { if args.get(1).is_some_and(|arg| arg == "--apply-update") { return Some( @@ -99,12 +133,41 @@ pub fn check_for_updates(channel: InstallChannel) -> Result Ok(UpdateCheckResult::Available(AvailableUpdate::Release( + release, + ))), + // GitHub releases publish before the winget-pkgs manifest PR merges, so + // offer the newest version `winget upgrade` can actually install. + InstallChannel::Winget => Ok(winget_update_result( + &parse_version(env!("CARGO_PKG_VERSION"))?, + &parse_version(&release.latest_version)?, + &winget_listed_versions()?, + )), + } +} + +fn winget_update_result( + current: &Version, + released: &Version, + listed: &[Version], +) -> UpdateCheckResult { + let newest_listed = listed + .iter() + .filter(|version| version.pre.is_empty()) + .max_by(|a, b| a.cmp_precedence(b)); + match newest_listed { + Some(listed) if listed.cmp_precedence(current).is_gt() => { + UpdateCheckResult::Available(AvailableUpdate::Winget { + version: listed.to_string(), + unlisted_release: released + .cmp_precedence(listed) + .is_gt() + .then(|| released.to_string()), + }) + } + _ => UpdateCheckResult::Pending(released.to_string()), } - Ok(UpdateCheckResult::Available(release)) } pub fn begin_winget_update() -> Result<(), String> { @@ -331,55 +394,56 @@ fn updates_dir() -> Result { .ok_or_else(|| "Unable to resolve a writable local updates directory.".to_string()) } -fn winget_has_version(version: &str) -> Result { +fn winget_listed_versions() -> Result, String> { let mut child = Command::new("winget.exe") - .args(winget_show_args(version)) + .args(WINGET_SHOW_VERSIONS_ARGS) .creation_flags(CREATE_NO_WINDOW) .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() .map_err(|e| format!("Unable to run WinGet: {e}"))?; + // Drain stdout on another thread so a full pipe cannot stall WinGet. + let mut stdout = child.stdout.take().expect("WinGet stdout is piped"); + let reader = std::thread::spawn(move || { + let mut output = Vec::new(); + stdout.read_to_end(&mut output).map(|_| output) + }); + let started = std::time::Instant::now(); - loop { + let status = loop { match child.try_wait() { - Ok(Some(status)) => return winget_show_outcome(status.code()), + Ok(Some(status)) => break status, Ok(None) if started.elapsed() < WINGET_SHOW_TIMEOUT => { std::thread::sleep(Duration::from_millis(250)); } Ok(None) => { let _ = child.kill(); let _ = child.wait(); - return Err("Timed out waiting for WinGet to check the available version.".into()); + return Err("Timed out waiting for WinGet to list available versions.".into()); } Err(error) => return Err(format!("Unable to wait for WinGet: {error}")), } - } -} - -fn winget_show_args(version: &str) -> [&str; 10] { - [ - "show", - "--id", - WINGET_PACKAGE_ID, - "--exact", - "--version", - version, - "--source", - "winget", - "--accept-source-agreements", - "--disable-interactivity", - ] + }; + let output = reader + .join() + .map_err(|_| "Unable to read WinGet output.".to_string())? + .map_err(|e| format!("Unable to read WinGet output: {e}"))?; + winget_versions_outcome(status.code(), &String::from_utf8_lossy(&output)) } -fn winget_show_outcome(exit_code: Option) -> Result { +fn winget_versions_outcome(exit_code: Option, stdout: &str) -> Result, String> { // Windows exit codes are HRESULTs; Rust reports them as signed values. match exit_code.map(|code| code as u32) { - Some(0) => Ok(true), - Some(WINGET_NO_MANIFEST_FOUND | WINGET_NO_APPLICATIONS_FOUND) => Ok(false), + // Headers are localized, so keep only the lines that are versions. + Some(0) => Ok(stdout + .lines() + .filter_map(|line| Version::parse(line.trim()).ok()) + .collect()), + Some(WINGET_NO_APPLICATIONS_FOUND) => Ok(Vec::new()), Some(code) => Err(format!( - "WinGet could not check the available version (exit code 0x{code:08X})." + "WinGet could not list available versions (exit code 0x{code:08X})." )), None => Err("WinGet exited without a status code.".into()), } diff --git a/src/updater/release.rs b/src/updater/release.rs index 4bd158b4..eadd8b7f 100644 --- a/src/updater/release.rs +++ b/src/updater/release.rs @@ -73,7 +73,7 @@ pub(super) fn release_descriptor( })) } -fn parse_version(version: &str) -> Result { +pub(super) fn parse_version(version: &str) -> Result { Version::parse(version.strip_prefix('v').unwrap_or(version)) .map_err(|e| format!("Invalid release version {version:?}: {e}")) } diff --git a/src/updater/tests.rs b/src/updater/tests.rs index 33ffb6f2..abb56ae3 100644 --- a/src/updater/tests.rs +++ b/src/updater/tests.rs @@ -200,31 +200,91 @@ fn winget_upgrade_command_quotes_each_path_as_a_powershell_literal() { } #[test] -fn winget_show_args_query_one_exact_version_non_interactively() { +fn winget_show_args_list_versions_non_interactively() { assert_eq!( - winget_show_args("2.15.17").join(" "), - "show --id CodeZeno.ClaudeCodeUsageMonitor --exact --version 2.15.17 \ - --source winget --accept-source-agreements --disable-interactivity" + WINGET_SHOW_VERSIONS_ARGS.join(" "), + "show --id CodeZeno.ClaudeCodeUsageMonitor --exact --versions --source winget --accept-source-agreements --disable-interactivity" ); } #[test] -fn winget_show_outcome_distinguishes_missing_versions_from_failures() { - assert_eq!(winget_show_outcome(Some(0)), Ok(true)); - for missing in [WINGET_NO_MANIFEST_FOUND, WINGET_NO_APPLICATIONS_FOUND] { - assert_eq!(winget_show_outcome(Some(missing as i32)), Ok(false)); +fn winget_versions_outcome_parses_versions_under_localized_headers() { + let stdout = concat!( + "Trouvé Claude Code Usage Monitor [CodeZeno.ClaudeCodeUsageMonitor] +", + "Version +------- +2.15.14 +2.15.0 + 2.14.55 +1.3 +", + ); + assert_eq!( + winget_versions_outcome(Some(0), stdout), + Ok(vec![v("2.15.14"), v("2.15.0"), v("2.14.55")]) + ); + assert_eq!( + winget_versions_outcome(Some(WINGET_NO_APPLICATIONS_FOUND as i32), ""), + Ok(Vec::new()) + ); + assert_eq!( + winget_versions_outcome(Some(0x8A15_0001_u32 as i32), ""), + Err("WinGet could not list available versions (exit code 0x8A150001).".into()) + ); + assert!(winget_versions_outcome(Some(1), "2.15.14").is_err()); + assert!(winget_versions_outcome(None, "2.15.14").is_err()); +} + +fn v(version: &str) -> Version { + Version::parse(version).unwrap() +} + +fn winget_result(current: &str, released: &str, listed: &[&str]) -> String { + let listed = listed.iter().map(|version| v(version)).collect::>(); + match winget_update_result(&v(current), &v(released), &listed) { + UpdateCheckResult::UpToDate => "up to date".into(), + UpdateCheckResult::Pending(released) => format!("pending {released}"), + UpdateCheckResult::Available(AvailableUpdate::Winget { + version, + unlisted_release, + }) => format!("winget {version} unlisted {unlisted_release:?}"), + UpdateCheckResult::Available(AvailableUpdate::Release(_)) => "release".into(), } +} + +#[test] +fn winget_offers_the_released_version_once_listed() { + assert_eq!( + winget_result("2.15.14", "2.15.19", &["2.15.0", "2.15.19", "2.15.14"]), + "winget 2.15.19 unlisted None" + ); +} + +#[test] +fn winget_offers_the_newest_listed_version_while_the_release_is_unlisted() { + assert_eq!( + winget_result("2.15.14", "2.15.19", &["2.15.14", "2.15.18", "2.15.17"]), + "winget 2.15.18 unlisted Some(\"2.15.19\")" + ); +} + +#[test] +fn winget_is_pending_when_nothing_newer_is_listed() { + assert_eq!( + winget_result("2.15.14", "2.15.19", &["2.15.0", "2.15.14"]), + "pending 2.15.19" + ); + assert_eq!(winget_result("2.15.14", "2.15.19", &[]), "pending 2.15.19"); assert_eq!( - winget_show_outcome(Some(0x8A15_0001_u32 as i32)), - Err("WinGet could not check the available version (exit code 0x8A150001).".into()) + winget_result("2.15.14", "2.15.19", &["2.15.18-beta1"]), + "pending 2.15.19" ); - assert!(winget_show_outcome(Some(1)).is_err()); - assert!(winget_show_outcome(None).is_err()); } #[test] #[ignore = "runs winget.exe against the live WinGet source"] -fn winget_has_version_queries_the_live_source() { - assert_eq!(winget_has_version("2.15.0"), Ok(true)); - assert_eq!(winget_has_version("99.0.0"), Ok(false)); +fn winget_lists_published_versions_from_the_live_source() { + let versions = winget_listed_versions().unwrap(); + assert!(versions.contains(&v("2.15.0")), "{versions:?}"); } diff --git a/src/window.rs b/src/window.rs index cda11ef0..ff8e21a8 100644 --- a/src/window.rs +++ b/src/window.rs @@ -43,7 +43,7 @@ use crate::theme_engine::{ MouseEventKind, ReferenceRegion, SurfaceNest, ThemeDocument, ThemeRuntime, VerticalAnchor, }; use crate::tray_icon; -use crate::updater::{self, InstallChannel, ReleaseDescriptor, UpdateCheckResult}; +use crate::updater::{self, AvailableUpdate, InstallChannel, ReleaseDescriptor, UpdateCheckResult}; /// Copyable HWND value used by the watchdog after the UI thread publishes it. #[derive(Clone, Copy, PartialEq, Eq)] @@ -151,7 +151,7 @@ enum UpdateStatus { Checking, Applying, UpToDate, - Available(ReleaseDescriptor), + Available(AvailableUpdate), } fn publish_update_status(state: &AppState) { @@ -160,15 +160,13 @@ fn publish_update_status(state: &AppState) { UpdateStatus::Idle | UpdateStatus::UpToDate => DashboardStatus::Idle, UpdateStatus::Checking => DashboardStatus::Checking, UpdateStatus::Applying => DashboardStatus::Applying, - UpdateStatus::Available(release) => { - DashboardStatus::Available(release.latest_version.clone()) - } + UpdateStatus::Available(update) => DashboardStatus::Available(update.version().to_owned()), }; crate::dashboard::publish_update_status(state.hwnd.to_hwnd(), status); } fn perform_update_action(hwnd: HWND) { - let (install_channel, release) = { + let update = { let state = lock_state(); let Some(state) = state.as_ref() else { return; @@ -179,18 +177,14 @@ fn perform_update_action(hwnd: HWND) { ) { return; } - ( - state.install_channel, - match &state.update_status { - UpdateStatus::Available(release) => Some(release.clone()), - _ => None, - }, - ) + match &state.update_status { + UpdateStatus::Available(update) => Some(update.clone()), + _ => None, + } }; - match (install_channel, release) { - (InstallChannel::Portable, Some(release)) => begin_update_apply(hwnd, release), - (InstallChannel::Winget, Some(_)) => begin_winget_update(hwnd), - (_, None) => begin_update_check(hwnd, true), + match update { + Some(update) => begin_update_install(hwnd, update), + None => begin_update_check(hwnd, true), } } @@ -1125,10 +1119,24 @@ fn show_error_message(hwnd: HWND, title: &str, message: &str) { } } -fn show_update_prompt(hwnd: HWND, strings: Strings, release: &ReleaseDescriptor) -> bool { - let message = strings - .update_prompt_now - .replace("{version}", &release.latest_version); +fn begin_update_install(hwnd: HWND, update: AvailableUpdate) { + match update { + AvailableUpdate::Release(release) => begin_update_apply(hwnd, release), + AvailableUpdate::Winget { .. } => begin_winget_update(hwnd), + } +} + +fn show_update_prompt(hwnd: HWND, strings: Strings, update: &AvailableUpdate) -> bool { + let message = match update { + AvailableUpdate::Winget { + unlisted_release: Some(release), + .. + } => strings + .update_prompt_winget_behind + .replace("{release}", release), + _ => strings.update_prompt_now.to_owned(), + } + .replace("{version}", update.version()); unsafe { let title_wide = native_interop::wide_str(strings.update_available); @@ -1226,20 +1234,17 @@ fn begin_update_check(hwnd: HWND, interactive: bool) { ); } } - Ok(UpdateCheckResult::Available(release)) => { + Ok(UpdateCheckResult::Available(update)) => { { let mut state = lock_state(); if let Some(s) = state.as_mut() { - s.update_status = UpdateStatus::Available(release.clone()); + s.update_status = UpdateStatus::Available(update.clone()); s.last_update_check_unix = Some(checked_at); } } 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), - } + if interactive && show_update_prompt(hwnd, strings, &update) { + begin_update_install(hwnd, update); } // Keep the dashboard busy until the install prompt is dismissed. if let Some(state) = lock_state().as_ref() { @@ -1316,7 +1321,8 @@ fn begin_update_apply(hwnd: HWND, release: ReleaseDescriptor) { { let mut state = lock_state(); if let Some(s) = state.as_mut() { - s.update_status = UpdateStatus::Available(release); + s.update_status = + UpdateStatus::Available(AvailableUpdate::Release(release)); publish_update_status(s); } }