mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-22 14:16:31 +00:00
feat: parse the CLI flags, wait for the previous instance and clean up after updates
The argv parser accepts --diagnose, --wait-pid and --updated-to and ignores anything else, including --apply-update, which no released version passes to the downloaded binary. Arguments are read lossily, so a non-UTF-8 argument no longer panics. The wait for a parent pid polls kill(pid, 0) on Unix and waits on the process handle on Windows, capped by the caller. The Windows platform module gains the WebView2 runtime probe, an error message box and the theme read. handoff::relaunch spawns the AppImage or the current executable detached with --wait-pid and returns the spawn error to the caller. The startup cleanup of <exe>.old.* siblings and staged updater executables moves out of the Win32 update module.
This commit is contained in:
1 parent
4ca4f075f3
commit
607a3103d3
6 files changed
+559
-134
No files matched your search
+164
@@ -0,0 +1,164 @@
|
||||
//! Command-line flags. Parsing is pure so every accepted form is unit-tested.
|
||||
//!
|
||||
//! Accepted flags:
|
||||
//! - `--diagnose` turns on the diagnostic log.
|
||||
//! - `--wait-pid <pid>` waits for an exiting instance before starting.
|
||||
//! - `--updated-to <version>` announces a finished update.
|
||||
//!
|
||||
//! Anything else, including a flag whose value is missing or malformed, is
|
||||
//! ignored so an old or newer caller can never stop the app from starting.
|
||||
|
||||
use std::ffi::OsString;
|
||||
|
||||
/// Longest version string accepted for `--updated-to`.
|
||||
const MAX_VERSION_LEN: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CliArgs {
|
||||
pub diagnose: bool,
|
||||
pub wait_pid: Option<u32>,
|
||||
pub updated_to: Option<String>,
|
||||
}
|
||||
|
||||
/// The process arguments, parsed. Non-UTF-8 arguments are read lossily
|
||||
/// instead of panicking, as `std::env::args` would.
|
||||
pub fn from_env() -> CliArgs {
|
||||
parse(std::env::args_os().skip(1))
|
||||
}
|
||||
|
||||
/// Parse the arguments after the program name.
|
||||
pub fn parse<I, S>(args: I) -> CliArgs
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
let args: Vec<String> = args
|
||||
.into_iter()
|
||||
.map(|a| a.into().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let mut out = CliArgs::default();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--diagnose" => out.diagnose = true,
|
||||
"--wait-pid" => {
|
||||
if let Some(pid) = args.get(i + 1).and_then(|v| parse_pid(v)) {
|
||||
out.wait_pid = Some(pid);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--updated-to" => {
|
||||
if let Some(version) = args.get(i + 1).filter(|v| is_version_like(v)) {
|
||||
out.updated_to = Some(version.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A positive process id. Zero is rejected: on Unix it names the whole
|
||||
/// process group, never a single parent.
|
||||
fn parse_pid(value: &str) -> Option<u32> {
|
||||
value.parse::<u32>().ok().filter(|pid| *pid != 0)
|
||||
}
|
||||
|
||||
/// A version as the updater passes it (`0.6.0`, `1.2.3-rc.1`). The text ends
|
||||
/// up in a notification, so anything else is dropped.
|
||||
fn is_version_like(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= MAX_VERSION_LEN
|
||||
&& value.starts_with(|c: char| c.is_ascii_digit())
|
||||
&& value
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(args: &[&str]) -> CliArgs {
|
||||
parse(args.iter().copied())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_flags_is_the_default() {
|
||||
assert_eq!(p(&[]), CliArgs::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnose_flag() {
|
||||
assert!(p(&["--diagnose"]).diagnose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_pid_takes_the_next_value() {
|
||||
assert_eq!(p(&["--wait-pid", "4242"]).wait_pid, Some(4242));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_pid_rejects_zero_garbage_and_a_missing_value() {
|
||||
assert_eq!(p(&["--wait-pid", "0"]).wait_pid, None);
|
||||
assert_eq!(p(&["--wait-pid", "-5"]).wait_pid, None);
|
||||
assert_eq!(p(&["--wait-pid"]).wait_pid, None);
|
||||
// A following flag is not swallowed as the value.
|
||||
let args = p(&["--wait-pid", "--diagnose"]);
|
||||
assert_eq!(args.wait_pid, None);
|
||||
assert!(args.diagnose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updated_to_alone() {
|
||||
// v0.3.13 to v0.4.0 relaunch with only this flag.
|
||||
assert_eq!(
|
||||
p(&["--updated-to", "0.6.0"]),
|
||||
CliArgs {
|
||||
updated_to: Some("0.6.0".into()),
|
||||
..CliArgs::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updated_to_combined_with_wait_pid() {
|
||||
assert_eq!(
|
||||
p(&[
|
||||
"--wait-pid",
|
||||
"17",
|
||||
"--updated-to",
|
||||
"1.2.3-rc.1",
|
||||
"--diagnose"
|
||||
]),
|
||||
CliArgs {
|
||||
diagnose: true,
|
||||
wait_pid: Some(17),
|
||||
updated_to: Some("1.2.3-rc.1".into()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updated_to_rejects_text_that_is_not_a_version() {
|
||||
assert_eq!(p(&["--updated-to", "--diagnose"]).updated_to, None);
|
||||
assert_eq!(p(&["--updated-to", "v0.6.0"]).updated_to, None);
|
||||
assert_eq!(p(&["--updated-to", "0.6.0 <b>"]).updated_to, None);
|
||||
let long = "1".repeat(MAX_VERSION_LEN + 1);
|
||||
assert_eq!(p(&["--updated-to", &long]).updated_to, None);
|
||||
assert_eq!(p(&["--updated-to"]).updated_to, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_are_ignored() {
|
||||
assert_eq!(
|
||||
p(&["--apply-update", "x", "--wait-pid", "9", "--frobnicate"]),
|
||||
CliArgs {
|
||||
wait_pid: Some(9),
|
||||
..CliArgs::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
//! Process handoff: relaunching the app and cleaning up after earlier
|
||||
//! updates.
|
||||
//!
|
||||
//! A relaunched instance gets `--wait-pid <our pid>` and waits for this
|
||||
//! process to exit before it takes the single-instance lock.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::core::settings::APP_DIR_NAME;
|
||||
|
||||
/// Spawn a detached copy of the app with `--wait-pid <pid>` followed by
|
||||
/// `extra_args`. The caller exits only when this returns `Ok`, so a failed
|
||||
/// spawn never leaves the user without a running app.
|
||||
pub fn relaunch(extra_args: &[OsString]) -> io::Result<()> {
|
||||
let exe = relaunch_target()?;
|
||||
let mut command = Command::new(&exe);
|
||||
command
|
||||
.arg("--wait-pid")
|
||||
.arg(std::process::id().to_string())
|
||||
.args(extra_args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
detach(&mut command);
|
||||
let child = command.spawn()?;
|
||||
log::info!(
|
||||
"relaunched {} as pid {} (parent pid {})",
|
||||
exe.display(),
|
||||
child.id(),
|
||||
std::process::id()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The file to start: the AppImage itself when running from one (the
|
||||
/// executable path points into its temporary mount), else this executable.
|
||||
fn relaunch_target() -> io::Result<PathBuf> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(appimage) = std::env::var_os("APPIMAGE").filter(|v| !v.is_empty()) {
|
||||
return Ok(PathBuf::from(appimage));
|
||||
}
|
||||
}
|
||||
std::env::current_exe()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn detach(command: &mut Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn detach(command: &mut Command) {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
// A new process group, so a Ctrl+C aimed at the old instance's
|
||||
// terminal does not also stop the new one.
|
||||
command.process_group(0);
|
||||
}
|
||||
|
||||
/// `data_local_dir()/ClaudeCodeUsageBubble/updates`, where updates are
|
||||
/// staged and `update-error.log` lives.
|
||||
pub fn updates_dir() -> Option<PathBuf> {
|
||||
dirs::data_local_dir().map(|d| d.join(APP_DIR_NAME).join("updates"))
|
||||
}
|
||||
|
||||
/// Delete what earlier updates left behind: `<exe file name>.old.*` next to
|
||||
/// the executable and, in `updates_dir`, the staged `update.exe` and
|
||||
/// `updater-helper-*.exe` copies. `update-error.log` is kept. Every failure
|
||||
/// is logged and skipped.
|
||||
pub fn cleanup_stale_files(exe: &Path, updates_dir: Option<&Path>) {
|
||||
if let (Some(dir), Some(name)) = (exe.parent(), exe.file_name()) {
|
||||
let prefix = format!("{}.old.", name.to_string_lossy());
|
||||
remove_matching(dir, |file| file.starts_with(&prefix));
|
||||
}
|
||||
if let Some(dir) = updates_dir {
|
||||
remove_matching(dir, |file| {
|
||||
file == "update.exe" || (file.starts_with("updater-helper-") && file.ends_with(".exe"))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_matching(dir: &Path, matches: impl Fn(&str) -> bool) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
if err.kind() != io::ErrorKind::NotFound {
|
||||
log::debug!("cleanup: read_dir {} failed: {err}", dir.display());
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if !matches(&name.to_string_lossy()) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => log::info!("cleanup: removed {}", path.display()),
|
||||
Err(err) => log::debug!("cleanup: remove {} failed: {err}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// A fresh, empty directory under the system temp dir.
|
||||
fn scratch_dir(tag: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ccub-handoff-{tag}-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn touch(path: &Path) {
|
||||
std::fs::write(path, b"x").expect("write file");
|
||||
}
|
||||
|
||||
fn names(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir)
|
||||
.expect("read dir")
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_only_leftovers_from_updates() {
|
||||
let root = scratch_dir("cleanup");
|
||||
let install = root.join("install");
|
||||
let updates = root.join("updates");
|
||||
std::fs::create_dir_all(&install).expect("install dir");
|
||||
std::fs::create_dir_all(&updates).expect("updates dir");
|
||||
|
||||
let exe = install.join("claude-code-usage-bubble.exe");
|
||||
touch(&exe);
|
||||
touch(&install.join("claude-code-usage-bubble.exe.old.1234"));
|
||||
touch(&install.join("claude-code-usage-bubble.exe.old.99"));
|
||||
touch(&install.join("other.exe.old.1"));
|
||||
touch(&install.join("settings.json"));
|
||||
// A directory with a matching name is left alone.
|
||||
std::fs::create_dir_all(install.join("claude-code-usage-bubble.exe.old.dir")).expect("dir");
|
||||
|
||||
touch(&updates.join("update.exe"));
|
||||
touch(&updates.join("updater-helper-4242.exe"));
|
||||
touch(&updates.join("updater-helper-notes.txt"));
|
||||
touch(&updates.join("update-error.log"));
|
||||
|
||||
cleanup_stale_files(&exe, Some(&updates));
|
||||
|
||||
assert_eq!(
|
||||
names(&install),
|
||||
[
|
||||
"claude-code-usage-bubble.exe",
|
||||
"claude-code-usage-bubble.exe.old.dir",
|
||||
"other.exe.old.1",
|
||||
"settings.json",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
names(&updates),
|
||||
["update-error.log", "updater-helper-notes.txt"]
|
||||
);
|
||||
std::fs::remove_dir_all(&root).expect("remove scratch dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_tolerates_missing_directories() {
|
||||
let root = scratch_dir("missing");
|
||||
let exe = root.join("absent").join("app");
|
||||
cleanup_stale_files(&exe, Some(&root.join("no-updates")));
|
||||
cleanup_stale_files(Path::new("app"), None);
|
||||
std::fs::remove_dir_all(&root).expect("remove scratch dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_dir_sits_under_the_app_data_dir() {
|
||||
if let Some(dir) = updates_dir() {
|
||||
assert!(dir.ends_with(Path::new(APP_DIR_NAME).join("updates")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
//! The binary in `src/main.rs` only calls [`run`]. Every module is `pub` so
|
||||
//! that items wired up incrementally do not trip `dead_code`.
|
||||
|
||||
pub mod cli;
|
||||
pub mod core;
|
||||
pub mod creds;
|
||||
pub mod diag;
|
||||
pub mod handoff;
|
||||
pub mod net;
|
||||
pub mod platform;
|
||||
pub mod usage;
|
||||
|
||||
@@ -1,5 +1,124 @@
|
||||
//! OS-specific code. Each platform file is compiled only on its own target,
|
||||
//! and portable callers reach it through `cfg`-gated calls.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
|
||||
/// How often the Unix wait checks whether the process is still there.
|
||||
#[cfg(unix)]
|
||||
const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Wait until process `pid` has exited, at most `timeout`. Returns true when
|
||||
/// it is gone, false when it was still running at the deadline.
|
||||
pub fn wait_pid(pid: u32, timeout: Duration) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows::wait_pid(pid, timeout)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
wait_until_gone(timeout, WAIT_POLL_INTERVAL, || unix_pid_alive(pid))
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll `alive` every `interval` until it returns false or `timeout` passes.
|
||||
/// Returns true when `alive` reported false.
|
||||
pub fn wait_until_gone(
|
||||
timeout: Duration,
|
||||
interval: Duration,
|
||||
mut alive: impl FnMut() -> bool,
|
||||
) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if !alive() {
|
||||
return true;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(interval.min(deadline - now));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `pid` names a live (or zombie) process. A process owned by
|
||||
/// another user still counts as alive.
|
||||
#[cfg(unix)]
|
||||
fn unix_pid_alive(pid: u32) -> bool {
|
||||
// Zero and negative ids address process groups, never one process.
|
||||
let Ok(pid) = libc::pid_t::try_from(pid) else {
|
||||
return false;
|
||||
};
|
||||
if pid <= 0 {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: signal 0 only checks for existence and permission.
|
||||
if unsafe { libc::kill(pid, 0) } == 0 {
|
||||
return true;
|
||||
}
|
||||
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
|
||||
}
|
||||
|
||||
/// Whether the system theme is dark at startup. Platforms without a
|
||||
/// readable system setting default to dark.
|
||||
pub fn system_is_dark() -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows::is_dark()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wait_until_gone_returns_once_the_process_is_gone() {
|
||||
let mut checks = 0;
|
||||
let gone = wait_until_gone(Duration::from_secs(5), Duration::from_millis(1), || {
|
||||
checks += 1;
|
||||
checks < 3
|
||||
});
|
||||
assert!(gone);
|
||||
assert_eq!(checks, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_until_gone_gives_up_at_the_deadline() {
|
||||
let start = Instant::now();
|
||||
let gone = wait_until_gone(Duration::from_millis(30), Duration::from_millis(5), || true);
|
||||
assert!(!gone);
|
||||
assert!(start.elapsed() >= Duration::from_millis(30));
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_process_is_waited_for_until_the_timeout() {
|
||||
assert!(!wait_pid(std::process::id(), Duration::from_millis(50)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_exited_process_is_gone() {
|
||||
let mut child = std::process::Command::new("true")
|
||||
.spawn()
|
||||
.expect("spawn true");
|
||||
let pid = child.id();
|
||||
// Reap it, so the pid no longer names even a zombie.
|
||||
child.wait().expect("wait");
|
||||
assert!(wait_pid(pid, Duration::from_secs(2)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn group_ids_are_never_alive() {
|
||||
assert!(!unix_pid_alive(0));
|
||||
assert!(!unix_pid_alive(u32::MAX));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
//! Windows-only helpers. This is the only module that talks to Win32 APIs.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::GetAvailableCoreWebView2BrowserVersionString;
|
||||
use windows::core::{HSTRING, PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::{CloseHandle, WAIT_OBJECT_0};
|
||||
use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND};
|
||||
|
||||
use crate::core::proxy::{self, SystemProxy};
|
||||
|
||||
const INTERNET_SETTINGS_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
const PERSONALIZE_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
|
||||
|
||||
/// The static proxy from `HKCU\…\Internet Settings`, or `None` when
|
||||
/// `ProxyEnable` is not 1 or no usable `ProxyServer` entry exists.
|
||||
@@ -16,3 +25,65 @@ pub fn system_proxy() -> Option<SystemProxy> {
|
||||
let proxy_override = key.get_string("ProxyOverride").ok();
|
||||
proxy::from_registry_values(enable, server.as_deref(), proxy_override.as_deref())
|
||||
}
|
||||
|
||||
/// Whether the apps theme is dark. `SystemUsesLightTheme` is 1 for light;
|
||||
/// a missing value means dark, as on a fresh Windows 11 install.
|
||||
pub fn is_dark() -> bool {
|
||||
let light = windows_registry::CURRENT_USER
|
||||
.open(PERSONALIZE_KEY)
|
||||
.and_then(|key| key.get_u32("SystemUsesLightTheme"));
|
||||
!matches!(light, Ok(1))
|
||||
}
|
||||
|
||||
/// Wait until process `pid` has exited, at most `timeout`. Returns true when
|
||||
/// it is gone (or never existed), false on timeout or a failed wait.
|
||||
pub fn wait_pid(pid: u32, timeout: Duration) -> bool {
|
||||
// SAFETY: plain FFI call; the returned handle is closed below.
|
||||
let Ok(handle) = (unsafe { OpenProcess(PROCESS_SYNCHRONIZE, false, pid) }) else {
|
||||
// No such process: it already exited.
|
||||
return true;
|
||||
};
|
||||
let ms = u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX);
|
||||
// SAFETY: `handle` is a valid process handle opened with SYNCHRONIZE.
|
||||
let result = unsafe { WaitForSingleObject(handle, ms) };
|
||||
// SAFETY: `handle` came from OpenProcess and is closed exactly once.
|
||||
if let Err(err) = unsafe { CloseHandle(handle) } {
|
||||
log::debug!("CloseHandle for pid {pid} failed: {err}");
|
||||
}
|
||||
result == WAIT_OBJECT_0
|
||||
}
|
||||
|
||||
/// Whether the Microsoft Edge WebView2 runtime is installed.
|
||||
pub fn webview2_available() -> bool {
|
||||
let mut version = PWSTR::null();
|
||||
// SAFETY: a null folder asks for the installed Evergreen runtime; the
|
||||
// out pointer is valid for the call.
|
||||
let result =
|
||||
unsafe { GetAvailableCoreWebView2BrowserVersionString(PCWSTR::null(), &mut version) };
|
||||
// Frees the CoTaskMem string; a null pointer yields "".
|
||||
let version = webview2_com::take_pwstr(version);
|
||||
match result {
|
||||
Ok(()) if !version.is_empty() => {
|
||||
log::info!("WebView2 runtime {version}");
|
||||
true
|
||||
}
|
||||
Ok(()) => false,
|
||||
Err(err) => {
|
||||
log::warn!("WebView2 runtime lookup failed: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a blocking error message box with no owner window.
|
||||
pub fn message_box_error(title: &str, text: &str) {
|
||||
// SAFETY: both strings outlive the call; no owner window is passed.
|
||||
let _ = unsafe {
|
||||
MessageBoxW(
|
||||
None,
|
||||
&HSTRING::from(text),
|
||||
&HSTRING::from(title),
|
||||
MB_OK | MB_ICONERROR | MB_SETFOREGROUND,
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
// 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 exe_w: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
|
||||
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::from_raw(exe_w.as_ptr()),
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user