// App orchestrator. // // One `Mutex>` 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, 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, registry: Arc>, snapshots: HashMap, last_poll_ok: bool, update_status: UpdateStatus, update_release: Option, last_balloon_at: Option, } #[derive(Clone, Default)] struct ProviderUiState { windows: UsageWindows, primary_text: String, secondary_text: String, } fn state() -> &'static Mutex> { static S: OnceLock>> = OnceLock::new(); S.get_or_init(|| Mutex::new(None)) } fn lock_state() -> MutexGuard<'static, Option> { 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 { 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 `.old.` 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 { 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::() 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, ®istry); } } fn apply_results( results: Vec<(ProviderId, Result)>, ) -> Vec { 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, registry: &Arc>) { 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 = 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, snapshots: HashMap, 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 = 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, current_interval: u32, update_check_interval_secs: Option, 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 = 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 = 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> = 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) { 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, "ed); } }