feat: port the data layer to ureq with the Windows system proxy

Replace the WinHTTP client with a ureq agent that keeps the same
Client/Response surface: status errors come back as responses, gzip,
60 s connect / 30 s receive / 120 s total timeouts, rustls with the
platform verifier and a per-request User-Agent override.

Environment proxies come from ureq. On Windows, when none is set, the
static Internet Settings proxy is read from HKCU and parsed by a pure
core module (per-scheme ProxyServer, ProxyOverride globs and <local>);
bypassed hosts go through a direct agent. PAC/WPAD is not evaluated.

The usage providers and credential sources compile again on every
platform. Refresh keeps the Windows candidates and CREATE_NO_WINDOW;
Unix spawns claude/codex from PATH plus the usual per-user install
directories and reaps the child on a detached thread. An empty
CODEX_HOME now counts as unset and a float expiresAt is truncated
instead of ignored.

Fixture tests cover the OAuth usage body, rate-limit headers (including
a 429 over loopback), the ChatGPT envelope, ISO 8601 edge cases,
credential JSON and proxy strings. An ignored live_poll test polls the
real providers.
This commit is contained in:
tiennm99 committed 2026-10-21 12:41:08 +07:00
1 parent 10c43d76af
commit 9e2666ff0e
34 files changed
+1686 -512

No files matched your search

