Skip to content

Commit ad4467e

Browse files
committed
v1.0.8
- Switched to using /api/oauth/usage thanks @Blimp-Labs - Fixed versioning in app
1 parent 55d3a88 commit ad4467e

4 files changed

Lines changed: 113 additions & 114 deletions

File tree

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ jobs:
1313
runs-on: windows-latest
1414
steps:
1515
- uses: actions/checkout@v4
16+
with:
17+
fetch-depth: 0
1618

1719
- uses: dtolnay/rust-toolchain@stable
1820

build.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
11
fn main() {
2-
// No custom linker flags needed.
3-
// Windows subsystem is set via #![windows_subsystem = "windows"] in main.rs
2+
// Derive version from the latest Git tag (e.g. "v1.0.7" → "1.0.7").
3+
let version = std::process::Command::new("git")
4+
.args(["describe", "--tags", "--abbrev=0"])
5+
.output()
6+
.ok()
7+
.and_then(|o| {
8+
if o.status.success() {
9+
String::from_utf8(o.stdout).ok()
10+
} else {
11+
None
12+
}
13+
})
14+
.unwrap_or_else(|| String::from("0.0.0"));
15+
16+
let version = version.trim().trim_start_matches('v');
17+
println!("cargo:rustc-env=APP_VERSION={version}");
418
}

src/poller.rs

Lines changed: 94 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,29 @@ use std::path::PathBuf;
22
use std::process::Command;
33
use std::time::{Duration, SystemTime, UNIX_EPOCH};
44

5-
use crate::models::{UsageData, UsageSection};
5+
use serde::Deserialize;
66

7-
const API_URL: &str = "https://api.anthropic.com/v1/messages";
7+
use crate::models::{UsageData, UsageSection};
88

9-
const MODEL_FALLBACK_CHAIN: &[&str] = &[
10-
"claude-3-haiku-20240307",
11-
"claude-haiku-4-5-20251001",
12-
];
9+
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
1310

1411
#[derive(Debug)]
1512
pub enum PollError {
1613
NoCredentials,
1714
TokenExpired,
18-
AllModelsFailed,
15+
RequestFailed,
16+
}
17+
18+
#[derive(Deserialize)]
19+
struct UsageResponse {
20+
five_hour: Option<UsageBucket>,
21+
seven_day: Option<UsageBucket>,
22+
}
23+
24+
#[derive(Deserialize)]
25+
struct UsageBucket {
26+
utilization: f64,
27+
resets_at: Option<String>,
1928
}
2029

