Compare commits

...
8 Commits
5 changed files with 153 additions and 58 deletions
Generated
+1 -1
View File
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "claude-code-usage-bubble" name = "claude-code-usage-bubble"
version = "0.3.8" version = "0.3.13"
dependencies = [ dependencies = [
"dirs", "dirs",
"embed-resource", "embed-resource",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "claude-code-usage-bubble" name = "claude-code-usage-bubble"
version = "0.3.8" version = "0.3.13"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows" description = "Floating bubble showing Claude Code and Codex usage on Windows"
+1
View File
@@ -267,6 +267,7 @@ pub fn run(args: crate::AppArgs) {
if let Ok(exe_path) = std::env::current_exe() { if let Ok(exe_path) = std::env::current_exe() {
update::handoff::cleanup_stale_old_exes(&exe_path); update::handoff::cleanup_stale_old_exes(&exe_path);
} }
update::install::cleanup_staged_update_files();
let poll_interval = lock_state() let poll_interval = lock_state()
.as_ref() .as_ref()
+2 -1
View File
@@ -22,6 +22,7 @@ use windows::Win32::System::Threading::{
/// so no zombie wait is required. /// so no zombie wait is required.
pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> { pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
let mut cmdline = build_command_line(exe, args); let mut cmdline = build_command_line(exe, args);
let exe_w: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
let si = STARTUPINFOW { let si = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32, cb: std::mem::size_of::<STARTUPINFOW>() as u32,
@@ -32,7 +33,7 @@ pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP; let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
let ok = unsafe { let ok = unsafe {
CreateProcessW( CreateProcessW(
PCWSTR::null(), PCWSTR::from_raw(exe_w.as_ptr()),
windows::core::PWSTR(cmdline.as_mut_ptr()), windows::core::PWSTR(cmdline.as_mut_ptr()),
None, None,
None, None,
+148 -55
View File
@@ -1,10 +1,10 @@
// Download a release asset and swap it in via native Win32 calls. // Download a release asset and swap it in via a detached helper process.
// //
// After writing the new .exe to a staging path and verifying its // The running process cannot reliably replace its own mapped image on
// SHA-256, we `MoveFileExW` the running exe sideways (so Windows // Windows. Instead, it writes the new .exe to a staging path, copies the
// releases the file lock on our own image), then `MoveFileExW` the // current executable to a helper path, starts that helper with
// staged exe into place, then spawn the new binary detached via // `--apply-update`, and then exits. The helper waits for the parent process
// `handoff::spawn_detached`. No shell, no console allocation. // to exit before replacing the install target with the staged new binary.
use std::ffi::OsString; use std::ffi::OsString;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -16,9 +16,7 @@ use windows::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS, MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
}; };
use windows::Win32::System::Threading::GetCurrentProcessId; use windows::Win32::System::Threading::GetCurrentProcessId;
use windows::Win32::UI::WindowsAndMessaging::{ use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
MessageBoxW, MB_ICONERROR, MB_OK,
};
use crate::net::Client; use crate::net::Client;
use crate::os::to_utf16_nul; use crate::os::to_utf16_nul;
@@ -35,20 +33,46 @@ pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error
if let Some(parent) = staging.parent() { if let Some(parent) = staging.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
download(http, &release.asset_url, &staging, release.asset_sha256.as_ref())?; download(
swap_and_spawn(&staging, &current, &release.version)?; http,
&release.asset_url,
&staging,
release.asset_sha256.as_ref(),
)?;
let helper = helper_path()?;
reject_unsafe_path(&helper)?;
prepare_update_helper(&current, &helper)?;
spawn_update_helper(&helper, &staging, &current, &release.version)?;
Ok(()) Ok(())
} }
/// CLI entry-point compatibility for `--apply-update <target> <source> <pid>`. /// CLI entry point for `--apply-update <target> <source> <parent-pid> <version>`.
/// The native handoff already does the swap-and-restart; if this binary /// Runs from the staged new binary, waits for the old UI process to exit,
/// is invoked with the legacy flag (e.g. from an older release's helper) /// replaces the installed exe, and starts the installed copy.
/// just exit cleanly so the upgrade still completes.
pub fn run_cli(args: &[String]) -> Option<i32> { pub fn run_cli(args: &[String]) -> Option<i32> {
if args.len() >= 2 && args[1] == "--apply-update" { if args.get(1).map(String::as_str) != Some("--apply-update") {
Some(0) return None;
} else { }
None let Some(target) = args.get(2).map(PathBuf::from) else {
return Some(2);
};
let Some(source) = args.get(3).map(PathBuf::from) else {
return Some(2);
};
let Some(parent_pid) = args.get(4).and_then(|s| s.parse::<u32>().ok()) else {
return Some(2);
};
let Some(version) = args.get(5).cloned() else {
return Some(2);
};
super::handoff::wait_for_parent_exit(parent_pid, 15_000);
match replace_from_helper(&source, &target, &version) {
Ok(()) => Some(0),
Err(e) => {
log::error!("apply-update failed: {e}");
Some(1)
}
} }
} }
@@ -63,7 +87,9 @@ fn download(
.header("User-Agent", super::release::user_agent()) .header("User-Agent", super::release::user_agent())
.send()?; .send()?;
if !(200..300).contains(&resp.status()) { if !(200..300).contains(&resp.status()) {
return Err(super::Error::Network(crate::net::Error::Status(resp.status()))); return Err(super::Error::Network(crate::net::Error::Status(
resp.status(),
)));
} }
let body = resp.body(); let body = resp.body();
if let Some(expected) = expected_sha256 { if let Some(expected) = expected_sha256 {
@@ -94,61 +120,68 @@ fn hex_encode(bytes: &[u8]) -> String {
fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> { fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> {
let s = p.to_string_lossy(); let s = p.to_string_lossy();
if s.contains('%') { if s.contains('%') {
return Err(super::Error::UnsafePath(format!( return Err(super::Error::UnsafePath(format!("path contains '%': {s}")));
"path contains '%': {s}"
)));
} }
Ok(()) Ok(())
} }
fn swap_and_spawn( fn spawn_update_helper(
source: &Path, helper: &Path,
staging: &Path,
target: &Path, target: &Path,
version: &super::release::Version, version: &super::release::Version,
) -> Result<(), super::Error> { ) -> Result<(), super::Error> {
let backup = backup_path(target);
// Step 1: rename running exe sideways. Windows allows renaming a
// file even while its image is mapped into memory; this releases
// the lock on the original `target` path. Same directory by
// construction, so plain MoveFileExW with no flags is sufficient.
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
// Step 2: move staged exe into place. Staging lives under
// %LOCALAPPDATA%, target lives wherever the user installed —
// COPY_ALLOWED lets MoveFileExW fall back to copy+delete when
// the two paths cross volumes (portable installs on D:/E:/etc.).
let step2_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
if let Err(swap_err) = move_file(source, target, step2_flags) {
// Best-effort revert. Same volume, no COPY_ALLOWED needed.
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("rollback also failed: {revert_err}; surfacing modal");
let target_name = target
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
surface_rollback_failure(&backup, &target_name);
}
return Err(swap_err);
}
// Step 3: spawn the new exe detached with --wait-pid + --updated-to.
let pid = unsafe { GetCurrentProcessId() }; let pid = unsafe { GetCurrentProcessId() };
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch); let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
let args = vec![ let args = vec![
OsString::from("--wait-pid"), OsString::from("--apply-update"),
target.as_os_str().to_os_string(),
staging.as_os_str().to_os_string(),
OsString::from(pid.to_string()), OsString::from(pid.to_string()),
OsString::from("--updated-to"),
OsString::from(version_str), OsString::from(version_str),
]; ];
super::handoff::spawn_detached(helper, &args).map_err(super::Error::Io)
}
fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<(), super::Error> {
let backup = backup_path(target);
// Parent has exited, so the install target is no longer mapped.
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
let swap_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
if let Err(swap_err) = move_file(source, target, swap_flags) {
// Compatibility for users updating from a release that invoked
// the downloaded binary itself as the helper. A mapped source exe
// may not be movable, but it can usually still be copied.
let copy_result = std::fs::copy(source, target);
if copy_result.is_err() {
log::error!("source move failed before copy fallback: {swap_err}");
}
if let Err(copy_err) = copy_result {
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("rollback also failed: {revert_err}; surfacing modal");
let target_name = target
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
surface_rollback_failure(&backup, &target_name);
}
return Err(super::Error::Io(copy_err));
}
}
let args = vec![OsString::from("--updated-to"), OsString::from(version)];
if let Err(spawn_err) = super::handoff::spawn_detached(target, &args) { if let Err(spawn_err) = super::handoff::spawn_detached(target, &args) {
// New binary is on disk but won't auto-launch. Roll back so
// the user's next "Restart" stays on the known-good version.
log::error!("spawn_detached failed after swap: {spawn_err}; attempting revert"); log::error!("spawn_detached failed after swap: {spawn_err}; attempting revert");
let _ = std::fs::remove_file(target);
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) { if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("post-spawn revert failed: {revert_err}"); log::error!("post-spawn revert failed: {revert_err}");
} }
return Err(super::Error::Io(spawn_err)); return Err(super::Error::Io(spawn_err));
} }
let _ = std::fs::remove_file(source);
Ok(()) Ok(())
} }
@@ -214,6 +247,47 @@ fn stage_path() -> Result<PathBuf, super::Error> {
.join("update.exe")) .join("update.exe"))
} }
fn helper_path() -> Result<PathBuf, super::Error> {
let base = dirs::data_local_dir().ok_or_else(|| {
super::Error::NotWritable("no local data directory available".to_string())
})?;
let pid = unsafe { GetCurrentProcessId() };
Ok(base
.join("ClaudeCodeUsageBubble")
.join("updates")
.join(format!("updater-helper-{pid}.exe")))
}
fn prepare_update_helper(current: &Path, helper: &Path) -> Result<(), super::Error> {
if let Some(parent) = helper.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(current, helper)?;
Ok(())
}
pub fn cleanup_staged_update_files() {
let Ok(stage) = stage_path() else {
return;
};
let Some(dir) = stage.parent() else {
return;
};
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name == "update.exe" || (name.starts_with("updater-helper-") && name.ends_with(".exe")) {
if let Err(e) = std::fs::remove_file(&path) {
log::debug!("cleanup_staged_update_files: remove {:?} failed: {e}", path);
}
}
}
}
fn ensure_writable(target: &Path) -> Result<(), super::Error> { fn ensure_writable(target: &Path) -> Result<(), super::Error> {
let parent = target.parent().ok_or_else(|| { let parent = target.parent().ok_or_else(|| {
super::Error::NotWritable("could not resolve install directory".to_string()) super::Error::NotWritable("could not resolve install directory".to_string())
@@ -223,3 +297,22 @@ fn ensure_writable(target: &Path) -> Result<(), super::Error> {
let _ = std::fs::remove_file(&probe); let _ = std::fs::remove_file(&probe);
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stage_and_helper_paths_are_distinct_exes() {
let stage = stage_path().expect("stage path");
let helper = helper_path().expect("helper path");
assert_eq!(stage.file_name().unwrap(), "update.exe");
assert!(helper
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("updater-helper-"));
assert_ne!(stage, helper);
}
}