mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-09 16:17:42 +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).
259 lines
7.9 KiB
Rust
259 lines
7.9 KiB
Rust
// Claude (Anthropic) usage provider.
|
|
//
|
|
// Two HTTP paths: the dedicated `/api/oauth/usage` endpoint (preferred,
|
|
// returns structured JSON with ISO 8601 reset times) and a fallback POST
|
|
// to `/v1/messages` that exposes rate-limit data via response headers.
|
|
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use serde::Deserialize;
|
|
|
|
use crate::creds::Locator;
|
|
use crate::net::Client;
|
|
use crate::usage::{headers, Error, ProviderId, UsageProvider, UsageWindows, Window};
|
|
|
|
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
|
const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
|
|
const BETA_HEADER: &str = "oauth-2025-04-20";
|
|
const API_VERSION: &str = "2023-06-01";
|
|
|
|
const PROBE_MODELS: &[&str] = &[
|
|
"claude-3-haiku-20240307",
|
|
"claude-haiku-4-5-20251001",
|
|
];
|
|
|
|
pub struct ClaudeProvider {
|
|
locator: Locator,
|
|
}
|
|
|
|
impl ClaudeProvider {
|
|
pub fn new(locator: Locator) -> Self {
|
|
Self { locator }
|
|
}
|
|
|
|
pub fn locator(&self) -> &Locator {
|
|
&self.locator
|
|
}
|
|
}
|
|
|
|
impl UsageProvider for ClaudeProvider {
|
|
fn id(&self) -> ProviderId {
|
|
ProviderId::Claude
|
|
}
|
|
|
|
fn poll(&mut self, http: &Client) -> Result<UsageWindows, Error> {
|
|
let source = self.locator.first_available().ok_or(Error::NoCredentials)?;
|
|
let token = source.read()?;
|
|
if token_is_expired(token.expires_at_unix_ms) {
|
|
return Err(Error::AuthRequired);
|
|
}
|
|
fetch_with_fallback(http, &token.access_token)
|
|
}
|
|
}
|
|
|
|
fn fetch_with_fallback(http: &Client, token: &str) -> Result<UsageWindows, Error> {
|
|
// First try the dedicated endpoint.
|
|
match try_usage_endpoint(http, token)? {
|
|
Some(windows) if has_reset_times(&windows) => return Ok(windows),
|
|
Some(partial) => {
|
|
// Got percentages but no reset times — fill them in from messages.
|
|
if let Ok(fallback) = try_messages_endpoint(http, token) {
|
|
return Ok(merge_resets(partial, fallback));
|
|
}
|
|
return Ok(partial);
|
|
}
|
|
None => {}
|
|
}
|
|
try_messages_endpoint(http, token)
|
|
}
|
|
|
|
fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>, Error> {
|
|
let resp = match http
|
|
.get(USAGE_URL)
|
|
.header("Authorization", &format!("Bearer {token}"))
|
|
.header("anthropic-beta", BETA_HEADER)
|
|
.send()
|
|
{
|
|
Ok(r) => r,
|
|
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
|
|
return Err(Error::AuthRequired);
|
|
}
|
|
Err(_) => return Ok(None),
|
|
};
|
|
if resp.status() == 401 || resp.status() == 403 {
|
|
return Err(Error::AuthRequired);
|
|
}
|
|
if !(200..300).contains(&resp.status()) {
|
|
return Ok(None);
|
|
}
|
|
let body: OauthUsage = match resp.json() {
|
|
Ok(b) => b,
|
|
Err(_) => return Ok(None),
|
|
};
|
|
let primary = body.five_hour.map(bucket_to_window).unwrap_or_default();
|
|
let secondary = body.seven_day.map(bucket_to_window).unwrap_or_default();
|
|
Ok(Some(UsageWindows { primary, secondary }))
|
|
}
|
|
|
|
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> {
|
|
for model in PROBE_MODELS {
|
|
let body = serde_json::json!({
|
|
"model": model,
|
|
"max_tokens": 1,
|
|
"messages": [{"role": "user", "content": "."}],
|
|
});
|
|
let resp = match http
|
|
.post(MESSAGES_URL)
|
|
.header("Authorization", &format!("Bearer {token}"))
|
|
.header("anthropic-version", API_VERSION)
|
|
.header("anthropic-beta", BETA_HEADER)
|
|
.json_body(&body)
|
|
.and_then(|rb| rb.send())
|
|
{
|
|
Ok(r) => r,
|
|
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
|
|
return Err(Error::AuthRequired);
|
|
}
|
|
Err(_) => continue,
|
|
};
|
|
if resp.status() == 401 || resp.status() == 403 {
|
|
return Err(Error::AuthRequired);
|
|
}
|
|
// Even an error response from Messages can carry rate-limit headers.
|
|
if resp.header("anthropic-ratelimit-unified-5h-utilization").is_some()
|
|
|| resp.header("anthropic-ratelimit-unified-7d-utilization").is_some()
|
|
{
|
|
return Ok(headers::parse_anthropic(&resp));
|
|
}
|
|
}
|
|
Err(Error::BadResponse(
|
|
"no rate-limit headers in messages response".into(),
|
|
))
|
|
}
|
|
|
|
fn bucket_to_window(bucket: Bucket) -> Window {
|
|
Window {
|
|
utilization: bucket.utilization.clamp(0.0, 100.0),
|
|
resets_at: bucket.resets_at.as_deref().and_then(parse_iso8601),
|
|
}
|
|
}
|
|
|
|
fn has_reset_times(w: &UsageWindows) -> bool {
|
|
w.primary.resets_at.is_some() && w.secondary.resets_at.is_some()
|
|
}
|
|
|
|
fn merge_resets(mut primary: UsageWindows, fallback: UsageWindows) -> UsageWindows {
|
|
if primary.primary.resets_at.is_none() {
|
|
primary.primary.resets_at = fallback.primary.resets_at;
|
|
}
|
|
if primary.secondary.resets_at.is_none() {
|
|
primary.secondary.resets_at = fallback.secondary.resets_at;
|
|
}
|
|
primary
|
|
}
|
|
|
|
fn token_is_expired(expires_at_unix_ms: Option<i64>) -> bool {
|
|
let Some(exp_ms) = expires_at_unix_ms else {
|
|
return false;
|
|
};
|
|
let now_ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as i64)
|
|
.unwrap_or(0);
|
|
now_ms >= exp_ms
|
|
}
|
|
|
|
// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM]") ---
|
|
|
|
fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
|
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 {
|
|
return None;
|
|
}
|
|
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_parts: Vec<&str> = time.split(':').collect();
|
|
if time_parts.len() != 3 {
|
|
return None;
|
|
}
|
|
let h: u64 = time_parts[0].parse().ok()?;
|
|
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 };
|
|
}
|
|
for month in 1..mo {
|
|
days += DAYS_IN_MONTH[month as usize];
|
|
if month == 2 && is_leap(y) {
|
|
days += 1;
|
|
}
|
|
}
|
|
days += d - 1;
|
|
|
|
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 {
|
|
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
|
}
|
|
|
|
// --- JSON shape ---
|
|
|
|
#[derive(Deserialize)]
|
|
struct OauthUsage {
|
|
five_hour: Option<Bucket>,
|
|
seven_day: Option<Bucket>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct Bucket {
|
|
utilization: f64,
|
|
resets_at: Option<String>,
|
|
}
|