diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index acb4a52..0000000 --- a/src/app.rs +++ /dev/null @@ -1,1527 +0,0 @@ -// 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_MODEL_OPENCODE_GO: u16 = 22; -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_SIZE_SMALLER: u16 = 34; -const IDM_SIZE_LARGER: u16 = 35; -const IDM_RESET_SIZE: u16 = 36; -// 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; - -// ---------- 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(); - 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); - } - update::install::cleanup_staged_update_files(); - - 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, - }; - for provider in ProviderId::LIVE_USAGE { - if settings.is_provider_enabled(provider) { - spawn_bubble(provider, &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(), - session_resets_at: None, - weekly_pct: None, - weekly_text: placeholder, - weekly_resets_at: None, - 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) { - set_bubble_size(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_MODEL_OPENCODE_GO => {} - IDM_START_WITH_WINDOWS => toggle_startup(), - IDM_RESET_POSITION => reset_positions(), - IDM_VERSION_ACTION => version_action(), - IDM_SIZE_SMALLER => resize_bubbles(-bubble::RESIZE_STEP_LOGICAL), - IDM_SIZE_LARGER => resize_bubbles(bubble::RESIZE_STEP_LOGICAL), - IDM_RESET_SIZE => set_bubble_size(bubble::DEFAULT_BUBBLE_SIZE), - 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)) - } - tray::IDM_TOGGLE_WIDGET => toggle_widget_visibility(), - IDM_RESTART => restart_app(), - _ => {} - } -} - -// ---------- 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(); - let session_resets_at = entry.and_then(|s| s.windows.primary.resets_at); - let weekly_resets_at = entry.and_then(|s| s.windows.secondary.resets_at); - bubble::update_data( - hwnd.to_hwnd(), - session_pct, - session_text, - session_resets_at, - weekly_pct, - weekly_text, - weekly_resets_at, - ); - } - 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().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(); - for provider in ProviderId::LIVE_USAGE { - if !snap.settings.is_provider_enabled(provider) { - continue; - } - let entry = snap.snapshots.get(&provider); - icons.push(TrayIconData { - kind: provider, - percent: if snap.last_poll_ok { - entry.map(|e| e.windows.primary.utilization) - } else { - None - }, - tooltip: tray_tooltip( - &provider_label(provider, &snap.i18n_strings), - entry, - &snap.i18n_strings, - ), - }); - } - tray::sync(snap.msg_hwnd.to_hwnd(), &icons); -} - -fn tray_tooltip(label: &str, entry: Option<&ProviderUiState>, strings: &LocaleStrings) -> String { - let session = entry - .map(|e| e.primary_text.as_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("..."); - let weekly = entry - .map(|e| e.secondary_text.as_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("..."); - format!( - "{label}\n{}: {session}\n{}: {weekly}\n{}", - strings.session_window, strings.weekly_window, strings.tray_left_click - ) -} - -fn provider_label(provider: ProviderId, strings: &LocaleStrings) -> String { - match provider { - ProviderId::Claude => strings.claude_label.clone(), - ProviderId::ChatGpt => strings.chatgpt_label.clone(), - ProviderId::OpenCodeGo => strings.opencode_go_label.clone(), - } -} - -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 = provider_label(provider, strings); - 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(), - ), - ProviderId::OpenCodeGo => ( - strings.opencode_go_label.clone(), - strings.update_failed.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, - current_interval: u32, - update_check_interval_secs: Option, - show_claude: bool, - show_chatgpt: bool, - show_opencode_go: bool, - widget_visible: bool, - install_channel: InstallChannel, - update_status: UpdateStatus, - bubble_size_logical: i32, -} - -fn show_context_menu(owner_hwnd: HWND) { - let snap = match lock_state().as_ref() { - Some(s) => ContextMenuSnapshot { - strings: s.i18n.strings().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, - show_opencode_go: s.settings.show_opencode_go, - widget_visible: s.settings.widget_visible, - install_channel: s.install_channel, - update_status: s.update_status, - bubble_size_logical: s.settings.bubble_size_logical, - }, - 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(providers) = CreatePopupMenu() else { - log::error!("CreatePopupMenu(providers) failed"); - let _ = DestroyMenu(menu); - return; - }; - append_item( - providers, - IDM_MODEL_CLAUDE, - &snap.strings.claude_label, - if snap.show_claude { - MF_CHECKED - } else { - MENU_ITEM_FLAGS(0) - }, - ); - append_item( - providers, - IDM_MODEL_CHATGPT, - &snap.strings.chatgpt_label, - if snap.show_chatgpt { - MF_CHECKED - } else { - MENU_ITEM_FLAGS(0) - }, - ); - append_item( - providers, - IDM_MODEL_OPENCODE_GO, - &snap.strings.opencode_go_label, - if snap.show_opencode_go { - MF_CHECKED | MF_GRAYED - } else { - MF_GRAYED - }, - ); - append_submenu(menu, providers, &snap.strings.providers); - - 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 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); - - let Ok(controls) = CreatePopupMenu() else { - log::error!("CreatePopupMenu(controls) failed"); - let _ = DestroyMenu(menu); - return; - }; - append_item( - controls, - IDM_SIZE_SMALLER, - &snap.strings.size_smaller, - if snap.bubble_size_logical <= bubble::MIN_BUBBLE_SIZE { - MF_GRAYED - } else { - MENU_ITEM_FLAGS(0) - }, - ); - append_item( - controls, - IDM_SIZE_LARGER, - &snap.strings.size_larger, - if snap.bubble_size_logical >= bubble::MAX_BUBBLE_SIZE { - MF_GRAYED - } else { - MENU_ITEM_FLAGS(0) - }, - ); - append_item( - controls, - IDM_RESET_SIZE, - &snap.strings.reset_size, - if snap.bubble_size_logical == bubble::DEFAULT_BUBBLE_SIZE { - MF_GRAYED - } else { - MENU_ITEM_FLAGS(0) - }, - ); - let _ = AppendMenuW(controls, MF_SEPARATOR, 0, PCWSTR::null()); - append_item(controls, 0, &snap.strings.control_left_click, MF_GRAYED); - append_item(controls, 0, &snap.strings.control_right_click, MF_GRAYED); - append_item(controls, 0, &snap.strings.control_drag, MF_GRAYED); - append_item(controls, 0, &snap.strings.control_ctrl_wheel, MF_GRAYED); - append_item(controls, 0, &snap.strings.control_tray_click, MF_GRAYED); - append_submenu(menu, controls, &snap.strings.controls); - - 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; - }; - if !model.metadata().live_usage { - return; - } - s.settings.toggle_provider(model); - if !s.settings.has_enabled_live_provider() { - s.settings.set_provider_enabled(model, true); - } - (s.settings.clone(), s.is_dark) - }; - settings::save(&settings); - - let want = settings.is_provider_enabled(model); - 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 resize_bubbles(delta: i32) { - let current = lock_state() - .as_ref() - .map(|s| s.settings.bubble_size_logical) - .unwrap_or(bubble::DEFAULT_BUBBLE_SIZE); - set_bubble_size(current + delta); -} - -fn set_bubble_size(size_logical: i32) { - let (hwnds, snap) = { - let mut s = lock_state(); - let Some(s) = s.as_mut() else { - return; - }; - let new_size = size_logical.clamp(bubble::MIN_BUBBLE_SIZE, bubble::MAX_BUBBLE_SIZE); - if new_size == s.settings.bubble_size_logical { - return; - } - s.settings.bubble_size_logical = new_size; - let hwnds = s.bubbles.values().map(|h| h.to_hwnd()).collect::>(); - (hwnds, s.settings.clone()) - }; - settings::save(&snap); - for hwnd in hwnds { - bubble::set_size_logical(hwnd, snap.bubble_size_logical); - } -} - -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); - } -} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..a19c649 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,98 @@ +//! Commands the webviews invoke. Every argument comes from a webview, so +//! each one is validated here before it reaches the runtime. + +use std::sync::Arc; + +use tauri::{AppHandle, Manager, State, WebviewWindow}; + +use crate::core::i18n::LocaleStrings; +use crate::core::payload::BubblePayload; +use crate::runtime::{Runtime, BUBBLE_LABEL_PREFIX}; +use crate::usage::ProviderId; + +/// Longest JS log message kept, in characters. +const MAX_JS_LOG_CHARS: usize = 2_000; + +/// The current payload for `provider`'s bubble, sized for the calling +/// window's scale factor. +#[tauri::command] +pub fn get_bubble_state( + window: WebviewWindow, + runtime: State<'_, Arc>, + provider: String, +) -> Result { + let provider = live_provider(&provider)?; + let scale = window.scale_factor().map_err(|err| err.to_string())?; + Ok(runtime.bubble_payload(provider, scale)) +} + +/// Every UI string, camelCase keys. +#[tauri::command] +pub fn get_strings(runtime: State<'_, Arc>) -> LocaleStrings { + runtime.with_state(|s| s.strings.clone()) +} + +/// Forward a webview error or message to the diagnostic log. +#[tauri::command] +pub fn log_js(window: WebviewWindow, level: String, message: String) { + let message = sanitize_log_message(&message); + let label = window.label(); + match level.as_str() { + "error" => log::error!(target: "js", "[{label}] {message}"), + "warn" => log::warn!(target: "js", "[{label}] {message}"), + "debug" => log::debug!(target: "js", "[{label}] {message}"), + _ => log::info!(target: "js", "[{label}] {message}"), + } +} + +/// The bubble rendered its first state and can be shown. +#[tauri::command] +pub fn bubble_ready(app: AppHandle, label: String) -> Result<(), String> { + if !label.starts_with(BUBBLE_LABEL_PREFIX) { + return Err("not a bubble window".into()); + } + let window = app + .get_webview_window(&label) + .ok_or_else(|| "no such bubble window".to_string())?; + window.show().map_err(|err| err.to_string()) +} + +fn live_provider(slug: &str) -> Result { + ProviderId::from_slug(slug) + .filter(|p| p.metadata().live_usage) + .ok_or_else(|| "unknown provider".to_string()) +} + +/// One log line: control characters become spaces, so a message cannot +/// forge extra log lines, and the length is capped. +fn sanitize_log_message(message: &str) -> String { + message + .chars() + .take(MAX_JS_LOG_CHARS) + .map(|c| if c.is_control() { ' ' } else { c }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_live_providers_are_accepted() { + assert_eq!(live_provider("claude"), Ok(ProviderId::Claude)); + assert_eq!(live_provider("chatgpt"), Ok(ProviderId::ChatGpt)); + assert!(live_provider("opencode-go").is_err()); + assert!(live_provider("").is_err()); + assert!(live_provider("CLAUDE").is_err()); + } + + #[test] + fn log_messages_stay_on_one_capped_line() { + assert_eq!(sanitize_log_message("a\nb\r\tc"), "a b c"); + let long = "x".repeat(MAX_JS_LOG_CHARS + 50); + assert_eq!( + sanitize_log_message(&long).chars().count(), + MAX_JS_LOG_CHARS + ); + } +} diff --git a/src/core/i18n.rs b/src/core/i18n.rs index aeca9fd..78f2486 100644 --- a/src/core/i18n.rs +++ b/src/core/i18n.rs @@ -5,10 +5,14 @@ use std::time::{Duration, SystemTime}; +use serde::Serialize; + use crate::usage::{ProviderId, Window}; -/// The strings every UI module needs. -#[derive(Clone, Debug)] +/// The strings every UI module needs. Serialised camelCase for the +/// webviews. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] pub struct LocaleStrings { pub window_title: String, pub refresh: String, diff --git a/src/lib.rs b/src/lib.rs index 79fd9b1..7794336 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,76 +4,18 @@ //! that items wired up incrementally do not trip `dead_code`. pub mod cli; +pub mod commands; pub mod core; pub mod creds; pub mod diag; pub mod handoff; pub mod net; pub mod platform; +pub mod runtime; pub mod scheduler; pub mod usage; -use tauri::{RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent}; - -/// Label of the scaffold window that proves the webview pipeline works. -const TEST_WINDOW_LABEL: &str = "bubble-test"; - /// Starts the application and blocks until it exits. pub fn run() { - let args: Vec = std::env::args().collect(); - let diagnose_enabled = args.iter().any(|a| a == "--diagnose"); - match diag::init(diagnose_enabled) { - Ok(Some(path)) => log::info!("startup args={args:?} log_path={}", path.display()), - Ok(None) => {} - Err(err) => eprintln!("diagnostic log could not be opened: {err}"), - } - - let app = tauri::Builder::default() - .setup(|app| { - let window = WebviewWindowBuilder::new( - app, - TEST_WINDOW_LABEL, - WebviewUrl::App("bubble.html".into()), - ) - .title("Claude Code Usage Bubble") - .inner_size(220.0, 66.0) - .transparent(true) - .decorations(false) - .always_on_top(true) - .skip_taskbar(true) - .resizable(false) - .build()?; - - // The process otherwise outlives its windows (see the exit - // handler below), so closing the only window must exit - // explicitly. - let handle = app.handle().clone(); - window.on_window_event(move |event| { - if let WindowEvent::CloseRequested { .. } = event { - handle.exit(0); - } - }); - Ok(()) - }) - .build(tauri::generate_context!()); - - let app = match app { - Ok(app) => app, - Err(err) => { - log::error!("tauri builder failed: {err}"); - eprintln!("Claude Code Usage Bubble failed to start: {err}"); - std::process::exit(1); - } - }; - - app.run(|_app, event| { - // Destroying the last window must not end the process; only an - // explicit `exit` (which carries an exit code) does. - if let RunEvent::ExitRequested { - code: None, api, .. - } = event - { - api.prevent_exit(); - } - }); + runtime::run(); } diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..c43af99 --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,661 @@ +//! App runtime: start-up, the shared state, the poll worker and the bridge +//! that pushes state to the webviews. +//! +//! Locking rule: no Tauri window, tray, menu or emit API is called while the +//! [`AppState`] mutex is held. Code reads state through +//! [`Runtime::with_state`], which returns owned data, and calls Tauri after +//! the guard is gone. Tauri calls from a worker block on the main thread, so +//! holding the lock across one can deadlock against a main-thread handler. + +use std::error::Error; +use std::ffi::OsString; +use std::io; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Sender}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant, SystemTime}; + +use tauri::{ + App, AppHandle, Emitter, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, Window, + WindowEvent, +}; + +use crate::cli::{self, CliArgs}; +use crate::core::geom::{self, Rect}; +use crate::core::i18n::{I18n, LocaleStrings}; +use crate::core::payload::{self, BubblePayload, Canvas}; +use crate::core::settings::{self, Settings}; +use crate::core::state::{Alert, PollOutcome, UpdateStatus, UsageState}; +use crate::scheduler::{self, Action, Message, SchedulerHost}; +use crate::usage::{self, refresh, ProviderId, Registry}; +use crate::{diag, handoff, net, platform}; + +/// Event carrying a [`BubblePayload`] to a bubble webview. +pub const BUBBLE_UPDATE_EVENT: &str = "bubble:update"; + +/// Label prefix of every bubble window. +pub const BUBBLE_LABEL_PREFIX: &str = "bubble-"; + +/// How long a relaunched instance waits for its parent to exit. The +/// single-instance plugin cannot retry, so this must outlast a slow +/// WebView2 teardown. +const WAIT_PID_TIMEOUT: Duration = Duration::from_secs(15); + +/// Per-provider budget for the CLI credential refresh. +const REFRESH_TIMEOUT: Duration = Duration::from_secs(8); + +#[cfg(windows)] +const WEBVIEW2_MISSING_TITLE: &str = "Claude Code Usage Bubble"; +#[cfg(windows)] +const WEBVIEW2_MISSING_TEXT: &str = "Claude Code Usage Bubble needs the Microsoft Edge \ +WebView2 Runtime.\n\nDownload the Evergreen runtime from:\n\ +https://developer.microsoft.com/microsoft-edge/webview2/"; + +/// Everything the app knows, behind one mutex in [`Runtime`]. +#[derive(Debug)] +pub struct AppState { + pub settings: Settings, + pub strings: LocaleStrings, + pub is_dark: bool, + pub usage: UsageState, + pub update_status: UpdateStatus, + /// The "update applied" alert from `--updated-to`, shown once the + /// notification backend is up. + pub pending_update_alert: Option, +} + +/// Holds the `AppHandle`; every push to a webview goes through it. +pub struct UiBridge { + app: AppHandle, +} + +impl UiBridge { + pub fn app(&self) -> &AppHandle { + &self.app + } + + /// Send each bubble its payload. The window lookups and emits run on the + /// main thread; `snapshot` is owned, so no state lock is involved. + fn emit_bubble_updates(&self, snapshot: BubbleSnapshot) { + let app = self.app.clone(); + let dispatched = self.app.run_on_main_thread(move || { + let now = SystemTime::now(); + for provider in &snapshot.providers { + let label = bubble_label(*provider); + let Some(window) = app.get_webview_window(&label) else { + continue; + }; + let scale = match window.scale_factor() { + Ok(scale) => scale, + Err(err) => { + log::warn!("scale factor of {label} unavailable: {err}"); + continue; + } + }; + let payload = snapshot.payload(*provider, scale, now); + if let Err(err) = app.emit_to(label.as_str(), BUBBLE_UPDATE_EVENT, payload) { + log::warn!("emit {BUBBLE_UPDATE_EVENT} to {label} failed: {err}"); + } + } + }); + if let Err(err) = dispatched { + log::warn!("bubble update could not reach the main thread: {err}"); + } + } +} + +/// An owned copy of what the bubble payloads need. +struct BubbleSnapshot { + providers: Vec, + usage: UsageState, + strings: LocaleStrings, + is_dark: bool, + size_logical: i32, +} + +impl BubbleSnapshot { + fn of(state: &AppState) -> Self { + Self { + providers: enabled_live_providers(&state.settings), + usage: state.usage.clone(), + strings: state.strings.clone(), + is_dark: state.is_dark, + size_logical: state.settings.bubble_size_logical, + } + } + + fn payload(&self, provider: ProviderId, scale_factor: f64, now: SystemTime) -> BubblePayload { + payload::bubble_payload( + provider, + self.usage.snapshots.get(&provider), + &self.strings, + self.is_dark, + bubble_canvas(self.size_logical, scale_factor), + now, + ) + } +} + +/// Shared runtime, managed by Tauri as `Arc`. +pub struct Runtime { + ui: UiBridge, + state: Mutex, + http: net::Client, + registry: Mutex, + /// At most one poll runs at a time; a Refresh spam or a timer firing + /// during a slow poll is dropped instead of stacking requests. + poll_in_flight: AtomicBool, + scheduler: Sender, +} + +impl Runtime { + /// Run `f` on the state and return what it returns. Return owned data + /// and call Tauri only after this returns (see the module docs). + pub fn with_state(&self, f: impl FnOnce(&mut AppState) -> T) -> T { + let mut guard = self.state.lock().unwrap_or_else(PoisonError::into_inner); + f(&mut guard) + } + + pub fn ui(&self) -> &UiBridge { + &self.ui + } + + /// Hand a message to the scheduler thread. + pub fn send(&self, message: Message) { + if self.scheduler.send(message).is_err() { + log::warn!("scheduler is not running; message dropped"); + } + } + + /// The payload for one bubble at the given window scale factor. + pub fn bubble_payload(&self, provider: ProviderId, scale_factor: f64) -> BubblePayload { + let snapshot = self.with_state(|s| BubbleSnapshot::of(s)); + snapshot.payload(provider, scale_factor, SystemTime::now()) + } + + /// Push the current state to every UI surface, then re-arm the + /// countdown for the next visible change. + pub fn propagate(&self) { + let (snapshot, countdown) = self.with_state(|s| { + ( + BubbleSnapshot::of(s), + s.usage.countdown_delay(SystemTime::now()), + ) + }); + self.ui.emit_bubble_updates(snapshot); + self.send(Message::RecomputeCountdown(countdown)); + } + + /// Start a poll on a worker thread unless one is already running. + pub fn spawn_poll(self: &Arc) { + if self + .poll_in_flight + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + log::debug!("poll already in flight; skipped"); + return; + } + let runtime = Arc::clone(self); + let spawned = std::thread::Builder::new() + .name("poll".into()) + .spawn(move || { + runtime.poll_once(); + runtime.poll_in_flight.store(false, Ordering::Release); + runtime.propagate(); + }); + if let Err(err) = spawned { + log::error!("poll thread could not start: {err}"); + self.poll_in_flight.store(false, Ordering::Release); + } + } + + /// Poll every enabled provider, apply the results, then run the CLI + /// credential refresh for the providers whose credentials were rejected. + /// Blocking; runs on the poll worker. + fn poll_once(&self) { + let settings = self.with_state(|s| s.settings.clone()); + let results = self + .registry + .lock() + .unwrap_or_else(PoisonError::into_inner) + .poll_enabled(&self.http, &settings); + let outcomes = results + .into_iter() + .map(|(provider, result)| (provider, poll_outcome(provider, result))) + .collect(); + let applied = + self.with_state(|s| s.usage.apply_results(outcomes, &s.strings, Instant::now())); + self.deliver_alerts(applied.alerts); + if !applied.auth_failures.is_empty() { + self.refresh_credentials(&applied.auth_failures); + } + } + + /// Run the CLI refresh for each provider in order. The session-expired + /// alert names the first provider that did not refresh; the next poll + /// picks the new credentials up, there is no immediate re-poll. + fn refresh_credentials(&self, failures: &[ProviderId]) { + let orchestrator = refresh::Orchestrator::new(REFRESH_TIMEOUT); + let mut expired: Option = None; + for &provider in failures { + let outcome = self + .registry + .lock() + .unwrap_or_else(PoisonError::into_inner) + .try_refresh(provider, &orchestrator); + log::info!("credential refresh for {}: {outcome:?}", provider.slug()); + if outcome != refresh::Outcome::Refreshed { + expired.get_or_insert(provider); + } + } + if let Some(provider) = expired { + let alert = + self.with_state(|s| s.usage.session_expired_alert(provider, Instant::now())); + self.deliver_alerts(alert.into_iter().collect()); + } + } + + /// Show alerts. Until a notification backend is registered they only + /// reach the diagnostic log. + fn deliver_alerts(&self, alerts: Vec) { + if alerts.is_empty() { + return; + } + let strings = self.with_state(|s| s.strings.clone()); + for alert in alerts { + let (title, body) = alert.text(&strings); + log::info!("alert: {title}: {body}"); + } + } + + /// Re-format the countdown texts after time passed and push them out. + fn refresh_countdowns(&self) { + self.with_state(|s| s.usage.refresh_texts(&s.strings)); + self.propagate(); + } + + /// The automatic update check. No updater is registered in this build, + /// so the deadline stays disarmed until a check reports back. + fn check_for_updates(&self) { + log::debug!("update check due; no updater is registered"); + } + + /// Spawn the next instance with `--wait-pid` plus `extra_args`, then + /// exit. On a failed spawn this instance keeps running. + pub fn relaunch_and_exit(&self, extra_args: &[OsString]) -> io::Result<()> { + handoff::relaunch(extra_args)?; + self.exit(); + Ok(()) + } + + /// Save the settings and start a fresh instance. + pub fn restart(&self) { + let snapshot = self.with_state(|s| s.settings.clone()); + if let Err(err) = settings::save(&snapshot) { + log::warn!("restart: settings save failed: {err}"); + } + if let Err(err) = self.relaunch_and_exit(&[]) { + log::error!("restart: relaunch failed, keeping this instance: {err}"); + } + } + + /// End the process. The scheduler stops from the `Exit` run event. + pub fn exit(&self) { + self.ui.app.exit(0); + } +} + +impl SchedulerHost for Arc { + fn run(&self, action: Action) { + match action { + Action::Poll => self.spawn_poll(), + Action::Countdown => self.refresh_countdowns(), + Action::CheckForUpdates => self.check_for_updates(), + } + } +} + +/// Map a provider error onto the portable outcome, logging transient ones. +fn poll_outcome( + provider: ProviderId, + result: Result, +) -> PollOutcome { + match result { + Ok(windows) => { + log::info!( + "poll {}: session {:.1}%, weekly {:.1}%", + provider.slug(), + windows.primary.utilization, + windows.secondary.utilization + ); + PollOutcome::Ok(windows) + } + Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => { + log::info!("poll {}: credentials rejected", provider.slug()); + PollOutcome::AuthFailed + } + Err(err) => { + log::warn!("poll {} failed: {err}", provider.slug()); + PollOutcome::Failed + } + } +} + +/// Window label of a provider's bubble. +pub fn bubble_label(provider: ProviderId) -> String { + format!("{BUBBLE_LABEL_PREFIX}{}", provider.slug()) +} + +/// Live providers the user has enabled, in display order. +pub fn enabled_live_providers(settings: &Settings) -> Vec { + ProviderId::LIVE_USAGE + .into_iter() + .filter(|p| settings.is_provider_enabled(*p)) + .collect() +} + +/// The bubble canvas in physical px for a window scale factor. +pub fn bubble_canvas(size_logical: i32, scale_factor: f64) -> Canvas { + let dpi = if scale_factor.is_finite() && scale_factor > 0.0 { + (scale_factor * f64::from(geom::BASE_DPI)).round() as u32 + } else { + geom::BASE_DPI + }; + let (w, h) = geom::bubble_size_px(size_logical, dpi); + Canvas { + w: u32::try_from(w).unwrap_or(1), + h: u32::try_from(h).unwrap_or(1), + dpi, + } +} + +/// Whether to run GTK on X11 (Xwayland) under a Wayland session: +/// positioning, snapping and always-on-top only work there. An explicit +/// `GDK_BACKEND` wins, and without an X server (`DISPLAY` unset) GTK would +/// fail to start, so the native Wayland backend stays. +pub fn needs_x11_backend( + wayland_display: Option, + gdk_backend: Option, + display: Option, +) -> bool { + let set = |v: &Option| v.as_ref().is_some_and(|v| !v.is_empty()); + set(&wayland_display) && !set(&gdk_backend) && set(&display) +} + +/// Process entry: everything that must happen before the Tauri builder, +/// then the builder itself. +pub fn run() { + let cli = cli::from_env(); + match diag::init(cli.diagnose) { + Ok(Some(path)) => log::info!( + "startup args={:?} log_path={}", + std::env::args_os().collect::>(), + path.display() + ), + Ok(None) => {} + Err(err) => eprintln!("diagnostic log could not be opened: {err}"), + } + + #[cfg(windows)] + if !platform::windows::webview2_available() { + log::error!("WebView2 runtime missing; exiting"); + platform::windows::message_box_error(WEBVIEW2_MISSING_TITLE, WEBVIEW2_MISSING_TEXT); + std::process::exit(0); + } + + if let Some(pid) = cli.wait_pid { + log::info!("waiting up to {WAIT_PID_TIMEOUT:?} for pid {pid} to exit"); + let started = Instant::now(); + if platform::wait_pid(pid, WAIT_PID_TIMEOUT) { + log::info!("pid {pid} gone after {:?}", started.elapsed()); + } else { + log::warn!("pid {pid} still running after {WAIT_PID_TIMEOUT:?}; starting anyway"); + } + } + + #[cfg(target_os = "linux")] + if needs_x11_backend( + std::env::var_os("WAYLAND_DISPLAY"), + std::env::var_os("GDK_BACKEND"), + std::env::var_os("DISPLAY"), + ) { + log::info!("Wayland session with Xwayland: using GDK_BACKEND=x11"); + // No other thread exists yet, so changing the environment is safe. + std::env::set_var("GDK_BACKEND", "x11"); + } + + let app = tauri::Builder::default() + // First, so a second instance exits before anything else starts. + .plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {})) + .invoke_handler(tauri::generate_handler![ + crate::commands::get_bubble_state, + crate::commands::get_strings, + crate::commands::log_js, + crate::commands::bubble_ready, + ]) + .on_window_event(on_window_event) + .setup(move |app| setup(app, &cli)) + .build(tauri::generate_context!()); + + let app = match app { + Ok(app) => app, + Err(err) => { + log::error!("tauri builder failed: {err}"); + eprintln!("Claude Code Usage Bubble failed to start: {err}"); + std::process::exit(1); + } + }; + + app.run(|app, event| match event { + // Destroying the last window must not end the process; only an + // explicit `exit` (which carries an exit code) does. + RunEvent::ExitRequested { + code: None, api, .. + } => api.prevent_exit(), + RunEvent::Exit => { + if let Some(runtime) = app.try_state::>() { + runtime.send(Message::Shutdown); + } + } + _ => {} + }); +} + +/// Bubbles ignore close requests (Alt+F4); they are only ever destroyed. +fn on_window_event(window: &Window, event: &WindowEvent) { + if let WindowEvent::CloseRequested { api, .. } = event { + if window.label().starts_with(BUBBLE_LABEL_PREFIX) { + api.prevent_close(); + } + } +} + +fn setup(app: &mut App, cli: &CliArgs) -> Result<(), Box> { + let mut settings = settings::load(); + settings::drop_offscreen_positions(&mut settings, &monitor_rects(app.handle())); + let is_dark = platform::system_is_dark(); + let http = net::Client::new(net::USER_AGENT)?; + + let state = AppState { + strings: I18n::load().strings().clone(), + is_dark, + usage: UsageState::default(), + update_status: UpdateStatus::Idle, + pending_update_alert: cli + .updated_to + .clone() + .map(|version| Alert::UpdateApplied { version }), + settings, + }; + let config = scheduler::Config { + poll_interval: Duration::from_millis(u64::from(state.settings.poll_interval_ms)), + update_interval_secs: state.settings.update_check_interval_secs, + last_update_check_unix: state.settings.last_update_check_unix, + }; + let first_provider = enabled_live_providers(&state.settings) + .first() + .copied() + .unwrap_or(ProviderId::Claude); + + let (tx, rx) = mpsc::channel(); + let runtime = Arc::new(Runtime { + ui: UiBridge { + app: app.handle().clone(), + }, + state: Mutex::new(state), + http, + registry: Mutex::new(Registry::with_defaults()), + poll_in_flight: AtomicBool::new(false), + scheduler: tx, + }); + app.manage(Arc::clone(&runtime)); + + match std::env::current_exe() { + Ok(exe) => handoff::cleanup_stale_files(&exe, handoff::updates_dir().as_deref()), + Err(err) => log::warn!("startup cleanup skipped, no executable path: {err}"), + } + + // The thread is detached; it stops on `Message::Shutdown` at exit. + scheduler::spawn(config, rx, Arc::clone(&runtime))?; + runtime.send(Message::PollNow); + + create_test_window(app, first_provider)?; + Ok(()) +} + +/// Monitor rects in physical px. Empty when they cannot be enumerated, +/// which keeps every saved position. +fn monitor_rects(app: &AppHandle) -> Vec { + match app.available_monitors() { + Ok(monitors) => monitors + .iter() + .map(|m| { + let pos = m.position(); + let size = m.size(); + Rect::from_xywh( + pos.x, + pos.y, + i32::try_from(size.width).unwrap_or(i32::MAX), + i32::try_from(size.height).unwrap_or(i32::MAX), + ) + }) + .collect(), + Err(err) => { + log::warn!("monitors could not be enumerated: {err}"); + Vec::new() + } + } +} + +/// A plain window for the first enabled provider that renders its payload +/// as JSON, standing in for the real bubble window. +fn create_test_window(app: &App, provider: ProviderId) -> tauri::Result<()> { + WebviewWindowBuilder::new( + app, + bubble_label(provider), + WebviewUrl::App("bubble.html".into()), + ) + .title("Claude Code Usage Bubble") + .inner_size(460.0, 420.0) + .always_on_top(true) + .skip_taskbar(true) + .build()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn os(v: &str) -> Option { + Some(OsString::from(v)) + } + + #[test] + fn x11_backend_only_under_wayland_with_xwayland_and_no_override() { + assert!(needs_x11_backend(os("wayland-0"), None, os(":0"))); + assert!(needs_x11_backend(os("wayland-0"), os(""), os(":0"))); + assert!(!needs_x11_backend(os("wayland-0"), os("wayland"), os(":0"))); + assert!(!needs_x11_backend(None, None, os(":0"))); + assert!(!needs_x11_backend(os(""), None, os(":0"))); + // No X server: forcing x11 would stop GTK from starting. + assert!(!needs_x11_backend(os("wayland-0"), None, None)); + } + + #[test] + fn canvas_follows_the_scale_factor() { + assert_eq!( + bubble_canvas(200, 1.0), + Canvas { + w: 200, + h: 66, + dpi: 96 + } + ); + assert_eq!( + bubble_canvas(200, 1.5), + Canvas { + w: 300, + h: 99, + dpi: 144 + } + ); + assert_eq!(bubble_canvas(200, 1.25).dpi, 120); + assert_eq!(bubble_canvas(200, f64::NAN).dpi, 96); + assert_eq!(bubble_canvas(200, 0.0).dpi, 96); + } + + #[test] + fn enabled_providers_keep_display_order_and_skip_non_live_ones() { + let mut settings = Settings { + show_codex: true, + show_opencode_go: true, + ..Settings::default() + }; + assert_eq!( + enabled_live_providers(&settings), + [ProviderId::Claude, ProviderId::ChatGpt] + ); + settings.show_claude_code = false; + assert_eq!(enabled_live_providers(&settings), [ProviderId::ChatGpt]); + } + + #[test] + fn bubble_labels_use_the_slug() { + assert_eq!(bubble_label(ProviderId::ChatGpt), "bubble-chatgpt"); + } + + #[test] + fn auth_errors_become_auth_failures() { + assert!(matches!( + poll_outcome(ProviderId::Claude, Err(usage::Error::TokenExpired)), + PollOutcome::AuthFailed + )); + assert!(matches!( + poll_outcome(ProviderId::Claude, Err(usage::Error::AuthRequired)), + PollOutcome::AuthFailed + )); + assert!(matches!( + poll_outcome(ProviderId::ChatGpt, Err(usage::Error::NoCredentials)), + PollOutcome::Failed + )); + } + + #[test] + fn snapshot_payload_uses_the_window_canvas() { + let state = AppState { + settings: Settings::default(), + strings: I18n::load().strings().clone(), + is_dark: true, + usage: UsageState::default(), + update_status: UpdateStatus::Idle, + pending_update_alert: None, + }; + let snapshot = BubbleSnapshot::of(&state); + assert_eq!(snapshot.providers, [ProviderId::Claude]); + let p = snapshot.payload(ProviderId::Claude, 2.0, SystemTime::now()); + assert_eq!((p.canvas_w, p.canvas_h, p.dpi), (400, 132, 192)); + assert_eq!(p.head_label, "…"); + } +} diff --git a/ui/bubble.html b/ui/bubble.html index ec57410..da055c4 100644 --- a/ui/bubble.html +++ b/ui/bubble.html @@ -7,25 +7,21 @@ html, body { margin: 0; - height: 100%; - background: transparent; - overflow: hidden; + background: #1f1f1f; + color: #eaeaea; + font: 12px ui-monospace, "Cascadia Mono", Consolas, monospace; } - .pill { - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; - height: 100%; - border-radius: 9999px; - background: #1f1f1f; - color: #f0f0f0; - font: 600 14px "Segoe UI", system-ui, sans-serif; + pre { + margin: 0; + padding: 8px; + white-space: pre-wrap; + word-break: break-word; } + -
Claude Code Usage Bubble
+
Waiting for the first payload...
diff --git a/ui/bubble.js b/ui/bubble.js new file mode 100644 index 0000000..4fb1d2e --- /dev/null +++ b/ui/bubble.js @@ -0,0 +1,48 @@ +// Test view: renders the bubble payload as raw JSON until the real bubble +// UI replaces this page. + +const tauri = window.__TAURI__; +const view = /** @type {HTMLElement} */ (document.getElementById('payload')); +const webview = tauri.webviewWindow.getCurrentWebviewWindow(); +const provider = webview.label.replace(/^bubble-/, ''); + +/** + * Forward a message to the diagnostic log. + * @param {'error' | 'warn' | 'info' | 'debug'} level Log level. + * @param {string} message Text to log. + * @returns {void} + */ +function logJs(level, message) { + tauri.core.invoke('log_js', { level, message }).catch(() => {}); +} + +/** + * Show one payload. + * @param {unknown} payload The bubble payload from Rust. + * @returns {void} + */ +function render(payload) { + view.textContent = JSON.stringify(payload, null, 2); +} + +window.addEventListener('error', (event) => { + logJs('error', `${event.message} at ${event.filename}:${event.lineno}`); +}); +window.addEventListener('unhandledrejection', (event) => { + logJs('error', `unhandled rejection: ${String(event.reason)}`); +}); + +webview + .listen('bubble:update', (event) => render(event.payload)) + .catch((err) => logJs('error', `listen failed: ${String(err)}`)); + +tauri.core + .invoke('get_bubble_state', { provider }) + .then((payload) => { + render(payload); + return tauri.core.invoke('bubble_ready', { label: webview.label }); + }) + .catch((err) => { + view.textContent = `get_bubble_state failed: ${String(err)}`; + logJs('error', `startup failed: ${String(err)}`); + });