Files
claude-code-usage-bubble/src/app.rs
T
tiennm99 e243c589a8 refactor: fix latent bugs, invert bubble→app deps, unify color path
Whole-project review pass over the entire crate. No new features.
All function definitions preserved; layout and visibility reorganised.

Bug fixes:
- bubble: ExtractIconExW HICON pair was leaked per bubble toggle.
  Extract once into a process-wide OnceLock, reuse forever (bounded).
- usage/anthropic: parse_iso8601 was stripping the UTC offset without
  applying it — negative-offset users saw countdowns up to 14h wrong.
  Now parses signed minutes and computes utc_secs = local - off*60.
  Also rejects y<1970, mo∉[1,12], d∉[1,max_day(mo,y)] up front so
  malformed API responses can't index DAYS_IN_MONTH out of bounds.
- usage: clamp utilization to [0,100] at all four Window construction
  sites so a misbehaving server can't render "121%".
- bubble: GetDC and CreateCompatibleDC results weren't checked. Guard
  both; release the screen DC on the CreateCompatibleDC failure path.

Refactor:
- Drop type TrayIconKind = ProviderId aliases (5 sites); use ProviderId
  directly everywhere. Inline the identity-function kind_to_provider.
- Delete panel::bar_color_for shim (was just argument-reorder glue).
- Replace local scale_to_dpi fns in bubble.rs and panel.rs with
  use crate::os::dpi::scale as scale_to_dpi (brings os::dpi into the
  live import graph; was unused before).
- Delete dead PCWSTR import + #[allow(dead_code)] sentinel in
  tray/badge.rs; fold the trailing `use BOOL` into the top imports.
- Inline app::primary_dpi() to crate::os::dpi::for_system().

app.rs:
- Add update_settings(|s: &mut AppState|) helper that locks state,
  runs the closure, snapshots Settings, drops the lock, then saves
  to disk. Convert four pure-mutate-then-save callsites.

Layering: bubble.rs no longer reaches upward into crate::app::.
Introduce bubble::Callbacks (fn-pointers), OnceLock<Callbacks>, and
bubble::install_callbacks(). The wnd_proc dispatches the six prior
upward calls via a private dispatch() helper. app::run installs
callbacks once at startup; the six on_bubble_* / recheck_theme fns
are demoted from pub fn to fn.

Resource-warning logs added: dispatch() warns on uninstalled
callbacks; app_icons() warns when ExtractIconExW returns nulls.

Build: cargo build --release clean; cargo clippy reports zero new
warnings (11 pre-existing, all in untouched code).
2026-05-23 11:22:40 +07:00

1470 lines
49 KiB
Rust

