mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-10 16:18:15 +00:00
1528 lines
50 KiB
Rust
1528 lines
50 KiB
Rust
// App orchestrator.
|
|
//
|
|
// One `Mutex<Option<AppState>>` is the single source of truth. The UI
|
|
// thread runs the message loop; a background thread polls the provider
|
|
// registry and posts `WM_APP_USAGE_UPDATED` back via a hidden
|
|
// message-only window owned by this module.
|
|
|
|
use std::collections::HashMap;
|
|
use std::ffi::OsString;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
use windows::core::PCWSTR;
|
|
use windows::Win32::Foundation::*;
|
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
|
use windows::Win32::System::Threading::{CreateMutexW, GetCurrentProcessId, Sleep};
|
|
use windows::Win32::UI::HiDpi::{
|
|
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
|
};
|
|
use windows::Win32::UI::WindowsAndMessaging::*;
|
|
|
|
use crate::bubble;
|
|
use crate::i18n::{self, I18n, LocaleStrings};
|
|
// Win32 message + timer IDs moved inline; see constants below.
|
|
use crate::net;
|
|
use crate::os;
|
|
use crate::panel::{self, PanelData};
|
|
use crate::settings::{self, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN};
|
|
use crate::tray::{self, TrayAction, TrayIcon as TrayIconData, WM_APP_TRAY};
|
|
use crate::update::{self, Channel as InstallChannel, CheckOutcome};
|
|
use crate::usage::{self, ProviderId, Registry, UsageWindows};
|
|
|
|
// Win32 message IDs owned by this module.
|
|
pub const WM_APP_USAGE_UPDATED: u32 = 0x8001;
|
|
// Posted from the update worker thread after `install::begin` has
|
|
// already swapped the binary on disk and spawned the new detached
|
|
// child. The UI thread responds with PostQuitMessage(0) so the old
|
|
// instance exits cleanly and releases the singleton mutex for the
|
|
// child waiting on `--wait-pid`.
|
|
pub const WM_APP_UPDATE_APPLIED: u32 = 0x8002;
|
|
|
|
// Timer IDs used with `SetTimer(msg_hwnd, …)`.
|
|
const TIMER_POLL: usize = 1;
|
|
const TIMER_COUNTDOWN: usize = 2;
|
|
const TIMER_RESET_POLL: usize = 3;
|
|
const TIMER_UPDATE_CHECK: usize = 4;
|
|
|
|
const APP_MUTEX_NAME: &str = r"Global\ClaudeCodeUsageBubble";
|
|
const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
|
|
const STARTUP_VALUE_NAME: &str = "ClaudeCodeUsageBubble";
|
|
const APP_CLASS_NAME: &str = "ClaudeCodeUsageBubbleApp";
|
|
const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
|
|
// The auto-check interval is user-configurable (see
|
|
// `Settings::update_check_interval_secs`). `None` means auto-check is disabled.
|
|
const BALLOON_COOLDOWN: Duration = Duration::from_secs(30 * 60);
|
|
const REFRESH_TIMEOUT: Duration = Duration::from_secs(8);
|
|
|
|
// ---------- Menu IDs ----------
|
|
|
|
const IDM_REFRESH: u16 = 1;
|
|
const IDM_EXIT: u16 = 2;
|
|
const IDM_FREQ_1MIN: u16 = 10;
|
|
const IDM_FREQ_5MIN: u16 = 11;
|
|
const IDM_FREQ_15MIN: u16 = 12;
|
|
const IDM_FREQ_1HOUR: u16 = 13;
|
|
const IDM_MODEL_CLAUDE: u16 = 20;
|
|
const IDM_MODEL_CHATGPT: u16 = 21;
|
|
const IDM_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<ProviderId, SendHwnd>,
|
|
settings: Settings,
|
|
i18n: I18n,
|
|
is_dark: bool,
|
|
install_channel: InstallChannel,
|
|
// http and registry live behind Arc so worker threads can hold them
|
|
// without keeping the global state mutex locked across blocking I/O.
|
|
// The registry's own mutex is only contended by other workers
|
|
// (the in-flight gate ensures at most one poll runs at a time), so
|
|
// the UI thread never waits on it.
|
|
http: Arc<net::Client>,
|
|
registry: Arc<Mutex<Registry>>,
|
|
snapshots: HashMap<ProviderId, ProviderUiState>,
|
|
last_poll_ok: bool,
|
|
update_status: UpdateStatus,
|
|
update_release: Option<update::Release>,
|
|
last_balloon_at: Option<Instant>,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct ProviderUiState {
|
|
windows: UsageWindows,
|
|
primary_text: String,
|
|
secondary_text: String,
|
|
}
|
|
|
|
fn state() -> &'static Mutex<Option<AppState>> {
|
|
static S: OnceLock<Mutex<Option<AppState>>> = OnceLock::new();
|
|
S.get_or_init(|| Mutex::new(None))
|
|
}
|
|
|
|
fn lock_state() -> MutexGuard<'static, Option<AppState>> {
|
|
state().lock().expect("app state mutex poisoned")
|
|
}
|
|
|
|
/// Run `f` with mutable access to `AppState`, then snapshot `Settings` and
|
|
/// persist it to disk. The lock is released before the disk write so the UI
|
|
/// thread doesn't block on I/O. No-op if state hasn't been initialised yet.
|
|
fn update_settings(f: impl FnOnce(&mut AppState)) {
|
|
let snap = {
|
|
let mut guard = lock_state();
|
|
let Some(s) = guard.as_mut() else {
|
|
return;
|
|
};
|
|
f(s);
|
|
s.settings.clone()
|
|
};
|
|
settings::save(&snap);
|
|
}
|
|
|
|
// ---------- Entry ----------
|
|
|
|
/// Acquire the singleton mutex, optionally retrying for ~3s if the
|
|
/// caller passed `--wait-pid` (i.e. we just spawned from an exiting
|
|
/// parent that has not yet released its handle).
|
|
fn acquire_singleton_mutex(retry: bool) -> Option<HANDLE> {
|
|
let mutex_name_w = os::to_utf16_nul(APP_MUTEX_NAME);
|
|
let max_attempts = if retry { 15 } else { 1 };
|
|
for attempt in 0..max_attempts {
|
|
let handle = unsafe { CreateMutexW(None, false, PCWSTR::from_raw(mutex_name_w.as_ptr())) };
|
|
match handle {
|
|
Ok(h) => {
|
|
let already = unsafe { GetLastError() } == ERROR_ALREADY_EXISTS;
|
|
if !already {
|
|
return Some(h);
|
|
}
|
|
// Mutex still held by parent. Close this handle and retry.
|
|
unsafe {
|
|
let _ = CloseHandle(h);
|
|
}
|
|
if attempt + 1 == max_attempts {
|
|
log::info!("another instance already running; exiting");
|
|
return None;
|
|
}
|
|
log::debug!(
|
|
"mutex still held by parent (attempt {}/{}), waiting 200ms",
|
|
attempt + 1,
|
|
max_attempts
|
|
);
|
|
unsafe { Sleep(200) };
|
|
}
|
|
Err(e) => {
|
|
log::error!("CreateMutex failed: {e}");
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn run(args: crate::AppArgs) {
|
|
unsafe {
|
|
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
|
}
|
|
|
|
let _mutex = match acquire_singleton_mutex(args.wait_pid_present) {
|
|
Some(h) => h,
|
|
None => return,
|
|
};
|
|
|
|
let settings = settings::load();
|
|
let i18n = I18n::load();
|
|
let is_dark = os::theme::is_dark();
|
|
let install_channel = update::current_channel();
|
|
let http = match net::Client::new(HTTP_USER_AGENT) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
log::error!("HTTP client init failed: {e}");
|
|
return;
|
|
}
|
|
};
|
|
let msg_hwnd = match create_message_window() {
|
|
Some(h) => h,
|
|
None => {
|
|
log::error!("failed to create app message window");
|
|
return;
|
|
}
|
|
};
|
|
|
|
*lock_state() = Some(AppState {
|
|
msg_hwnd: SendHwnd::from_hwnd(msg_hwnd),
|
|
bubbles: HashMap::new(),
|
|
settings,
|
|
i18n,
|
|
is_dark,
|
|
install_channel,
|
|
http: Arc::new(http),
|
|
registry: Arc::new(Mutex::new(Registry::with_defaults())),
|
|
snapshots: HashMap::new(),
|
|
last_poll_ok: false,
|
|
update_status: UpdateStatus::Idle,
|
|
update_release: None,
|
|
last_balloon_at: None,
|
|
});
|
|
|
|
bubble::install_callbacks(bubble::Callbacks {
|
|
on_click: on_bubble_click,
|
|
on_right_click: on_bubble_right_click,
|
|
on_moved: on_bubble_moved,
|
|
on_resized: on_bubble_resized,
|
|
on_menu_command,
|
|
on_settings_changed: recheck_theme,
|
|
});
|
|
|
|
create_initial_bubbles();
|
|
refresh_tray_icons();
|
|
|
|
// Post-update tasks: show "Updated to vX.Y.Z" balloon (driven by
|
|
// --updated-to passed by the previous instance) and sweep any
|
|
// stale `<exe>.old.<pid>` siblings left by past in-place swaps.
|
|
if let Some(v) = args.updated_to.as_ref() {
|
|
announce_update_applied(msg_hwnd, v);
|
|
}
|
|
if let Ok(exe_path) = std::env::current_exe() {
|
|
update::handoff::cleanup_stale_old_exes(&exe_path);
|
|
}
|
|
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<HWND> {
|
|
let class_w = os::to_utf16_nul(APP_CLASS_NAME);
|
|
let title_w = os::to_utf16_nul("Claude Code Usage Bubble");
|
|
unsafe {
|
|
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
|
|
let wc = WNDCLASSEXW {
|
|
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
|
lpfnWndProc: Some(msg_wnd_proc),
|
|
hInstance: HINSTANCE(hinstance.0),
|
|
lpszClassName: PCWSTR::from_raw(class_w.as_ptr()),
|
|
..Default::default()
|
|
};
|
|
let _ = RegisterClassExW(&wc);
|
|
CreateWindowExW(
|
|
WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
|
|
PCWSTR::from_raw(class_w.as_ptr()),
|
|
PCWSTR::from_raw(title_w.as_ptr()),
|
|
WS_POPUP,
|
|
-1000,
|
|
-1000,
|
|
1,
|
|
1,
|
|
HWND::default(),
|
|
HMENU::default(),
|
|
hinstance,
|
|
None,
|
|
)
|
|
.ok()
|
|
}
|
|
}
|
|
|
|
fn create_initial_bubbles() {
|
|
let (settings, is_dark) = match lock_state().as_ref() {
|
|
Some(s) => (s.settings.clone(), s.is_dark),
|
|
None => return,
|
|
};
|
|
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<UsageWindows, usage::Error>)>,
|
|
) -> Vec<ProviderId> {
|
|
let mut auth_failures = Vec::new();
|
|
let mut crossings: Vec<(ProviderId, u8)> = Vec::new();
|
|
{
|
|
let mut guard = lock_state();
|
|
let Some(state) = guard.as_mut() else {
|
|
return auth_failures;
|
|
};
|
|
if results.is_empty() {
|
|
return auth_failures;
|
|
}
|
|
let strings = state.i18n.strings().clone();
|
|
let mut any_ok = false;
|
|
for (id, outcome) in results {
|
|
match outcome {
|
|
Ok(windows) => {
|
|
let entry = state.snapshots.entry(id).or_default();
|
|
let old_pct = entry.windows.primary.utilization;
|
|
let new_pct = windows.primary.utilization;
|
|
// Fire a balloon only the cycle a provider CROSSES a
|
|
// threshold so the user is nudged once, not on every
|
|
// subsequent poll while parked above it.
|
|
for threshold in [80u8, 95u8] {
|
|
if old_pct < threshold as f64 && new_pct >= threshold as f64 {
|
|
crossings.push((id, threshold));
|
|
}
|
|
}
|
|
entry.windows = windows;
|
|
entry.primary_text = i18n::format_window(&windows.primary, &strings);
|
|
entry.secondary_text = i18n::format_window(&windows.secondary, &strings);
|
|
any_ok = true;
|
|
}
|
|
Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => {
|
|
auth_failures.push(id);
|
|
let entry = state.snapshots.entry(id).or_default();
|
|
entry.primary_text = "!".into();
|
|
entry.secondary_text = "!".into();
|
|
}
|
|
Err(e) => {
|
|
log::warn!("provider {id:?} poll failed: {e}");
|
|
let entry = state.snapshots.entry(id).or_default();
|
|
entry.primary_text = "…".into();
|
|
entry.secondary_text = "…".into();
|
|
}
|
|
}
|
|
}
|
|
state.last_poll_ok = any_ok;
|
|
}
|
|
// Lock released. Fire any threshold balloons outside the critical
|
|
// section so tray::notify and i18n cloning don't block the UI thread.
|
|
for (id, threshold) in crossings {
|
|
show_threshold_balloon(id, threshold);
|
|
}
|
|
auth_failures
|
|
}
|
|
|
|
fn attempt_refresh(failures: Vec<ProviderId>, registry: &Arc<Mutex<Registry>>) {
|
|
let orchestrator = usage::refresh::Orchestrator::new(REFRESH_TIMEOUT);
|
|
// Pick the first provider whose refresh did not succeed and balloon
|
|
// for it specifically. If both providers fail in the same cycle the
|
|
// second will resurface on the next poll once the first is re-auth'd.
|
|
let mut balloon_for: Option<ProviderId> = None;
|
|
for id in failures {
|
|
let outcome = {
|
|
let reg = registry.lock().expect("registry mutex poisoned");
|
|
reg.try_refresh(id, &orchestrator)
|
|
};
|
|
log::info!("refresh for {id:?}: {outcome:?}");
|
|
if !matches!(outcome, usage::refresh::Outcome::Refreshed) {
|
|
balloon_for.get_or_insert(id);
|
|
}
|
|
}
|
|
if let Some(provider) = balloon_for {
|
|
show_token_expired_balloon(provider);
|
|
}
|
|
}
|
|
|
|
fn refresh_countdowns() {
|
|
{
|
|
let mut s = lock_state();
|
|
let Some(s) = s.as_mut() else {
|
|
return;
|
|
};
|
|
let strings = s.i18n.strings().clone();
|
|
for entry in s.snapshots.values_mut() {
|
|
entry.primary_text = i18n::format_window(&entry.windows.primary, &strings);
|
|
entry.secondary_text = i18n::format_window(&entry.windows.secondary, &strings);
|
|
}
|
|
}
|
|
propagate_to_ui();
|
|
}
|
|
|
|
fn propagate_to_ui() {
|
|
let snapshot = {
|
|
let s = lock_state();
|
|
s.as_ref().map(|s| UiSnapshot {
|
|
bubbles: s.bubbles.clone(),
|
|
snapshots: s.snapshots.clone(),
|
|
settings: s.settings.clone(),
|
|
i18n_strings: s.i18n.strings().clone(),
|
|
is_dark: s.is_dark,
|
|
msg_hwnd: s.msg_hwnd,
|
|
last_poll_ok: s.last_poll_ok,
|
|
})
|
|
};
|
|
let Some(snap) = snapshot else {
|
|
return;
|
|
};
|
|
|
|
for (kind, hwnd) in snap.bubbles.iter() {
|
|
let entry = snap.snapshots.get(kind);
|
|
let session_pct = entry.map(|s| s.windows.primary.utilization);
|
|
let weekly_pct = entry.map(|s| s.windows.secondary.utilization);
|
|
// The bubble paints the percent inline inside the bar fill, so it
|
|
// only needs the countdown string on the right. The panel still
|
|
// shows the combined "X% · Yh" string via `primary_text`.
|
|
let session_text = entry
|
|
.map(|s| i18n::format_countdown(s.windows.primary.resets_at, &snap.i18n_strings))
|
|
.unwrap_or_default();
|
|
let weekly_text = entry
|
|
.map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings))
|
|
.unwrap_or_default();
|
|
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<ProviderId, SendHwnd>,
|
|
snapshots: HashMap<ProviderId, ProviderUiState>,
|
|
settings: Settings,
|
|
i18n_strings: LocaleStrings,
|
|
is_dark: bool,
|
|
msg_hwnd: SendHwnd,
|
|
last_poll_ok: bool,
|
|
}
|
|
|
|
fn build_panel_data(model: ProviderId) -> PanelData {
|
|
let s = lock_state();
|
|
let Some(s) = s.as_ref() else {
|
|
return placeholder_panel(model);
|
|
};
|
|
let strings = s.i18n.strings().clone();
|
|
let provider_state = s.snapshots.get(&model).cloned().unwrap_or_default();
|
|
PanelData {
|
|
model,
|
|
session_pct: provider_state.windows.primary.utilization,
|
|
session_text: provider_state.primary_text,
|
|
weekly_pct: provider_state.windows.secondary.utilization,
|
|
weekly_text: provider_state.secondary_text,
|
|
is_dark: s.is_dark,
|
|
strings,
|
|
}
|
|
}
|
|
|
|
fn build_panel_data_from(snap: &UiSnapshot, model: ProviderId, p: &ProviderUiState) -> PanelData {
|
|
PanelData {
|
|
model,
|
|
session_pct: p.windows.primary.utilization,
|
|
session_text: p.primary_text.clone(),
|
|
weekly_pct: p.windows.secondary.utilization,
|
|
weekly_text: p.secondary_text.clone(),
|
|
is_dark: snap.is_dark,
|
|
strings: snap.i18n_strings.clone(),
|
|
}
|
|
}
|
|
|
|
fn placeholder_panel(model: ProviderId) -> PanelData {
|
|
let strings = i18n::I18n::load().strings().clone();
|
|
PanelData {
|
|
model,
|
|
session_pct: 0.0,
|
|
session_text: String::new(),
|
|
weekly_pct: 0.0,
|
|
weekly_text: String::new(),
|
|
is_dark: false,
|
|
strings,
|
|
}
|
|
}
|
|
|
|
fn schedule_countdown_timer(snap: &UiSnapshot) {
|
|
let mut min_ttl: Option<Duration> = None;
|
|
for entry in snap.snapshots.values() {
|
|
for w in [&entry.windows.primary, &entry.windows.secondary] {
|
|
if let Some(d) = i18n::time_until_display_change(w.resets_at) {
|
|
min_ttl = Some(min_ttl.map_or(d, |prev| prev.min(d)));
|
|
}
|
|
}
|
|
}
|
|
if let Some(d) = min_ttl {
|
|
let ms = (d.as_millis().min(u32::MAX as u128) as u32).max(1_000);
|
|
unsafe {
|
|
let _ = KillTimer(snap.msg_hwnd.to_hwnd(), TIMER_COUNTDOWN);
|
|
SetTimer(snap.msg_hwnd.to_hwnd(), TIMER_COUNTDOWN, ms, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Tray icons ----------
|
|
|
|
fn refresh_tray_icons() {
|
|
let snap = {
|
|
let s = lock_state();
|
|
s.as_ref().map(|s| UiSnapshot {
|
|
bubbles: s.bubbles.clone(),
|
|
snapshots: s.snapshots.clone(),
|
|
settings: s.settings.clone(),
|
|
i18n_strings: s.i18n.strings().clone(),
|
|
is_dark: s.is_dark,
|
|
msg_hwnd: s.msg_hwnd,
|
|
last_poll_ok: s.last_poll_ok,
|
|
})
|
|
};
|
|
if let Some(snap) = snap {
|
|
refresh_tray_icons_with(&snap);
|
|
}
|
|
}
|
|
|
|
fn refresh_tray_icons_with(snap: &UiSnapshot) {
|
|
let mut icons = Vec::new();
|
|
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<u64>,
|
|
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<HWND> = lock_state()
|
|
.as_ref()
|
|
.map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect())
|
|
.unwrap_or_default();
|
|
for h in hwnds {
|
|
bubble::set_user_visible(h, visible);
|
|
}
|
|
}
|
|
|
|
fn reset_positions() {
|
|
let snap = {
|
|
let mut s = lock_state();
|
|
let Some(s) = s.as_mut() else {
|
|
return;
|
|
};
|
|
s.settings.bubble_positions.reset_all();
|
|
s.settings.clone()
|
|
};
|
|
settings::save(&snap);
|
|
let hwnds: Vec<HWND> = lock_state()
|
|
.as_ref()
|
|
.map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect())
|
|
.unwrap_or_default();
|
|
for h in hwnds {
|
|
bubble::destroy(h);
|
|
}
|
|
if let Some(s) = lock_state().as_mut() {
|
|
s.bubbles.clear();
|
|
}
|
|
create_initial_bubbles();
|
|
// The freshly-spawned bubbles boot with a "…" placeholder. Push the
|
|
// cached snapshot so they render the last-known data immediately, and
|
|
// kick a poll for users who used Reset Position to recover from
|
|
// staleness.
|
|
propagate_to_ui();
|
|
spawn_poll_thread();
|
|
}
|
|
|
|
fn 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::<Vec<_>>();
|
|
(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<dyn std::error::Error + Send + Sync>> = match channel {
|
|
InstallChannel::Winget => {
|
|
// Winget channel is reserved for future use; until a
|
|
// winget package ships, this branch is unreachable.
|
|
Err("winget channel not supported yet".into())
|
|
}
|
|
InstallChannel::Portable => {
|
|
update::install::begin(&http, &release).map_err(|e| e.into())
|
|
}
|
|
};
|
|
match result {
|
|
Ok(()) => unsafe {
|
|
let _ = PostMessageW(
|
|
msg_hwnd.to_hwnd(),
|
|
WM_APP_UPDATE_APPLIED,
|
|
WPARAM(0),
|
|
LPARAM(0),
|
|
);
|
|
},
|
|
Err(e) => {
|
|
log::error!("update apply failed: {e}");
|
|
if let Some(s) = lock_state().as_mut() {
|
|
s.update_status = UpdateStatus::Failed;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
Act::Check(hwnd) => begin_update_check(hwnd.to_hwnd()),
|
|
}
|
|
}
|
|
|
|
fn schedule_update_check_timer(hwnd: HWND) {
|
|
let (last, interval) = match lock_state().as_ref() {
|
|
Some(s) => (
|
|
s.settings.last_update_check_unix,
|
|
s.settings.update_check_interval_secs,
|
|
),
|
|
None => return,
|
|
};
|
|
let Some(interval) = interval else {
|
|
// Auto-check disabled. Manual "Check for updates" still works via
|
|
// the menu; this just suppresses the timer.
|
|
return;
|
|
};
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
let due = last.map_or(true, |t| now.saturating_sub(t) >= interval);
|
|
if due {
|
|
begin_update_check(hwnd);
|
|
} else {
|
|
let remaining = interval.saturating_sub(now.saturating_sub(last.unwrap_or(0)));
|
|
let ms = (remaining.saturating_mul(1000).min(u32::MAX as u64)) as u32;
|
|
unsafe {
|
|
SetTimer(hwnd, TIMER_UPDATE_CHECK, ms, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn begin_update_check(hwnd: HWND) {
|
|
if let Some(s) = lock_state().as_mut() {
|
|
s.update_status = UpdateStatus::Checking;
|
|
}
|
|
let send_hwnd = SendHwnd::from_hwnd(hwnd);
|
|
std::thread::spawn(move || {
|
|
let result = match net::Client::new(HTTP_USER_AGENT) {
|
|
Ok(c) => update::release::fetch_latest(&c),
|
|
Err(e) => Err(update::Error::Network(e)),
|
|
};
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
let snap_opt = {
|
|
let mut s = lock_state();
|
|
s.as_mut().map(|s| {
|
|
s.settings.last_update_check_unix = Some(now);
|
|
match result {
|
|
Ok(CheckOutcome::UpToDate) => {
|
|
s.update_status = UpdateStatus::UpToDate;
|
|
s.update_release = None;
|
|
}
|
|
Ok(CheckOutcome::Available(r)) => {
|
|
s.update_status = UpdateStatus::Available;
|
|
s.update_release = Some(r);
|
|
}
|
|
Err(_) => {
|
|
s.update_status = UpdateStatus::Failed;
|
|
}
|
|
}
|
|
(s.settings.clone(), s.settings.update_check_interval_secs)
|
|
})
|
|
};
|
|
let next_interval = snap_opt.as_ref().and_then(|(_, iv)| *iv);
|
|
if let Some((snap, _)) = snap_opt {
|
|
settings::save(&snap);
|
|
}
|
|
if let Some(interval) = next_interval {
|
|
let ms = (interval.saturating_mul(1000).min(u32::MAX as u64)) as u32;
|
|
unsafe {
|
|
SetTimer(send_hwnd.to_hwnd(), TIMER_UPDATE_CHECK, ms, None);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn set_update_check_interval(value: Option<u64>) {
|
|
let (snap, msg_hwnd) = {
|
|
let mut s = lock_state();
|
|
let Some(s) = s.as_mut() else {
|
|
return;
|
|
};
|
|
s.settings.update_check_interval_secs = value;
|
|
(s.settings.clone(), s.msg_hwnd)
|
|
};
|
|
settings::save(&snap);
|
|
let hwnd = msg_hwnd.to_hwnd();
|
|
unsafe {
|
|
let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK);
|
|
}
|
|
if value.is_some() {
|
|
schedule_update_check_timer(hwnd);
|
|
}
|
|
}
|
|
|
|
// ---------- Restart ----------
|
|
|
|
/// Relaunch the running binary by spawning a detached child via
|
|
/// `CreateProcessW`. The child waits on our PID before acquiring the
|
|
/// singleton mutex, so no shell handoff or timer is required.
|
|
fn restart_app() {
|
|
// Defensive flush — bubble positions and most settings already persist
|
|
// on change, but a final save is cheap insurance. Snapshot then drop the
|
|
// lock before the disk write so the UI thread doesn't block on I/O.
|
|
let snap = lock_state().as_ref().map(|s| s.settings.clone());
|
|
if let Some(s) = snap {
|
|
settings::save(&s);
|
|
}
|
|
|
|
let exe = match std::env::current_exe() {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
log::error!("restart: current_exe failed: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let pid = unsafe { GetCurrentProcessId() };
|
|
let args = vec![
|
|
OsString::from("--wait-pid"),
|
|
OsString::from(pid.to_string()),
|
|
];
|
|
match update::handoff::spawn_detached(&exe, &args) {
|
|
Ok(()) => {
|
|
log::info!("restart: spawned detached child (parent pid={pid}), posting quit");
|
|
unsafe { PostQuitMessage(0) };
|
|
}
|
|
Err(e) => log::error!("restart: spawn_detached failed: {e}"),
|
|
}
|
|
}
|
|
|
|
// ---------- Start-with-Windows ----------
|
|
|
|
fn is_startup_enabled() -> bool {
|
|
os::registry::value_exists(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME)
|
|
}
|
|
|
|
fn toggle_startup() {
|
|
if is_startup_enabled() {
|
|
let _ = os::registry::delete_value(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME);
|
|
} else if let Ok(exe) = std::env::current_exe() {
|
|
let quoted = format!("\"{}\"", exe.to_string_lossy());
|
|
let _ = os::registry::write_string(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME, "ed);
|
|
}
|
|
}
|