mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-06-06 12:12:29 +00:00
e243c589a8
Whole-project review pass over the entire crate. No new features. All function definitions preserved; layout and visibility reorganised. Bug fixes: - bubble: ExtractIconExW HICON pair was leaked per bubble toggle. Extract once into a process-wide OnceLock, reuse forever (bounded). - usage/anthropic: parse_iso8601 was stripping the UTC offset without applying it — negative-offset users saw countdowns up to 14h wrong. Now parses signed minutes and computes utc_secs = local - off*60. Also rejects y<1970, mo∉[1,12], d∉[1,max_day(mo,y)] up front so malformed API responses can't index DAYS_IN_MONTH out of bounds. - usage: clamp utilization to [0,100] at all four Window construction sites so a misbehaving server can't render "121%". - bubble: GetDC and CreateCompatibleDC results weren't checked. Guard both; release the screen DC on the CreateCompatibleDC failure path. Refactor: - Drop type TrayIconKind = ProviderId aliases (5 sites); use ProviderId directly everywhere. Inline the identity-function kind_to_provider. - Delete panel::bar_color_for shim (was just argument-reorder glue). - Replace local scale_to_dpi fns in bubble.rs and panel.rs with use crate::os::dpi::scale as scale_to_dpi (brings os::dpi into the live import graph; was unused before). - Delete dead PCWSTR import + #[allow(dead_code)] sentinel in tray/badge.rs; fold the trailing `use BOOL` into the top imports. - Inline app::primary_dpi() to crate::os::dpi::for_system(). app.rs: - Add update_settings(|s: &mut AppState|) helper that locks state, runs the closure, snapshots Settings, drops the lock, then saves to disk. Convert four pure-mutate-then-save callsites. Layering: bubble.rs no longer reaches upward into crate::app::. Introduce bubble::Callbacks (fn-pointers), OnceLock<Callbacks>, and bubble::install_callbacks(). The wnd_proc dispatches the six prior upward calls via a private dispatch() helper. app::run installs callbacks once at startup; the six on_bubble_* / recheck_theme fns are demoted from pub fn to fn. Resource-warning logs added: dispatch() warns on uninstalled callbacks; app_icons() warns when ExtractIconExW returns nulls. Build: cargo build --release clean; cargo clippy reports zero new warnings (11 pre-existing, all in untouched code).
1470 lines
49 KiB
Rust
1470 lines
49 KiB
Rust
// App orchestrator.
|
|
//
|
|
// One `Mutex<Option<AppState>>` is the single source of truth. The UI
|
|
// thread runs the message loop; a background thread polls the provider
|
|
// registry and posts `WM_APP_USAGE_UPDATED` back via a hidden
|
|
// message-only window owned by this module.
|
|
|
|
use std::collections::HashMap;
|
|
use std::ffi::OsString;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
use windows::core::PCWSTR;
|
|
use windows::Win32::Foundation::*;
|
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
|
use windows::Win32::System::Threading::{CreateMutexW, GetCurrentProcessId, Sleep};
|
|
use windows::Win32::UI::HiDpi::{
|
|
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
|
};
|
|
use windows::Win32::UI::WindowsAndMessaging::*;
|
|
|
|
use crate::bubble;
|
|
use crate::i18n::{self, I18n, LocaleStrings};
|
|
// Win32 message + timer IDs moved inline; see constants below.
|
|
use crate::net;
|
|
use crate::os;
|
|
use crate::panel::{self, PanelData};
|
|
use crate::settings::{self, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN};
|
|
use crate::tray::{self, TrayAction, TrayIcon as TrayIconData, WM_APP_TRAY};
|
|
use crate::update::{self, Channel as InstallChannel, CheckOutcome};
|
|
use crate::usage::{self, ProviderId, Registry, UsageWindows};
|
|
|
|
// Win32 message IDs owned by this module.
|
|
pub const WM_APP_USAGE_UPDATED: u32 = 0x8001;
|
|
// Posted from the update worker thread after `install::begin` has
|
|
// already swapped the binary on disk and spawned the new detached
|
|
// child. The UI thread responds with PostQuitMessage(0) so the old
|
|
// instance exits cleanly and releases the singleton mutex for the
|
|
// child waiting on `--wait-pid`.
|
|
pub const WM_APP_UPDATE_APPLIED: u32 = 0x8002;
|
|
|
|
// Timer IDs used with `SetTimer(msg_hwnd, …)`.
|
|
const TIMER_POLL: usize = 1;
|
|
const TIMER_COUNTDOWN: usize = 2;
|
|
const TIMER_RESET_POLL: usize = 3;
|
|
const TIMER_UPDATE_CHECK: usize = 4;
|
|
|
|
const APP_MUTEX_NAME: &str = r"Global\ClaudeCodeUsageBubble";
|
|
const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
|
|
const STARTUP_VALUE_NAME: &str = "ClaudeCodeUsageBubble";
|
|
const APP_CLASS_NAME: &str = "ClaudeCodeUsageBubbleApp";
|
|
const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
|
|
// The auto-check interval is user-configurable (see
|
|
// `Settings::update_check_interval_secs`). `None` means auto-check is disabled.
|
|
const BALLOON_COOLDOWN: Duration = Duration::from_secs(30 * 60);
|
|
const REFRESH_TIMEOUT: Duration = Duration::from_secs(8);
|
|
|
|
// ---------- Menu IDs ----------
|
|
|
|
const IDM_REFRESH: u16 = 1;
|
|
const IDM_EXIT: u16 = 2;
|
|
const IDM_FREQ_1MIN: u16 = 10;
|
|
const IDM_FREQ_5MIN: u16 = 11;
|
|
const IDM_FREQ_15MIN: u16 = 12;
|
|
const IDM_FREQ_1HOUR: u16 = 13;
|
|
const IDM_MODEL_CLAUDE: u16 = 20;
|
|
const IDM_MODEL_CHATGPT: u16 = 21;
|
|
const IDM_START_WITH_WINDOWS: u16 = 30;
|
|
const IDM_RESET_POSITION: u16 = 31;
|
|
const IDM_VERSION_ACTION: u16 = 32;
|
|
const IDM_RESTART: u16 = 33;
|
|
const IDM_LANG_SYSTEM: u16 = 40;
|
|
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
|
|
// clear of it (and any future tray ids in the 5x band).
|
|
const IDM_UPDATE_AUTO_OFF: u16 = 60;
|
|
const IDM_UPDATE_AUTO_HOURLY: u16 = 61;
|
|
const IDM_UPDATE_AUTO_DAILY: u16 = 62;
|
|
const IDM_UPDATE_AUTO_WEEKLY: u16 = 63;
|
|
// IMPORTANT: language ids are dynamic and start at IDM_LANG_BASE.
|
|
// Keep IDM_LANG_BASE the highest static id so the catch-all match arm
|
|
// stays unambiguous.
|
|
const IDM_LANG_BASE: u16 = 100;
|
|
|
|
// ---------- State ----------
|
|
|
|
#[derive(Clone, Copy, Default)]
|
|
struct SendHwnd(isize);
|
|
unsafe impl Send for SendHwnd {}
|
|
impl SendHwnd {
|
|
fn from_hwnd(h: HWND) -> Self {
|
|
Self(h.0 as isize)
|
|
}
|
|
fn to_hwnd(self) -> HWND {
|
|
HWND(self.0 as *mut _)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum UpdateStatus {
|
|
Idle,
|
|
Checking,
|
|
UpToDate,
|
|
Available,
|
|
Applying,
|
|
Failed,
|
|
}
|
|
|
|
struct AppState {
|
|
msg_hwnd: SendHwnd,
|
|
bubbles: HashMap<ProviderId, SendHwnd>,
|
|
settings: Settings,
|
|
i18n: I18n,
|
|
is_dark: bool,
|
|
install_channel: InstallChannel,
|
|
// http and registry live behind Arc so worker threads can hold them
|
|
// without keeping the global state mutex locked across blocking I/O.
|
|
// The registry's own mutex is only contended by other workers
|
|
// (the in-flight gate ensures at most one poll runs at a time), so
|
|
// the UI thread never waits on it.
|
|
http: Arc<net::Client>,
|
|
registry: Arc<Mutex<Registry>>,
|
|
snapshots: HashMap<ProviderId, ProviderUiState>,
|
|
last_poll_ok: bool,
|
|
update_status: UpdateStatus,
|
|
update_release: Option<update::Release>,
|
|
last_balloon_at: Option<Instant>,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct ProviderUiState {
|
|
windows: UsageWindows,
|
|
primary_text: String,
|
|
secondary_text: String,
|
|
}
|
|
|
|
fn state() -> &'static Mutex<Option<AppState>> {
|
|
static S: OnceLock<Mutex<Option<AppState>>> = OnceLock::new();
|
|
S.get_or_init(|| Mutex::new(None))
|
|
}
|
|
|
|
fn lock_state() -> MutexGuard<'static, Option<AppState>> {
|
|
state().lock().expect("app state mutex poisoned")
|
|
}
|
|
|
|
/// Run `f` with mutable access to `AppState`, then snapshot `Settings` and
|
|
/// persist it to disk. The lock is released before the disk write so the UI
|
|
/// thread doesn't block on I/O. No-op if state hasn't been initialised yet.
|
|
fn update_settings(f: impl FnOnce(&mut AppState)) {
|
|
let snap = {
|
|
let mut guard = lock_state();
|
|
let Some(s) = guard.as_mut() else {
|
|
return;
|
|
};
|
|
f(s);
|
|
s.settings.clone()
|
|
};
|
|
settings::save(&snap);
|
|
}
|
|
|
|
// ---------- Entry ----------
|
|
|
|
/// Acquire the singleton mutex, optionally retrying for ~3s if the
|
|
/// caller passed `--wait-pid` (i.e. we just spawned from an exiting
|
|
/// parent that has not yet released its handle).
|
|
fn acquire_singleton_mutex(retry: bool) -> Option<HANDLE> {
|
|
let mutex_name_w = os::to_utf16_nul(APP_MUTEX_NAME);
|
|
let max_attempts = if retry { 15 } else { 1 };
|
|
for attempt in 0..max_attempts {
|
|
let handle = unsafe { CreateMutexW(None, false, PCWSTR::from_raw(mutex_name_w.as_ptr())) };
|
|
match handle {
|
|
Ok(h) => {
|
|
let already = unsafe { GetLastError() } == ERROR_ALREADY_EXISTS;
|
|
if !already {
|
|
return Some(h);
|
|
}
|
|
// Mutex still held by parent. Close this handle and retry.
|
|
unsafe {
|
|
let _ = CloseHandle(h);
|
|
}
|
|
if attempt + 1 == max_attempts {
|
|
log::info!("another instance already running; exiting");
|
|
return None;
|
|
}
|
|
log::debug!(
|
|
"mutex still held by parent (attempt {}/{}), waiting 200ms",
|
|
attempt + 1,
|
|
max_attempts
|
|
);
|
|
unsafe { Sleep(200) };
|
|
}
|
|
Err(e) => {
|
|
log::error!("CreateMutex failed: {e}");
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn run(args: crate::AppArgs) {
|
|
unsafe {
|
|
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
|
}
|
|
|
|
let _mutex = match acquire_singleton_mutex(args.wait_pid_present) {
|
|
Some(h) => h,
|
|
None => return,
|
|
};
|
|
|
|
let settings = settings::load();
|
|
let i18n = I18n::load(settings.language.as_deref());
|
|
let is_dark = os::theme::is_dark();
|
|
let install_channel = update::current_channel();
|
|
let http = match net::Client::new(HTTP_USER_AGENT) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
log::error!("HTTP client init failed: {e}");
|
|
return;
|
|
}
|
|
};
|
|
let msg_hwnd = match create_message_window() {
|
|
Some(h) => h,
|
|
None => {
|
|
log::error!("failed to create app message window");
|
|
return;
|
|
}
|
|
};
|
|
|
|
*lock_state() = Some(AppState {
|
|
msg_hwnd: SendHwnd::from_hwnd(msg_hwnd),
|
|
bubbles: HashMap::new(),
|
|
settings,
|
|
i18n,
|
|
is_dark,
|
|
install_channel,
|
|
http: Arc::new(http),
|
|
registry: Arc::new(Mutex::new(Registry::with_defaults())),
|
|
snapshots: HashMap::new(),
|
|
last_poll_ok: false,
|
|
update_status: UpdateStatus::Idle,
|
|
update_release: None,
|
|
last_balloon_at: None,
|
|
});
|
|
|
|
bubble::install_callbacks(bubble::Callbacks {
|
|
on_click: on_bubble_click,
|
|
on_right_click: on_bubble_right_click,
|
|
on_moved: on_bubble_moved,
|
|
on_resized: on_bubble_resized,
|
|
on_menu_command,
|
|
on_settings_changed: recheck_theme,
|
|
});
|
|
|
|
create_initial_bubbles();
|
|
refresh_tray_icons();
|
|
|
|
// Post-update tasks: show "Updated to vX.Y.Z" balloon (driven by
|
|
// --updated-to passed by the previous instance) and sweep any
|
|
// stale `<exe>.old.<pid>` siblings left by past in-place swaps.
|
|
if let Some(v) = args.updated_to.as_ref() {
|
|
announce_update_applied(msg_hwnd, v);
|
|
}
|
|
if let Ok(exe_path) = std::env::current_exe() {
|
|
update::handoff::cleanup_stale_old_exes(&exe_path);
|
|
}
|
|
|
|
let poll_interval = lock_state()
|
|
.as_ref()
|
|
.map(|s| s.settings.poll_interval_ms)
|
|
.unwrap_or(POLL_5_MIN);
|
|
unsafe {
|
|
SetTimer(msg_hwnd, TIMER_POLL, poll_interval, None);
|
|
}
|
|
schedule_update_check_timer(msg_hwnd);
|
|
spawn_poll_thread();
|
|
|
|
log::info!("app::run entered message loop");
|
|
let mut msg = MSG::default();
|
|
unsafe {
|
|
while GetMessageW(&mut msg, HWND::default(), 0, 0).as_bool() {
|
|
let _ = TranslateMessage(&msg);
|
|
DispatchMessageW(&msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn create_message_window() -> Option<HWND> {
|
|
let class_w = os::to_utf16_nul(APP_CLASS_NAME);
|
|
let title_w = os::to_utf16_nul("Claude Code Usage Bubble");
|
|
unsafe {
|
|
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
|
|
let wc = WNDCLASSEXW {
|
|
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
|
lpfnWndProc: Some(msg_wnd_proc),
|
|
hInstance: HINSTANCE(hinstance.0),
|
|
lpszClassName: PCWSTR::from_raw(class_w.as_ptr()),
|
|
..Default::default()
|
|
};
|
|
let _ = RegisterClassExW(&wc);
|
|
CreateWindowExW(
|
|
WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
|
|
PCWSTR::from_raw(class_w.as_ptr()),
|
|
PCWSTR::from_raw(title_w.as_ptr()),
|
|
WS_POPUP,
|
|
-1000,
|
|
-1000,
|
|
1,
|
|
1,
|
|
HWND::default(),
|
|
HMENU::default(),
|
|
hinstance,
|
|
None,
|
|
)
|
|
.ok()
|
|
}
|
|
}
|
|
|
|
fn create_initial_bubbles() {
|
|
let (settings, is_dark) = match lock_state().as_ref() {
|
|
Some(s) => (s.settings.clone(), s.is_dark),
|
|
None => return,
|
|
};
|
|
if settings.show_claude_code {
|
|
spawn_bubble(ProviderId::Claude, &settings, is_dark);
|
|
}
|
|
if settings.show_codex {
|
|
spawn_bubble(ProviderId::ChatGpt, &settings, is_dark);
|
|
}
|
|
}
|
|
|
|
fn spawn_bubble(kind: ProviderId, settings: &Settings, is_dark: bool) {
|
|
// "…" matches the in-flight/transient-error placeholder used by
|
|
// `apply_results`, so the bubble has visible feedback during the first
|
|
// poll rather than rendering with two empty grey tracks.
|
|
let placeholder = "…".to_string();
|
|
let hwnd = bubble::create(bubble::BubbleConfig {
|
|
model: kind,
|
|
size_logical: settings.bubble_size_logical,
|
|
position: settings.bubble_positions.get(kind),
|
|
session_pct: None,
|
|
session_text: placeholder.clone(),
|
|
weekly_pct: None,
|
|
weekly_text: placeholder,
|
|
is_dark,
|
|
});
|
|
if hwnd != HWND::default() {
|
|
if let Some(s) = lock_state().as_mut() {
|
|
s.bubbles.insert(kind, SendHwnd::from_hwnd(hwnd));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Message-only window proc ----------
|
|
|
|
unsafe extern "system" fn msg_wnd_proc(
|
|
hwnd: HWND,
|
|
msg: u32,
|
|
wparam: WPARAM,
|
|
lparam: LPARAM,
|
|
) -> LRESULT {
|
|
match msg {
|
|
WM_APP_USAGE_UPDATED => {
|
|
propagate_to_ui();
|
|
LRESULT(0)
|
|
}
|
|
WM_APP_UPDATE_APPLIED => {
|
|
PostQuitMessage(0);
|
|
LRESULT(0)
|
|
}
|
|
WM_APP_TRAY => {
|
|
let action = tray::callback::handle(lparam);
|
|
handle_tray_action(action);
|
|
LRESULT(0)
|
|
}
|
|
WM_TIMER => {
|
|
on_timer(hwnd, wparam.0);
|
|
LRESULT(0)
|
|
}
|
|
WM_DESTROY => {
|
|
PostQuitMessage(0);
|
|
LRESULT(0)
|
|
}
|
|
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
|
}
|
|
}
|
|
|
|
// ---------- Bubble callbacks ----------
|
|
|
|
fn on_bubble_click(hwnd: HWND, model: ProviderId) {
|
|
let data = build_panel_data(model);
|
|
panel::toggle(data, hwnd);
|
|
}
|
|
|
|
fn on_bubble_right_click(hwnd: HWND, _model: ProviderId, _pt: POINT) {
|
|
show_context_menu(hwnd);
|
|
}
|
|
|
|
fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) {
|
|
update_settings(|s| s.settings.bubble_positions.set(model, pos));
|
|
}
|
|
|
|
fn on_bubble_resized(_model: ProviderId, size_logical: i32) {
|
|
update_settings(|s| s.settings.bubble_size_logical = size_logical);
|
|
}
|
|
|
|
fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
|
let id = (id & 0xFFFF) as u16;
|
|
match id {
|
|
IDM_REFRESH => spawn_poll_thread(),
|
|
IDM_EXIT => unsafe { PostQuitMessage(0) },
|
|
IDM_FREQ_1MIN => set_poll_interval(POLL_1_MIN),
|
|
IDM_FREQ_5MIN => set_poll_interval(POLL_5_MIN),
|
|
IDM_FREQ_15MIN => set_poll_interval(POLL_15_MIN),
|
|
IDM_FREQ_1HOUR => set_poll_interval(POLL_1_HOUR),
|
|
IDM_MODEL_CLAUDE => toggle_model(ProviderId::Claude),
|
|
IDM_MODEL_CHATGPT => toggle_model(ProviderId::ChatGpt),
|
|
IDM_START_WITH_WINDOWS => toggle_startup(),
|
|
IDM_RESET_POSITION => reset_positions(),
|
|
IDM_VERSION_ACTION => version_action(),
|
|
IDM_UPDATE_AUTO_OFF => set_update_check_interval(None),
|
|
IDM_UPDATE_AUTO_HOURLY => {
|
|
set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS))
|
|
}
|
|
IDM_UPDATE_AUTO_DAILY => {
|
|
set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS))
|
|
}
|
|
IDM_UPDATE_AUTO_WEEKLY => {
|
|
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
|
|
}
|
|
IDM_LANG_SYSTEM => set_language(None),
|
|
// Static ids in the 30-99 band must match BEFORE the dynamic
|
|
// language guard, otherwise `x >= IDM_LANG_BASE` would swallow any
|
|
// future id that creeps into the >=100 range.
|
|
tray::IDM_TOGGLE_WIDGET => toggle_widget_visibility(),
|
|
IDM_RESTART => restart_app(),
|
|
x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ---------- Timers ----------
|
|
|
|
fn on_timer(hwnd: HWND, id: usize) {
|
|
match id {
|
|
TIMER_POLL | TIMER_RESET_POLL => spawn_poll_thread(),
|
|
TIMER_COUNTDOWN => refresh_countdowns(),
|
|
TIMER_UPDATE_CHECK => {
|
|
unsafe {
|
|
let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK);
|
|
}
|
|
begin_update_check(hwnd);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ---------- Poll thread ----------
|
|
|
|
/// At-most-one-in-flight gate for the poll worker. Spam-clicking Refresh
|
|
/// would otherwise stack concurrent HTTPS calls onto the same registry,
|
|
/// which both wastes bandwidth and (before the registry mutex landed)
|
|
/// could let two poll cycles interleave their writes to the snapshot map.
|
|
static POLL_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
|
|
|
|
fn spawn_poll_thread() {
|
|
if POLL_IN_FLIGHT
|
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
let msg_hwnd = match lock_state().as_ref() {
|
|
Some(s) => s.msg_hwnd,
|
|
None => {
|
|
POLL_IN_FLIGHT.store(false, Ordering::Release);
|
|
return;
|
|
}
|
|
};
|
|
std::thread::spawn(move || {
|
|
do_poll();
|
|
POLL_IN_FLIGHT.store(false, Ordering::Release);
|
|
unsafe {
|
|
let _ = PostMessageW(
|
|
msg_hwnd.to_hwnd(),
|
|
WM_APP_USAGE_UPDATED,
|
|
WPARAM(0),
|
|
LPARAM(0),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn do_poll() {
|
|
// Snapshot the inputs we need, then DROP the global lock before any
|
|
// HTTPS call. Holding `lock_state()` through `poll_enabled` would block
|
|
// the UI thread on every paint/menu for the duration of the request.
|
|
let (http, registry, settings) = {
|
|
let s = lock_state();
|
|
let Some(s) = s.as_ref() else {
|
|
return;
|
|
};
|
|
(s.http.clone(), s.registry.clone(), s.settings.clone())
|
|
};
|
|
let results = {
|
|
let mut reg = registry.lock().expect("registry mutex poisoned");
|
|
reg.poll_enabled(&http, &settings)
|
|
};
|
|
let auth_failures = apply_results(results);
|
|
if !auth_failures.is_empty() {
|
|
attempt_refresh(auth_failures, ®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();
|
|
bubble::update_data(
|
|
hwnd.to_hwnd(),
|
|
session_pct,
|
|
session_text,
|
|
weekly_pct,
|
|
weekly_text,
|
|
);
|
|
}
|
|
refresh_tray_icons_with(&snap);
|
|
|
|
if panel::is_visible() {
|
|
if let Some(model) = panel::current_model() {
|
|
if let Some(provider_state) = snap.snapshots.get(&model) {
|
|
panel::refresh_data(build_panel_data_from(&snap, model, provider_state));
|
|
}
|
|
}
|
|
}
|
|
schedule_countdown_timer(&snap);
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct UiSnapshot {
|
|
bubbles: HashMap<ProviderId, SendHwnd>,
|
|
snapshots: HashMap<ProviderId, ProviderUiState>,
|
|
settings: Settings,
|
|
i18n_strings: LocaleStrings,
|
|
is_dark: bool,
|
|
msg_hwnd: SendHwnd,
|
|
last_poll_ok: bool,
|
|
}
|
|
|
|
fn build_panel_data(model: ProviderId) -> PanelData {
|
|
let s = lock_state();
|
|
let Some(s) = s.as_ref() else {
|
|
return placeholder_panel(model);
|
|
};
|
|
let strings = s.i18n.strings().clone();
|
|
let provider_state = s.snapshots.get(&model).cloned().unwrap_or_default();
|
|
PanelData {
|
|
model,
|
|
session_pct: provider_state.windows.primary.utilization,
|
|
session_text: provider_state.primary_text,
|
|
weekly_pct: provider_state.windows.secondary.utilization,
|
|
weekly_text: provider_state.secondary_text,
|
|
is_dark: s.is_dark,
|
|
strings,
|
|
}
|
|
}
|
|
|
|
fn build_panel_data_from(snap: &UiSnapshot, model: ProviderId, p: &ProviderUiState) -> PanelData {
|
|
PanelData {
|
|
model,
|
|
session_pct: p.windows.primary.utilization,
|
|
session_text: p.primary_text.clone(),
|
|
weekly_pct: p.windows.secondary.utilization,
|
|
weekly_text: p.secondary_text.clone(),
|
|
is_dark: snap.is_dark,
|
|
strings: snap.i18n_strings.clone(),
|
|
}
|
|
}
|
|
|
|
fn placeholder_panel(model: ProviderId) -> PanelData {
|
|
let strings = i18n::I18n::load(None).strings().clone();
|
|
PanelData {
|
|
model,
|
|
session_pct: 0.0,
|
|
session_text: String::new(),
|
|
weekly_pct: 0.0,
|
|
weekly_text: String::new(),
|
|
is_dark: false,
|
|
strings,
|
|
}
|
|
}
|
|
|
|
fn schedule_countdown_timer(snap: &UiSnapshot) {
|
|
let mut min_ttl: Option<Duration> = None;
|
|
for entry in snap.snapshots.values() {
|
|
for w in [&entry.windows.primary, &entry.windows.secondary] {
|
|
if let Some(d) = i18n::time_until_display_change(w.resets_at) {
|
|
min_ttl = Some(min_ttl.map_or(d, |prev| prev.min(d)));
|
|
}
|
|
}
|
|
}
|
|
if let Some(d) = min_ttl {
|
|
let ms = (d.as_millis().min(u32::MAX as u128) as u32).max(1_000);
|
|
unsafe {
|
|
let _ = KillTimer(snap.msg_hwnd.to_hwnd(), TIMER_COUNTDOWN);
|
|
SetTimer(snap.msg_hwnd.to_hwnd(), TIMER_COUNTDOWN, ms, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Tray icons ----------
|
|
|
|
fn refresh_tray_icons() {
|
|
let snap = {
|
|
let s = lock_state();
|
|
s.as_ref().map(|s| UiSnapshot {
|
|
bubbles: s.bubbles.clone(),
|
|
snapshots: s.snapshots.clone(),
|
|
settings: s.settings.clone(),
|
|
i18n_strings: s.i18n.strings().clone(),
|
|
is_dark: s.is_dark,
|
|
msg_hwnd: s.msg_hwnd,
|
|
last_poll_ok: s.last_poll_ok,
|
|
})
|
|
};
|
|
if let Some(snap) = snap {
|
|
refresh_tray_icons_with(&snap);
|
|
}
|
|
}
|
|
|
|
fn refresh_tray_icons_with(snap: &UiSnapshot) {
|
|
let mut icons = Vec::new();
|
|
if snap.settings.show_claude_code {
|
|
let entry = snap.snapshots.get(&ProviderId::Claude);
|
|
icons.push(TrayIconData {
|
|
kind: ProviderId::Claude,
|
|
percent: if snap.last_poll_ok {
|
|
entry.map(|e| e.windows.primary.utilization)
|
|
} else {
|
|
None
|
|
},
|
|
tooltip: format!(
|
|
"{} {}: {} | {}: {}",
|
|
snap.i18n_strings.claude_label,
|
|
snap.i18n_strings.session_window,
|
|
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
|
|
snap.i18n_strings.weekly_window,
|
|
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
|
|
),
|
|
});
|
|
}
|
|
if snap.settings.show_codex {
|
|
let entry = snap.snapshots.get(&ProviderId::ChatGpt);
|
|
icons.push(TrayIconData {
|
|
kind: ProviderId::ChatGpt,
|
|
percent: if snap.last_poll_ok {
|
|
entry.map(|e| e.windows.primary.utilization)
|
|
} else {
|
|
None
|
|
},
|
|
tooltip: format!(
|
|
"{} {}: {} | {}: {}",
|
|
snap.i18n_strings.chatgpt_label,
|
|
snap.i18n_strings.session_window,
|
|
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
|
|
snap.i18n_strings.weekly_window,
|
|
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
|
|
),
|
|
});
|
|
}
|
|
tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
|
|
}
|
|
|
|
fn handle_tray_action(action: TrayAction) {
|
|
match action {
|
|
TrayAction::None => {}
|
|
TrayAction::ToggleWidget => toggle_widget_visibility(),
|
|
TrayAction::ShowContextMenu => {
|
|
let owner = lock_state()
|
|
.as_ref()
|
|
.and_then(|s| s.bubbles.values().next().copied())
|
|
.map(|h| h.to_hwnd())
|
|
.unwrap_or_default();
|
|
if owner != HWND::default() {
|
|
show_context_menu(owner);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Re-read the system theme and, if it changed, push the new value into
|
|
/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows
|
|
/// posts that to every top-level window when the user toggles light/dark
|
|
/// in Settings, so this naturally fires once per change.
|
|
fn recheck_theme() {
|
|
let now_dark = os::theme::is_dark();
|
|
let changed = {
|
|
let mut s = lock_state();
|
|
let Some(s) = s.as_mut() else {
|
|
return;
|
|
};
|
|
if s.is_dark == now_dark {
|
|
false
|
|
} else {
|
|
s.is_dark = now_dark;
|
|
true
|
|
}
|
|
};
|
|
if changed {
|
|
propagate_to_ui();
|
|
refresh_tray_icons();
|
|
}
|
|
}
|
|
|
|
fn show_threshold_balloon(provider: ProviderId, threshold: u8) {
|
|
let payload = {
|
|
let mut s = lock_state();
|
|
let Some(s) = s.as_mut() else {
|
|
return;
|
|
};
|
|
// Reuse the same cooldown as the token-expired balloon: any one
|
|
// balloon at a time keeps notifications calm.
|
|
if let Some(last) = s.last_balloon_at {
|
|
if last.elapsed() < BALLOON_COOLDOWN {
|
|
return;
|
|
}
|
|
}
|
|
s.last_balloon_at = Some(Instant::now());
|
|
let strings = s.i18n.strings();
|
|
let provider_label = match provider {
|
|
ProviderId::Claude => strings.claude_label.clone(),
|
|
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
|
|
};
|
|
let title = format!("{provider_label} · {threshold}%");
|
|
let body = if threshold >= 95 {
|
|
strings.threshold_95_body.clone()
|
|
} else {
|
|
strings.threshold_80_body.clone()
|
|
};
|
|
(s.msg_hwnd, provider, title, body)
|
|
};
|
|
tray::notify_warning(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
|
}
|
|
|
|
fn show_token_expired_balloon(failed: ProviderId) {
|
|
let payload = {
|
|
let mut s = lock_state();
|
|
let Some(s) = s.as_mut() else {
|
|
return;
|
|
};
|
|
if let Some(last) = s.last_balloon_at {
|
|
if last.elapsed() < BALLOON_COOLDOWN {
|
|
return;
|
|
}
|
|
}
|
|
s.last_balloon_at = Some(Instant::now());
|
|
let strings = s.i18n.strings();
|
|
let (title, body) = match failed {
|
|
ProviderId::Claude => (
|
|
strings.token_expired_title.clone(),
|
|
strings.token_expired_body.clone(),
|
|
),
|
|
ProviderId::ChatGpt => (
|
|
strings.chatgpt_token_expired_title.clone(),
|
|
strings.chatgpt_token_expired_body.clone(),
|
|
),
|
|
};
|
|
(s.msg_hwnd, failed, title, body)
|
|
};
|
|
tray::notify_warning(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
|
}
|
|
|
|
/// Show the "Updated to vX.Y.Z" balloon on first launch after an
|
|
/// auto-update. Picks Claude as the host icon if it's registered;
|
|
/// otherwise falls back to Codex. If neither is registered the
|
|
/// notification silently drops — better than crashing.
|
|
fn announce_update_applied(_msg_hwnd: HWND, version: &str) {
|
|
let payload = {
|
|
let s = lock_state();
|
|
let Some(s) = s.as_ref() else {
|
|
return;
|
|
};
|
|
let strings = s.i18n.strings();
|
|
let title = strings.update_applied_title.clone();
|
|
let body = format!("{}{}", strings.update_applied_body, version);
|
|
let host = if s.settings.show_claude_code {
|
|
ProviderId::Claude
|
|
} else {
|
|
ProviderId::ChatGpt
|
|
};
|
|
(s.msg_hwnd, host, title, body)
|
|
};
|
|
tray::notify_info(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
|
}
|
|
|
|
// ---------- Context menu ----------
|
|
|
|
struct ContextMenuSnapshot {
|
|
strings: LocaleStrings,
|
|
available: Vec<(String, String)>,
|
|
language_override: Option<String>,
|
|
current_interval: u32,
|
|
update_check_interval_secs: Option<u64>,
|
|
show_claude: bool,
|
|
show_chatgpt: bool,
|
|
widget_visible: bool,
|
|
install_channel: InstallChannel,
|
|
update_status: UpdateStatus,
|
|
}
|
|
|
|
fn show_context_menu(owner_hwnd: HWND) {
|
|
let snap = match lock_state().as_ref() {
|
|
Some(s) => ContextMenuSnapshot {
|
|
strings: s.i18n.strings().clone(),
|
|
available: s
|
|
.i18n
|
|
.available()
|
|
.map(|(c, n)| (c.to_string(), n.to_string()))
|
|
.collect(),
|
|
language_override: s.settings.language.clone(),
|
|
current_interval: s.settings.poll_interval_ms,
|
|
update_check_interval_secs: s.settings.update_check_interval_secs,
|
|
show_claude: s.settings.show_claude_code,
|
|
show_chatgpt: s.settings.show_codex,
|
|
widget_visible: s.settings.widget_visible,
|
|
install_channel: s.install_channel,
|
|
update_status: s.update_status,
|
|
},
|
|
None => return,
|
|
};
|
|
|
|
unsafe {
|
|
let menu = match CreatePopupMenu() {
|
|
Ok(m) => m,
|
|
Err(_) => return,
|
|
};
|
|
|
|
append_item(menu, IDM_REFRESH, &snap.strings.refresh, MENU_ITEM_FLAGS(0));
|
|
|
|
let Ok(freq) = CreatePopupMenu() else {
|
|
log::error!("CreatePopupMenu(freq) failed");
|
|
let _ = DestroyMenu(menu);
|
|
return;
|
|
};
|
|
for (id, interval, label) in [
|
|
(IDM_FREQ_1MIN, POLL_1_MIN, &snap.strings.one_minute),
|
|
(IDM_FREQ_5MIN, POLL_5_MIN, &snap.strings.five_minutes),
|
|
(IDM_FREQ_15MIN, POLL_15_MIN, &snap.strings.fifteen_minutes),
|
|
(IDM_FREQ_1HOUR, POLL_1_HOUR, &snap.strings.one_hour),
|
|
] {
|
|
let flags = if interval == snap.current_interval {
|
|
MF_CHECKED
|
|
} else {
|
|
MENU_ITEM_FLAGS(0)
|
|
};
|
|
append_item(freq, id, label, flags);
|
|
}
|
|
append_submenu(menu, freq, &snap.strings.update_frequency);
|
|
|
|
let Ok(models) = CreatePopupMenu() else {
|
|
log::error!("CreatePopupMenu(models) failed");
|
|
let _ = DestroyMenu(menu);
|
|
return;
|
|
};
|
|
append_item(
|
|
models,
|
|
IDM_MODEL_CLAUDE,
|
|
&snap.strings.claude_label,
|
|
if snap.show_claude { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
|
);
|
|
append_item(
|
|
models,
|
|
IDM_MODEL_CHATGPT,
|
|
&snap.strings.chatgpt_label,
|
|
if snap.show_chatgpt { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
|
);
|
|
append_submenu(menu, models, &snap.strings.models);
|
|
|
|
let Ok(settings_menu) = CreatePopupMenu() else {
|
|
log::error!("CreatePopupMenu(settings_menu) failed");
|
|
let _ = DestroyMenu(menu);
|
|
return;
|
|
};
|
|
append_item(
|
|
settings_menu,
|
|
IDM_START_WITH_WINDOWS,
|
|
&snap.strings.start_with_windows,
|
|
if is_startup_enabled() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
|
);
|
|
append_item(
|
|
settings_menu,
|
|
IDM_RESET_POSITION,
|
|
&snap.strings.reset_position,
|
|
MENU_ITEM_FLAGS(0),
|
|
);
|
|
|
|
let Ok(lang) = CreatePopupMenu() else {
|
|
log::error!("CreatePopupMenu(lang) failed");
|
|
let _ = DestroyMenu(settings_menu);
|
|
let _ = DestroyMenu(menu);
|
|
return;
|
|
};
|
|
append_item(
|
|
lang,
|
|
IDM_LANG_SYSTEM,
|
|
&snap.strings.system_default,
|
|
if snap.language_override.is_none() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
|
);
|
|
for (i, (code, name)) in snap.available.iter().enumerate() {
|
|
let id = IDM_LANG_BASE + i as u16;
|
|
let flags = if snap
|
|
.language_override
|
|
.as_deref()
|
|
.map(|c| c == code)
|
|
.unwrap_or(false)
|
|
{
|
|
MF_CHECKED
|
|
} else {
|
|
MENU_ITEM_FLAGS(0)
|
|
};
|
|
append_item(lang, id, name, flags);
|
|
}
|
|
append_submenu(settings_menu, lang, &snap.strings.language);
|
|
let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null());
|
|
|
|
let version_label = version_action_label(&snap);
|
|
let version_flags = if matches!(
|
|
snap.update_status,
|
|
UpdateStatus::Checking | UpdateStatus::Applying
|
|
) {
|
|
MF_GRAYED
|
|
} else {
|
|
MENU_ITEM_FLAGS(0)
|
|
};
|
|
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
|
|
|
|
let Ok(auto_update) = CreatePopupMenu() else {
|
|
log::error!("CreatePopupMenu(auto_update) failed");
|
|
let _ = DestroyMenu(settings_menu);
|
|
let _ = DestroyMenu(menu);
|
|
return;
|
|
};
|
|
for (id, value, label) in [
|
|
(IDM_UPDATE_AUTO_OFF, None, &snap.strings.auto_check_disabled),
|
|
(
|
|
IDM_UPDATE_AUTO_HOURLY,
|
|
Some(settings::UPDATE_CHECK_HOURLY_SECS),
|
|
&snap.strings.auto_check_hourly,
|
|
),
|
|
(
|
|
IDM_UPDATE_AUTO_DAILY,
|
|
Some(settings::UPDATE_CHECK_DAILY_SECS),
|
|
&snap.strings.auto_check_daily,
|
|
),
|
|
(
|
|
IDM_UPDATE_AUTO_WEEKLY,
|
|
Some(settings::UPDATE_CHECK_WEEKLY_SECS),
|
|
&snap.strings.auto_check_weekly,
|
|
),
|
|
] {
|
|
let flags = if value == snap.update_check_interval_secs {
|
|
MF_CHECKED
|
|
} else {
|
|
MENU_ITEM_FLAGS(0)
|
|
};
|
|
append_item(auto_update, id, label, flags);
|
|
}
|
|
append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check);
|
|
append_submenu(menu, settings_menu, &snap.strings.settings);
|
|
|
|
append_item(
|
|
menu,
|
|
tray::IDM_TOGGLE_WIDGET,
|
|
&snap.strings.show_widget,
|
|
if snap.widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
|
);
|
|
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
|
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
|
|
append_item(menu, IDM_EXIT, &snap.strings.exit, MENU_ITEM_FLAGS(0));
|
|
|
|
let mut pt = POINT::default();
|
|
let _ = GetCursorPos(&mut pt);
|
|
let _ = SetForegroundWindow(owner_hwnd);
|
|
let _ = TrackPopupMenu(menu, TPM_RIGHTBUTTON, pt.x, pt.y, 0, owner_hwnd, None);
|
|
let _ = DestroyMenu(menu);
|
|
}
|
|
}
|
|
|
|
fn append_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) {
|
|
let w = os::to_utf16_nul(label);
|
|
unsafe {
|
|
let _ = AppendMenuW(menu, flags, id as usize, PCWSTR::from_raw(w.as_ptr()));
|
|
}
|
|
}
|
|
|
|
fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) {
|
|
let w = os::to_utf16_nul(label);
|
|
unsafe {
|
|
let _ = AppendMenuW(menu, MF_POPUP, submenu.0 as usize, PCWSTR::from_raw(w.as_ptr()));
|
|
}
|
|
}
|
|
|
|
fn version_action_label(snap: &ContextMenuSnapshot) -> String {
|
|
let base = match snap.update_status {
|
|
UpdateStatus::Idle => snap.strings.check_for_updates.clone(),
|
|
UpdateStatus::Checking => snap.strings.checking_for_updates.clone(),
|
|
UpdateStatus::UpToDate => snap.strings.up_to_date.clone(),
|
|
UpdateStatus::Available => snap.strings.update_available.clone(),
|
|
UpdateStatus::Applying => snap.strings.applying_update.clone(),
|
|
UpdateStatus::Failed => snap.strings.update_failed.clone(),
|
|
};
|
|
// Append the running binary's version so the user can see what
|
|
// they are on without opening an About dialog. Using middle-dot as
|
|
// the separator matches the bubble's countdown formatting.
|
|
let with_version = format!("{base} \u{00b7} v{}", env!("CARGO_PKG_VERSION"));
|
|
match snap.install_channel {
|
|
InstallChannel::Winget => format!("{with_version} ({})", snap.strings.update_via_winget),
|
|
InstallChannel::Portable => with_version,
|
|
}
|
|
}
|
|
|
|
// ---------- Menu actions ----------
|
|
|
|
fn set_poll_interval(ms: u32) {
|
|
let (snap, msg_hwnd) = {
|
|
let mut s = lock_state();
|
|
let Some(s) = s.as_mut() else {
|
|
return;
|
|
};
|
|
s.settings.poll_interval_ms = ms;
|
|
(s.settings.clone(), s.msg_hwnd)
|
|
};
|
|
settings::save(&snap);
|
|
unsafe {
|
|
let _ = KillTimer(msg_hwnd.to_hwnd(), TIMER_POLL);
|
|
SetTimer(msg_hwnd.to_hwnd(), TIMER_POLL, ms, None);
|
|
}
|
|
}
|
|
|
|
fn toggle_model(model: ProviderId) {
|
|
let (settings, is_dark) = {
|
|
let mut s = lock_state();
|
|
let Some(s) = s.as_mut() else {
|
|
return;
|
|
};
|
|
match model {
|
|
ProviderId::Claude => s.settings.show_claude_code = !s.settings.show_claude_code,
|
|
ProviderId::ChatGpt => s.settings.show_codex = !s.settings.show_codex,
|
|
}
|
|
if !s.settings.show_claude_code && !s.settings.show_codex {
|
|
match model {
|
|
ProviderId::Claude => s.settings.show_claude_code = true,
|
|
ProviderId::ChatGpt => s.settings.show_codex = true,
|
|
}
|
|
}
|
|
(s.settings.clone(), s.is_dark)
|
|
};
|
|
settings::save(&settings);
|
|
|
|
let want = match model {
|
|
ProviderId::Claude => settings.show_claude_code,
|
|
ProviderId::ChatGpt => settings.show_codex,
|
|
};
|
|
let existing = lock_state().as_ref().and_then(|s| s.bubbles.get(&model).copied());
|
|
match (want, existing) {
|
|
(true, None) => spawn_bubble(model, &settings, is_dark),
|
|
(false, Some(h)) => {
|
|
bubble::destroy(h.to_hwnd());
|
|
if let Some(s) = lock_state().as_mut() {
|
|
s.bubbles.remove(&model);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
refresh_tray_icons();
|
|
spawn_poll_thread();
|
|
}
|
|
|
|
fn toggle_widget_visibility() {
|
|
let (visible, snap) = {
|
|
let mut s = lock_state();
|
|
let Some(s) = s.as_mut() else {
|
|
return;
|
|
};
|
|
s.settings.widget_visible = !s.settings.widget_visible;
|
|
(s.settings.widget_visible, s.settings.clone())
|
|
};
|
|
settings::save(&snap);
|
|
let hwnds: Vec<HWND> = lock_state()
|
|
.as_ref()
|
|
.map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect())
|
|
.unwrap_or_default();
|
|
for h in hwnds {
|
|
bubble::set_user_visible(h, visible);
|
|
}
|
|
}
|
|
|
|
fn reset_positions() {
|
|
let snap = {
|
|
let mut s = lock_state();
|
|
let Some(s) = s.as_mut() else {
|
|
return;
|
|
};
|
|
s.settings.bubble_positions.reset_all();
|
|
s.settings.clone()
|
|
};
|
|
settings::save(&snap);
|
|
let hwnds: Vec<HWND> = lock_state()
|
|
.as_ref()
|
|
.map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect())
|
|
.unwrap_or_default();
|
|
for h in hwnds {
|
|
bubble::destroy(h);
|
|
}
|
|
if let Some(s) = lock_state().as_mut() {
|
|
s.bubbles.clear();
|
|
}
|
|
create_initial_bubbles();
|
|
// The freshly-spawned bubbles boot with a "…" placeholder. Push the
|
|
// cached snapshot so they render the last-known data immediately, and
|
|
// kick a poll for users who used Reset Position to recover from
|
|
// staleness.
|
|
propagate_to_ui();
|
|
spawn_poll_thread();
|
|
}
|
|
|
|
fn set_language(_dummy: Option<()>) {
|
|
update_settings(|s| {
|
|
s.i18n.set_active(None);
|
|
s.settings.language = None;
|
|
});
|
|
propagate_to_ui();
|
|
}
|
|
|
|
fn set_language_by_index(idx: usize) {
|
|
update_settings(|s| {
|
|
let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string());
|
|
if let Some(c) = code.as_deref() {
|
|
s.i18n.set_active(Some(c));
|
|
}
|
|
s.settings.language = code;
|
|
});
|
|
propagate_to_ui();
|
|
}
|
|
|
|
fn version_action() {
|
|
enum Act {
|
|
Apply(update::Release, InstallChannel),
|
|
Check(SendHwnd),
|
|
}
|
|
let act = match lock_state().as_ref() {
|
|
Some(s) => match (s.update_status, s.update_release.as_ref()) {
|
|
(UpdateStatus::Available, Some(r)) => Act::Apply(r.clone(), s.install_channel),
|
|
_ => Act::Check(s.msg_hwnd),
|
|
},
|
|
None => return,
|
|
};
|
|
match act {
|
|
Act::Apply(release, channel) => {
|
|
// Set the Applying status synchronously so the menu reflects
|
|
// it immediately, then move the (potentially several-second)
|
|
// download to a worker thread. Holding the UI thread here
|
|
// would freeze paints and menus until the download finished.
|
|
let (http, msg_hwnd) = {
|
|
let mut guard = lock_state();
|
|
let Some(s) = guard.as_mut() else {
|
|
return;
|
|
};
|
|
s.update_status = UpdateStatus::Applying;
|
|
(s.http.clone(), s.msg_hwnd)
|
|
};
|
|
std::thread::spawn(move || {
|
|
let result: Result<(), Box<dyn std::error::Error + Send + Sync>> = match channel {
|
|
InstallChannel::Winget => {
|
|
// Winget channel is reserved for future use; until a
|
|
// winget package ships, this branch is unreachable.
|
|
Err("winget channel not supported yet".into())
|
|
}
|
|
InstallChannel::Portable => {
|
|
update::install::begin(&http, &release).map_err(|e| e.into())
|
|
}
|
|
};
|
|
match result {
|
|
Ok(()) => unsafe {
|
|
let _ = PostMessageW(
|
|
msg_hwnd.to_hwnd(),
|
|
WM_APP_UPDATE_APPLIED,
|
|
WPARAM(0),
|
|
LPARAM(0),
|
|
);
|
|
},
|
|
Err(e) => {
|
|
log::error!("update apply failed: {e}");
|
|
if let Some(s) = lock_state().as_mut() {
|
|
s.update_status = UpdateStatus::Failed;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
Act::Check(hwnd) => begin_update_check(hwnd.to_hwnd()),
|
|
}
|
|
}
|
|
|
|
fn schedule_update_check_timer(hwnd: HWND) {
|
|
let (last, interval) = match lock_state().as_ref() {
|
|
Some(s) => (
|
|
s.settings.last_update_check_unix,
|
|
s.settings.update_check_interval_secs,
|
|
),
|
|
None => return,
|
|
};
|
|
let Some(interval) = interval else {
|
|
// Auto-check disabled. Manual "Check for updates" still works via
|
|
// the menu; this just suppresses the timer.
|
|
return;
|
|
};
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
let due = last.map_or(true, |t| now.saturating_sub(t) >= interval);
|
|
if due {
|
|
begin_update_check(hwnd);
|
|
} else {
|
|
let remaining = interval.saturating_sub(now.saturating_sub(last.unwrap_or(0)));
|
|
let ms = (remaining.saturating_mul(1000).min(u32::MAX as u64)) as u32;
|
|
unsafe {
|
|
SetTimer(hwnd, TIMER_UPDATE_CHECK, ms, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn begin_update_check(hwnd: HWND) {
|
|
if let Some(s) = lock_state().as_mut() {
|
|
s.update_status = UpdateStatus::Checking;
|
|
}
|
|
let send_hwnd = SendHwnd::from_hwnd(hwnd);
|
|
std::thread::spawn(move || {
|
|
let result = match net::Client::new(HTTP_USER_AGENT) {
|
|
Ok(c) => update::release::fetch_latest(&c),
|
|
Err(e) => Err(update::Error::Network(e)),
|
|
};
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
let snap_opt = {
|
|
let mut s = lock_state();
|
|
s.as_mut().map(|s| {
|
|
s.settings.last_update_check_unix = Some(now);
|
|
match result {
|
|
Ok(CheckOutcome::UpToDate) => {
|
|
s.update_status = UpdateStatus::UpToDate;
|
|
s.update_release = None;
|
|
}
|
|
Ok(CheckOutcome::Available(r)) => {
|
|
s.update_status = UpdateStatus::Available;
|
|
s.update_release = Some(r);
|
|
}
|
|
Err(_) => {
|
|
s.update_status = UpdateStatus::Failed;
|
|
}
|
|
}
|
|
(s.settings.clone(), s.settings.update_check_interval_secs)
|
|
})
|
|
};
|
|
let next_interval = snap_opt.as_ref().and_then(|(_, iv)| *iv);
|
|
if let Some((snap, _)) = snap_opt {
|
|
settings::save(&snap);
|
|
}
|
|
if let Some(interval) = next_interval {
|
|
let ms = (interval.saturating_mul(1000).min(u32::MAX as u64)) as u32;
|
|
unsafe {
|
|
SetTimer(send_hwnd.to_hwnd(), TIMER_UPDATE_CHECK, ms, None);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn set_update_check_interval(value: Option<u64>) {
|
|
let (snap, msg_hwnd) = {
|
|
let mut s = lock_state();
|
|
let Some(s) = s.as_mut() else {
|
|
return;
|
|
};
|
|
s.settings.update_check_interval_secs = value;
|
|
(s.settings.clone(), s.msg_hwnd)
|
|
};
|
|
settings::save(&snap);
|
|
let hwnd = msg_hwnd.to_hwnd();
|
|
unsafe {
|
|
let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK);
|
|
}
|
|
if value.is_some() {
|
|
schedule_update_check_timer(hwnd);
|
|
}
|
|
}
|
|
|
|
// ---------- Restart ----------
|
|
|
|
/// Relaunch the running binary by spawning a detached child via
|
|
/// `CreateProcessW`. The child waits on our PID before acquiring the
|
|
/// singleton mutex, so no shell handoff or timer is required.
|
|
fn restart_app() {
|
|
// Defensive flush — bubble positions and most settings already persist
|
|
// on change, but a final save is cheap insurance. Snapshot then drop the
|
|
// lock before the disk write so the UI thread doesn't block on I/O.
|
|
let snap = lock_state().as_ref().map(|s| s.settings.clone());
|
|
if let Some(s) = snap {
|
|
settings::save(&s);
|
|
}
|
|
|
|
let exe = match std::env::current_exe() {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
log::error!("restart: current_exe failed: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let pid = unsafe { GetCurrentProcessId() };
|
|
let args = vec![
|
|
OsString::from("--wait-pid"),
|
|
OsString::from(pid.to_string()),
|
|
];
|
|
match update::handoff::spawn_detached(&exe, &args) {
|
|
Ok(()) => {
|
|
log::info!("restart: spawned detached child (parent pid={pid}), posting quit");
|
|
unsafe { PostQuitMessage(0) };
|
|
}
|
|
Err(e) => log::error!("restart: spawn_detached failed: {e}"),
|
|
}
|
|
}
|
|
|
|
// ---------- Start-with-Windows ----------
|
|
|
|
fn is_startup_enabled() -> bool {
|
|
os::registry::value_exists(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME)
|
|
}
|
|
|
|
fn toggle_startup() {
|
|
if is_startup_enabled() {
|
|
let _ = os::registry::delete_value(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME);
|
|
} else if let Ok(exe) = std::env::current_exe() {
|
|
let quoted = format!("\"{}\"", exe.to_string_lossy());
|
|
let _ = os::registry::write_string(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME, "ed);
|
|
}
|
|
}
|