// App orchestrator.
//
// One `Mutex<Option<AppState>>` is the single source of truth. The UI
// thread runs the message loop; a background thread polls the provider
// registry and posts `WM_APP_USAGE_UPDATED` back via a hidden
// message-only window owned by this module.
use std::collections::HashMap;
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};
use windows::core::PCWSTR;
use windows::Win32::Foundation::*;
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::System::Threading::{CreateMutexW, GetCurrentProcessId, Sleep};
use windows::Win32::UI::HiDpi::{
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
};
use windows::Win32::UI::WindowsAndMessaging::*;
use crate::bubble;
use crate::i18n::{self, I18n, LocaleStrings};
// Win32 message + timer IDs moved inline; see constants below.
use crate::net;
use crate::os;
use crate::panel::{self, PanelData};
use crate::settings::{self, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN};
use crate::tray::{self, TrayAction, TrayIcon as TrayIconData, WM_APP_TRAY};
use crate::update::{self, Channel as InstallChannel, CheckOutcome};
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 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, …)`.
const TIMER_POLL: usize = 1;
const TIMER_COUNTDOWN: usize = 2;
const TIMER_RESET_POLL: usize = 3;
const TIMER_UPDATE_CHECK: usize = 4;
const APP_MUTEX_NAME: &str = r"Global\ClaudeCodeUsageBubble";
const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
const STARTUP_VALUE_NAME: &str = "ClaudeCodeUsageBubble";
const APP_CLASS_NAME: &str = "ClaudeCodeUsageBubbleApp";
const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
// The auto-check interval is user-configurable (see
// `Settings::update_check_interval_secs`). `None` means auto-check is disabled.
const BALLOON_COOLDOWN: Duration = Duration::from_secs(30 * 60);
const REFRESH_TIMEOUT: Duration = Duration::from_secs(8);
// ---------- Menu IDs ----------
const IDM_REFRESH: u16 = 1;
const IDM_EXIT: u16 = 2;
const IDM_FREQ_1MIN: u16 = 10;
const IDM_FREQ_5MIN: u16 = 11;
const IDM_FREQ_15MIN: u16 = 12;
const IDM_FREQ_1HOUR: u16 = 13;
const IDM_MODEL_CLAUDE: u16 = 20;
const IDM_MODEL_CHATGPT: u16 = 21;
const IDM_START_WITH_WINDOWS: u16 = 30;
const IDM_RESET_POSITION: u16 = 31;
const IDM_VERSION_ACTION: u16 = 32;
const IDM_RESTART: u16 = 33;
const IDM_LANG_SYSTEM: u16 = 40;
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
// clear of it (and any future tray ids in the 5x band).
const IDM_UPDATE_AUTO_OFF: u16 = 60;
const IDM_UPDATE_AUTO_HOURLY: u16 = 61;
const IDM_UPDATE_AUTO_DAILY: u16 = 62;
const IDM_UPDATE_AUTO_WEEKLY: u16 = 63;
// IMPORTANT: language ids are dynamic and start at IDM_LANG_BASE.
// Keep IDM_LANG_BASE the highest static id so the catch-all match arm
// stays unambiguous.
const IDM_LANG_BASE: u16 = 100;
// ---------- State ----------
#[derive(Clone, Copy, Default)]
struct SendHwnd(isize);
unsafe impl Send for SendHwnd {}
impl SendHwnd {
fn from_hwnd(h: HWND) -> Self {
Self(h.0 as isize)
}
fn to_hwnd(self) -> HWND {
HWND(self.0 as *mut _)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UpdateStatus {
Idle,
Checking,
UpToDate,
Available,
Applying,
Failed,
}
struct AppState {
msg_hwnd: SendHwnd,
bubbles: HashMap<ProviderId, SendHwnd>,
settings: Settings,
i18n: I18n,
is_dark: bool,
install_channel: InstallChannel,
// http and registry live behind Arc so worker threads can hold them
// without keeping the global state mutex locked across blocking I/O.
// The registry's own mutex is only contended by other workers
// (the in-flight gate ensures at most one poll runs at a time), so
// the UI thread never waits on it.
http: Arc<net::Client>,
registry: Arc<Mutex<Registry>>,
snapshots: HashMap<ProviderId, ProviderUiState>,
last_poll_ok: bool,
update_status: UpdateStatus,
update_release: Option<update::Release>,
last_balloon_at: Option<Instant>,
}
#[derive(Clone, Default)]
struct ProviderUiState {
windows: UsageWindows,
primary_text: String,
secondary_text: String,
}
fn state() -> &'static Mutex<Option<AppState>> {
static S: OnceLock<Mutex<Option<AppState>>> = OnceLock::new();
S.get_or_init(|| Mutex::new(None))
}
fn lock_state() -> MutexGuard<'static, Option<AppState>> {
state().lock().expect("app state mutex poisoned")
}
/// Run `f` with mutable access to `AppState`, then snapshot `Settings` and
/// persist it to disk. The lock is released before the disk write so the UI
/// thread doesn't block on I/O. No-op if state hasn't been initialised yet.
fn update_settings(f: impl FnOnce(&mut AppState)) {
let snap = {
let mut guard = lock_state();
let Some(s) = guard.as_mut() else {
return;
};
f(s);
s.settings.clone()
};
settings::save(&snap);
}
// ---------- Entry ----------
/// 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 = match acquire_singleton_mutex(args.wait_pid_present) {
Some(h) => h,
None => return,
};
let settings = settings::load();
let i18n = I18n::load(settings.language.as_deref());
let is_dark = os::theme::is_dark();
let install_channel = update::current_channel();
let http = match net::Client::new(HTTP_USER_AGENT) {
Ok(c) => c,
Err(e) => {
log::error!("HTTP client init failed: {e}");
return;
}
};
let msg_hwnd = match create_message_window() {
Some(h) => h,
None => {
log::error!("failed to create app message window");
return;
}
};
*lock_state() = Some(AppState {
msg_hwnd: SendHwnd::from_hwnd(msg_hwnd),
bubbles: HashMap::new(),
settings,
i18n,
is_dark,
install_channel,
http: Arc::new(http),
registry: Arc::new(Mutex::new(Registry::with_defaults())),
snapshots: HashMap::new(),
last_poll_ok: false,
update_status: UpdateStatus::Idle,
update_release: None,
last_balloon_at: None,
});
bubble::install_callbacks(bubble::Callbacks {
on_click: on_bubble_click,
on_right_click: on_bubble_right_click,
on_moved: on_bubble_moved,
on_resized: on_bubble_resized,
on_menu_command,
on_settings_changed: recheck_theme,
});
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)
.unwrap_or(POLL_5_MIN);
unsafe {
SetTimer(msg_hwnd, TIMER_POLL, poll_interval, None);
}
schedule_update_check_timer(msg_hwnd);
spawn_poll_thread();
log::info!("app::run entered message loop");
let mut msg = MSG::default();
unsafe {
while GetMessageW(&mut msg, HWND::default(), 0, 0).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
fn create_message_window() -> Option<HWND> {
let class_w = os::to_utf16_nul(APP_CLASS_NAME);
let title_w = os::to_utf16_nul("Claude Code Usage Bubble");
unsafe {
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
lpfnWndProc: Some(msg_wnd_proc),
hInstance: HINSTANCE(hinstance.0),
lpszClassName: PCWSTR::from_raw(class_w.as_ptr()),
..Default::default()
};
let _ = RegisterClassExW(&wc);
CreateWindowExW(
WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
PCWSTR::from_raw(class_w.as_ptr()),
PCWSTR::from_raw(title_w.as_ptr()),
WS_POPUP,
-1000,
-1000,
1,
1,
HWND::default(),
HMENU::default(),
hinstance,
None,
)
.ok()
}
}
fn create_initial_bubbles() {
let (settings, is_dark) = match lock_state().as_ref() {
Some(s) => (s.settings.clone(), s.is_dark),
None => return,
};
if settings.show_claude_code {
spawn_bubble(ProviderId::Claude, &settings, is_dark);
}
if settings.show_codex {
spawn_bubble(ProviderId::ChatGpt, &settings, is_dark);
}
}
fn spawn_bubble(kind: ProviderId, settings: &Settings, is_dark: bool) {
// "…" matches the in-flight/transient-error placeholder used by
// `apply_results`, so the bubble has visible feedback during the first
// poll rather than rendering with two empty grey tracks.
let placeholder = "".to_string();
let hwnd = bubble::create(bubble::BubbleConfig {
model: kind,
size_logical: settings.bubble_size_logical,
position: settings.bubble_positions.get(kind),
session_pct: None,
session_text: placeholder.clone(),
weekly_pct: None,
weekly_text: placeholder,
is_dark,
});
if hwnd != HWND::default() {
if let Some(s) = lock_state().as_mut() {
s.bubbles.insert(kind, SendHwnd::from_hwnd(hwnd));
}
}
}
// ---------- Message-only window proc ----------
unsafe extern "system" fn msg_wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
WM_APP_USAGE_UPDATED => {
propagate_to_ui();
LRESULT(0)
}
WM_APP_UPDATE_APPLIED => {
PostQuitMessage(0);
LRESULT(0)
}
WM_APP_TRAY => {
let action = tray::callback::handle(lparam);
handle_tray_action(action);
LRESULT(0)
}
WM_TIMER => {
on_timer(hwnd, wparam.0);
LRESULT(0)
}
WM_DESTROY => {
PostQuitMessage(0);
LRESULT(0)
}
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}
// ---------- Bubble callbacks ----------
fn on_bubble_click(hwnd: HWND, model: ProviderId) {
let data = build_panel_data(model);
panel::toggle(data, hwnd);
}
fn on_bubble_right_click(hwnd: HWND, _model: ProviderId, _pt: POINT) {
show_context_menu(hwnd);
}
fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) {
update_settings(|s| s.settings.bubble_positions.set(model, pos));
}
fn on_bubble_resized(_model: ProviderId, size_logical: i32) {
update_settings(|s| s.settings.bubble_size_logical = size_logical);
}
fn on_menu_command(id: u32, _owner_hwnd: HWND) {
let id = (id & 0xFFFF) as u16;
match id {
IDM_REFRESH => spawn_poll_thread(),
IDM_EXIT => unsafe { PostQuitMessage(0) },
IDM_FREQ_1MIN => set_poll_interval(POLL_1_MIN),
IDM_FREQ_5MIN => set_poll_interval(POLL_5_MIN),
IDM_FREQ_15MIN => set_poll_interval(POLL_15_MIN),
IDM_FREQ_1HOUR => set_poll_interval(POLL_1_HOUR),
IDM_MODEL_CLAUDE => toggle_model(ProviderId::Claude),
IDM_MODEL_CHATGPT => toggle_model(ProviderId::ChatGpt),
IDM_START_WITH_WINDOWS => toggle_startup(),
IDM_RESET_POSITION => reset_positions(),
IDM_VERSION_ACTION => version_action(),
IDM_UPDATE_AUTO_OFF => set_update_check_interval(None),
IDM_UPDATE_AUTO_HOURLY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS))
}
IDM_UPDATE_AUTO_DAILY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS))
}
IDM_UPDATE_AUTO_WEEKLY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
}
IDM_LANG_SYSTEM => set_language(None),
// Static ids in the 30-99 band must match BEFORE the dynamic
// language guard, otherwise `x >= IDM_LANG_BASE` would swallow any
// future id that creeps into the >=100 range.
tray::IDM_TOGGLE_WIDGET => toggle_widget_visibility(),
IDM_RESTART => restart_app(),
x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize),
_ => {}
}
}
// ---------- Timers ----------
fn on_timer(hwnd: HWND, id: usize) {
match id {
TIMER_POLL | TIMER_RESET_POLL => spawn_poll_thread(),
TIMER_COUNTDOWN => refresh_countdowns(),
TIMER_UPDATE_CHECK => {
unsafe {
let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK);
}
begin_update_check(hwnd);
}
_ => {}
}
}
// ---------- Poll thread ----------
/// At-most-one-in-flight gate for the poll worker. Spam-clicking Refresh
/// would otherwise stack concurrent HTTPS calls onto the same registry,
/// which both wastes bandwidth and (before the registry mutex landed)
/// could let two poll cycles interleave their writes to the snapshot map.
static POLL_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
fn spawn_poll_thread() {
if POLL_IN_FLIGHT
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
let msg_hwnd = match lock_state().as_ref() {
Some(s) => s.msg_hwnd,
None => {
POLL_IN_FLIGHT.store(false, Ordering::Release);
return;
}
};
std::thread::spawn(move || {
do_poll();
POLL_IN_FLIGHT.store(false, Ordering::Release);
unsafe {
let _ = PostMessageW(
msg_hwnd.to_hwnd(),
WM_APP_USAGE_UPDATED,
WPARAM(0),
LPARAM(0),
);
}
});
}
fn do_poll() {
// Snapshot the inputs we need, then DROP the global lock before any
// HTTPS call. Holding `lock_state()` through `poll_enabled` would block
// the UI thread on every paint/menu for the duration of the request.
let (http, registry, settings) = {
let s = lock_state();
let Some(s) = s.as_ref() else {
return;
};
(s.http.clone(), s.registry.clone(), s.settings.clone())
};
let results = {
let mut reg = registry.lock().expect("registry mutex poisoned");
reg.poll_enabled(&http, &settings)
};
let auth_failures = apply_results(results);
if !auth_failures.is_empty() {
attempt_refresh(auth_failures, &registry);
}
}
fn apply_results(
results: Vec<(ProviderId, Result<UsageWindows, usage::Error>)>,
) -> Vec<ProviderId> {
let mut auth_failures = Vec::new();
let mut crossings: Vec<(ProviderId, u8)> = Vec::new();
{
let mut guard = lock_state();
let Some(state) = guard.as_mut() else {
return auth_failures;
};
if results.is_empty() {
return auth_failures;
}
let strings = state.i18n.strings().clone();
let mut any_ok = false;
for (id, outcome) in results {
match outcome {
Ok(windows) => {
let entry = state.snapshots.entry(id).or_default();
let old_pct = entry.windows.primary.utilization;
let new_pct = windows.primary.utilization;
// Fire a balloon only the cycle a provider CROSSES a
// threshold so the user is nudged once, not on every
// subsequent poll while parked above it.
for threshold in [80u8, 95u8] {
if old_pct < threshold as f64 && new_pct >= threshold as f64 {
crossings.push((id, threshold));
}
}
entry.windows = windows;
entry.primary_text = i18n::format_window(&windows.primary, &strings);
entry.secondary_text = i18n::format_window(&windows.secondary, &strings);
any_ok = true;
}
Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => {
auth_failures.push(id);
let entry = state.snapshots.entry(id).or_default();
entry.primary_text = "!".into();
entry.secondary_text = "!".into();
}
Err(e) => {
log::warn!("provider {id:?} poll failed: {e}");
let entry = state.snapshots.entry(id).or_default();
entry.primary_text = "".into();
entry.secondary_text = "".into();
}
}
}
state.last_poll_ok = any_ok;
}
// Lock released. Fire any threshold balloons outside the critical
// section so tray::notify and i18n cloning don't block the UI thread.
for (id, threshold) in crossings {
show_threshold_balloon(id, threshold);
}
auth_failures
}
fn attempt_refresh(failures: Vec<ProviderId>, registry: &Arc<Mutex<Registry>>) {
let orchestrator = usage::refresh::Orchestrator::new(REFRESH_TIMEOUT);
// Pick the first provider whose refresh did not succeed and balloon
// for it specifically. If both providers fail in the same cycle the
// second will resurface on the next poll once the first is re-auth'd.
let mut balloon_for: Option<ProviderId> = None;
for id in failures {
let outcome = {
let reg = registry.lock().expect("registry mutex poisoned");
reg.try_refresh(id, &orchestrator)
};
log::info!("refresh for {id:?}: {outcome:?}");
if !matches!(outcome, usage::refresh::Outcome::Refreshed) {
balloon_for.get_or_insert(id);
}
}
if let Some(provider) = balloon_for {
show_token_expired_balloon(provider);
}
}
fn refresh_countdowns() {
{
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
let strings = s.i18n.strings().clone();
for entry in s.snapshots.values_mut() {
entry.primary_text = i18n::format_window(&entry.windows.primary, &strings);
entry.secondary_text = i18n::format_window(&entry.windows.secondary, &strings);
}
}
propagate_to_ui();
}
fn propagate_to_ui() {
let snapshot = {
let s = lock_state();
s.as_ref().map(|s| UiSnapshot {
bubbles: s.bubbles.clone(),
snapshots: s.snapshots.clone(),
settings: s.settings.clone(),
i18n_strings: s.i18n.strings().clone(),
is_dark: s.is_dark,
msg_hwnd: s.msg_hwnd,
last_poll_ok: s.last_poll_ok,
})
};
let Some(snap) = snapshot else {
return;
};
for (kind, hwnd) in snap.bubbles.iter() {
let entry = snap.snapshots.get(kind);
let session_pct = entry.map(|s| s.windows.primary.utilization);
let weekly_pct = entry.map(|s| s.windows.secondary.utilization);
// The bubble paints the percent inline inside the bar fill, so it
// only needs the countdown string on the right. The panel still
// shows the combined "X% · Yh" string via `primary_text`.
let session_text = entry
.map(|s| i18n::format_countdown(s.windows.primary.resets_at, &snap.i18n_strings))
.unwrap_or_default();
let weekly_text = entry
.map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings))
.unwrap_or_default();
bubble::update_data(
hwnd.to_hwnd(),
session_pct,
session_text,
weekly_pct,
weekly_text,
);
}
refresh_tray_icons_with(&snap);
if panel::is_visible() {
if let Some(model) = panel::current_model() {
if let Some(provider_state) = snap.snapshots.get(&model) {
panel::refresh_data(build_panel_data_from(&snap, model, provider_state));
}
}
}
schedule_countdown_timer(&snap);
}
#[derive(Clone)]
struct UiSnapshot {
bubbles: HashMap<ProviderId, SendHwnd>,
snapshots: HashMap<ProviderId, ProviderUiState>,
settings: Settings,
i18n_strings: LocaleStrings,
is_dark: bool,
msg_hwnd: SendHwnd,
last_poll_ok: bool,
}
fn build_panel_data(model: ProviderId) -> PanelData {
let s = lock_state();
let Some(s) = s.as_ref() else {
return placeholder_panel(model);
};
let strings = s.i18n.strings().clone();
let provider_state = s.snapshots.get(&model).cloned().unwrap_or_default();
PanelData {
model,
session_pct: provider_state.windows.primary.utilization,
session_text: provider_state.primary_text,
weekly_pct: provider_state.windows.secondary.utilization,
weekly_text: provider_state.secondary_text,
is_dark: s.is_dark,
strings,
}
}
fn build_panel_data_from(snap: &UiSnapshot, model: ProviderId, p: &ProviderUiState) -> PanelData {
PanelData {
model,
session_pct: p.windows.primary.utilization,
session_text: p.primary_text.clone(),
weekly_pct: p.windows.secondary.utilization,
weekly_text: p.secondary_text.clone(),
is_dark: snap.is_dark,
strings: snap.i18n_strings.clone(),
}
}
fn placeholder_panel(model: ProviderId) -> PanelData {
let strings = i18n::I18n::load(None).strings().clone();
PanelData {
model,
session_pct: 0.0,
session_text: String::new(),
weekly_pct: 0.0,
weekly_text: String::new(),
is_dark: false,
strings,
}
}
fn schedule_countdown_timer(snap: &UiSnapshot) {
let mut min_ttl: Option<Duration> = None;
for entry in snap.snapshots.values() {
for w in [&entry.windows.primary, &entry.windows.secondary] {
if let Some(d) = i18n::time_until_display_change(w.resets_at) {
min_ttl = Some(min_ttl.map_or(d, |prev| prev.min(d)));
}
}
}
if let Some(d) = min_ttl {
let ms = (d.as_millis().min(u32::MAX as u128) as u32).max(1_000);
unsafe {
let _ = KillTimer(snap.msg_hwnd.to_hwnd(), TIMER_COUNTDOWN);
SetTimer(snap.msg_hwnd.to_hwnd(), TIMER_COUNTDOWN, ms, None);
}
}
}
// ---------- Tray icons ----------
fn refresh_tray_icons() {
let snap = {
let s = lock_state();
s.as_ref().map(|s| UiSnapshot {
bubbles: s.bubbles.clone(),
snapshots: s.snapshots.clone(),
settings: s.settings.clone(),
i18n_strings: s.i18n.strings().clone(),
is_dark: s.is_dark,
msg_hwnd: s.msg_hwnd,
last_poll_ok: s.last_poll_ok,
})
};
if let Some(snap) = snap {
refresh_tray_icons_with(&snap);
}
}
fn refresh_tray_icons_with(snap: &UiSnapshot) {
let mut icons = Vec::new();
if snap.settings.show_claude_code {
let entry = snap.snapshots.get(&ProviderId::Claude);
icons.push(TrayIconData {
kind: ProviderId::Claude,
percent: if snap.last_poll_ok {
entry.map(|e| e.windows.primary.utilization)
} else {
None
},
tooltip: format!(
"{} {}: {} | {}: {}",
snap.i18n_strings.claude_label,
snap.i18n_strings.session_window,
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
snap.i18n_strings.weekly_window,
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
),
});
}
if snap.settings.show_codex {
let entry = snap.snapshots.get(&ProviderId::ChatGpt);
icons.push(TrayIconData {
kind: ProviderId::ChatGpt,
percent: if snap.last_poll_ok {
entry.map(|e| e.windows.primary.utilization)
} else {
None
},
tooltip: format!(
"{} {}: {} | {}: {}",
snap.i18n_strings.chatgpt_label,
snap.i18n_strings.session_window,
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
snap.i18n_strings.weekly_window,
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
),
});
}
tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
}
fn handle_tray_action(action: TrayAction) {
match action {
TrayAction::None => {}
TrayAction::ToggleWidget => toggle_widget_visibility(),
TrayAction::ShowContextMenu => {
let owner = lock_state()
.as_ref()
.and_then(|s| s.bubbles.values().next().copied())
.map(|h| h.to_hwnd())
.unwrap_or_default();
if owner != HWND::default() {
show_context_menu(owner);
}
}
}
}
/// Re-read the system theme and, if it changed, push the new value into
/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows
/// posts that to every top-level window when the user toggles light/dark
/// in Settings, so this naturally fires once per change.
fn recheck_theme() {
let now_dark = os::theme::is_dark();
let changed = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
if s.is_dark == now_dark {
false
} else {
s.is_dark = now_dark;
true
}
};
if changed {
propagate_to_ui();
refresh_tray_icons();
}
}
fn show_threshold_balloon(provider: ProviderId, threshold: u8) {
let payload = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
// Reuse the same cooldown as the token-expired balloon: any one
// balloon at a time keeps notifications calm.
if let Some(last) = s.last_balloon_at {
if last.elapsed() < BALLOON_COOLDOWN {
return;
}
}
s.last_balloon_at = Some(Instant::now());
let strings = s.i18n.strings();
let provider_label = match provider {
ProviderId::Claude => strings.claude_label.clone(),
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
};
let title = format!("{provider_label} · {threshold}%");
let body = if threshold >= 95 {
strings.threshold_95_body.clone()
} else {
strings.threshold_80_body.clone()
};
(s.msg_hwnd, provider, title, body)
};
tray::notify_warning(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
}
fn show_token_expired_balloon(failed: ProviderId) {
let payload = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
if let Some(last) = s.last_balloon_at {
if last.elapsed() < BALLOON_COOLDOWN {
return;
}
}
s.last_balloon_at = Some(Instant::now());
let strings = s.i18n.strings();
let (title, body) = match failed {
ProviderId::Claude => (
strings.token_expired_title.clone(),
strings.token_expired_body.clone(),
),
ProviderId::ChatGpt => (
strings.chatgpt_token_expired_title.clone(),
strings.chatgpt_token_expired_body.clone(),
),
};
(s.msg_hwnd, failed, title, body)
};
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 ----------
struct ContextMenuSnapshot {
strings: LocaleStrings,
available: Vec<(String, String)>,
language_override: Option<String>,
current_interval: u32,
update_check_interval_secs: Option<u64>,
show_claude: bool,
show_chatgpt: bool,
widget_visible: bool,
install_channel: InstallChannel,
update_status: UpdateStatus,
}
fn show_context_menu(owner_hwnd: HWND) {
let snap = match lock_state().as_ref() {
Some(s) => ContextMenuSnapshot {
strings: s.i18n.strings().clone(),
available: s
.i18n
.available()
.map(|(c, n)| (c.to_string(), n.to_string()))
.collect(),
language_override: s.settings.language.clone(),
current_interval: s.settings.poll_interval_ms,
update_check_interval_secs: s.settings.update_check_interval_secs,
show_claude: s.settings.show_claude_code,
show_chatgpt: s.settings.show_codex,
widget_visible: s.settings.widget_visible,
install_channel: s.install_channel,
update_status: s.update_status,
},
None => return,
};
unsafe {
let menu = match CreatePopupMenu() {
Ok(m) => m,
Err(_) => return,
};
append_item(menu, IDM_REFRESH, &snap.strings.refresh, MENU_ITEM_FLAGS(0));
let Ok(freq) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(freq) failed");
let _ = DestroyMenu(menu);
return;
};
for (id, interval, label) in [
(IDM_FREQ_1MIN, POLL_1_MIN, &snap.strings.one_minute),
(IDM_FREQ_5MIN, POLL_5_MIN, &snap.strings.five_minutes),
(IDM_FREQ_15MIN, POLL_15_MIN, &snap.strings.fifteen_minutes),
(IDM_FREQ_1HOUR, POLL_1_HOUR, &snap.strings.one_hour),
] {
let flags = if interval == snap.current_interval {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
};
append_item(freq, id, label, flags);
}
append_submenu(menu, freq, &snap.strings.update_frequency);
let Ok(models) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(models) failed");
let _ = DestroyMenu(menu);
return;
};
append_item(
models,
IDM_MODEL_CLAUDE,
&snap.strings.claude_label,
if snap.show_claude { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
);
append_item(
models,
IDM_MODEL_CHATGPT,
&snap.strings.chatgpt_label,
if snap.show_chatgpt { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
);
append_submenu(menu, models, &snap.strings.models);
let Ok(settings_menu) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(settings_menu) failed");
let _ = DestroyMenu(menu);
return;
};
append_item(
settings_menu,
IDM_START_WITH_WINDOWS,
&snap.strings.start_with_windows,
if is_startup_enabled() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
);
append_item(
settings_menu,
IDM_RESET_POSITION,
&snap.strings.reset_position,
MENU_ITEM_FLAGS(0),
);
let Ok(lang) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(lang) failed");
let _ = DestroyMenu(settings_menu);
let _ = DestroyMenu(menu);
return;
};
append_item(
lang,
IDM_LANG_SYSTEM,
&snap.strings.system_default,
if snap.language_override.is_none() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
);
for (i, (code, name)) in snap.available.iter().enumerate() {
let id = IDM_LANG_BASE + i as u16;
let flags = if snap
.language_override
.as_deref()
.map(|c| c == code)
.unwrap_or(false)
{
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
};
append_item(lang, id, name, flags);
}
append_submenu(settings_menu, lang, &snap.strings.language);
let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null());
let version_label = version_action_label(&snap);
let version_flags = if matches!(
snap.update_status,
UpdateStatus::Checking | UpdateStatus::Applying
) {
MF_GRAYED
} else {
MENU_ITEM_FLAGS(0)
};
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
let Ok(auto_update) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(auto_update) failed");
let _ = DestroyMenu(settings_menu);
let _ = DestroyMenu(menu);
return;
};
for (id, value, label) in [
(IDM_UPDATE_AUTO_OFF, None, &snap.strings.auto_check_disabled),
(
IDM_UPDATE_AUTO_HOURLY,
Some(settings::UPDATE_CHECK_HOURLY_SECS),
&snap.strings.auto_check_hourly,
),
(
IDM_UPDATE_AUTO_DAILY,
Some(settings::UPDATE_CHECK_DAILY_SECS),
&snap.strings.auto_check_daily,
),
(
IDM_UPDATE_AUTO_WEEKLY,
Some(settings::UPDATE_CHECK_WEEKLY_SECS),
&snap.strings.auto_check_weekly,
),
] {
let flags = if value == snap.update_check_interval_secs {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
};
append_item(auto_update, id, label, flags);
}
append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check);
append_submenu(menu, settings_menu, &snap.strings.settings);
append_item(
menu,
tray::IDM_TOGGLE_WIDGET,
&snap.strings.show_widget,
if snap.widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
);
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
append_item(menu, IDM_EXIT, &snap.strings.exit, MENU_ITEM_FLAGS(0));
let mut pt = POINT::default();
let _ = GetCursorPos(&mut pt);
let _ = SetForegroundWindow(owner_hwnd);
let _ = TrackPopupMenu(menu, TPM_RIGHTBUTTON, pt.x, pt.y, 0, owner_hwnd, None);
let _ = DestroyMenu(menu);
}
}
fn append_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) {
let w = os::to_utf16_nul(label);
unsafe {
let _ = AppendMenuW(menu, flags, id as usize, PCWSTR::from_raw(w.as_ptr()));
}
}
fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) {
let w = os::to_utf16_nul(label);
unsafe {
let _ = AppendMenuW(menu, MF_POPUP, submenu.0 as usize, PCWSTR::from_raw(w.as_ptr()));
}
}
fn version_action_label(snap: &ContextMenuSnapshot) -> String {
let base = match snap.update_status {
UpdateStatus::Idle => snap.strings.check_for_updates.clone(),
UpdateStatus::Checking => snap.strings.checking_for_updates.clone(),
UpdateStatus::UpToDate => snap.strings.up_to_date.clone(),
UpdateStatus::Available => snap.strings.update_available.clone(),
UpdateStatus::Applying => snap.strings.applying_update.clone(),
UpdateStatus::Failed => snap.strings.update_failed.clone(),
};
// Append the running binary's version so the user can see what
// they are on without opening an About dialog. Using middle-dot as
// the separator matches the bubble's countdown formatting.
let with_version = format!("{base} \u{00b7} v{}", env!("CARGO_PKG_VERSION"));
match snap.install_channel {
InstallChannel::Winget => format!("{with_version} ({})", snap.strings.update_via_winget),
InstallChannel::Portable => with_version,
}
}
// ---------- Menu actions ----------
fn set_poll_interval(ms: u32) {
let (snap, msg_hwnd) = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
s.settings.poll_interval_ms = ms;
(s.settings.clone(), s.msg_hwnd)
};
settings::save(&snap);
unsafe {
let _ = KillTimer(msg_hwnd.to_hwnd(), TIMER_POLL);
SetTimer(msg_hwnd.to_hwnd(), TIMER_POLL, ms, None);
}
}
fn toggle_model(model: ProviderId) {
let (settings, is_dark) = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
match model {
ProviderId::Claude => s.settings.show_claude_code = !s.settings.show_claude_code,
ProviderId::ChatGpt => s.settings.show_codex = !s.settings.show_codex,
}
if !s.settings.show_claude_code && !s.settings.show_codex {
match model {
ProviderId::Claude => s.settings.show_claude_code = true,
ProviderId::ChatGpt => s.settings.show_codex = true,
}
}
(s.settings.clone(), s.is_dark)
};
settings::save(&settings);
let want = match model {
ProviderId::Claude => settings.show_claude_code,
ProviderId::ChatGpt => settings.show_codex,
};
let existing = lock_state().as_ref().and_then(|s| s.bubbles.get(&model).copied());
match (want, existing) {
(true, None) => spawn_bubble(model, &settings, is_dark),
(false, Some(h)) => {
bubble::destroy(h.to_hwnd());
if let Some(s) = lock_state().as_mut() {
s.bubbles.remove(&model);
}
}
_ => {}
}
refresh_tray_icons();
spawn_poll_thread();
}
fn toggle_widget_visibility() {
let (visible, snap) = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
s.settings.widget_visible = !s.settings.widget_visible;
(s.settings.widget_visible, s.settings.clone())
};
settings::save(&snap);
let hwnds: Vec<HWND> = lock_state()
.as_ref()
.map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect())
.unwrap_or_default();
for h in hwnds {
bubble::set_user_visible(h, visible);
}
}
fn reset_positions() {
let snap = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
s.settings.bubble_positions.reset_all();
s.settings.clone()
};
settings::save(&snap);
let hwnds: Vec<HWND> = lock_state()
.as_ref()
.map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect())
.unwrap_or_default();
for h in hwnds {
bubble::destroy(h);
}
if let Some(s) = lock_state().as_mut() {
s.bubbles.clear();
}
create_initial_bubbles();
// The freshly-spawned bubbles boot with a "…" placeholder. Push the
// cached snapshot so they render the last-known data immediately, and
// kick a poll for users who used Reset Position to recover from
// staleness.
propagate_to_ui();
spawn_poll_thread();
}
fn set_language(_dummy: Option<()>) {
update_settings(|s| {
s.i18n.set_active(None);
s.settings.language = None;
});
propagate_to_ui();
}
fn set_language_by_index(idx: usize) {
update_settings(|s| {
let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string());
if let Some(c) = code.as_deref() {
s.i18n.set_active(Some(c));
}
s.settings.language = code;
});
propagate_to_ui();
}
fn version_action() {
enum Act {
Apply(update::Release, InstallChannel),
Check(SendHwnd),
}
let act = match lock_state().as_ref() {
Some(s) => match (s.update_status, s.update_release.as_ref()) {
(UpdateStatus::Available, Some(r)) => Act::Apply(r.clone(), s.install_channel),
_ => Act::Check(s.msg_hwnd),
},
None => return,
};
match act {
Act::Apply(release, channel) => {
// Set the Applying status synchronously so the menu reflects
// it immediately, then move the (potentially several-second)
// download to a worker thread. Holding the UI thread here
// would freeze paints and menus until the download finished.
let (http, msg_hwnd) = {
let mut guard = lock_state();
let Some(s) = guard.as_mut() else {
return;
};
s.update_status = UpdateStatus::Applying;
(s.http.clone(), s.msg_hwnd)
};
std::thread::spawn(move || {
let result: Result<(), Box<dyn std::error::Error + Send + Sync>> = match channel {
InstallChannel::Winget => {
// Winget channel is reserved for future use; until a
// winget package ships, this branch is unreachable.
Err("winget channel not supported yet".into())
}
InstallChannel::Portable => {
update::install::begin(&http, &release).map_err(|e| e.into())
}
};
match result {
Ok(()) => unsafe {
let _ = PostMessageW(
msg_hwnd.to_hwnd(),
WM_APP_UPDATE_APPLIED,
WPARAM(0),
LPARAM(0),
);
},
Err(e) => {
log::error!("update apply failed: {e}");
if let Some(s) = lock_state().as_mut() {
s.update_status = UpdateStatus::Failed;
}
}
}
});
}
Act::Check(hwnd) => begin_update_check(hwnd.to_hwnd()),
}
}
fn schedule_update_check_timer(hwnd: HWND) {
let (last, interval) = match lock_state().as_ref() {
Some(s) => (
s.settings.last_update_check_unix,
s.settings.update_check_interval_secs,
),
None => return,
};
let Some(interval) = interval else {
// Auto-check disabled. Manual "Check for updates" still works via
// the menu; this just suppresses the timer.
return;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let due = last.map_or(true, |t| now.saturating_sub(t) >= interval);
if due {
begin_update_check(hwnd);
} else {
let remaining = interval.saturating_sub(now.saturating_sub(last.unwrap_or(0)));
let ms = (remaining.saturating_mul(1000).min(u32::MAX as u64)) as u32;
unsafe {
SetTimer(hwnd, TIMER_UPDATE_CHECK, ms, None);
}
}
}
fn begin_update_check(hwnd: HWND) {
if let Some(s) = lock_state().as_mut() {
s.update_status = UpdateStatus::Checking;
}
let send_hwnd = SendHwnd::from_hwnd(hwnd);
std::thread::spawn(move || {
let result = match net::Client::new(HTTP_USER_AGENT) {
Ok(c) => update::release::fetch_latest(&c),
Err(e) => Err(update::Error::Network(e)),
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let snap_opt = {
let mut s = lock_state();
s.as_mut().map(|s| {
s.settings.last_update_check_unix = Some(now);
match result {
Ok(CheckOutcome::UpToDate) => {
s.update_status = UpdateStatus::UpToDate;
s.update_release = None;
}
Ok(CheckOutcome::Available(r)) => {
s.update_status = UpdateStatus::Available;
s.update_release = Some(r);
}
Err(_) => {
s.update_status = UpdateStatus::Failed;
}
}
(s.settings.clone(), s.settings.update_check_interval_secs)
})
};
let next_interval = snap_opt.as_ref().and_then(|(_, iv)| *iv);
if let Some((snap, _)) = snap_opt {
settings::save(&snap);
}
if let Some(interval) = next_interval {
let ms = (interval.saturating_mul(1000).min(u32::MAX as u64)) as u32;
unsafe {
SetTimer(send_hwnd.to_hwnd(), TIMER_UPDATE_CHECK, ms, None);
}
}
});
}
fn set_update_check_interval(value: Option<u64>) {
let (snap, msg_hwnd) = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
s.settings.update_check_interval_secs = value;
(s.settings.clone(), s.msg_hwnd)
};
settings::save(&snap);
let hwnd = msg_hwnd.to_hwnd();
unsafe {
let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK);
}
if value.is_some() {
schedule_update_check_timer(hwnd);
}
}
// ---------- Restart ----------
/// 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
// lock before the disk write so the UI thread doesn't block on I/O.
let snap = lock_state().as_ref().map(|s| s.settings.clone());
if let Some(s) = snap {
settings::save(&s);
}
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
log::error!("restart: current_exe failed: {e}");
return;
}
};
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: spawn_detached failed: {e}"),
}
}
// ---------- Start-with-Windows ----------
fn is_startup_enabled() -> bool {
os::registry::value_exists(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME)
}
fn toggle_startup() {
if is_startup_enabled() {
let _ = os::registry::delete_value(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME);
} else if let Ok(exe) = std::env::current_exe() {
let quoted = format!("\"{}\"", exe.to_string_lossy());
let _ = os::registry::write_string(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME, &quoted);
}
}