mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-21 20:16:50 +00:00
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:
1 parent
10c43d76af
commit
9e2666ff0e
34 files changed
+1686
-512
No files matched your search
Generated
+140
-5
@@ -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
@@ -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 = [] }
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"five_hour": { "utilization": 3.0, "resets_at": "2025-10-21T10:00:00Z" }
|
||||
}
|
||||
+6
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"five_hour": null,
|
||||
"seven_day": null
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"rate_limit": {
|
||||
"primary_window": { "used_percent": -5.0, "reset_at": -1 },
|
||||
"secondary_window": { "used_percent": 20.0, "reset_at": 0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"plan_type": "free",
|
||||
"rate_limit": null
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"primary_window": { "used_percent": 55.0, "reset_at": 1761040800 },
|
||||
"secondary_window": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"claudeAiOauth": { "accessToken": "sk-ant-oat01-fixture", "expiresAt": 1761040800000.5 }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-oat01-fixture",
|
||||
"refreshToken": "sk-ant-ort01-fixture",
|
||||
"expiresAt": 1761040800000,
|
||||
"scopes": ["user:inference", "user:profile"],
|
||||
"subscriptionType": "pro"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"mcpOAuth": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"claudeAiOauth": { "expiresAt": 1761040800000 }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"tokens": { "access_token": "", "account_id": null }
|
||||
}
|
||||
Vendored
+10
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"tokens": { "account_id": "fixture-account" }
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"OPENAI_API_KEY": "sk-fixture",
|
||||
"tokens": null
|
||||
}
|
||||
Reference in new issue
Block a user