2130
pub fn poll() -> Result<UsageData, PollError> {
@@ -27,7 +36,6 @@ pub fn poll() -> Result<UsageData, PollError> {
2736
if is_token_expired(creds.expires_at) {
2837
cli_refresh_token();
2938

30-
// Re-read credentials in case the CLI refreshed them
3139
match read_credentials() {
3240
Some(refreshed) => creds = refreshed,
3341
None => return Err(PollError::NoCredentials),
@@ -38,20 +46,17 @@ pub fn poll() -> Result<UsageData, PollError> {
3846
}
3947
}
4048

41-
fetch_usage_with_fallback(&creds.access_token)
49+
fetch_usage(&creds.access_token)
4250
}
4351

4452
/// Invoke the Claude CLI with a minimal prompt to force its internal
45-
/// OAuth token refresh. `claude -p "."` makes the CLI
46-
/// authenticate (refreshing the access token if expired), perform a
47-
/// tiny API call, and exit — updating the credentials file on disk.
53+
/// OAuth token refresh.
4854
fn cli_refresh_token() {
4955
let claude_path = resolve_claude_path();
5056
let is_cmd = claude_path.to_lowercase().ends_with(".cmd");
5157

5258
let args: &[&str] = &["-p", "."];
5359

54-
// Clear env vars that prevent nested Claude Code sessions
5560
let mut cmd = if is_cmd {
5661
let mut c = Command::new("cmd.exe");
5762
c.arg("/c").arg(&claude_path).args(args);
@@ -70,12 +75,7 @@ fn cli_refresh_token() {
7075
}
7176

7277
/// Resolve the full path to the `claude` CLI executable.
73-
/// First tries the bare command name (works if on PATH), then falls back
74-
/// to `where.exe claude` which searches the system/user PATH from the
75-
/// registry — important for processes started via the Windows Run key
76-
/// that may not inherit the full shell PATH.
7778
fn resolve_claude_path() -> String {
78-
// Quick check: try claude.cmd first (Windows npm wrapper), then bare "claude"
7979
for name in &["claude.cmd", "claude"] {
8080
if Command::new(name)
8181
.arg("--version")
@@ -88,8 +88,6 @@ fn resolve_claude_path() -> String {
8888
}
8989
}
9090

91-
// Use where.exe to search the system/user PATH from the registry.
92-
// Try claude.cmd first (the Windows batch wrapper npm creates).
9391
for name in &["claude.cmd", "claude"] {
9492
if let Ok(output) = Command::new("where.exe").arg(name).output() {
9593
if output.status.success() {
@@ -107,14 +105,35 @@ fn resolve_claude_path() -> String {
107105
"claude.cmd".to_string()
108106
}
109107

110-
fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
111-
for model in MODEL_FALLBACK_CHAIN {
112-
if let Some(data) = try_model(token, model) {
113-
return Ok(data);
114-
}
108+
fn fetch_usage(token: &str) -> Result<UsageData, PollError> {
109+
let tls = native_tls::TlsConnector::new().map_err(|_| PollError::RequestFailed)?;
110+
let agent = ureq::AgentBuilder::new()
111+
.timeout(Duration::from_secs(30))
112+
.tls_connector(std::sync::Arc::new(tls))
113+
.build();
114+
115+
let response: UsageResponse = agent
116+
.get(USAGE_URL)
117+
.set("Authorization", &format!("Bearer {token}"))
118+
.set("anthropic-beta", "oauth-2025-04-20")
119+
.call()
120+
.map_err(|_| PollError::RequestFailed)?
121+
.into_json()
122+
.map_err(|_| PollError::RequestFailed)?;
123+
124+
let mut data = UsageData::default();
125+
126+
if let Some(bucket) = &response.five_hour {
127+
data.session.percentage = bucket.utilization;
128+
data.session.resets_at = parse_iso8601(bucket.resets_at.as_deref());
115129
}
116130

117-
Err(PollError::AllModelsFailed)
131+
if let Some(bucket) = &response.seven_day {
132+
data.weekly.percentage = bucket.utilization;
133+
data.weekly.resets_at = parse_iso8601(bucket.resets_at.as_deref());
134+
}
135+
136+
Ok(data)
118137
}
119138

120139
struct Credentials {
@@ -148,100 +167,64 @@ fn is_token_expired(expires_at: Option<i64>) -> bool {
148167
now >= exp
149168
}
150169

151-
fn try_model(token: &str, model: &str) -> Option<UsageData> {
152-
let tls = native_tls::TlsConnector::new().ok()?;
153-
let agent = ureq::AgentBuilder::new()
154-
.timeout(Duration::from_secs(30))
155-
.tls_connector(std::sync::Arc::new(tls))
156-
.build();
157-
158-
let body = serde_json::json!({
159-
"model": model,
160-
"max_tokens": 1,
161-
"messages": [{"role": "user", "content": "."}]
162-
});
163-
164-
let response = match agent
165-
.post(API_URL)
166-
.set("Authorization", &format!("Bearer {token}"))
167-
.set("anthropic-version", "2023-06-01")
168-
.set("anthropic-beta", "oauth-2025-04-20")
169-
.send_json(&body)
170-
{
171-
Ok(resp) => resp,
172-
Err(ureq::Error::Status(_code, resp)) => resp,
173-
Err(_) => return None,
174-
};
175-
176-
let h5 = response.header("anthropic-ratelimit-unified-5h-utilization");
177-
let h7 = response.header("anthropic-ratelimit-unified-7d-utilization");
178-
let hs = response.header("anthropic-ratelimit-unified-status");
179-
180-
let has_rate_limit_headers = h5.is_some() || h7.is_some() || hs.is_some();
181-
182-
if has_rate_limit_headers {
183-
Some(parse_headers(&response))
184-
} else {
185-
None
170+
/// Parse an ISO 8601 timestamp string into a SystemTime.
171+
fn parse_iso8601(s: Option<&str>) -> Option<SystemTime> {
172+
let s = s?;
173+
// Strip timezone offset to get "YYYY-MM-DDTHH:MM:SS" or with fractional seconds
174+
// The API returns formats like "2026-03-05T08:00:00.321598+00:00"
175+
let datetime_part = s.split('+').next().unwrap_or(s);
176+
let datetime_part = datetime_part.split('Z').next().unwrap_or(datetime_part);
177+
178+
// Try parsing with and without fractional seconds
179+
let formats = ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S"];
180+
for fmt in &formats {
181+
if let Ok(secs) = parse_datetime_to_unix(datetime_part, fmt) {
182+
return Some(UNIX_EPOCH + Duration::from_secs(secs));
183+
}
186184
}
185+
None
187186
}
188187

189-
fn parse_headers(response: &ureq::Response) -> UsageData {
190-
let mut data = UsageData::default();
191-
192-
// Session (5-hour window)
193-
data.session.percentage = get_header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0;
194-
data.session.resets_at = unix_to_system_time(get_header_i64(response, "anthropic-ratelimit-unified-5h-reset"));
195-
196-
// Weekly (7-day window)
197-
data.weekly.percentage = get_header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0;
198-
data.weekly.resets_at = unix_to_system_time(get_header_i64(response, "anthropic-ratelimit-unified-7d-reset"));
199-
200-
// Overall reset/status fallback
201-
let overall_reset = get_header_i64(response, "anthropic-ratelimit-unified-reset");
202-
203-
if data.session.percentage == 0.0 && data.weekly.percentage == 0.0 {
204-
let status = get_header_str(response, "anthropic-ratelimit-unified-status");
205-
if status.as_deref() == Some("rejected") {
206-
let claim = get_header_str(response, "anthropic-ratelimit-unified-representative-claim");
207-
match claim.as_deref() {
208-
Some("five_hour") => data.session.percentage = 100.0,
209-
Some("seven_day") => data.weekly.percentage = 100.0,
210-
_ => {}
211-
}
212-
}
188+
/// Minimal datetime parser — avoids pulling in chrono/time crates.
189+
fn parse_datetime_to_unix(s: &str, _fmt: &str) -> Result<u64, ()> {
190+
// Extract date and time parts from "YYYY-MM-DDTHH:MM:SS[.frac]"
191+
let (date_str, time_str) = s.split_once('T').ok_or(())?;
192+
let date_parts: Vec<&str> = date_str.split('-').collect();
193+
if date_parts.len() != 3 { return Err(()); }
194+
195+
let year: u64 = date_parts[0].parse().map_err(|_| ())?;
196+
let month: u64 = date_parts[1].parse().map_err(|_| ())?;
197+
let day: u64 = date_parts[2].parse().map_err(|_| ())?;
198+
199+
// Strip fractional seconds
200+
let time_base = time_str.split('.').next().unwrap_or(time_str);
201+
let time_parts: Vec<&str> = time_base.split(':').collect();
202+
if time_parts.len() != 3 { return Err(()); }
203+
204+
let hour: u64 = time_parts[0].parse().map_err(|_| ())?;
205+
let min: u64 = time_parts[1].parse().map_err(|_| ())?;
206+
let sec: u64 = time_parts[2].parse().map_err(|_| ())?;
207+
208+
// Days from year (using a simplified calculation for dates after 1970)
209+
let mut days: u64 = 0;
210+
for y in 1970..year {
211+
days += if is_leap(y) { 366 } else { 365 };
212+
}
213213

214-
if data.session.resets_at.is_none() && overall_reset.is_some() {
215-
data.session.resets_at = unix_to_system_time(overall_reset);
214+
let month_days = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
215+
for m in 1..month {
216+
days += month_days[m as usize];
217+
if m == 2 && is_leap(year) {
218+
days += 1;
216219
}
217220
}
221+
days += day - 1;
218222

219-
data
220-
}
221-
222-
fn get_header_f64(response: &ureq::Response, name: &str) -> f64 {
223-
response
224-
.header(name)
225-
.and_then(|s| s.parse::<f64>().ok())
226-
.unwrap_or(0.0)
223+
Ok(days * 86400 + hour * 3600 + min * 60 + sec)
227224
}
228225

229-
fn get_header_i64(response: &ureq::Response, name: &str) -> Option<i64> {
230-
response
231-
.header(name)
232-
.and_then(|s| s.parse::<i64>().ok())
233-
}
234-
235-
fn get_header_str(response: &ureq::Response, name: &str) -> Option<String> {
236-
response.header(name).map(String::from)
237-
}
238-
239-
fn unix_to_system_time(unix_secs: Option<i64>) -> Option<SystemTime> {
240-
let secs = unix_secs?;
241-
if secs < 0 {
242-
return None;
243-
}
244-
Some(UNIX_EPOCH + Duration::from_secs(secs as u64))
226+
fn is_leap(y: u64) -> bool {
227+
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
245228
}
246229

247230
/// Format a usage section as "X% · Yh" style text

src/window.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1239,7 +1239,7 @@ fn show_context_menu(hwnd: HWND) {
12391239

12401240
let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null());
12411241

1242-
let version_str = native_interop::wide_str(&format!("v{}", env!("CARGO_PKG_VERSION")));
1242+
let version_str = native_interop::wide_str(&format!("v{}", env!("APP_VERSION")));
12431243
let _ = AppendMenuW(
12441244
settings_menu,
12451245
MF_GRAYED,

0 commit comments

Comments
 (0)