mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-10 06:20:24 +00:00
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).
128 lines
3.6 KiB
Rust
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))
|
|
}
|
|
}
|