From e243c589a84adb722319a471df4bcc22c53e6269 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 23 May 2026 11:22:40 +0700 Subject: [PATCH] =?UTF-8?q?refactor:=20fix=20latent=20bugs,=20invert=20bub?= =?UTF-8?q?ble=E2=86=92app=20deps,=20unify=20color=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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). --- src/app.rs | 110 +++++++++++++++++--------------------- src/bubble.rs | 116 ++++++++++++++++++++++++++++------------- src/panel.rs | 20 +++---- src/settings.rs | 7 ++- src/tray/badge.rs | 9 +--- src/usage/anthropic.rs | 66 ++++++++++++++++------- src/usage/chatgpt.rs | 2 +- src/usage/headers.rs | 8 ++- 8 files changed, 192 insertions(+), 146 deletions(-) diff --git a/src/app.rs b/src/app.rs index bf421dc..e190473 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,9 +27,7 @@ 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}; -use crate::usage::ProviderId as TrayIconKind; -use crate::tray::WM_APP_TRAY; +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}; @@ -110,7 +108,7 @@ enum UpdateStatus { struct AppState { msg_hwnd: SendHwnd, - bubbles: HashMap, + bubbles: HashMap, settings: Settings, i18n: I18n, is_dark: bool, @@ -145,6 +143,21 @@ fn lock_state() -> MutexGuard<'static, Option> { state().lock().expect("app state mutex poisoned") } +/// Run `f` with mutable access to `AppState`, then snapshot `Settings` and +/// persist it to disk. The lock is released before the disk write so the UI +/// thread doesn't block on I/O. No-op if state hasn't been initialised yet. +fn update_settings(f: impl FnOnce(&mut AppState)) { + let snap = { + let mut guard = lock_state(); + let Some(s) = guard.as_mut() else { + return; + }; + f(s); + s.settings.clone() + }; + settings::save(&snap); +} + // ---------- Entry ---------- /// Acquire the singleton mutex, optionally retrying for ~3s if the @@ -230,6 +243,15 @@ pub fn run(args: crate::AppArgs) { 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(); @@ -307,7 +329,7 @@ fn create_initial_bubbles() { } } -fn spawn_bubble(kind: TrayIconKind, settings: &Settings, is_dark: bool) { +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. @@ -365,40 +387,24 @@ unsafe extern "system" fn msg_wnd_proc( // ---------- Bubble callbacks ---------- -pub fn on_bubble_click(hwnd: HWND, model: TrayIconKind) { +fn on_bubble_click(hwnd: HWND, model: ProviderId) { let data = build_panel_data(model); panel::toggle(data, hwnd); } -pub fn on_bubble_right_click(hwnd: HWND, _model: TrayIconKind, _pt: POINT) { +fn on_bubble_right_click(hwnd: HWND, _model: ProviderId, _pt: POINT) { show_context_menu(hwnd); } -pub fn on_bubble_moved(model: TrayIconKind, pos: (i32, i32)) { - let snap = { - let mut s = lock_state(); - let Some(s) = s.as_mut() else { - return; - }; - s.settings.bubble_positions.set(model, pos); - s.settings.clone() - }; - settings::save(&snap); +fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) { + update_settings(|s| s.settings.bubble_positions.set(model, pos)); } -pub fn on_bubble_resized(_model: TrayIconKind, size_logical: i32) { - let snap = { - let mut s = lock_state(); - let Some(s) = s.as_mut() else { - return; - }; - s.settings.bubble_size_logical = size_logical; - s.settings.clone() - }; - settings::save(&snap); +fn on_bubble_resized(_model: ProviderId, size_logical: i32) { + update_settings(|s| s.settings.bubble_size_logical = size_logical); } -pub fn on_menu_command(id: u32, _owner_hwnd: HWND) { +fn on_menu_command(id: u32, _owner_hwnd: HWND) { let id = (id & 0xFFFF) as u16; match id { IDM_REFRESH => spawn_poll_thread(), @@ -618,8 +624,7 @@ fn propagate_to_ui() { }; for (kind, hwnd) in snap.bubbles.iter() { - let id = kind_to_provider(*kind); - let entry = snap.snapshots.get(&id); + 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 @@ -643,8 +648,7 @@ fn propagate_to_ui() { if panel::is_visible() { if let Some(model) = panel::current_model() { - let id = kind_to_provider(model); - if let Some(provider_state) = snap.snapshots.get(&id) { + if let Some(provider_state) = snap.snapshots.get(&model) { panel::refresh_data(build_panel_data_from(&snap, model, provider_state)); } } @@ -654,7 +658,7 @@ fn propagate_to_ui() { #[derive(Clone)] struct UiSnapshot { - bubbles: HashMap, + bubbles: HashMap, snapshots: HashMap, settings: Settings, i18n_strings: LocaleStrings, @@ -663,21 +667,13 @@ struct UiSnapshot { last_poll_ok: bool, } -fn kind_to_provider(k: TrayIconKind) -> ProviderId { - match k { - ProviderId::Claude => ProviderId::Claude, - ProviderId::ChatGpt => ProviderId::ChatGpt, - } -} - -fn build_panel_data(model: TrayIconKind) -> PanelData { +fn build_panel_data(model: ProviderId) -> PanelData { let s = lock_state(); let Some(s) = s.as_ref() else { return placeholder_panel(model); }; - let id = kind_to_provider(model); let strings = s.i18n.strings().clone(); - let provider_state = s.snapshots.get(&id).cloned().unwrap_or_default(); + let provider_state = s.snapshots.get(&model).cloned().unwrap_or_default(); PanelData { model, session_pct: provider_state.windows.primary.utilization, @@ -689,7 +685,7 @@ fn build_panel_data(model: TrayIconKind) -> PanelData { } } -fn build_panel_data_from(snap: &UiSnapshot, model: TrayIconKind, p: &ProviderUiState) -> PanelData { +fn build_panel_data_from(snap: &UiSnapshot, model: ProviderId, p: &ProviderUiState) -> PanelData { PanelData { model, session_pct: p.windows.primary.utilization, @@ -701,7 +697,7 @@ fn build_panel_data_from(snap: &UiSnapshot, model: TrayIconKind, p: &ProviderUiS } } -fn placeholder_panel(model: TrayIconKind) -> PanelData { +fn placeholder_panel(model: ProviderId) -> PanelData { let strings = i18n::I18n::load(None).strings().clone(); PanelData { model, @@ -816,7 +812,7 @@ fn handle_tray_action(action: TrayAction) { /// 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. -pub fn recheck_theme() { +fn recheck_theme() { let now_dark = os::theme::is_dark(); let changed = { let mut s = lock_state(); @@ -1161,7 +1157,7 @@ fn set_poll_interval(ms: u32) { } } -fn toggle_model(model: TrayIconKind) { +fn toggle_model(model: ProviderId) { let (settings, is_dark) = { let mut s = lock_state(); let Some(s) = s.as_mut() else { @@ -1249,33 +1245,21 @@ fn reset_positions() { } fn set_language(_dummy: Option<()>) { - let snap = { - let mut s = lock_state(); - let Some(s) = s.as_mut() else { - return; - }; + update_settings(|s| { s.i18n.set_active(None); s.settings.language = None; - s.settings.clone() - }; - settings::save(&snap); + }); propagate_to_ui(); } fn set_language_by_index(idx: usize) { - let snap = { - let mut s = lock_state(); - let Some(s) = s.as_mut() else { - return; - }; + 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; - s.settings.clone() - }; - settings::save(&snap); + }); propagate_to_ui(); } diff --git a/src/bubble.rs b/src/bubble.rs index 06b4599..8090dca 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -26,13 +26,13 @@ use windows::Win32::UI::Shell::{ }; use windows::Win32::UI::WindowsAndMessaging::*; +use crate::os::dpi::scale as scale_to_dpi; use crate::os::{to_utf16_nul as wide_str, Rgb as Color}; const TIMER_FULLSCREEN_CHECK: usize = 5; const TIMER_PULSE: usize = 6; const PULSE_INTERVAL_MS: u32 = 80; use crate::usage::ProviderId; -type TrayIconKind = ProviderId; // ---------- Public types & API ---------- @@ -88,7 +88,7 @@ fn aspect_at_width(w_logical: i32) -> (i32, i32) { } pub struct BubbleConfig { - pub model: TrayIconKind, + pub model: ProviderId, pub size_logical: i32, pub position: Option<(i32, i32)>, pub session_pct: Option, @@ -103,6 +103,34 @@ fn bubble_height_logical(width_logical: i32) -> i32 { ((width_logical * den) / num).max(20) } +/// Owner-supplied event callbacks. The bubble window proc is a leaf — it +/// doesn't know about `app`. The owner installs these once at startup so the +/// proc can dispatch UI events back without an upward `crate::app::` reach. +pub struct Callbacks { + pub on_click: fn(HWND, ProviderId), + pub on_right_click: fn(HWND, ProviderId, POINT), + pub on_moved: fn(ProviderId, (i32, i32)), + pub on_resized: fn(ProviderId, i32), + pub on_menu_command: fn(u32, HWND), + pub on_settings_changed: fn(), +} + +static CALLBACKS: OnceLock = OnceLock::new(); + +/// Install the owner's callbacks. Called once by `app::run` before any +/// bubble is created. Subsequent calls are silently ignored. +pub fn install_callbacks(cb: Callbacks) { + let _ = CALLBACKS.set(cb); +} + +fn dispatch(f: F) { + if let Some(cb) = CALLBACKS.get() { + f(cb); + } else { + log::warn!("bubble event dispatched before install_callbacks; event dropped"); + } +} + /// Register the bubble window class. Idempotent; safe to call before the first /// `create()` from the UI thread. pub fn register_class() { @@ -133,7 +161,7 @@ pub fn create(config: BubbleConfig) -> HWND { let initial_size_logical = config .size_logical .clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE); - let dpi_for_create = primary_dpi(); + let dpi_for_create = crate::os::dpi::for_system(); let width_px = scale_to_dpi(initial_size_logical, dpi_for_create); let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi_for_create); let (x, y) = config @@ -166,19 +194,11 @@ pub fn create(config: BubbleConfig) -> HWND { } // Embed app icon in window non-client (mostly cosmetic; toolwindows - // don't show captions but the icon helps in dev tooling). + // don't show captions but the icon helps in dev tooling). The HICONs + // are extracted once at process startup and reused across every bubble + // create() so we don't leak a pair per toggle cycle. + let (large_icon, small_icon) = app_icons(); unsafe { - let mut large_icon = HICON::default(); - let mut small_icon = HICON::default(); - let mut exe = [0u16; 260]; - GetModuleFileNameW(HMODULE::default(), &mut exe); - let _ = ExtractIconExW( - PCWSTR::from_raw(exe.as_ptr()), - 0, - Some(&mut large_icon), - Some(&mut small_icon), - 1, - ); if !large_icon.is_invalid() { let _ = SendMessageW( hwnd, @@ -245,6 +265,33 @@ pub fn destroy(hwnd: HWND) { } } +/// Extract the EXE's own icon pair once per process. Stored as raw pointer +/// values because `HICON` is `!Send`/`!Sync`; reconstituted for each caller. +/// The pair is intentionally never destroyed — Windows tears them down on +/// process exit, and one pair per process is bounded leak rather than the +/// O(bubble-toggles) leak we'd get from extracting per `create()`. +fn app_icons() -> (HICON, HICON) { + static ICONS: OnceLock<(isize, isize)> = OnceLock::new(); + let (big, small) = *ICONS.get_or_init(|| unsafe { + let mut large = HICON::default(); + let mut small = HICON::default(); + let mut exe = [0u16; 260]; + GetModuleFileNameW(HMODULE::default(), &mut exe); + let _ = ExtractIconExW( + PCWSTR::from_raw(exe.as_ptr()), + 0, + Some(&mut large), + Some(&mut small), + 1, + ); + if large.is_invalid() && small.is_invalid() { + log::warn!("ExtractIconExW yielded null handles; bubbles will be iconless"); + } + (large.0 as isize, small.0 as isize) + }); + (HICON(big as *mut _), HICON(small as *mut _)) +} + pub fn update_data( hwnd: HWND, session_pct: Option, @@ -339,7 +386,7 @@ pub fn position(hwnd: HWND) -> Option<(i32, i32)> { Some((r.left, r.top)) } -pub fn model(hwnd: HWND) -> Option { +pub fn model(hwnd: HWND) -> Option { lock_bubbles() .get(&(hwnd.0 as isize)) .map(|b| b.model) @@ -354,7 +401,7 @@ pub fn size_logical(hwnd: HWND) -> Option { // ---------- State ---------- struct BubbleState { - model: TrayIconKind, + model: ProviderId, size_logical: i32, dpi: u32, session_pct: Option, @@ -422,18 +469,18 @@ unsafe extern "system" fn wnd_proc( snap_to_edge(hwnd); if let Some(model) = model(hwnd) { if let Some(pos) = position(hwnd) { - crate::app::on_bubble_moved(model, pos); + dispatch(|cb| (cb.on_moved)(model, pos)); } } } else if let Some(model) = model(hwnd) { - crate::app::on_bubble_click(hwnd, model); + dispatch(|cb| (cb.on_click)(hwnd, model)); } LRESULT(0) } WM_NCRBUTTONUP => { if let Some(model) = model(hwnd) { let pt = lparam_to_point(lparam); - crate::app::on_bubble_right_click(hwnd, model, pt); + dispatch(|cb| (cb.on_right_click)(hwnd, model, pt)); } LRESULT(0) } @@ -484,7 +531,7 @@ unsafe extern "system" fn wnd_proc( LRESULT(0) } WM_COMMAND => { - crate::app::on_menu_command(wparam.0 as u32, hwnd); + dispatch(|cb| (cb.on_menu_command)(wparam.0 as u32, hwnd)); LRESULT(0) } WM_SETTINGCHANGE => { @@ -494,7 +541,7 @@ unsafe extern "system" fn wnd_proc( // the app to re-read the light/dark setting — Windows fires // this message when the user flips the OS theme in Settings. clamp_into_work_area(hwnd); - crate::app::recheck_theme(); + dispatch(|cb| (cb.on_settings_changed)()); LRESULT(0) } WM_DESTROY => { @@ -591,7 +638,7 @@ fn resize_step(hwnd: HWND, delta: i32) { } render(hwnd); if let Some(m) = model(hwnd) { - crate::app::on_bubble_resized(m, new_logical); + dispatch(|cb| (cb.on_resized)(m, new_logical)); } } @@ -810,7 +857,7 @@ fn clamp_into_work_area(hwnd: HWND) { // so they don't visually stack. let is_codex = lock_bubbles() .get(&(hwnd.0 as isize)) - .is_some_and(|b| matches!(b.model, TrayIconKind::ChatGpt)); + .is_some_and(|b| matches!(b.model, ProviderId::ChatGpt)); if is_codex && nx == wa.right - w && ny == wa.bottom - h { const STAGGER_GAP: i32 = 24; ny = (ny - h - STAGGER_GAP).max(wa.top); @@ -1060,7 +1107,7 @@ fn rgb_to_dib(c: Color) -> u32 { } struct PaintInputs { - model: TrayIconKind, + model: ProviderId, session_pct: Option, session_text: String, weekly_pct: Option, @@ -1092,7 +1139,14 @@ fn render(hwnd: HWND) { unsafe { let screen_dc = GetDC(hwnd); + if screen_dc.is_invalid() { + return; + } let mem_dc = CreateCompatibleDC(screen_dc); + if mem_dc.is_invalid() { + ReleaseDC(hwnd, screen_dc); + return; + } let layout = compute_layout(size_logical, dpi, mem_dc); let bmi = BITMAPINFO { @@ -1204,7 +1258,7 @@ fn row_band(layout: &BarLayout, row_top: i32) -> (i32, i32) { (top, bot) } -fn paint_accent_stripe(pixels: &mut [u32], layout: &BarLayout, model: TrayIconKind, is_dark: bool) { +fn paint_accent_stripe(pixels: &mut [u32], layout: &BarLayout, model: ProviderId, is_dark: bool) { let stripe = rgb_to_dib(crate::usage_color::accent_color_for(model, is_dark)); for y in 0..layout.canvas_h { for x in 0..layout.accent_right { @@ -1460,15 +1514,7 @@ fn use_dark_text_over(c: Color) -> bool { // ---------- Helpers ---------- -fn primary_dpi() -> u32 { - unsafe { GetDpiForSystem().max(96) } -} - -fn scale_to_dpi(logical: i32, dpi: u32) -> i32 { - ((logical as i64) * (dpi as i64) / 96) as i32 -} - -fn default_position(width_px: i32, height_px: i32, model: TrayIconKind) -> (i32, i32) { +fn default_position(width_px: i32, height_px: i32, model: ProviderId) -> (i32, i32) { // Bottom-right of primary work area, with a 24-pixel gap from the edges. // Stagger the Codex bubble above the Claude one if both are enabled. unsafe { diff --git a/src/panel.rs b/src/panel.rs index 31ec3dd..ab0826f 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -16,9 +16,9 @@ use windows::Win32::UI::HiDpi::GetDpiForWindow; use windows::Win32::UI::WindowsAndMessaging::*; use crate::i18n::LocaleStrings; +use crate::os::dpi::scale as scale_to_dpi; use crate::os::{to_utf16_nul as wide_str, Rgb as Color}; use crate::usage::ProviderId; -type TrayIconKind = ProviderId; const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel"; const PANEL_W_LOGICAL: i32 = 280; @@ -30,7 +30,7 @@ const RIGHT_TEXT_W_LOGICAL: i32 = 96; const BAR_HEIGHT_LOGICAL: i32 = 14; pub struct PanelData { - pub model: TrayIconKind, + pub model: ProviderId, pub session_pct: f64, pub session_text: String, pub weekly_pct: f64, @@ -86,7 +86,7 @@ pub fn is_visible() -> bool { .unwrap_or(false) } -pub fn current_model() -> Option { +pub fn current_model() -> Option { lock_state().as_ref().map(|p| p.data.model) } @@ -291,8 +291,10 @@ fn paint(hwnd: HWND, hdc: HDC) { } else { Color::from_hex("#D6D6D6") }; - let session_accent = bar_color_for(data.model, data.session_pct, data.is_dark); - let weekly_accent = bar_color_for(data.model, data.weekly_pct, data.is_dark); + let session_accent = + crate::usage_color::bar_fill_color(data.model, data.is_dark, data.session_pct); + let weekly_accent = + crate::usage_color::bar_fill_color(data.model, data.is_dark, data.weekly_pct); unsafe { let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref())); @@ -498,10 +500,6 @@ fn draw_text( } } -fn bar_color_for(model: ProviderId, percent: f64, is_dark: bool) -> Color { - crate::usage_color::bar_fill_color(model, is_dark, percent) -} - fn clone_data() -> Option { let guard = lock_state(); let p = guard.as_ref()?; @@ -544,7 +542,3 @@ fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) { } (x, y) } - -fn scale_to_dpi(logical: i32, dpi: u32) -> i32 { - ((logical as i64) * (dpi as i64) / 96) as i32 -} diff --git a/src/settings.rs b/src/settings.rs index 6ac6fe3..1fb5a91 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -6,7 +6,6 @@ use windows::Win32::Graphics::Gdi::{MonitorFromRect, MONITOR_DEFAULTTONULL}; use crate::bubble::DEFAULT_BUBBLE_SIZE; use crate::usage::ProviderId; -type TrayIconKind = ProviderId; // 140px matches MIN_BUBBLE_SIZE — a saved top-left a few px past the work-area // edge still passes the validator, but a position fully on a disconnected @@ -52,19 +51,19 @@ pub struct BubblePositions { } impl BubblePositions { - pub fn get(&self, model: TrayIconKind) -> Option<(i32, i32)> { + pub fn get(&self, model: ProviderId) -> Option<(i32, i32)> { match model { ProviderId::Claude => self.claude, ProviderId::ChatGpt => self.codex, } } - pub fn set(&mut self, model: TrayIconKind, pos: (i32, i32)) { + pub fn set(&mut self, model: ProviderId, pos: (i32, i32)) { match model { ProviderId::Claude => self.claude = Some(pos), ProviderId::ChatGpt => self.codex = Some(pos), } } - pub fn reset(&mut self, model: TrayIconKind) { + pub fn reset(&mut self, model: ProviderId) { match model { ProviderId::Claude => self.claude = None, ProviderId::ChatGpt => self.codex = None, diff --git a/src/tray/badge.rs b/src/tray/badge.rs index 22eb5e4..320466b 100644 --- a/src/tray/badge.rs +++ b/src/tray/badge.rs @@ -9,8 +9,7 @@ use std::ffi::c_void; use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform}; -use windows::core::PCWSTR; -use windows::Win32::Foundation::HWND; +use windows::Win32::Foundation::{BOOL, HWND}; use windows::Win32::Graphics::Gdi::{ CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER, DIB_RGB_COLORS, HBITMAP, @@ -199,9 +198,3 @@ fn pixmap_to_hicon(pixmap: &Pixmap) -> Option { hicon } } - -// Silence import warnings if we end up not needing PCWSTR after later edits. -#[allow(dead_code)] -const _: PCWSTR = PCWSTR::null(); - -use windows::Win32::Foundation::BOOL; diff --git a/src/usage/anthropic.rs b/src/usage/anthropic.rs index 8c8edfa..102872f 100644 --- a/src/usage/anthropic.rs +++ b/src/usage/anthropic.rs @@ -133,7 +133,7 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result Window { Window { - utilization: bucket.utilization, + utilization: bucket.utilization.clamp(0.0, 100.0), resets_at: bucket.resets_at.as_deref().and_then(parse_iso8601), } } @@ -163,18 +163,21 @@ fn token_is_expired(expires_at_unix_ms: Option) -> bool { now_ms >= exp_ms } -// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|+00:00]") --- +// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM]") --- fn parse_iso8601(s: &str) -> Option { - let trimmed = s.split('Z').next().unwrap_or(s); - let trimmed = trimmed.split('+').next().unwrap_or(trimmed); - let trimmed = trimmed.split('-').take(3).collect::>().join("-"); - // We want the original `s` for parsing time-part. Re-split on 'T'. - let (date, time) = s - .split_once('T') - .map(|(d, t)| (d, t)) - .or_else(|| Some(("", "")))?; - let _ = trimmed; // shadow; using the raw `date` + `time` below. + let (date, time_with_offset) = s.split_once('T')?; + + // Split the time-and-offset on the first 'Z' / '+' / '-' marker. + let offset_pos = time_with_offset + .char_indices() + .find(|(_, c)| matches!(c, 'Z' | '+' | '-')) + .map(|(i, _)| i); + let (time, offset_str) = match offset_pos { + Some(p) => (&time_with_offset[..p], &time_with_offset[p..]), + None => (time_with_offset, ""), + }; + let time = time.split_once('.').map_or(time, |(t, _)| t); let date_parts: Vec<&str> = date.split('-').collect(); if date_parts.len() != 3 { @@ -183,13 +186,16 @@ fn parse_iso8601(s: &str) -> Option { let y: u64 = date_parts[0].parse().ok()?; let mo: u64 = date_parts[1].parse().ok()?; let d: u64 = date_parts[2].parse().ok()?; + if y < 1970 || mo == 0 || mo > 12 || d == 0 { + return None; + } + const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let max_day = DAYS_IN_MONTH[mo as usize] + if mo == 2 && is_leap(y) { 1 } else { 0 }; + if d > max_day { + return None; + } - let time_no_offset = time - .split(|c| c == 'Z' || c == '+' || (c == '-' && time.find(c) != Some(0))) - .next() - .unwrap_or(time); - let time_no_frac = time_no_offset.split('.').next().unwrap_or(time_no_offset); - let time_parts: Vec<&str> = time_no_frac.split(':').collect(); + let time_parts: Vec<&str> = time.split(':').collect(); if time_parts.len() != 3 { return None; } @@ -197,11 +203,26 @@ fn parse_iso8601(s: &str) -> Option { let mi: u64 = time_parts[1].parse().ok()?; let se: u64 = time_parts[2].parse().ok()?; + let offset_minutes: i64 = if offset_str.is_empty() || offset_str == "Z" { + 0 + } else { + let sign: i64 = if offset_str.starts_with('+') { + 1 + } else if offset_str.starts_with('-') { + -1 + } else { + return None; + }; + let (oh_str, om_str) = offset_str[1..].split_once(':')?; + let oh: i64 = oh_str.parse().ok()?; + let om: i64 = om_str.parse().ok()?; + sign * (oh * 60 + om) + }; + let mut days: u64 = 0; for year in 1970..y { days += if is_leap(year) { 366 } else { 365 }; } - const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; for month in 1..mo { days += DAYS_IN_MONTH[month as usize]; if month == 2 && is_leap(y) { @@ -209,8 +230,13 @@ fn parse_iso8601(s: &str) -> Option { } } days += d - 1; - let secs = days * 86_400 + h * 3_600 + mi * 60 + se; - Some(UNIX_EPOCH + Duration::from_secs(secs)) + + let local_secs = days * 86_400 + h * 3_600 + mi * 60 + se; + let utc_secs = (local_secs as i64) - offset_minutes * 60; + if utc_secs < 0 { + return None; + } + Some(UNIX_EPOCH + Duration::from_secs(utc_secs as u64)) } fn is_leap(year: u64) -> bool { diff --git a/src/usage/chatgpt.rs b/src/usage/chatgpt.rs index 12bbd3d..51d152a 100644 --- a/src/usage/chatgpt.rs +++ b/src/usage/chatgpt.rs @@ -84,7 +84,7 @@ fn envelope_to_windows(envelope: Envelope) -> Option { fn window_from(w: ApiWindow) -> Window { Window { - utilization: w.used_percent, + utilization: w.used_percent.clamp(0.0, 100.0), resets_at: unix_to_systemtime(Some(w.reset_at)), } } diff --git a/src/usage/headers.rs b/src/usage/headers.rs index c2d2031..bedde0d 100644 --- a/src/usage/headers.rs +++ b/src/usage/headers.rs @@ -15,14 +15,18 @@ use crate::usage::{UsageWindows, Window}; pub fn parse_anthropic(response: &Response) -> UsageWindows { UsageWindows { primary: Window { - utilization: header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0, + utilization: (header_f64(response, "anthropic-ratelimit-unified-5h-utilization") + * 100.0) + .clamp(0.0, 100.0), resets_at: unix_to_systemtime(header_i64( response, "anthropic-ratelimit-unified-5h-reset", )), }, secondary: Window { - utilization: header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0, + utilization: (header_f64(response, "anthropic-ratelimit-unified-7d-utilization") + * 100.0) + .clamp(0.0, 100.0), resets_at: unix_to_systemtime(header_i64( response, "anthropic-ratelimit-unified-7d-reset",