From 83f60d1c1c53f809ac81e54ccf33584903b7e499 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 21 Oct 2026 13:20:15 +0700 Subject: [PATCH] feat: run the app on a Tauri runtime with shared state and commands run() keeps the pre-builder order: diagnostic log, the Windows WebView2 guard (a message box with the download link, then exit 0), the --wait-pid wait capped at 15 s, GDK_BACKEND=x11 under Wayland when Xwayland is present, and the single-instance plugin registered first with a no-op callback. setup() loads the settings, drops positions on missing monitors, reads the theme, cleans up after earlier updates, starts the scheduler and polls. The state sits behind one mutex reached only through with_state, so no Tauri call runs under the lock. The poll worker keeps the in-flight gate, applies the results, runs the CLI credential refresh and pushes bubble payloads from the main thread. Commands: get_bubble_state, get_strings, log_js and bubble_ready. Bubble windows ignore close requests; Restart saves the settings and exits only once the new instance spawned. The scaffold window now renders its payload as JSON. The Win32 app module is removed. --- src/app.rs | 1527 ---------------------------------------------- src/commands.rs | 98 +++ src/core/i18n.rs | 8 +- src/lib.rs | 64 +- src/runtime.rs | 661 ++++++++++++++++++++ ui/bubble.html | 24 +- ui/bubble.js | 48 ++ 7 files changed, 826 insertions(+), 1604 deletions(-) delete mode 100644 src/app.rs create mode 100644 src/commands.rs create mode 100644 src/runtime.rs create mode 100644 ui/bubble.js 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)}`); + });