mirror of
https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git
synced 2026-06-20 10:19:27 +08:00
v1.2.0
- Added check for updates and ability to self-update
This commit is contained in:
parent
d6ea143f05
commit
832e70fd72
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-monitor"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"native-tls",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -5,8 +5,13 @@ mod models;
|
||||
mod native_interop;
|
||||
mod poller;
|
||||
mod theme;
|
||||
mod updater;
|
||||
mod window;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if let Some(exit_code) = updater::handle_cli_mode(&args) {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
window::run();
|
||||
}
|
||||
|
||||
383
src/updater.rs
Normal file
383
src/updater.rs
Normal file
@ -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<GitHubAsset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitHubAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
pub fn handle_cli_mode(args: &[String]) -> Option<i32> {
|
||||
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::<u32>().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<UpdateCheckResult, String> {
|
||||
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<Option<ReleaseDescriptor>, 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<ureq::Agent, String> {
|
||||
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<PathBuf, String> {
|
||||
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::<u32>().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<u16> {
|
||||
value.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
211
src/window.rs
211
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<LanguageId>) {
|
||||
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()),
|
||||
);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user