refactor: fix latent bugs, invert bubble→app deps, unify color path

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).
This commit is contained in:
2026-05-23 11:22:40 +07:00
parent a5dec52aaa
commit e243c589a8
8 changed files with 192 additions and 146 deletions
+47 -63
View File
@@ -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<TrayIconKind, SendHwnd>,
bubbles: HashMap<ProviderId, SendHwnd>,
settings: Settings,
i18n: I18n,
is_dark: bool,
@@ -145,6 +143,21 @@ 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
@@ -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<TrayIconKind, SendHwnd>,
bubbles: HashMap<ProviderId, SendHwnd>,
snapshots: HashMap<ProviderId, ProviderUiState>,
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();
}
+81 -35
View File
@@ -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<f64>,
@@ -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<Callbacks> = 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: FnOnce(&Callbacks)>(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<f64>,
@@ -339,7 +386,7 @@ pub fn position(hwnd: HWND) -> Option<(i32, i32)> {
Some((r.left, r.top))
}
pub fn model(hwnd: HWND) -> Option<TrayIconKind> {
pub fn model(hwnd: HWND) -> Option<ProviderId> {
lock_bubbles()
.get(&(hwnd.0 as isize))
.map(|b| b.model)
@@ -354,7 +401,7 @@ pub fn size_logical(hwnd: HWND) -> Option<i32> {
// ---------- State ----------
struct BubbleState {
model: TrayIconKind,
model: ProviderId,
size_logical: i32,
dpi: u32,
session_pct: Option<f64>,
@@ -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<f64>,
session_text: String,
weekly_pct: Option<f64>,
@@ -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 {
+7 -13
View File
@@ -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<TrayIconKind> {
pub fn current_model() -> Option<ProviderId> {
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<PanelData> {
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
}
+3 -4
View File
@@ -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,
+1 -8
View File
@@ -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> {
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;
+46 -20
View File
@@ -133,7 +133,7 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
fn bucket_to_window(bucket: Bucket) -> 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<i64>) -> 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<SystemTime> {
let trimmed = s.split('Z').next().unwrap_or(s);
let trimmed = trimmed.split('+').next().unwrap_or(trimmed);
let trimmed = trimmed.split('-').take(3).collect::<Vec<_>>().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<SystemTime> {
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<SystemTime> {
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<SystemTime> {
}
}
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 {
+1 -1
View File
@@ -84,7 +84,7 @@ fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
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)),
}
}
+6 -2
View File
@@ -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",