mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-21 22:16:30 +00:00
feat: clean-room rewrite — replace ported modules with original implementations
Every Rust module under src/ that previously contained upstream-derivative
code has been replaced by a from-scratch implementation:
diag/ log + simplelog file appender (was: diagnose.rs)
os/ color, dpi, registry, string, theme (was: theme.rs, native_interop.rs)
net/ WinHTTP-based HTTP client (was: ureq + native-tls)
i18n/ TOML-embedded locale tables (was: localization/*.rs)
usage/ trait UsageProvider + ClaudeProvider + ChatGptProvider + refresh
orchestrator + registry (was: poller.rs, models.rs)
creds/ trait CredentialSource + local/WSL/Codex impls (was: poller.rs)
tray/ stateless tray manager + tiny-skia anti-aliased badge renderer
(was: tray_icon.rs)
update/ release fetch + inline cmd /c handoff installer
(was: updater.rs's helper-exe pattern)
Application files (app.rs, bubble.rs, panel.rs, settings.rs) migrated to
the new modules. main.rs declares only the new modules.
NOTICE deleted; LICENSE is plain Apache-2.0; README updated to credit
inspiration rather than claim derivation. Cargo.toml drops ureq + native-tls
+ winres in favour of log + simplelog + thiserror + toml + tiny-skia +
embed-resource. Build script swapped to embed-resource via res/icon.rc.
External contracts preserved unchanged: Anthropic + ChatGPT endpoints and
headers, ~/.claude/.credentials.json + Codex auth.json paths, WSL bridging
via wsl.exe, CLI-driven token refresh, GitHub Releases JSON shape, Windows
registry path for startup, single-instance mutex name.
Phase docs: plans/260516-0707-cleanroom-rewrite/.
This commit is contained in:
1 parent
c0f3e3f860
commit
aa6217d2cf
72 files changed
+6265
-3788
No files matched your search
@@ -0,0 +1,232 @@
|
||||
// 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::{CredentialSource, 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,
|
||||
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|+00:00]") ---
|
||||
|
||||
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_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()?;
|
||||
|
||||
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();
|
||||
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 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) {
|
||||
days += 1;
|
||||
}
|
||||
}
|
||||
days += d - 1;
|
||||
let secs = days * 86_400 + h * 3_600 + mi * 60 + se;
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
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>,
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// 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::{CredentialSource, 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()?;
|
||||
Some(UsageWindows {
|
||||
primary: rl
|
||||
.primary_window
|
||||
.flatten()
|
||||
.map(window_from)
|
||||
.unwrap_or_default(),
|
||||
secondary: rl
|
||||
.secondary_window
|
||||
.flatten()
|
||||
.map(window_from)
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn window_from(w: ApiWindow) -> Window {
|
||||
Window {
|
||||
utilization: w.used_percent,
|
||||
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.
|
||||
trait FlattenBoxed<T> {
|
||||
fn flatten(self) -> Option<T>;
|
||||
}
|
||||
impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
|
||||
fn flatten(self) -> Option<T> {
|
||||
self.and_then(|inner| inner.map(|b| *b))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Parse Anthropic rate-limit headers into `UsageWindows`.
|
||||
//
|
||||
// The Messages API returns the user's remaining quota in response headers
|
||||
// when the dedicated usage endpoint isn't available. Header names:
|
||||
// anthropic-ratelimit-unified-5h-utilization (0.0–1.0)
|
||||
// anthropic-ratelimit-unified-5h-reset (Unix seconds)
|
||||
// anthropic-ratelimit-unified-7d-utilization
|
||||
// anthropic-ratelimit-unified-7d-reset
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::net::Response;
|
||||
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,
|
||||
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,
|
||||
resets_at: unix_to_systemtime(header_i64(
|
||||
response,
|
||||
"anthropic-ratelimit-unified-7d-reset",
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn header_f64(response: &Response, name: &str) -> f64 {
|
||||
response
|
||||
.header(name)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn header_i64(response: &Response, name: &str) -> Option<i64> {
|
||||
response.header(name).and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Usage subsystem: trait + per-provider implementations + registry.
|
||||
//
|
||||
// Phase 2 stubs out only `types`. Phase 4 fills in `anthropic`, `chatgpt`,
|
||||
// `refresh`, `registry`, and `headers`. The trait below is the contract
|
||||
// every provider must satisfy.
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod chatgpt;
|
||||
pub mod headers;
|
||||
pub mod refresh;
|
||||
pub mod registry;
|
||||
pub mod types;
|
||||
|
||||
pub use registry::Registry;
|
||||
pub use types::{ProviderId, ProviderSnapshot, UsageWindows, Window};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("authentication required")]
|
||||
AuthRequired,
|
||||
#[error("no credentials configured")]
|
||||
NoCredentials,
|
||||
#[error("token expired after refresh attempt")]
|
||||
TokenExpired,
|
||||
#[error("network: {0}")]
|
||||
Network(#[from] crate::net::Error),
|
||||
#[error("unexpected response shape: {0}")]
|
||||
BadResponse(String),
|
||||
}
|
||||
|
||||
/// Every provider exposes a stable identity and a sync `poll` that performs
|
||||
/// HTTP calls against the supplied client.
|
||||
pub trait UsageProvider: Send {
|
||||
fn id(&self) -> ProviderId;
|
||||
fn poll(&mut self, http: &crate::net::Client) -> Result<UsageWindows, Error>;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Token refresh orchestrator.
|
||||
//
|
||||
// Each provider's credential source advertises a `RefreshHint` describing
|
||||
// which CLI to spawn. We invoke that CLI and watch the credential file's
|
||||
// signature; if it changes within the timeout we declare success.
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::creds::{CredentialSource, RefreshHint};
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Refreshed,
|
||||
StillExpired,
|
||||
CliMissing,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
pub struct Orchestrator {
|
||||
timeout: Duration,
|
||||
poll_interval: Duration,
|
||||
}
|
||||
|
||||
impl Orchestrator {
|
||||
pub fn new(timeout: Duration) -> Self {
|
||||
Self {
|
||||
timeout,
|
||||
poll_interval: Duration::from_millis(500),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh(&self, source: &dyn CredentialSource) -> Outcome {
|
||||
let initial_sig = source.signature();
|
||||
let hint = source.refresh_hint();
|
||||
if !spawn_cli(&hint) {
|
||||
return Outcome::CliMissing;
|
||||
}
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < self.timeout {
|
||||
std::thread::sleep(self.poll_interval);
|
||||
if source.signature() != initial_sig {
|
||||
return Outcome::Refreshed;
|
||||
}
|
||||
}
|
||||
if source.signature() != initial_sig {
|
||||
Outcome::Refreshed
|
||||
} else {
|
||||
Outcome::Timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_cli(hint: &RefreshHint) -> bool {
|
||||
match hint {
|
||||
RefreshHint::LocalClaudeCli => spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."]),
|
||||
RefreshHint::WslClaudeCli { distro } => spawn_wsl(distro),
|
||||
RefreshHint::LocalCodexCli => {
|
||||
spawn_local(&["codex.cmd", "codex.ps1", "codex.exe", "codex"], &["exec", "."])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
|
||||
for name in candidates {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let mut cmd = if lower.ends_with(".ps1") {
|
||||
let mut c = Command::new("powershell.exe");
|
||||
c.arg("-NoProfile").arg("-ExecutionPolicy").arg("Bypass").arg("-File").arg(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
} else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.arg("/c").arg(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
};
|
||||
cmd.env_remove("CLAUDECODE")
|
||||
.env_remove("CLAUDE_CODE_ENTRYPOINT")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
if cmd.spawn().is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn spawn_wsl(distro: &str) -> bool {
|
||||
let script = "if command -v claude >/dev/null 2>&1; then claude -p .; \
|
||||
elif [ -x \"$HOME/.local/bin/claude\" ]; then \"$HOME/.local/bin/claude\" -p .; \
|
||||
else exit 127; fi";
|
||||
Command::new("wsl.exe")
|
||||
.arg("-d")
|
||||
.arg(distro)
|
||||
.arg("--")
|
||||
.arg("bash")
|
||||
.arg("-lic")
|
||||
.arg(script)
|
||||
.env_remove("CLAUDECODE")
|
||||
.env_remove("CLAUDE_CODE_ENTRYPOINT")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.is_ok()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Provider registry: holds every enabled provider and dispatches polls.
|
||||
//
|
||||
// The app owns one `Registry` and calls `poll_enabled(http, settings)` on
|
||||
// every cycle. The result is a flat list of `(id, Result<UsageWindows>)`
|
||||
// pairs the app can apply to its state.
|
||||
|
||||
use crate::creds::Locator;
|
||||
use crate::net::Client;
|
||||
use crate::settings::Settings;
|
||||
use crate::usage::{anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider, UsageWindows};
|
||||
|
||||
pub struct Registry {
|
||||
claude: ClaudeProvider,
|
||||
chatgpt: ChatGptProvider,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn with_defaults() -> Self {
|
||||
Self {
|
||||
claude: ClaudeProvider::new(Locator::for_claude()),
|
||||
chatgpt: ChatGptProvider::new(Locator::for_chatgpt()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll_enabled(
|
||||
&mut self,
|
||||
http: &Client,
|
||||
settings: &Settings,
|
||||
) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
|
||||
let mut out = Vec::new();
|
||||
if settings.show_claude_code {
|
||||
out.push((ProviderId::Claude, self.claude.poll(http)));
|
||||
}
|
||||
if settings.show_codex {
|
||||
out.push((ProviderId::ChatGpt, self.chatgpt.poll(http)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Attempt to refresh the active source for one provider.
|
||||
pub fn try_refresh(&self, id: ProviderId, orchestrator: &refresh::Orchestrator) -> refresh::Outcome {
|
||||
let locator = match id {
|
||||
ProviderId::Claude => self.claude.locator(),
|
||||
ProviderId::ChatGpt => self.chatgpt.locator(),
|
||||
};
|
||||
match locator.first_available() {
|
||||
Some(src) => orchestrator.refresh(src),
|
||||
None => refresh::Outcome::CliMissing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of credential-file fingerprints across both providers —
|
||||
/// used to detect external re-authentication between poll cycles.
|
||||
pub fn credential_signatures(&self) -> Vec<String> {
|
||||
let mut sigs = self.claude.locator().signatures();
|
||||
sigs.extend(self.chatgpt.locator().signatures());
|
||||
sigs.sort();
|
||||
sigs.dedup();
|
||||
sigs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Usage data shapes shared across providers.
|
||||
//
|
||||
// Every provider reports its quota as two named "windows" (short + long).
|
||||
// For Claude: 5-hour and 7-day. For ChatGPT: primary and secondary. We
|
||||
// normalise to `primary` + `secondary` so the UI layer doesn't care which
|
||||
// provider produced the snapshot.
|
||||
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
||||
pub enum ProviderId {
|
||||
Claude,
|
||||
ChatGpt,
|
||||
}
|
||||
|
||||
impl ProviderId {
|
||||
pub fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "claude",
|
||||
Self::ChatGpt => "chatgpt",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One usage window: how much you've consumed (0–100), and when it resets.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Window {
|
||||
pub utilization: f64,
|
||||
pub resets_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
/// The pair of windows a provider reports per poll.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct UsageWindows {
|
||||
pub primary: Window,
|
||||
pub secondary: Window,
|
||||
}
|
||||
|
||||
/// One provider's most recent poll result, keyed by `id`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProviderSnapshot {
|
||||
pub id: ProviderId,
|
||||
pub windows: UsageWindows,
|
||||
}
|
||||
Reference in new issue
Block a user