diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..55f4471 --- /dev/null +++ b/src/cli.rs @@ -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 ` waits for an exiting instance before starting. +//! - `--updated-to ` 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, + pub updated_to: Option, +} + +/// 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(args: I) -> CliArgs +where + I: IntoIterator, + S: Into, +{ + let args: Vec = 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 { + value.parse::().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 "]).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() + } + ); + } +} diff --git a/src/handoff.rs b/src/handoff.rs new file mode 100644 index 0000000..e650350 --- /dev/null +++ b/src/handoff.rs @@ -0,0 +1,203 @@ +//! Process handoff: relaunching the app and cleaning up after earlier +//! updates. +//! +//! A relaunched instance gets `--wait-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 ` 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 { + #[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 { + dirs::data_local_dir().map(|d| d.join(APP_DIR_NAME).join("updates")) +} + +/// Delete what earlier updates left behind: `.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 { + let mut names: Vec = 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"))); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index b13ea9a..69cd96d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index baa8729..9d9a2e4 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -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)); + } +} diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 055bf98..f8e5aab 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -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 { 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, + ) + }; +} diff --git a/src/update/handoff.rs b/src/update/handoff.rs deleted file mode 100644 index 02acb45..0000000 --- a/src/update/handoff.rs +++ /dev/null @@ -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 = exe.as_os_str().encode_wide().chain(Some(0)).collect(); - - let si = STARTUPINFOW { - cb: std::mem::size_of::() 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 `.old.` 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 { - // 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 , --updated-to ) 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 = std::ffi::OsString::from(line).encode_wide().collect(); - wide.push(0); - wide -}