mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-13 20:14:46 +00:00
feat(update): replace cmd.exe handoff with native Win32 spawn
Both the in-app restart and the auto-update install previously shelled out to cmd.exe so the new instance could wait for the old one to release the singleton mutex and the locked exe file. On some Windows configurations the `start ""` inside `cmd /c ...` can flash a console window despite CREATE_NO_WINDOW + DETACHED_PROCESS flags. The replacement spawns the child binary directly via CreateProcessW; since the main exe is built with windows_subsystem = "windows", no console is ever allocated. - New `src/update/handoff.rs` exposes `spawn_detached`, `wait_for_parent_exit`, and `cleanup_stale_old_exes`. - New CLI flags `--wait-pid <pid>` and `--updated-to <version>` parsed early in `main`; the child waits up to 5s on the parent PID via OpenProcess+WaitForSingleObject before falling through to a 3s mutex-acquisition retry. - `restart_app` and `install::begin` both spawn detached children using the new helper. - Update install now uses MoveFileExW twice (rename running exe sideways, then move staged exe into place with MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED so portable installs on non-system drives still work). Rollback restores the backup if either the swap OR the post-swap detached spawn fails, and a MessageBoxW modal surfaces the backup path if the rollback itself fails. - First launch after an auto-update shows a blue-info tray balloon "Updated to vX.Y.Z" via a new `tray::notify_info` (the existing `tray::notify` is split into `notify_warning` + `notify_info` sharing a `notify_inner`). - Startup sweeps stale `<exe>.old.<pid>` siblings left by past in-place updates. - Three new `LocaleStrings` fields translated across all 8 supported locales (en/nl/es/fr/de/ja/ko/zh-TW).
This commit is contained in:
1 parent
1ba2883989
commit
1cd5b778f4
23 files changed
+1306
-112
No files matched your search
+97
-59
@@ -6,8 +6,7 @@
|
||||
// message-only window owned by this module.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::ffi::OsString;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
@@ -15,7 +14,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::System::Threading::CreateMutexW;
|
||||
use windows::Win32::System::Threading::{CreateMutexW, GetCurrentProcessId, Sleep};
|
||||
use windows::Win32::UI::HiDpi::{
|
||||
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||
};
|
||||
@@ -36,10 +35,11 @@ use crate::usage::{self, ProviderId, Registry, UsageWindows};
|
||||
|
||||
// Win32 message IDs owned by this module.
|
||||
pub const WM_APP_USAGE_UPDATED: u32 = 0x8001;
|
||||
// Posted from the update worker thread when the swap-and-restart cmd
|
||||
// handoff has been launched successfully. The UI thread responds by
|
||||
// calling PostQuitMessage(0) to release the file lock on the running
|
||||
// .exe so cmd.exe can overwrite it.
|
||||
// Posted from the update worker thread after `install::begin` has
|
||||
// already swapped the binary on disk and spawned the new detached
|
||||
// child. The UI thread responds with PostQuitMessage(0) so the old
|
||||
// instance exits cleanly and releases the singleton mutex for the
|
||||
// child waiting on `--wait-pid`.
|
||||
pub const WM_APP_UPDATE_APPLIED: u32 = 0x8002;
|
||||
|
||||
// Timer IDs used with `SetTimer(msg_hwnd, …)`.
|
||||
@@ -147,27 +147,52 @@ fn lock_state() -> MutexGuard<'static, Option<AppState>> {
|
||||
|
||||
// ---------- Entry ----------
|
||||
|
||||
pub fn run() {
|
||||
/// Acquire the singleton mutex, optionally retrying for ~3s if the
|
||||
/// caller passed `--wait-pid` (i.e. we just spawned from an exiting
|
||||
/// parent that has not yet released its handle).
|
||||
fn acquire_singleton_mutex(retry: bool) -> Option<HANDLE> {
|
||||
let mutex_name_w = os::to_utf16_nul(APP_MUTEX_NAME);
|
||||
let max_attempts = if retry { 15 } else { 1 };
|
||||
for attempt in 0..max_attempts {
|
||||
let handle = unsafe { CreateMutexW(None, false, PCWSTR::from_raw(mutex_name_w.as_ptr())) };
|
||||
match handle {
|
||||
Ok(h) => {
|
||||
let already = unsafe { GetLastError() } == ERROR_ALREADY_EXISTS;
|
||||
if !already {
|
||||
return Some(h);
|
||||
}
|
||||
// Mutex still held by parent. Close this handle and retry.
|
||||
unsafe {
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
if attempt + 1 == max_attempts {
|
||||
log::info!("another instance already running; exiting");
|
||||
return None;
|
||||
}
|
||||
log::debug!(
|
||||
"mutex still held by parent (attempt {}/{}), waiting 200ms",
|
||||
attempt + 1,
|
||||
max_attempts
|
||||
);
|
||||
unsafe { Sleep(200) };
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("CreateMutex failed: {e}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn run(args: crate::AppArgs) {
|
||||
unsafe {
|
||||
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||
}
|
||||
|
||||
let mutex_name_w = os::to_utf16_nul(APP_MUTEX_NAME);
|
||||
let _mutex = unsafe {
|
||||
let handle = CreateMutexW(None, false, PCWSTR::from_raw(mutex_name_w.as_ptr()));
|
||||
match handle {
|
||||
Ok(h) => {
|
||||
if GetLastError() == ERROR_ALREADY_EXISTS {
|
||||
log::info!("another instance already running; exiting");
|
||||
return;
|
||||
}
|
||||
h
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("CreateMutex failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _mutex = match acquire_singleton_mutex(args.wait_pid_present) {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let settings = settings::load();
|
||||
@@ -208,6 +233,16 @@ pub fn run() {
|
||||
create_initial_bubbles();
|
||||
refresh_tray_icons();
|
||||
|
||||
// Post-update tasks: show "Updated to vX.Y.Z" balloon (driven by
|
||||
// --updated-to passed by the previous instance) and sweep any
|
||||
// stale `<exe>.old.<pid>` siblings left by past in-place swaps.
|
||||
if let Some(v) = args.updated_to.as_ref() {
|
||||
announce_update_applied(msg_hwnd, v);
|
||||
}
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
update::handoff::cleanup_stale_old_exes(&exe_path);
|
||||
}
|
||||
|
||||
let poll_interval = lock_state()
|
||||
.as_ref()
|
||||
.map(|s| s.settings.poll_interval_ms)
|
||||
@@ -828,7 +863,7 @@ fn show_threshold_balloon(provider: ProviderId, threshold: u8) {
|
||||
};
|
||||
(s.msg_hwnd, provider, title, body)
|
||||
};
|
||||
tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
||||
tray::notify_warning(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
||||
}
|
||||
|
||||
fn show_token_expired_balloon(failed: ProviderId) {
|
||||
@@ -856,7 +891,30 @@ fn show_token_expired_balloon(failed: ProviderId) {
|
||||
};
|
||||
(s.msg_hwnd, failed, title, body)
|
||||
};
|
||||
tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
||||
tray::notify_warning(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
||||
}
|
||||
|
||||
/// Show the "Updated to vX.Y.Z" balloon on first launch after an
|
||||
/// auto-update. Picks Claude as the host icon if it's registered;
|
||||
/// otherwise falls back to Codex. If neither is registered the
|
||||
/// notification silently drops — better than crashing.
|
||||
fn announce_update_applied(_msg_hwnd: HWND, version: &str) {
|
||||
let payload = {
|
||||
let s = lock_state();
|
||||
let Some(s) = s.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let strings = s.i18n.strings();
|
||||
let title = strings.update_applied_title.clone();
|
||||
let body = format!("{}{}", strings.update_applied_body, version);
|
||||
let host = if s.settings.show_claude_code {
|
||||
ProviderId::Claude
|
||||
} else {
|
||||
ProviderId::ChatGpt
|
||||
};
|
||||
(s.msg_hwnd, host, title, body)
|
||||
};
|
||||
tray::notify_info(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
||||
}
|
||||
|
||||
// ---------- Context menu ----------
|
||||
@@ -1377,17 +1435,9 @@ fn set_update_check_interval(value: Option<u64>) {
|
||||
|
||||
// ---------- Restart ----------
|
||||
|
||||
// Windows CreateProcess flags. Match the values used by `update::install`
|
||||
// so the cmd-handoff child detaches cleanly without flashing a console.
|
||||
const RESTART_CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const RESTART_DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
|
||||
/// Relaunch the running binary via a detached cmd.exe handoff.
|
||||
///
|
||||
/// The 1-second `timeout` gives the current process time to release the
|
||||
/// `Global\ClaudeCodeUsageBubble` mutex before the relaunched instance's
|
||||
/// `CreateMutexW` runs, otherwise the new instance would see
|
||||
/// `ERROR_ALREADY_EXISTS` and exit immediately.
|
||||
/// Relaunch the running binary by spawning a detached child via
|
||||
/// `CreateProcessW`. The child waits on our PID before acquiring the
|
||||
/// singleton mutex, so no shell handoff or timer is required.
|
||||
fn restart_app() {
|
||||
// Defensive flush — bubble positions and most settings already persist
|
||||
// on change, but a final save is cheap insurance. Snapshot then drop the
|
||||
@@ -1404,30 +1454,18 @@ fn restart_app() {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let exe_str = exe.to_string_lossy();
|
||||
// cmd.exe expands `%var%` inside double quotes, so a path containing `%`
|
||||
// would let the environment leak into the relaunch. Refuse — matches the
|
||||
// defense already used in `update::install`.
|
||||
if exe_str.contains('%') {
|
||||
log::error!("restart: refusing path containing '%': {exe_str}");
|
||||
return;
|
||||
}
|
||||
let exe_str = exe_str.replace('"', "");
|
||||
let cmd = format!(r#"timeout /t 1 /nobreak >nul & start "" "{exe_str}""#);
|
||||
let spawned = Command::new("cmd.exe")
|
||||
.raw_arg("/c")
|
||||
.raw_arg(format!("\"{cmd}\""))
|
||||
.creation_flags(RESTART_CREATE_NO_WINDOW | RESTART_DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
match spawned {
|
||||
Ok(_) => {
|
||||
log::info!("restart: cmd handoff spawned, posting quit");
|
||||
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let args = vec![
|
||||
OsString::from("--wait-pid"),
|
||||
OsString::from(pid.to_string()),
|
||||
];
|
||||
match update::handoff::spawn_detached(&exe, &args) {
|
||||
Ok(()) => {
|
||||
log::info!("restart: spawned detached child (parent pid={pid}), posting quit");
|
||||
unsafe { PostQuitMessage(0) };
|
||||
}
|
||||
Err(e) => log::error!("restart: cmd spawn failed: {e}"),
|
||||
Err(e) => log::error!("restart: spawn_detached failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,3 +44,6 @@ chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
|
||||
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
||||
threshold_80_body = "5-Stunden-Limit naht."
|
||||
threshold_95_body = "Limit fast erreicht — gönn dir eine Pause."
|
||||
update_applied_title = "Update angewendet"
|
||||
update_applied_body = "Aktualisiert auf v"
|
||||
update_rollback_failed_body = "Update fehlgeschlagen. Deine ursprüngliche Binärdatei liegt unter: "
|
||||
@@ -44,3 +44,6 @@ chatgpt_token_expired_title = "Codex session expired"
|
||||
chatgpt_token_expired_body = "Sign in again to keep tracking your usage."
|
||||
threshold_80_body = "Approaching the 5-hour limit."
|
||||
threshold_95_body = "Limit is close — consider easing up."
|
||||
update_applied_title = "Update applied"
|
||||
update_applied_body = "Updated to v"
|
||||
update_rollback_failed_body = "Update failed. Your original binary is saved at: "
|
||||
@@ -44,3 +44,6 @@ chatgpt_token_expired_title = "Sesión de Codex caducada"
|
||||
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
|
||||
threshold_80_body = "Cerca del límite de 5 horas."
|
||||
threshold_95_body = "Límite casi alcanzado — reduce el ritmo."
|
||||
update_applied_title = "Actualización aplicada"
|
||||
update_applied_body = "Actualizado a v"
|
||||
update_rollback_failed_body = "Actualización fallida. Tu binario original está guardado en: "
|
||||
@@ -44,3 +44,6 @@ chatgpt_token_expired_title = "Session Codex expirée"
|
||||
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
||||
threshold_80_body = "Approche de la limite de 5 heures."
|
||||
threshold_95_body = "Limite proche — pensez à lever le pied."
|
||||
update_applied_title = "Mise à jour appliquée"
|
||||
update_applied_body = "Mis à jour vers v"
|
||||
update_rollback_failed_body = "Échec de la mise à jour. Votre binaire d'origine est enregistré à : "
|
||||
@@ -44,3 +44,6 @@ chatgpt_token_expired_title = "Codexのセッションが切れました"
|
||||
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||
threshold_80_body = "5時間の上限に近づいています。"
|
||||
threshold_95_body = "上限に近づきました — ペースを落としましょう。"
|
||||
update_applied_title = "更新を適用しました"
|
||||
update_applied_body = "バージョン v"
|
||||
update_rollback_failed_body = "更新に失敗しました。元のバイナリは次の場所に保存されています: "
|
||||
@@ -44,3 +44,6 @@ chatgpt_token_expired_title = "Codex 세션 만료"
|
||||
chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
||||
threshold_80_body = "5시간 한도에 가까워지고 있어요."
|
||||
threshold_95_body = "한도 임박 — 잠시 쉬어가세요."
|
||||
update_applied_title = "업데이트가 적용되었습니다"
|
||||
update_applied_body = "버전 v"
|
||||
update_rollback_failed_body = "업데이트 실패. 원본 바이너리는 다음 위치에 저장되었습니다: "
|
||||
@@ -44,3 +44,6 @@ chatgpt_token_expired_title = "Codex-sessie verlopen"
|
||||
chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
||||
threshold_80_body = "Je nadert de 5-uurslimiet."
|
||||
threshold_95_body = "Limiet bijna bereikt — overweeg even gas terug te nemen."
|
||||
update_applied_title = "Update toegepast"
|
||||
update_applied_body = "Bijgewerkt naar v"
|
||||
update_rollback_failed_body = "Update mislukt. Je oorspronkelijke bestand staat op: "
|
||||
@@ -44,3 +44,6 @@ chatgpt_token_expired_title = "Codex 工作階段已過期"
|
||||
chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。"
|
||||
threshold_80_body = "接近 5 小時上限。"
|
||||
threshold_95_body = "上限將至 — 建議稍作休息。"
|
||||
update_applied_title = "已套用更新"
|
||||
update_applied_body = "已更新至 v"
|
||||
update_rollback_failed_body = "更新失敗。您的原始執行檔已保存於: "
|
||||
@@ -68,6 +68,13 @@ pub struct LocaleStrings {
|
||||
pub threshold_80_body: String,
|
||||
/// Body text for the 95% threshold balloon.
|
||||
pub threshold_95_body: String,
|
||||
/// Title for the tray balloon shown on first launch after an auto-update.
|
||||
pub update_applied_title: String,
|
||||
/// Prefix for the tray balloon body. Call site appends the version (e.g. "0.1.10").
|
||||
pub update_applied_body: String,
|
||||
/// Prefix for the rollback-failed MessageBox body. Call site appends
|
||||
/// the backup path and a separator with the expected target filename.
|
||||
pub update_rollback_failed_body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
+29
-3
@@ -36,8 +36,34 @@ fn main() {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
if diagnose_enabled {
|
||||
log::info!("entering app::run");
|
||||
let wait_pid = args
|
||||
.iter()
|
||||
.position(|a| a == "--wait-pid")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
if let Some(pid) = wait_pid {
|
||||
if diagnose_enabled {
|
||||
log::info!("waiting up to 5s for parent pid {pid} to exit");
|
||||
}
|
||||
update::handoff::wait_for_parent_exit(pid, 5_000);
|
||||
}
|
||||
app::run();
|
||||
|
||||
let updated_to = args
|
||||
.iter()
|
||||
.position(|a| a == "--updated-to")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.cloned();
|
||||
|
||||
if diagnose_enabled {
|
||||
log::info!("entering app::run (wait_pid={wait_pid:?} updated_to={updated_to:?})");
|
||||
}
|
||||
app::run(AppArgs {
|
||||
wait_pid_present: wait_pid.is_some(),
|
||||
updated_to,
|
||||
});
|
||||
}
|
||||
|
||||
pub struct AppArgs {
|
||||
pub wait_pid_present: bool,
|
||||
pub updated_to: Option<String>,
|
||||
}
|
||||
+20
-5
@@ -11,8 +11,8 @@ use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::Shell::{
|
||||
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_WARNING, NIM_ADD,
|
||||
NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW,
|
||||
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_INFO, NIIF_WARNING,
|
||||
NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::DestroyIcon;
|
||||
|
||||
@@ -81,13 +81,28 @@ pub fn sync(owner: HWND, desired: &[TrayIcon]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a balloon notification on an already-registered icon.
|
||||
pub fn notify(owner: HWND, kind: IconKind, title: &str, body: &str) {
|
||||
/// Show a yellow-warning balloon on an already-registered icon.
|
||||
pub fn notify_warning(owner: HWND, kind: IconKind, title: &str, body: &str) {
|
||||
notify_inner(owner, kind, title, body, NIIF_WARNING);
|
||||
}
|
||||
|
||||
/// Show a blue-info balloon on an already-registered icon.
|
||||
pub fn notify_info(owner: HWND, kind: IconKind, title: &str, body: &str) {
|
||||
notify_inner(owner, kind, title, body, NIIF_INFO);
|
||||
}
|
||||
|
||||
fn notify_inner(
|
||||
owner: HWND,
|
||||
kind: IconKind,
|
||||
title: &str,
|
||||
body: &str,
|
||||
flags: NOTIFY_ICON_INFOTIP_FLAGS,
|
||||
) {
|
||||
let mut data = build_data(owner, kind);
|
||||
data.uFlags = NIF_INFO;
|
||||
write_utf16(&mut data.szInfoTitle, title);
|
||||
write_utf16(&mut data.szInfo, body);
|
||||
data.dwInfoFlags = NIIF_WARNING;
|
||||
data.dwInfoFlags = flags;
|
||||
unsafe {
|
||||
let _ = Shell_NotifyIconW(NIM_MODIFY, &data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Native Win32 process + file handoff primitives used by the in-app
|
||||
// restart path and the auto-update install path. The main binary uses
|
||||
// `windows_subsystem = "windows"`, so spawning the child directly via
|
||||
// `CreateProcessW` allocates no console — nothing can flash.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::path::Path;
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::{CloseHandle, FALSE, HANDLE, WAIT_OBJECT_0};
|
||||
use windows::Win32::System::Threading::{
|
||||
CreateProcessW, OpenProcess, WaitForSingleObject, CREATE_NEW_PROCESS_GROUP,
|
||||
CREATE_NO_WINDOW, DETACHED_PROCESS, PROCESS_INFORMATION, PROCESS_SYNCHRONIZE,
|
||||
STARTUPINFOW,
|
||||
};
|
||||
|
||||
/// Spawn `exe` with the supplied args as a detached, console-less child.
|
||||
///
|
||||
/// Caller is fire-and-forget: the child's handles are closed immediately
|
||||
/// so no zombie wait is required.
|
||||
pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
|
||||
let mut cmdline = build_command_line(exe, args);
|
||||
|
||||
let si = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut pi = PROCESS_INFORMATION::default();
|
||||
|
||||
let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
|
||||
let ok = unsafe {
|
||||
CreateProcessW(
|
||||
PCWSTR::null(),
|
||||
windows::core::PWSTR(cmdline.as_mut_ptr()),
|
||||
None,
|
||||
None,
|
||||
FALSE,
|
||||
flags,
|
||||
None,
|
||||
PCWSTR::null(),
|
||||
&si,
|
||||
&mut pi,
|
||||
)
|
||||
};
|
||||
|
||||
if ok.is_err() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
if !pi.hThread.is_invalid() {
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
}
|
||||
if !pi.hProcess.is_invalid() {
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
}
|
||||
}
|
||||
// Suppress the unused-variable warning until si.lpReserved fields ever matter.
|
||||
let _ = &si;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait up to `timeout_ms` for `pid` to exit. Silent on any failure —
|
||||
/// caller treats this as a best-effort barrier before acquiring the
|
||||
/// singleton mutex.
|
||||
pub fn wait_for_parent_exit(pid: u32, timeout_ms: u32) {
|
||||
let handle: HANDLE = match unsafe { OpenProcess(PROCESS_SYNCHRONIZE, FALSE, pid) } {
|
||||
Ok(h) if !h.is_invalid() => h,
|
||||
_ => return,
|
||||
};
|
||||
unsafe {
|
||||
let res = WaitForSingleObject(handle, timeout_ms);
|
||||
if res != WAIT_OBJECT_0 {
|
||||
log::debug!("wait_for_parent_exit pid={pid} timeout/err res={:?}", res.0);
|
||||
}
|
||||
let _ = CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove leftover `<exe>.old.<pid>` siblings from previous in-place updates.
|
||||
/// Filled in by phase 4; stubbed here so phase 1 can wire the call sites.
|
||||
pub fn cleanup_stale_old_exes(current_exe: &Path) {
|
||||
let Some(dir) = current_exe.parent() else {
|
||||
return;
|
||||
};
|
||||
let Some(stem) = current_exe.file_name() else {
|
||||
return;
|
||||
};
|
||||
let prefix = format!("{}.old.", stem.to_string_lossy());
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy().starts_with(&prefix) {
|
||||
if let Err(e) = std::fs::remove_file(entry.path()) {
|
||||
log::debug!(
|
||||
"cleanup_stale_old_exes: remove {:?} failed: {e}",
|
||||
entry.path()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_command_line(exe: &Path, args: &[OsString]) -> Vec<u16> {
|
||||
// CreateProcessW parses argv[0] from a quoted exe path. We wrap the
|
||||
// exe in `"…"` and join args separated by spaces. Args are quoted
|
||||
// only when they contain whitespace; our callers pass simple tokens
|
||||
// (--wait-pid <number>, --updated-to <version>) so naive quoting is
|
||||
// sufficient.
|
||||
let mut line = String::new();
|
||||
line.push('"');
|
||||
line.push_str(&exe.to_string_lossy());
|
||||
line.push('"');
|
||||
for a in args {
|
||||
line.push(' ');
|
||||
let s = a.to_string_lossy();
|
||||
if s.chars().any(|c| c.is_whitespace()) {
|
||||
line.push('"');
|
||||
line.push_str(&s);
|
||||
line.push('"');
|
||||
} else {
|
||||
line.push_str(&s);
|
||||
}
|
||||
}
|
||||
let mut wide: Vec<u16> = std::ffi::OsString::from(line).encode_wide().collect();
|
||||
wide.push(0);
|
||||
wide
|
||||
}
|
||||
+127
-43
@@ -1,42 +1,47 @@
|
||||
// Download a release asset and hand off via inline `cmd /c`.
|
||||
// Download a release asset and swap it in via native Win32 calls.
|
||||
//
|
||||
// We avoid the helper-exe pattern entirely: after writing the new .exe
|
||||
// to a staging path, we spawn cmd.exe with a one-liner that waits 2 s,
|
||||
// moves the new binary over the running one (Windows releases the file
|
||||
// lock when our process exits), and relaunches it.
|
||||
// After writing the new .exe to a staging path and verifying its
|
||||
// SHA-256, we `MoveFileExW` the running exe sideways (so Windows
|
||||
// releases the file lock on our own image), then `MoveFileExW` the
|
||||
// staged exe into place, then spawn the new binary detached via
|
||||
// `handoff::spawn_detached`. No shell, no console allocation.
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::net::Client;
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
|
||||
};
|
||||
use windows::Win32::System::Threading::GetCurrentProcessId;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
MessageBoxW, MB_ICONERROR, MB_OK,
|
||||
};
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
use crate::net::Client;
|
||||
use crate::os::to_utf16_nul;
|
||||
|
||||
pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error> {
|
||||
let current = std::env::current_exe()?;
|
||||
ensure_writable(¤t)?;
|
||||
let staging = stage_path()?;
|
||||
// Refuse to proceed if either path contains `%`. Inside double quotes
|
||||
// cmd.exe still expands `%var%` references, so a path containing `%`
|
||||
// would let cmd substitute environment variables into the swap step.
|
||||
// Such paths are vanishingly rare on real Windows installs; failing
|
||||
// fast is safer than rolling a bespoke cmd-escape layer.
|
||||
// Defense in depth: `MoveFileExW` itself is immune to `%`-expansion
|
||||
// (no shell parses our paths), but the existing rejection guards
|
||||
// future code paths that might invoke external tools, so keep it.
|
||||
reject_unsafe_path(¤t)?;
|
||||
reject_unsafe_path(&staging)?;
|
||||
if let Some(parent) = staging.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
download(http, &release.asset_url, &staging, release.asset_sha256.as_ref())?;
|
||||
spawn_handoff(&staging, ¤t)?;
|
||||
swap_and_spawn(&staging, ¤t, &release.version)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CLI entry-point compatibility for `--apply-update <target> <source> <pid>`.
|
||||
/// The inline-cmd handoff already does the swap-and-restart; if this binary
|
||||
/// The native handoff already does the swap-and-restart; if this binary
|
||||
/// is invoked with the legacy flag (e.g. from an older release's helper)
|
||||
/// just exit cleanly so the upgrade still completes.
|
||||
pub fn run_cli(args: &[String]) -> Option<i32> {
|
||||
@@ -50,7 +55,7 @@ pub fn run_cli(args: &[String]) -> Option<i32> {
|
||||
fn download(
|
||||
http: &Client,
|
||||
url: &str,
|
||||
to: &std::path::Path,
|
||||
to: &Path,
|
||||
expected_sha256: Option<&[u8; 32]>,
|
||||
) -> Result<(), super::Error> {
|
||||
let resp = http
|
||||
@@ -86,40 +91,119 @@ fn hex_encode(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn reject_unsafe_path(p: &std::path::Path) -> Result<(), super::Error> {
|
||||
fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> {
|
||||
let s = p.to_string_lossy();
|
||||
if s.contains('%') {
|
||||
return Err(super::Error::UnsafePath(format!(
|
||||
"path contains '%' which cmd.exe expands as a variable: {s}"
|
||||
"path contains '%': {s}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(), super::Error> {
|
||||
let src_str = source.to_string_lossy().replace('"', "");
|
||||
let tgt_str = target.to_string_lossy().replace('"', "");
|
||||
// 2-second wait gives the current process time to exit and release the
|
||||
// file lock before `move` overwrites it.
|
||||
let cmd = format!(
|
||||
r#"timeout /t 2 /nobreak >nul & move /y "{src_str}" "{tgt_str}" & start "" "{tgt_str}""#
|
||||
);
|
||||
// raw_arg bypasses Rust's std auto-escaping which would turn the inner
|
||||
// `"` characters into `\"`. cmd.exe does not recognise `\"`, so the
|
||||
// escaped form makes `start` see the path as `\\` and emit a
|
||||
// "Windows cannot find '\\'" dialog. Feeding the command line raw
|
||||
// preserves the quotes cmd.exe actually expects.
|
||||
Command::new("cmd.exe")
|
||||
.raw_arg("/c")
|
||||
.raw_arg(format!("\"{cmd}\""))
|
||||
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
fn swap_and_spawn(
|
||||
source: &Path,
|
||||
target: &Path,
|
||||
version: &super::release::Version,
|
||||
) -> 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 version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
|
||||
let args = vec![
|
||||
OsString::from("--wait-pid"),
|
||||
OsString::from(pid.to_string()),
|
||||
OsString::from("--updated-to"),
|
||||
OsString::from(version_str),
|
||||
];
|
||||
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");
|
||||
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
|
||||
log::error!("post-spawn revert failed: {revert_err}");
|
||||
}
|
||||
return Err(super::Error::Io(spawn_err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_file(src: &Path, dst: &Path, flags: MOVE_FILE_FLAGS) -> Result<(), super::Error> {
|
||||
let src_w = to_utf16_nul(&src.to_string_lossy());
|
||||
let dst_w = to_utf16_nul(&dst.to_string_lossy());
|
||||
let result = unsafe {
|
||||
MoveFileExW(
|
||||
PCWSTR::from_raw(src_w.as_ptr()),
|
||||
PCWSTR::from_raw(dst_w.as_ptr()),
|
||||
flags,
|
||||
)
|
||||
};
|
||||
result.map_err(|e| {
|
||||
super::Error::SwapFailed(format!(
|
||||
"MoveFileExW({} -> {}): {e}",
|
||||
src.display(),
|
||||
dst.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn backup_path(target: &Path) -> PathBuf {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let fname = target
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "exe".to_string());
|
||||
let mut p = target.to_owned();
|
||||
p.set_file_name(format!("{fname}.old.{pid}"));
|
||||
p
|
||||
}
|
||||
|
||||
fn surface_rollback_failure(backup: &Path, target_name: &str) {
|
||||
// Pull the localized body from i18n; the caller passes the
|
||||
// user-meaningful filename so we can format it in-place.
|
||||
let strings = crate::i18n::I18n::load(None).strings().clone();
|
||||
let body = format!(
|
||||
"{}{}\n\n{}",
|
||||
strings.update_rollback_failed_body,
|
||||
backup.display(),
|
||||
target_name
|
||||
);
|
||||
let title_w = to_utf16_nul(&strings.update_failed);
|
||||
let body_w = to_utf16_nul(&body);
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
None,
|
||||
PCWSTR::from_raw(body_w.as_ptr()),
|
||||
PCWSTR::from_raw(title_w.as_ptr()),
|
||||
MB_OK | MB_ICONERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_path() -> Result<PathBuf, super::Error> {
|
||||
let base = dirs::data_local_dir().ok_or_else(|| {
|
||||
super::Error::NotWritable("no local data directory available".to_string())
|
||||
@@ -130,7 +214,7 @@ fn stage_path() -> Result<PathBuf, super::Error> {
|
||||
.join("update.exe"))
|
||||
}
|
||||
|
||||
fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> {
|
||||
fn ensure_writable(target: &Path) -> Result<(), super::Error> {
|
||||
let parent = target.parent().ok_or_else(|| {
|
||||
super::Error::NotWritable("could not resolve install directory".to_string())
|
||||
})?;
|
||||
|
||||
+6
-2
@@ -1,10 +1,12 @@
|
||||
// Self-update subsystem.
|
||||
//
|
||||
// Two stages: `release::fetch_latest` checks GitHub releases for a newer
|
||||
// build; `install::begin` downloads the .exe and hands off to a detached
|
||||
// `cmd /c` script that swaps the binary and restarts.
|
||||
// build; `install::begin` downloads the .exe, swaps it in via native
|
||||
// `MoveFileExW`, then spawns the new binary detached via
|
||||
// `CreateProcessW`. No shell handoff — nothing can flash a console.
|
||||
|
||||
pub mod channel;
|
||||
pub mod handoff;
|
||||
pub mod install;
|
||||
pub mod release;
|
||||
|
||||
@@ -24,6 +26,8 @@ pub enum Error {
|
||||
ChecksumMismatch { expected: String, actual: String },
|
||||
#[error("path rejected for safety: {0}")]
|
||||
UnsafePath(String),
|
||||
#[error("file swap failed: {0}")]
|
||||
SwapFailed(String),
|
||||
}
|
||||
|
||||
pub use channel::{current as current_channel, Channel};
|
||||
|
||||
Reference in new issue
Block a user