Files
claude-code-usage-bubble/src/usage/chatgpt.rs
T
tiennm99 e243c589a8 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).
2026-05-23 11:22:40 +07:00

128 lines
3.6 KiB
Rust

// Codex (ChatGPT) usage provider.
//
// Single endpoint: `/backend-api/wham/usage`. Response shape includes
// `rate_limit.{primary_window,secondary_window}.{used_percent,reset_at}`.
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use crate::creds::Locator;
use crate::net::Client;
use crate::usage::{Error, ProviderId, UsageProvider, UsageWindows, Window};
const USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
pub struct ChatGptProvider {
locator: Locator,
}
impl ChatGptProvider {
pub fn new(locator: Locator) -> Self {
Self { locator }
}
pub fn locator(&self) -> &Locator {
&self.locator
}
}
impl UsageProvider for ChatGptProvider {
fn id(&self) -> ProviderId {
ProviderId::ChatGpt
}
fn poll(&mut self, http: &Client) -> Result<UsageWindows, Error> {
let source = self.locator.first_available().ok_or(Error::NoCredentials)?;
let token = source.read()?;
let mut req = http
.get(USAGE_URL)
.header("Authorization", &format!("Bearer {}", token.access_token))
.header("User-Agent", "codex-cli");
if let Some(account_id) = token.account_id.as_deref().filter(|s| !s.is_empty()) {
req = req.header("ChatGPT-Account-Id", account_id);
}
let resp = match req.send() {
Ok(r) => r,
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
return Err(Error::AuthRequired);
}
Err(e) => return Err(Error::Network(e)),
};
if resp.status() == 401 || resp.status() == 403 {
return Err(Error::AuthRequired);
}
if !(200..300).contains(&resp.status()) {
return Err(Error::BadResponse(format!(
"Codex usage endpoint returned {}",
resp.status()
)));
}
let body: Envelope = resp
.json()
.map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?;
envelope_to_windows(body)
.ok_or_else(|| Error::BadResponse("missing rate_limit section".into()))
}
}
fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
let rl = envelope.rate_limit.flatten_box()?;
Some(UsageWindows {
primary: rl
.primary_window
.flatten_box()
.map(window_from)
.unwrap_or_default(),
secondary: rl
.secondary_window
.flatten_box()
.map(window_from)
.unwrap_or_default(),
})
}
fn window_from(w: ApiWindow) -> Window {
Window {
utilization: w.used_percent.clamp(0.0, 100.0),
resets_at: unix_to_systemtime(Some(w.reset_at)),
}
}
fn unix_to_systemtime(secs: Option<i64>) -> Option<SystemTime> {
let s = secs?;
if s < 0 {
return None;
}
Some(UNIX_EPOCH + Duration::from_secs(s as u64))
}
#[derive(Deserialize)]
struct Envelope {
rate_limit: Option<Option<Box<RateLimit>>>,
}
#[derive(Deserialize)]
struct RateLimit {
primary_window: Option<Option<Box<ApiWindow>>>,
secondary_window: Option<Option<Box<ApiWindow>>>,
}
#[derive(Deserialize)]
struct ApiWindow {
used_percent: f64,
reset_at: i64,
}
// Helpers used to make `Option<Option<Box<…>>>` flatten cleanly. We can't
// reuse the std `Option::flatten` name — the inherent method (which returns
// `Option<Box<T>>`) would shadow this trait method.
trait FlattenBoxed<T> {
fn flatten_box(self) -> Option<T>;
}
impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
fn flatten_box(self) -> Option<T> {
self.and_then(|inner| inner.map(|b| *b))
}
}