- Fixed update process for portable vs WinGet installs
This commit is contained in:
Craig Constable 2026-03-26 09:35:15 +10:00
parent 9c8f73cf69
commit 65cf2b5d16
6 changed files with 187 additions and 43 deletions

2
Cargo.lock generated
View File

@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-monitor"
version = "1.2.7"
version = "1.2.8"
dependencies = [
"dirs",
"native-tls",

View File

@ -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"

View File

@ -104,6 +104,7 @@ What the app stores locally:
- Widget position
- Polling frequency
- Language preference
- Last update check time
What it does **not** do:

View File

@ -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;

View File

@ -84,10 +84,19 @@ pub fn check_for_updates() -> Result<UpdateCheckResult, String> {
}
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(),
&current_exe.to_string_lossy(),
&current_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<PathBuf, String> {
.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<PathBuf> {
}
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 {

View File

@ -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<u64>,
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_update_check_unix: Option<u64>,
}
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<u64>) -> 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)