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:
tiennm99 committed 2026-05-16 10:09:43 +07:00
1 parent c0f3e3f860
commit aa6217d2cf
72 files changed
+6265 -3788

No files matched your search

+232
View File
@@ -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>,
}
+125
View File
@@ -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))
}
}
+51
View File
@@ -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))
}
+36
View File
@@ -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>;
}
+123
View File
@@ -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()
}
+61
View File
@@ -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
}
}
+44
View File
@@ -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,
}