Generated
+140 -5
View File
@@ -509,6 +509,7 @@ name = "claude-code-usage-bubble"
version = "0.5.0"
dependencies = [
"dirs 6.0.0",
"flate2",
"libc",
"libloading 0.9.0",
"log",
@@ -526,6 +527,7 @@ dependencies = [
"ureq",
"webview2-com",
"windows",
"windows-registry",
"x11rb",
]
@@ -1891,6 +1893,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -2462,6 +2494,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2975,6 +3013,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -2984,6 +3034,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.16"
@@ -3010,6 +3087,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3067,6 +3153,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.38.0"
@@ -3299,6 +3408,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simplelog"
version = "0.12.2"
@@ -3546,7 +3671,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3602,7 +3727,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3756,7 +3881,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3779,7 +3904,7 @@ checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -4271,6 +4396,7 @@ dependencies = [
"percent-encoding",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
"webpki-roots",
@@ -4548,6 +4674,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.10"
@@ -4974,7 +5109,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
+7 -1
View File
@@ -19,7 +19,8 @@ path = "src/main.rs"
tauri = { version = "2", features = ["tray-icon", "macos-private-api"] }
tauri-plugin-single-instance = "2"
tauri-plugin-autostart = "2"
ureq = "3"
# rustls with the OS verifier keeps corporate roots trusted; gzip is a default.
ureq = { version = "3", features = ["platform-verifier"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6"
@@ -51,6 +52,11 @@ windows = { version = "0.62", features = [
] }
# Same major as tauri's; used for the WebView2 runtime check.
webview2-com = "0.39"
# Same major as the copy auto-launch pulls; reads the system proxy.
windows-registry = "0.6"
[dev-dependencies]
flate2 = "1"
[build-dependencies]
tauri-build = { version = "2", features = [] }
+1
View File
@@ -5,6 +5,7 @@ pub mod geom;
pub mod i18n;
pub mod menu_model;
pub mod payload;
pub mod proxy;
pub mod settings;
pub mod state;
pub mod usage_color;
+221
View File
@@ -0,0 +1,221 @@
//! Parsing of the Windows "Internet Settings" static proxy.
//!
//! The registry reader lives in `platform::windows`; everything here is pure
//! so it can be tested on any host. PAC/WPAD scripts are not evaluated.
/// A static system proxy and the hosts that bypass it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SystemProxy {
/// Proxy URI such as `http://proxy.corp:8080`, ready for `ureq::Proxy::new`.
pub url: String,
/// `ProxyOverride` entries, trimmed and non-empty.
pub bypass: Vec<String>,
}
impl SystemProxy {
/// Whether a request to `host` must skip the proxy.
pub fn bypasses(&self, host: &str) -> bool {
bypass_matches(&self.bypass, host)
}
}
/// Build the proxy from the raw `ProxyEnable`, `ProxyServer` and
/// `ProxyOverride` registry values. Returns `None` unless the proxy is
/// enabled and the server string names a usable entry.
pub fn from_registry_values(
enable: Option<u32>,
server: Option<&str>,
proxy_override: Option<&str>,
) -> Option<SystemProxy> {
if enable != Some(1) {
return None;
}
let url = parse_proxy_server(server?)?;
Some(SystemProxy {
url,
bypass: parse_override(proxy_override.unwrap_or_default()),
})
}
/// Pick the proxy for HTTPS traffic out of a `ProxyServer` value.
///
/// A per-scheme value (`http=h:p;https=h:p;…`) uses the `https=` entry, else
/// the `http=` entry. A bare `h:p` applies to every scheme. The result always
/// carries a scheme, defaulting to `http://` (a CONNECT proxy).
pub fn parse_proxy_server(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
let target = if value.contains('=') {
let entries: Vec<(String, &str)> = value
.split(';')
.filter_map(|entry| entry.split_once('='))
.map(|(scheme, addr)| (scheme.trim().to_ascii_lowercase(), addr.trim()))
.filter(|(_, addr)| !addr.is_empty())
.collect();
let pick = |scheme: &str| {
entries
.iter()
.find(|(s, _)| s == scheme)
.map(|(_, addr)| *addr)
};
pick("https").or_else(|| pick("http"))?
} else {
value
};
if target.contains("://") {
Some(target.to_string())
} else {
Some(format!("http://{target}"))
}
}
/// Split a `ProxyOverride` value on `;` into trimmed, non-empty entries.
pub fn parse_override(value: &str) -> Vec<String> {
value
.split(';')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_string)
.collect()
}
/// Whether `host` matches any bypass entry. Entries are case-insensitive
/// globs where `*` matches any run of characters; `<local>` matches every
/// host without a dot.
pub fn bypass_matches(entries: &[String], host: &str) -> bool {
let host = host.to_ascii_lowercase();
entries.iter().any(|entry| {
if entry.eq_ignore_ascii_case("<local>") {
!host.contains('.')
} else {
glob_match(&entry.to_ascii_lowercase(), &host)
}
})
}
/// Iterative `*`-only glob match with single-star backtracking.
fn glob_match(pattern: &str, text: &str) -> bool {
let p = pattern.as_bytes();
let t = text.as_bytes();
let (mut pi, mut ti) = (0, 0);
let mut star: Option<(usize, usize)> = None;
while ti < t.len() {
if pi < p.len() && p[pi] == b'*' {
star = Some((pi, ti));
pi += 1;
} else if pi < p.len() && p[pi] == t[ti] {
pi += 1;
ti += 1;
} else if let Some((sp, st)) = star {
pi = sp + 1;
ti = st + 1;
star = Some((sp, st + 1));
} else {
return false;
}
}
p[pi..].iter().all(|&c| c == b'*')
}
#[cfg(test)]
mod tests {
use super::*;
fn entries(value: &str) -> Vec<String> {
parse_override(value)
}
#[test]
fn per_scheme_server_prefers_https_entry() {
assert_eq!(
parse_proxy_server("http=web:80;https=secure:8443;ftp=f:21").as_deref(),
Some("http://secure:8443")
);
}
#[test]
fn per_scheme_server_without_https_uses_http_entry() {
assert_eq!(
parse_proxy_server("ftp=f:21; HTTP=web:3128").as_deref(),
Some("http://web:3128")
);
}
#[test]
fn per_scheme_server_without_http_entries_is_none() {
assert_eq!(parse_proxy_server("socks=s:1080"), None);
assert_eq!(parse_proxy_server("https=;http="), None);
}
#[test]
fn bare_server_applies_to_all_schemes() {
assert_eq!(
parse_proxy_server(" proxy.corp:8080 ").as_deref(),
Some("http://proxy.corp:8080")
);
assert_eq!(
parse_proxy_server("http://proxy.corp:8080").as_deref(),
Some("http://proxy.corp:8080")
);
assert_eq!(parse_proxy_server(" "), None);
}
#[test]
fn registry_values_need_enable_flag_and_server() {
assert_eq!(
from_registry_values(Some(0), Some("p:1"), Some("<local>")),
None
);
assert_eq!(from_registry_values(None, Some("p:1"), None), None);
assert_eq!(from_registry_values(Some(1), None, None), None);
assert_eq!(
from_registry_values(Some(1), Some("p:1"), Some("*.corp; <local> ;")),
Some(SystemProxy {
url: "http://p:1".into(),
bypass: vec!["*.corp".into(), "<local>".into()],
})
);
assert_eq!(
from_registry_values(Some(1), Some("p:1"), None).map(|p| p.bypass),
Some(Vec::new())
);
}
#[test]
fn override_wildcard_matches_subdomains_case_insensitively() {
let list = entries("*.anthropic.com");
assert!(bypass_matches(&list, "api.anthropic.com"));
assert!(bypass_matches(&list, "API.Anthropic.COM"));
assert!(!bypass_matches(&list, "anthropic.com"));
assert!(!bypass_matches(&list, "chatgpt.com"));
}
#[test]
fn override_exact_host_matches_only_that_host() {
let list = entries("chatgpt.com");
assert!(bypass_matches(&list, "chatgpt.com"));
assert!(!bypass_matches(&list, "api.chatgpt.com"));
assert!(!bypass_matches(&list, "chatgpt.com.evil"));
}
#[test]
fn override_local_matches_dotless_hosts() {
let list = entries("<LOCAL>");
assert!(bypass_matches(&list, "intranet"));
assert!(bypass_matches(&list, "localhost"));
assert!(!bypass_matches(&list, "api.anthropic.com"));
assert!(!bypass_matches(&list, "127.0.0.1"));
}
#[test]
fn override_globs_cover_prefixes_and_inner_stars() {
let list = entries("10.*;api.*.com");
assert!(bypass_matches(&list, "10.1.2.3"));
assert!(bypass_matches(&list, "api.anthropic.com"));
assert!(!bypass_matches(&list, "chatgpt.com"));
assert!(bypass_matches(&entries("*"), "anything.example"));
assert!(!bypass_matches(&[], "chatgpt.com"));
}
}
+80 -17
View File
@@ -5,6 +5,7 @@
// timestamp in the file; we discover expiry only when the server returns
// 401 or 403 to a poll.
use std::ffi::OsString;
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
@@ -17,16 +18,36 @@ pub struct LocalCodexCreds {
impl LocalCodexCreds {
pub fn detect() -> Option<Self> {
let path = if let Some(home) = std::env::var_os("CODEX_HOME") {
PathBuf::from(home).join("auth.json")
} else {
dirs::home_dir()?.join(".codex").join("auth.json")
};
let path = auth_path(std::env::var_os("CODEX_HOME"), dirs::home_dir())?;
let id = format!("codex:{}", path.display());
Some(Self { path, id })
}
}
/// `$CODEX_HOME/auth.json`, else `~/.codex/auth.json`. An empty `CODEX_HOME`
/// counts as unset (as in the Codex CLI), so it never resolves to a path
/// relative to the working directory.
fn auth_path(codex_home: Option<OsString>, home: Option<PathBuf>) -> Option<PathBuf> {
match codex_home.filter(|h| !h.is_empty()) {
Some(dir) => Some(PathBuf::from(dir).join("auth.json")),
None => Some(home?.join(".codex").join("auth.json")),
}
}
/// Parse the `auth.json` shape written by the Codex CLI.
fn parse_codex_json(content: &str) -> Result<super::Token, super::Error> {
let parsed: Envelope = serde_json::from_str(content)?;
let tokens = parsed.tokens.ok_or(super::Error::MissingField("tokens"))?;
if tokens.access_token.is_empty() {
return Err(super::Error::MissingField("access_token"));
}
Ok(super::Token {
access_token: tokens.access_token,
expires_at_unix_ms: None,
account_id: tokens.account_id,
})
}
impl super::CredentialSource for LocalCodexCreds {
fn id(&self) -> &str {
&self.id
@@ -34,18 +55,7 @@ impl super::CredentialSource for LocalCodexCreds {
fn read(&self) -> Result<super::Token, super::Error> {
let content = std::fs::read_to_string(&self.path)?;
let parsed: Envelope = serde_json::from_str(&content)?;
let tokens = parsed
.tokens
.ok_or(super::Error::MissingField("tokens"))?;
if tokens.access_token.is_empty() {
return Err(super::Error::MissingField("access_token"));
}
Ok(super::Token {
access_token: tokens.access_token,
expires_at_unix_ms: None,
account_id: tokens.account_id,
})
parse_codex_json(&content)
}
fn signature(&self) -> Option<String> {
@@ -74,3 +84,56 @@ struct Tokens {
access_token: String,
account_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::Error;
fn fixture(name: &str) -> String {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}
#[test]
fn full_auth_reads_token_and_account() {
let token = parse_codex_json(&fixture("codex-auth-full.json")).expect("token");
assert_eq!(token.access_token, "fixture-access-token");
assert_eq!(token.account_id.as_deref(), Some("fixture-account"));
assert_eq!(token.expires_at_unix_ms, None);
}
#[test]
fn null_tokens_is_a_missing_field() {
let err = parse_codex_json(&fixture("codex-auth-null-tokens.json")).unwrap_err();
assert!(matches!(err, Error::MissingField("tokens")), "{err}");
let err = parse_codex_json("{}").unwrap_err();
assert!(matches!(err, Error::MissingField("tokens")), "{err}");
}
#[test]
fn empty_or_missing_access_token_is_rejected() {
let err = parse_codex_json(&fixture("codex-auth-empty-access-token.json")).unwrap_err();
assert!(matches!(err, Error::MissingField("access_token")), "{err}");
let err = parse_codex_json(&fixture("codex-auth-missing-access-token.json")).unwrap_err();
assert!(matches!(err, Error::Json(_)), "{err}");
}
#[test]
fn codex_home_wins_unless_empty() {
let home = Some(PathBuf::from("/home/u"));
assert_eq!(
auth_path(Some(OsString::from("/opt/codex")), home.clone()),
Some(PathBuf::from("/opt/codex/auth.json"))
);
assert_eq!(
auth_path(Some(OsString::new()), home.clone()),
Some(PathBuf::from("/home/u/.codex/auth.json"))
);
assert_eq!(
auth_path(None, home),
Some(PathBuf::from("/home/u/.codex/auth.json"))
);
assert_eq!(auth_path(Some(OsString::new()), None), None);
}
}
+48 -1
View File
@@ -62,10 +62,57 @@ pub(crate) fn parse_claude_json(content: &str) -> Result<super::Token, super::Er
.and_then(|v| v.as_str())
.ok_or(super::Error::MissingField("accessToken"))?
.to_string();
let expires_at_unix_ms = oauth.get("expiresAt").and_then(|v| v.as_i64());
// The CLI writes integer milliseconds; a float is truncated rather than
// dropped, so a client-side expiry check still runs.
let expires_at_unix_ms = oauth
.get("expiresAt")
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)));
Ok(super::Token {
access_token,
expires_at_unix_ms,
account_id: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::Error;
fn fixture(name: &str) -> String {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}
#[test]
fn full_credentials_read_token_and_expiry() {
let token = parse_claude_json(&fixture("claude-credentials-full.json")).expect("token");
assert_eq!(token.access_token, "sk-ant-oat01-fixture");
assert_eq!(token.expires_at_unix_ms, Some(1_761_040_800_000));
assert_eq!(token.account_id, None);
}
#[test]
fn float_expiry_is_truncated_to_milliseconds() {
let token =
parse_claude_json(&fixture("claude-credentials-float-expiry.json")).expect("token");
assert_eq!(token.expires_at_unix_ms, Some(1_761_040_800_000));
}
#[test]
fn missing_expiry_is_none() {
let token = parse_claude_json(r#"{"claudeAiOauth":{"accessToken":"t","expiresAt":null}}"#)
.expect("token");
assert_eq!(token.expires_at_unix_ms, None);
}
#[test]
fn missing_fields_are_reported() {
let err = parse_claude_json(&fixture("claude-credentials-missing-token.json")).unwrap_err();
assert!(matches!(err, Error::MissingField("accessToken")), "{err}");
let err = parse_claude_json(&fixture("claude-credentials-missing-oauth.json")).unwrap_err();
assert!(matches!(err, Error::MissingField("claudeAiOauth")), "{err}");
let err = parse_claude_json("not json").unwrap_err();
assert!(matches!(err, Error::Json(_)), "{err}");
}
}
+3
View File
@@ -4,7 +4,10 @@
//! that items wired up incrementally do not trip `dead_code`.
pub mod core;
pub mod creds;
pub mod diag;
pub mod net;
pub mod platform;
pub mod usage;
use tauri::{RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent};
+490 -7
View File
@@ -1,9 +1,492 @@
// `net` namespace: HTTP client implementations.
//
// `winhttp` is the only backend right now (Windows-only app). It produces
// `Response` values that are cheap to inspect via `status`, `header`,
// `text`, and `json`.
//! Minimal blocking HTTP client on `ureq`.
//!
//! One [`Client`] is shared across poll workers. 4xx and 5xx responses come
//! back as ordinary [`Response`] values so providers can read the status and
//! the rate-limit headers of an error reply. Tokens and request headers are
//! never logged here.
pub mod winhttp;
use std::time::Duration;
pub use winhttp::{Client, Error, Response};
use serde::de::DeserializeOwned;
use serde::Serialize;
use ureq::http::{self, Uri};
use ureq::tls::{RootCerts, TlsConfig};
use ureq::{Agent, Proxy};
use crate::core::proxy::SystemProxy;
/// Default User-Agent; a per-request `User-Agent` header replaces it.
pub const USER_AGENT: &str = concat!("claude-code-usage-bubble/", env!("CARGO_PKG_VERSION"));
const CONNECT_TIMEOUT: Duration = Duration::from_secs(60);
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(30);
const GLOBAL_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("HTTP transport: {0}")]
Transport(String),
#[error("response was not valid UTF-8")]
Utf8,
#[error("JSON parse: {0}")]
Json(#[from] serde_json::Error),
/// Kept for the providers' 401/403 match arms. The client reports every
/// status as a `Response`, so it never builds this variant itself.
#[error("response status {0}")]
Status(u32),
}
impl From<ureq::Error> for Error {
fn from(err: ureq::Error) -> Self {
Error::Transport(err.to_string())
}
}
impl From<http::Error> for Error {
fn from(err: http::Error) -> Self {
Error::Transport(err.to_string())
}
}
/// The static system proxy that applies when no environment proxy is set.
///
/// Environment proxies (`HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, …) are read by
/// `ureq` itself and win over the system proxy. On Windows the fallback is the
/// `Internet Settings` proxy; other platforms have none.
pub fn system_proxy() -> Option<SystemProxy> {
if Proxy::try_from_env().is_some() {
return None;
}
#[cfg(windows)]
{
crate::platform::windows::system_proxy()
}
#[cfg(not(windows))]
{
None
}
}
/// A system-proxied agent plus the bypass rules that route around it.
struct Proxied {
agent: Agent,
proxy: SystemProxy,
}
pub struct Client {
/// Direct agent, or the environment-proxied one when an env proxy is set.
direct: Agent,
proxied: Option<Proxied>,
}
impl Client {
/// Create a client whose default User-Agent is `user_agent`.
pub fn new(user_agent: &str) -> Result<Self, Error> {
Self::build(user_agent, Proxy::try_from_env(), system_proxy())
}
/// A client that ignores every proxy, for loopback tests.
#[cfg(test)]
pub(crate) fn without_proxy(user_agent: &str) -> Self {
Self::build(user_agent, None, None).expect("client without proxy")
}
fn build(
user_agent: &str,
env_proxy: Option<Proxy>,
system: Option<SystemProxy>,
) -> Result<Self, Error> {
let direct = make_agent(user_agent, env_proxy);
let proxied = match system {
Some(proxy) => match Proxy::new(&proxy.url) {
Ok(p) => Some(Proxied {
agent: make_agent(user_agent, Some(p)),
proxy,
}),
Err(err) => {
// The proxy URL is not logged: it may carry credentials.
log::warn!("system proxy ignored, it is not a valid proxy URI: {err}");
None
}
},
None => None,
};
Ok(Self { direct, proxied })
}
/// The system proxy this client routes through, if any. The updater
/// reuses it so both paths reach the network the same way.
pub fn system_proxy(&self) -> Option<&SystemProxy> {
self.proxied.as_ref().map(|p| &p.proxy)
}
pub fn get<'a>(&'a self, url: &str) -> RequestBuilder<'a> {
RequestBuilder::new(self, http::Method::GET, url)
}
pub fn post<'a>(&'a self, url: &str) -> RequestBuilder<'a> {
RequestBuilder::new(self, http::Method::POST, url)
}
/// The agent for `uri`: direct when the host matches a bypass entry or no
/// system proxy is configured, proxied otherwise.
fn agent_for(&self, uri: &Uri) -> &Agent {
match &self.proxied {
Some(p) if !uri.host().is_some_and(|h| p.proxy.bypasses(h)) => &p.agent,
_ => &self.direct,
}
}
}
fn make_agent(user_agent: &str, proxy: Option<Proxy>) -> Agent {
let config = Agent::config_builder()
.http_status_as_error(false)
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_recv_response(Some(RECEIVE_TIMEOUT))
.timeout_recv_body(Some(RECEIVE_TIMEOUT))
.timeout_global(Some(GLOBAL_TIMEOUT))
.user_agent(user_agent)
.proxy(proxy)
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.build(),
)
.build();
Agent::new_with_config(config)
}
pub struct RequestBuilder<'a> {
client: &'a Client,
method: http::Method,
url: String,
headers: Vec<(String, String)>,
body: Option<Vec<u8>>,
}
impl<'a> RequestBuilder<'a> {
fn new(client: &'a Client, method: http::Method, url: &str) -> Self {
Self {
client,
method,
url: url.to_string(),
headers: Vec::new(),
body: None,
}
}
/// Add a header. A `User-Agent` header replaces the client default.
pub fn header(mut self, name: &str, value: &str) -> Self {
self.headers.push((name.to_string(), value.to_string()));
self
}
pub fn json_body<T: Serialize>(mut self, body: &T) -> Result<Self, Error> {
self.body = Some(serde_json::to_vec(body)?);
self.headers
.push(("Content-Type".into(), "application/json".into()));
Ok(self)
}
pub fn send(self) -> Result<Response, Error> {
let uri: Uri = self
.url
.parse()
.map_err(|err| Error::Transport(format!("invalid URL: {err}")))?;
let agent = self.client.agent_for(&uri);
let mut builder = http::Request::builder().method(self.method).uri(uri);
for (name, value) in &self.headers {
builder = builder.header(name.as_str(), value.as_str());
}
let response = match self.body {
Some(body) => agent.run(builder.body(body)?)?,
None => agent.run(builder.body(())?)?,
};
let status = u32::from(response.status().as_u16());
let headers = response
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_string(),
String::from_utf8_lossy(value.as_bytes()).into_owned(),
)
})
.collect();
let body = response.into_body().read_to_vec()?;
Ok(Response {
status,
headers,
body,
})
}
}
pub struct Response {
status: u32,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
impl Response {
/// Build a response by hand, for tests of code that inspects responses.
#[cfg(test)]
pub(crate) fn from_parts(status: u32, headers: &[(&str, &str)], body: &[u8]) -> Self {
Self {
status,
headers: headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
body: body.to_vec(),
}
}
pub fn status(&self) -> u32 {
self.status
}
/// First header named `name`, compared case-insensitively.
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
pub fn body(&self) -> &[u8] {
&self.body
}
pub fn text(&self) -> Result<&str, Error> {
std::str::from_utf8(&self.body).map_err(|_| Error::Utf8)
}
pub fn json<T: DeserializeOwned>(&self) -> Result<T, Error> {
Ok(serde_json::from_slice(&self.body)?)
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use std::time::Duration;
use super::test_server::serve_once;
use super::*;
fn direct_client() -> Client {
Client::without_proxy(USER_AGENT)
}
fn received(served: &test_server::Served) -> String {
served
.request
.recv_timeout(Duration::from_secs(10))
.expect("server saw the request")
}
#[test]
fn error_status_is_a_response_with_headers() {
let served = serve_once(
"429 Too Many Requests",
&[("anthropic-ratelimit-unified-5h-utilization", "0.5")],
b"{\"error\":\"rate_limited\"}".to_vec(),
);
let resp = direct_client()
.get(&format!("{}/v1/messages", served.base))
.send()
.expect("429 is not a transport error");
assert_eq!(resp.status(), 429);
assert_eq!(
resp.header("Anthropic-Ratelimit-Unified-5h-Utilization"),
Some("0.5")
);
assert_eq!(resp.text().expect("utf8"), "{\"error\":\"rate_limited\"}");
received(&served);
}
#[test]
fn sends_default_user_agent() {
let served = serve_once("200 OK", &[], Vec::new());
direct_client().get(&served.base).send().expect("send");
let request = received(&served).to_ascii_lowercase();
let expected = format!("user-agent: {}\r\n", USER_AGENT.to_ascii_lowercase());
assert!(request.contains(&expected), "{request}");
assert!(USER_AGENT.starts_with("claude-code-usage-bubble/"));
}
#[test]
fn per_request_user_agent_replaces_default() {
let served = serve_once("200 OK", &[], Vec::new());
direct_client()
.get(&served.base)
.header("User-Agent", "codex-cli")
.send()
.expect("send");
let request = received(&served).to_ascii_lowercase();
assert!(request.contains("user-agent: codex-cli\r\n"), "{request}");
assert_eq!(request.matches("user-agent:").count(), 1, "{request}");
}
#[test]
fn json_body_is_posted_with_content_type() {
let served = serve_once("200 OK", &[], b"{\"ok\":true}".to_vec());
let resp = direct_client()
.post(&served.base)
.json_body(&serde_json::json!({"max_tokens": 1}))
.and_then(RequestBuilder::send)
.expect("send");
let value: serde_json::Value = resp.json().expect("json");
assert_eq!(value["ok"], true);
let request = received(&served);
assert!(request.starts_with("POST / HTTP/1.1\r\n"), "{request}");
assert!(request
.to_ascii_lowercase()
.contains("content-type: application/json\r\n"));
assert!(request.ends_with("{\"max_tokens\":1}"), "{request}");
}
#[test]
fn gzip_body_is_decompressed() {
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(b"plain text").expect("compress");
let gz = encoder.finish().expect("finish");
let served = serve_once("200 OK", &[("Content-Encoding", "gzip")], gz);
let resp = direct_client().get(&served.base).send().expect("send");
assert_eq!(resp.body(), b"plain text");
let request = received(&served).to_ascii_lowercase();
assert!(request.contains("accept-encoding: gzip"), "{request}");
}
#[test]
fn transport_failure_is_an_error() {
let served = serve_once("200 OK", &[], Vec::new());
let base = served.base.clone();
// Free the port by completing the one request the server accepts.
direct_client().get(&base).send().expect("send");
received(&served);
let err = direct_client()
.get(&base)
.send()
.err()
.expect("closed port fails");
assert!(matches!(err, Error::Transport(_)), "{err}");
assert!(matches!(
direct_client().get("not a url").send(),
Err(Error::Transport(_))
));
}
#[test]
fn system_proxy_is_used_unless_the_host_is_bypassed() {
let proxy = serve_once("200 OK", &[], Vec::new());
let origin = serve_once("200 OK", &[], b"direct".to_vec());
let system = SystemProxy {
url: proxy.base.clone(),
bypass: Vec::new(),
};
let client = Client::build(USER_AGENT, None, Some(system.clone())).expect("client");
assert_eq!(client.system_proxy(), Some(&system));
// The fake proxy does not tunnel, so the request itself fails; what
// matters is that it went to the proxy as a CONNECT for the origin.
let _ = client.get(&format!("{}/x", origin.base)).send();
let request = received(&proxy);
let authority = origin.base.trim_start_matches("http://");
assert!(
request.starts_with(&format!("CONNECT {authority} HTTP/1.1")),
"{request}"
);
let bypassing = Client::build(
USER_AGENT,
None,
Some(SystemProxy {
url: proxy.base.clone(),
bypass: vec!["127.0.0.*".into()],
}),
)
.expect("client");
let resp = bypassing.get(&origin.base).send().expect("send");
assert_eq!(resp.body(), b"direct");
received(&origin);
}
#[test]
fn invalid_system_proxy_falls_back_to_direct() {
let client = Client::build(
USER_AGENT,
None,
Some(SystemProxy {
url: "http://bad host:notaport".into(),
bypass: Vec::new(),
}),
)
.expect("client");
assert_eq!(client.system_proxy(), None);
}
}
#[cfg(test)]
pub(crate) mod test_server {
//! A one-shot HTTP/1.1 server on loopback that returns a canned reply and
//! hands the raw request back to the test.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
pub struct Served {
/// `http://127.0.0.1:<port>`.
pub base: String,
/// Raw request head (request line plus headers), then the body.
pub request: mpsc::Receiver<String>,
}
/// Serve one request with `status`, `headers` and `body`.
pub fn serve_once(status: &str, headers: &[(&str, &str)], body: Vec<u8>) -> Served {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
let port = listener.local_addr().expect("local addr").port();
let mut head = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n", body.len());
for (k, v) in headers {
head.push_str(&format!("{k}: {v}\r\n"));
}
head.push_str("Connection: close\r\n\r\n");
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let Ok((stream, _)) = listener.accept() else {
return;
};
let mut reader = BufReader::new(stream);
let mut request = String::new();
let mut content_length = 0usize;
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
if let Some((k, v)) = line.split_once(':') {
if k.eq_ignore_ascii_case("content-length") {
content_length = v.trim().parse().unwrap_or(0);
}
}
request.push_str(&line);
if line == "\r\n" {
break;
}
}
let mut request_body = vec![0u8; content_length];
let _ = reader.read_exact(&mut request_body);
request.push_str(&String::from_utf8_lossy(&request_body));
let mut stream = reader.into_inner();
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(&body);
let _ = tx.send(request);
});
Served {
base: format!("http://127.0.0.1:{port}"),
request: rx,
}
}
}
-431
View File
@@ -1,431 +0,0 @@
// Minimal blocking HTTP client built on Win32 WinHTTP.
//
// One `Client` owns a session handle and is thread-safe (WinHTTP sessions
// can be used from multiple threads per MSDN). Each `send()` call manages
// its own connection + request handle lifetime via RAII guards so failures
// at any point clean up correctly.
//
// We deliberately do NOT use `WinHttpCrackUrl` — the small `parse_url`
// helper below is enough for the HTTPS URLs this app actually talks to and
// keeps the call sites simpler.
use std::ffi::c_void;
use std::ptr::null_mut;
use serde::de::DeserializeOwned;
use serde::Serialize;
use windows::core::PCWSTR;
use windows::Win32::Networking::WinHttp::*;
use crate::os::string::to_utf16_nul;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("WinHTTP call failed: {context}")]
Win { context: String },
#[error("invalid URL: {0}")]
Url(String),
#[error("response was not valid UTF-8")]
Utf8,
#[error("JSON parse: {0}")]
Json(#[from] serde_json::Error),
#[error("response status {0}")]
Status(u32),
}
pub struct Client {
session: SessionHandle,
}
// WinHTTP session handles are safe to use concurrently per the Microsoft docs
// (https://learn.microsoft.com/en-us/windows/win32/winhttp/winhttp-functions).
unsafe impl Send for Client {}
unsafe impl Sync for Client {}
impl Client {
/// Create a new HTTP client. `user_agent` is sent on every request.
pub fn new(user_agent: &str) -> Result<Self, Error> {
let ua = to_utf16_nul(user_agent);
let session = unsafe {
WinHttpOpen(
PCWSTR::from_raw(ua.as_ptr()),
WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
PCWSTR::null(),
PCWSTR::null(),
0,
)
};
if session.is_null() {
return Err(Error::Win {
context: "WinHttpOpen".into(),
});
}
// Ask WinHTTP to decompress gzip/deflate transparently so callers
// get plain bytes back from `Response::body()`. Best-effort; if it
// fails the request still works, callers just see raw compressed
// bytes on responses that opt-in to compression.
unsafe {
let flags: u32 = WINHTTP_DECOMPRESSION_FLAG_GZIP | WINHTTP_DECOMPRESSION_FLAG_DEFLATE;
let flag_bytes = flags.to_ne_bytes();
if let Err(e) = WinHttpSetOption(
Some(session as *const c_void),
WINHTTP_OPTION_DECOMPRESSION,
Some(&flag_bytes),
) {
log::warn!("WinHttpSetOption(DECOMPRESSION) failed: {e}");
}
}
Ok(Self {
session: SessionHandle(session),
})
}
pub fn get<'a>(&'a self, url: &str) -> RequestBuilder<'a> {
RequestBuilder::new(self, Method::Get, url)
}
pub fn post<'a>(&'a self, url: &str) -> RequestBuilder<'a> {
RequestBuilder::new(self, Method::Post, url)
}
}
#[derive(Clone, Copy)]
enum Method {
Get,
Post,
}
impl Method {
fn verb(self) -> &'static str {
match self {
Method::Get => "GET",
Method::Post => "POST",
}
}
}
pub struct RequestBuilder<'a> {
client: &'a Client,
method: Method,
url: String,
headers: Vec<(String, String)>,
body: Option<Vec<u8>>,
}
impl<'a> RequestBuilder<'a> {
fn new(client: &'a Client, method: Method, url: &str) -> Self {
Self {
client,
method,
url: url.to_string(),
headers: Vec::new(),
body: None,
}
}
pub fn header(mut self, name: &str, value: &str) -> Self {
self.headers.push((name.to_string(), value.to_string()));
self
}
pub fn json_body<T: Serialize>(mut self, body: &T) -> Result<Self, Error> {
self.body = Some(serde_json::to_vec(body)?);
self.headers
.push(("Content-Type".into(), "application/json".into()));
Ok(self)
}
pub fn send(self) -> Result<Response, Error> {
let parsed = parse_url(&self.url)?;
let host_w = to_utf16_nul(&parsed.host);
let path_w = to_utf16_nul(&parsed.path);
let verb_w = to_utf16_nul(self.method.verb());
let connect = unsafe {
WinHttpConnect(
self.client.session.0,
PCWSTR::from_raw(host_w.as_ptr()),
parsed.port,
0,
)
};
if connect.is_null() {
return Err(Error::Win {
context: "WinHttpConnect".into(),
});
}
let _connect_guard = HandleGuard(connect);
let flags = if parsed.secure {
WINHTTP_FLAG_SECURE
} else {
WINHTTP_OPEN_REQUEST_FLAGS(0)
};
let request = unsafe {
WinHttpOpenRequest(
connect,
PCWSTR::from_raw(verb_w.as_ptr()),
PCWSTR::from_raw(path_w.as_ptr()),
PCWSTR::null(),
PCWSTR::null(),
std::ptr::null::<PCWSTR>(),
flags,
)
};
if request.is_null() {
return Err(Error::Win {
context: "WinHttpOpenRequest".into(),
});
}
let _request_guard = HandleGuard(request);
// Combine headers into a single CRLF-separated string. The binding
// takes a UTF-16 slice; length is derived from the slice and no
// trailing NUL is required.
if !self.headers.is_empty() {
let header_str = self
.headers
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect::<Vec<_>>()
.join("\r\n");
let header_w: Vec<u16> = header_str.encode_utf16().collect();
unsafe {
WinHttpAddRequestHeaders(
request,
&header_w[..],
WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE,
)
}
.map_err(|e| Error::Win {
context: format!("WinHttpAddRequestHeaders: {e}"),
})?;
}
// Send body if present. dwTotalLength = body length; dwOptionalLength
// mirrors it for synchronous sends with the buffer included up front.
let body_bytes: &[u8] = self.body.as_deref().unwrap_or(&[]);
let body_ptr = if body_bytes.is_empty() {
None
} else {
Some(body_bytes.as_ptr() as *const c_void)
};
let body_len = body_bytes.len() as u32;
unsafe {
WinHttpSendRequest(request, None, body_ptr, body_len, body_len, 0)
}
.map_err(|e| Error::Win {
context: format!("WinHttpSendRequest: {e}"),
})?;
unsafe { WinHttpReceiveResponse(request, null_mut()) }.map_err(|e| Error::Win {
context: format!("WinHttpReceiveResponse: {e}"),
})?;
let status = query_status_code(request)?;
let headers = query_raw_headers(request)?;
let body = read_body(request)?;
Ok(Response {
status,
headers,
body,
})
}
}
pub struct Response {
status: u32,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
impl Response {
pub fn status(&self) -> u32 {
self.status
}
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
pub fn body(&self) -> &[u8] {
&self.body
}
pub fn text(&self) -> Result<&str, Error> {
std::str::from_utf8(&self.body).map_err(|_| Error::Utf8)
}
pub fn json<T: DeserializeOwned>(&self) -> Result<T, Error> {
Ok(serde_json::from_slice(&self.body)?)
}
}
// ---------- Low-level helpers ----------
struct SessionHandle(*mut c_void);
impl Drop for SessionHandle {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe {
let _ = WinHttpCloseHandle(self.0);
}
}
}
}
struct HandleGuard(*mut c_void);
impl Drop for HandleGuard {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe {
let _ = WinHttpCloseHandle(self.0);
}
}
}
}
fn query_status_code(request: *mut c_void) -> Result<u32, Error> {
let mut status: u32 = 0;
let mut size: u32 = std::mem::size_of::<u32>() as u32;
unsafe {
WinHttpQueryHeaders(
request,
WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
PCWSTR::null(),
Some((&mut status as *mut u32) as *mut c_void),
&mut size,
std::ptr::null_mut(),
)
}
.map_err(|e| Error::Win {
context: format!("WinHttpQueryHeaders(STATUS_CODE): {e}"),
})?;
Ok(status)
}
fn query_raw_headers(request: *mut c_void) -> Result<Vec<(String, String)>, Error> {
// First call sizes the buffer (returns Err with ERROR_INSUFFICIENT_BUFFER
// and writes the required byte count to `needed`).
let mut needed: u32 = 0;
let _ = unsafe {
WinHttpQueryHeaders(
request,
WINHTTP_QUERY_RAW_HEADERS_CRLF,
PCWSTR::null(),
None,
&mut needed,
std::ptr::null_mut(),
)
};
if needed == 0 {
return Ok(Vec::new());
}
let chars = (needed as usize) / std::mem::size_of::<u16>();
let mut buf: Vec<u16> = vec![0u16; chars];
unsafe {
WinHttpQueryHeaders(
request,
WINHTTP_QUERY_RAW_HEADERS_CRLF,
PCWSTR::null(),
Some(buf.as_mut_ptr() as *mut c_void),
&mut needed,
std::ptr::null_mut(),
)
}
.map_err(|e| Error::Win {
context: format!("WinHttpQueryHeaders(RAW_HEADERS_CRLF): {e}"),
})?;
let text = String::from_utf16_lossy(&buf[..chars.saturating_sub(1)]);
Ok(parse_header_block(&text))
}
fn parse_header_block(block: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut lines = block.split("\r\n");
let _ = lines.next(); // status line, e.g. "HTTP/1.1 200 OK"
for line in lines {
if line.is_empty() {
continue;
}
if let Some((k, v)) = line.split_once(':') {
out.push((k.trim().to_string(), v.trim().to_string()));
}
}
out
}
fn read_body(request: *mut c_void) -> Result<Vec<u8>, Error> {
let mut body = Vec::new();
loop {
let mut available: u32 = 0;
unsafe { WinHttpQueryDataAvailable(request, &mut available) }.map_err(|e| Error::Win {
context: format!("WinHttpQueryDataAvailable: {e}"),
})?;
if available == 0 {
break;
}
let mut chunk = vec![0u8; available as usize];
let mut read: u32 = 0;
unsafe {
WinHttpReadData(
request,
chunk.as_mut_ptr() as *mut c_void,
available,
&mut read,
)
}
.map_err(|e| Error::Win {
context: format!("WinHttpReadData: {e}"),
})?;
chunk.truncate(read as usize);
body.append(&mut chunk);
}
Ok(body)
}
// ---------- URL parsing ----------
struct ParsedUrl {
host: String,
port: u16,
path: String,
secure: bool,
}
fn parse_url(url: &str) -> Result<ParsedUrl, Error> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| Error::Url(url.to_string()))?;
let secure = match scheme.to_ascii_lowercase().as_str() {
"https" => true,
"http" => false,
other => return Err(Error::Url(format!("unsupported scheme: {other}"))),
};
let (host_port, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, "/"),
};
let (host, port) = match host_port.rsplit_once(':') {
Some((h, p)) => (
h.to_string(),
p.parse::<u16>().map_err(|_| Error::Url(url.to_string()))?,
),
None => (host_port.to_string(), if secure { 443 } else { 80 }),
};
if host.is_empty() {
return Err(Error::Url(url.to_string()));
}
Ok(ParsedUrl {
host,
port,
path: path.to_string(),
secure,
})
}
+5
View File
@@ -0,0 +1,5 @@
//! OS-specific code. Each platform file is compiled only on its own target,
//! and portable callers reach it through `cfg`-gated calls.
#[cfg(windows)]
pub mod windows;
+18
View File
@@ -0,0 +1,18 @@
//! Windows-only helpers. This is the only module that talks to Win32 APIs.
use crate::core::proxy::{self, SystemProxy};
const INTERNET_SETTINGS_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
/// The static proxy from `HKCU\…\Internet Settings`, or `None` when
/// `ProxyEnable` is not 1 or no usable `ProxyServer` entry exists.
/// PAC/WPAD (`AutoConfigURL`, auto-detect) is deliberately not evaluated.
pub fn system_proxy() -> Option<SystemProxy> {
let key = windows_registry::CURRENT_USER
.open(INTERNET_SETTINGS_KEY)
.ok()?;
let enable = key.get_u32("ProxyEnable").ok();
let server = key.get_string("ProxyServer").ok();
let proxy_override = key.get_string("ProxyOverride").ok();
proxy::from_registry_values(enable, server.as_deref(), proxy_override.as_deref())
}
+138 -12
View File
@@ -17,10 +17,7 @@ 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",
];
const PROBE_MODELS: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"];
pub struct ClaudeProvider {
locator: Locator,
@@ -86,13 +83,16 @@ fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>
if !(200..300).contains(&resp.status()) {
return Ok(None);
}
let body: OauthUsage = match resp.json() {
Ok(b) => b,
Err(_) => return Ok(None),
};
Ok(parse_usage_body(resp.body()))
}
/// Windows from an `/api/oauth/usage` body, or `None` when the body does not
/// match the expected shape (which sends the caller to the Messages fallback).
fn parse_usage_body(body: &[u8]) -> Option<UsageWindows> {
let body: OauthUsage = serde_json::from_slice(body).ok()?;
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 }))
Some(UsageWindows { primary, secondary })
}
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> {
@@ -120,8 +120,12 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
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()
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));
}
@@ -240,7 +244,7 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
}
fn is_leap(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
// --- JSON shape ---
@@ -256,3 +260,125 @@ struct Bucket {
utilization: f64,
resets_at: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn at(secs: u64) -> Option<SystemTime> {
Some(UNIX_EPOCH + Duration::from_secs(secs))
}
fn fixture(name: &str) -> Vec<u8> {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}
#[test]
fn full_usage_body_maps_both_buckets() {
let w = parse_usage_body(&fixture("anthropic-usage-full.json")).expect("parses");
assert_eq!(w.primary.utilization, 42.5);
assert_eq!(w.primary.resets_at, at(1_761_040_800));
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
assert!(has_reset_times(&w));
}
#[test]
fn partial_usage_body_keeps_percentages_and_merges_fallback_resets() {
let w = parse_usage_body(&fixture("anthropic-usage-partial.json")).expect("parses");
assert_eq!(w.primary.utilization, 7.0);
assert_eq!(w.primary.resets_at, None);
assert_eq!(w.secondary.resets_at, at(1_709_251_199));
assert!(!has_reset_times(&w));
let fallback = UsageWindows {
primary: Window {
utilization: 99.0,
resets_at: at(10),
},
secondary: Window {
utilization: 99.0,
resets_at: at(20),
},
};
let merged = merge_resets(w, fallback);
assert_eq!(merged.primary.utilization, 7.0, "percent is not replaced");
assert_eq!(merged.primary.resets_at, at(10));
assert_eq!(merged.secondary.resets_at, at(1_709_251_199));
}
#[test]
fn missing_bucket_defaults_to_empty_window() {
let w = parse_usage_body(&fixture("anthropic-usage-five-hour-only.json")).expect("parses");
assert_eq!(w.primary.utilization, 3.0);
assert_eq!(w.secondary.utilization, 0.0);
assert_eq!(w.secondary.resets_at, None);
}
#[test]
fn null_buckets_parse_as_empty_windows() {
let w = parse_usage_body(&fixture("anthropic-usage-null-buckets.json")).expect("parses");
assert_eq!(w.primary.utilization, 0.0);
assert_eq!(w.secondary.utilization, 0.0);
assert!(!has_reset_times(&w));
}
#[test]
fn null_utilization_rejects_body_so_fallback_runs() {
assert!(parse_usage_body(&fixture("anthropic-usage-null-utilization.json")).is_none());
assert!(parse_usage_body(b"not json").is_none());
}
#[test]
fn iso8601_accepts_utc_offsets_and_fractions() {
assert_eq!(parse_iso8601("2025-10-21T10:00:00Z"), at(1_761_040_800));
assert_eq!(
parse_iso8601("2025-10-21T10:00:00.999999+00:00"),
at(1_761_040_800)
);
assert_eq!(
parse_iso8601("2025-10-27T03:00:00+07:00"),
at(1_761_508_800)
);
assert_eq!(
parse_iso8601("2025-10-21T05:30:00-04:30"),
at(1_761_040_800)
);
assert_eq!(parse_iso8601("2025-10-21T10:00:00"), at(1_761_040_800));
assert_eq!(parse_iso8601("2024-02-29T23:59:59Z"), at(1_709_251_199));
assert_eq!(parse_iso8601("1970-01-01T00:00:00Z"), at(0));
}
#[test]
fn iso8601_rejects_malformed_values() {
assert_eq!(
parse_iso8601("2025-10-21T10:00:00+0000"),
None,
"colonless offset"
);
assert_eq!(parse_iso8601("2025-10-21t10:00:00Z"), None, "lowercase t");
assert_eq!(parse_iso8601("1969-12-31T23:59:59Z"), None, "before 1970");
assert_eq!(
parse_iso8601("1970-01-01T00:30:00+01:00"),
None,
"negative epoch"
);
assert_eq!(
parse_iso8601("2025-02-29T00:00:00Z"),
None,
"not a leap year"
);
assert_eq!(parse_iso8601("2025-13-01T00:00:00Z"), None);
assert_eq!(parse_iso8601("2025-10-21 10:00:00Z"), None);
assert_eq!(parse_iso8601("2025-10-21T10:00Z"), None);
assert_eq!(parse_iso8601(""), None);
}
#[test]
fn token_expiry_compares_milliseconds() {
assert!(!token_is_expired(None));
assert!(token_is_expired(Some(0)));
assert!(!token_is_expired(Some(i64::MAX)));
}
}
+49
View File
@@ -125,3 +125,52 @@ impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
self.and_then(|inner| inner.map(|b| *b))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(secs: u64) -> Option<SystemTime> {
Some(UNIX_EPOCH + Duration::from_secs(secs))
}
fn envelope(name: &str) -> Envelope {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
serde_json::from_str(&text).expect("fixture parses")
}
#[test]
fn full_envelope_maps_both_windows() {
let w = envelope_to_windows(envelope("chatgpt-usage-full.json")).expect("rate_limit");
assert_eq!(w.primary.utilization, 12.0);
assert_eq!(w.primary.resets_at, at(1_761_040_800));
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
}
#[test]
fn null_rate_limit_is_a_bad_response() {
assert!(envelope_to_windows(envelope("chatgpt-usage-null-rate-limit.json")).is_none());
let missing: Envelope = serde_json::from_str("{}").expect("parses");
assert!(envelope_to_windows(missing).is_none());
}
#[test]
fn null_window_defaults_to_empty() {
let w =
envelope_to_windows(envelope("chatgpt-usage-null-windows.json")).expect("rate_limit");
assert_eq!(w.primary.utilization, 55.0);
assert_eq!(w.secondary.utilization, 0.0);
assert_eq!(w.secondary.resets_at, None);
}
#[test]
fn negative_reset_has_no_reset_time_and_percent_is_clamped() {
let w =
envelope_to_windows(envelope("chatgpt-usage-negative-reset.json")).expect("rate_limit");
assert_eq!(w.primary.utilization, 0.0);
assert_eq!(w.primary.resets_at, None);
assert_eq!(w.secondary.resets_at, at(0));
}
}
+92
View File
@@ -53,3 +53,95 @@ fn unix_to_systemtime(secs: Option<i64>) -> Option<SystemTime> {
}
Some(UNIX_EPOCH + Duration::from_secs(s as u64))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::test_server::serve_once;
use crate::net::{Client, USER_AGENT};
fn at(secs: u64) -> Option<SystemTime> {
Some(UNIX_EPOCH + Duration::from_secs(secs))
}
#[test]
fn fractions_scale_to_percent_and_resets_parse() {
let resp = Response::from_parts(
200,
&[
("anthropic-ratelimit-unified-5h-utilization", "0.425"),
("anthropic-ratelimit-unified-5h-reset", "1761040800"),
("Anthropic-Ratelimit-Unified-7d-Utilization", "1.2"),
("anthropic-ratelimit-unified-7d-reset", "1761508800"),
],
b"",
);
let w = parse_anthropic(&resp);
assert!((w.primary.utilization - 42.5).abs() < 1e-9);
assert_eq!(w.primary.resets_at, at(1_761_040_800));
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
}
#[test]
fn missing_or_garbage_values_fall_back_to_empty() {
let resp = Response::from_parts(
200,
&[
("anthropic-ratelimit-unified-5h-utilization", "abc"),
("anthropic-ratelimit-unified-5h-reset", "soon"),
],
b"",
);
let w = parse_anthropic(&resp);
assert_eq!(w.primary.utilization, 0.0);
assert_eq!(w.primary.resets_at, None);
assert_eq!(w.secondary.utilization, 0.0);
assert_eq!(w.secondary.resets_at, None);
}
#[test]
fn negative_values_clamp_and_drop_reset() {
let resp = Response::from_parts(
200,
&[
("anthropic-ratelimit-unified-5h-utilization", "-0.3"),
("anthropic-ratelimit-unified-5h-reset", "-1"),
("anthropic-ratelimit-unified-7d-utilization", "0.5"),
("anthropic-ratelimit-unified-7d-reset", "0"),
],
b"",
);
let w = parse_anthropic(&resp);
assert_eq!(w.primary.utilization, 0.0);
assert_eq!(w.primary.resets_at, None);
assert_eq!(w.secondary.utilization, 50.0);
assert_eq!(w.secondary.resets_at, at(0));
}
#[test]
fn rate_limited_reply_still_yields_headers_over_http() {
let served = serve_once(
"429 Too Many Requests",
&[
("anthropic-ratelimit-unified-5h-utilization", "1.0"),
("anthropic-ratelimit-unified-5h-reset", "1761040800"),
("anthropic-ratelimit-unified-7d-utilization", "0.25"),
("anthropic-ratelimit-unified-7d-reset", "1761508800"),
],
br#"{"type":"error","error":{"type":"rate_limit_error"}}"#.to_vec(),
);
let client = Client::without_proxy(USER_AGENT);
let resp = client
.post(&served.base)
.json_body(&serde_json::json!({"max_tokens": 1}))
.and_then(crate::net::RequestBuilder::send)
.expect("429 is a response, not an error");
assert_eq!(resp.status(), 429);
let w = parse_anthropic(&resp);
assert_eq!(w.primary.utilization, 100.0);
assert_eq!(w.primary.resets_at, at(1_761_040_800));
assert_eq!(w.secondary.utilization, 25.0);
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
}
}
+31 -3
View File
@@ -1,7 +1,35 @@
// Usage subsystem. Only the shared data shapes are compiled for now; the
// providers, registry, headers and refresh orchestrator return with the
// HTTP client port.
// Usage subsystem: the provider trait, the per-provider implementations,
// the registry that polls them and the credential refresh orchestrator.
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, 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("credentials: {0}")]
Creds(#[from] crate::creds::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>;
}
+220 -34
View File
@@ -4,14 +4,22 @@
// 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::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use crate::creds::{CredentialSource, RefreshHint};
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Directories searched after `PATH` on Unix. A desktop session often starts
/// GUI apps with a shorter `PATH` than the login shell, which misses the
/// usual per-user install locations of the Claude and Codex CLIs.
#[cfg(unix)]
const EXTRA_HOME_DIRS: &[&str] = &[".local/bin", ".claude/local"];
#[cfg(unix)]
const EXTRA_SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Refreshed,
@@ -55,54 +63,232 @@ impl Orchestrator {
}
fn spawn_cli(hint: &RefreshHint) -> bool {
let (candidates, args) = cli_for(hint);
spawn_first(candidates, args)
}
#[cfg(windows)]
fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) {
match hint {
RefreshHint::LocalClaudeCli => {
spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."])
}
RefreshHint::LocalCodexCli => spawn_local(
RefreshHint::LocalClaudeCli => (&["claude.cmd", "claude.exe", "claude"], &["-p", "."]),
RefreshHint::LocalCodexCli => (
&["codex.cmd", "codex.ps1", "codex.exe", "codex"],
&["exec", "."],
),
}
}
fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
#[cfg(unix)]
fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) {
match hint {
RefreshHint::LocalClaudeCli => (&["claude"], &["-p", "."]),
RefreshHint::LocalCodexCli => (&["codex"], &["exec", "."]),
}
}
/// Spawn the first candidate that starts. The child is detached: a reaper
/// thread waits on it so no zombie is left behind.
fn spawn_first(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
let Some(mut cmd) = command_for(name, args) else {
continue;
};
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() {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(CREATE_NO_WINDOW);
}
if let Ok(child) = cmd.spawn() {
reap_detached(child);
return true;
}
}
false
}
fn reap_detached(mut child: Child) {
std::thread::spawn(move || {
if let Err(err) = child.wait() {
log::warn!("refresh CLI could not be reaped: {err}");
}
});
}
#[cfg(windows)]
fn command_for(name: &str, args: &[&str]) -> Option<Command> {
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);
c
} else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
let mut c = Command::new("cmd.exe");
c.arg("/c").arg(name);
c
} else {
Command::new(name)
};
cmd.args(args);
Some(cmd)
}
#[cfg(unix)]
fn command_for(name: &str, args: &[&str]) -> Option<Command> {
let path_var = std::env::var_os("PATH");
let home = dirs::home_dir();
let dirs = search_dirs(path_var.as_deref(), home.as_deref());
let program = find_executable(name, &dirs)?;
let mut cmd = Command::new(program);
cmd.args(args);
Some(cmd)
}
/// `PATH` entries in order, then the extra install locations, without
/// duplicates. Empty and relative entries are skipped so the working
/// directory is never searched.
#[cfg(unix)]
fn search_dirs(
path_var: Option<&std::ffi::OsStr>,
home: Option<&std::path::Path>,
) -> Vec<std::path::PathBuf> {
use std::path::PathBuf;
let mut out: Vec<PathBuf> = Vec::new();
let from_path = path_var.map(|p| std::env::split_paths(p).collect::<Vec<_>>());
let extras_home = home
.into_iter()
.flat_map(|h| EXTRA_HOME_DIRS.iter().map(move |d| h.join(d)));
let extras_system = EXTRA_SYSTEM_DIRS.iter().map(PathBuf::from);
for dir in from_path
.unwrap_or_default()
.into_iter()
.chain(extras_home)
.chain(extras_system)
{
if dir.is_absolute() && !out.contains(&dir) {
out.push(dir);
}
}
out
}
/// First `dir/name` that is a regular file with an execute bit set.
#[cfg(unix)]
fn find_executable(name: &str, dirs: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
use std::os::unix::fs::PermissionsExt;
dirs.iter().map(|d| d.join(name)).find(|candidate| {
std::fs::metadata(candidate)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
})
}
#[cfg(all(test, unix))]
mod tests {
use std::ffi::OsString;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ccub-refresh-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default()
));
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn write_file(path: &Path, mode: u32) {
std::fs::write(path, "#!/bin/sh\n").expect("write");
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).expect("chmod");
}
#[test]
fn search_dirs_puts_path_first_then_extras_without_duplicates() {
let path = OsString::from("/usr/bin::relative/bin:/usr/local/bin:/usr/bin");
let dirs = search_dirs(Some(&path), Some(Path::new("/home/u")));
let expected: Vec<PathBuf> = [
"/usr/bin",
"/usr/local/bin",
"/home/u/.local/bin",
"/home/u/.claude/local",
"/opt/homebrew/bin",
]
.iter()
.map(PathBuf::from)
.collect();
assert_eq!(dirs, expected);
}
#[test]
fn search_dirs_without_path_or_home_keeps_system_extras() {
let dirs = search_dirs(None, None);
assert_eq!(
dirs,
vec![
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin")
]
);
}
#[test]
fn find_executable_skips_missing_and_non_executable_files() {
let first = temp_dir("first");
let second = temp_dir("second");
write_file(&first.join("claude"), 0o644);
std::fs::create_dir_all(first.join("codex")).expect("dir named like the CLI");
write_file(&second.join("claude"), 0o755);
let dirs = vec![first.clone(), second.clone()];
assert_eq!(
find_executable("claude", &dirs),
Some(second.join("claude"))
);
assert_eq!(find_executable("codex", &dirs), None);
assert_eq!(find_executable("absent", &dirs), None);
let _ = std::fs::remove_dir_all(first);
let _ = std::fs::remove_dir_all(second);
}
#[test]
fn spawned_child_is_reaped() {
let dir = temp_dir("spawn");
let script = dir.join("fake-cli");
let marker = dir.join("ran");
std::fs::write(
&script,
format!("#!/bin/sh\necho \"$@\" > '{}'\n", marker.display()),
)
.expect("write script");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let name = script.to_str().expect("utf8 path");
assert!(spawn_first(
&["definitely-not-a-cli-name", name],
&["-p", "."]
));
let deadline = Instant::now() + Duration::from_secs(10);
while !marker.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
let written = std::fs::read_to_string(&marker).unwrap_or_default();
assert_eq!(written.trim(), "-p .");
assert!(!spawn_first(&["definitely-not-a-cli-name"], &[]));
let _ = std::fs::remove_dir_all(dir);
}
}
+47 -1
View File
@@ -4,9 +4,9 @@
// every cycle. The result is a flat list of `(id, Result<UsageWindows>)`
// pairs the app can apply to its state.
use crate::core::settings::Settings;
use crate::creds::Locator;
use crate::net::Client;
use crate::settings::Settings;
use crate::usage::{
anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider,
UsageWindows,
@@ -74,3 +74,49 @@ impl Registry {
sigs
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::USER_AGENT;
#[test]
fn live_usage_excludes_opencode_go() {
// `poll_enabled` relies on this for its `unreachable!` arm.
assert!(!ProviderId::LIVE_USAGE.contains(&ProviderId::OpenCodeGo));
for provider in ProviderId::ALL {
assert_eq!(
ProviderId::LIVE_USAGE.contains(&provider),
provider.metadata().live_usage
);
}
}
/// Polls the real providers with the credentials on this machine. Run by
/// hand: `cargo test -- --ignored live_poll --nocapture`.
#[test]
#[ignore = "talks to the real provider endpoints"]
fn live_poll() {
let http = Client::new(USER_AGENT).expect("client");
let mut settings = Settings::default();
for provider in ProviderId::ALL {
settings.set_provider_enabled(provider, true);
}
let mut registry = Registry::with_defaults();
let results = registry.poll_enabled(&http, &settings);
assert_eq!(results.len(), ProviderId::LIVE_USAGE.len());
for (provider, result) in results {
match result {
Ok(w) => println!(
"{}: 5h {:.1}% resets {:?}, weekly {:.1}% resets {:?}",
provider.slug(),
w.primary.utilization,
w.primary.resets_at,
w.secondary.utilization,
w.secondary.resets_at
),
Err(err) => println!("{}: {err}", provider.slug()),
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"five_hour": { "utilization": 3.0, "resets_at": "2025-10-21T10:00:00Z" }
}
+6
View File
@@ -0,0 +1,6 @@
{
"five_hour": { "utilization": 42.5, "resets_at": "2025-10-21T10:00:00.123456+00:00" },
"seven_day": { "utilization": 130.0, "resets_at": "2025-10-27T03:00:00+07:00" },
"seven_day_opus": null,
"extra_usage": { "is_enabled": false }
}
+4
View File
@@ -0,0 +1,4 @@
{
"five_hour": null,
"seven_day": null
}
+4
View File
@@ -0,0 +1,4 @@
{
"five_hour": { "utilization": null, "resets_at": "2025-10-21T10:00:00Z" },
"seven_day": { "utilization": 10.0, "resets_at": "2025-10-27T03:00:00+07:00" }
}
+4
View File
@@ -0,0 +1,4 @@
{
"five_hour": { "utilization": 7.0, "resets_at": null },
"seven_day": { "utilization": 61.0, "resets_at": "2024-02-29T23:59:59Z" }
}
+20
View File
@@ -0,0 +1,20 @@
{
"plan_type": "plus",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 12,
"limit_window_seconds": 18000,
"reset_after_seconds": 3600,
"reset_at": 1761040800
},
"secondary_window": {
"used_percent": 104.5,
"limit_window_seconds": 604800,
"reset_after_seconds": 86400,
"reset_at": 1761508800
}
},
"credits": null
}
+6
View File
@@ -0,0 +1,6 @@
{
"rate_limit": {
"primary_window": { "used_percent": -5.0, "reset_at": -1 },
"secondary_window": { "used_percent": 20.0, "reset_at": 0 }
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"plan_type": "free",
"rate_limit": null
}
+7
View File
@@ -0,0 +1,7 @@
{
"rate_limit": {
"allowed": true,
"primary_window": { "used_percent": 55.0, "reset_at": 1761040800 },
"secondary_window": null
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"claudeAiOauth": { "accessToken": "sk-ant-oat01-fixture", "expiresAt": 1761040800000.5 }
}
+9
View File
@@ -0,0 +1,9 @@
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-fixture",
"refreshToken": "sk-ant-ort01-fixture",
"expiresAt": 1761040800000,
"scopes": ["user:inference", "user:profile"],
"subscriptionType": "pro"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"mcpOAuth": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"claudeAiOauth": { "expiresAt": 1761040800000 }
}
+3
View File
@@ -0,0 +1,3 @@
{
"tokens": { "access_token": "", "account_id": null }
}
+10
View File
@@ -0,0 +1,10 @@
{
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "fixture-id-token",
"access_token": "fixture-access-token",
"refresh_token": "fixture-refresh-token",
"account_id": "fixture-account"
},
"last_refresh": "2025-10-21T10:00:00Z"
}
+3
View File
@@ -0,0 +1,3 @@
{
"tokens": { "account_id": "fixture-account" }
}
+4
View File
@@ -0,0 +1,4 @@
{
"OPENAI_API_KEY": "sk-fixture",
"tokens": null
}