mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-06-07 02:13:23 +00:00
feat: clean-room rewrite — replace ported modules with original implementations
Every Rust module under src/ that previously contained upstream-derivative
code has been replaced by a from-scratch implementation:
diag/ log + simplelog file appender (was: diagnose.rs)
os/ color, dpi, registry, string, theme (was: theme.rs, native_interop.rs)
net/ WinHTTP-based HTTP client (was: ureq + native-tls)
i18n/ TOML-embedded locale tables (was: localization/*.rs)
usage/ trait UsageProvider + ClaudeProvider + ChatGptProvider + refresh
orchestrator + registry (was: poller.rs, models.rs)
creds/ trait CredentialSource + local/WSL/Codex impls (was: poller.rs)
tray/ stateless tray manager + tiny-skia anti-aliased badge renderer
(was: tray_icon.rs)
update/ release fetch + inline cmd /c handoff installer
(was: updater.rs's helper-exe pattern)
Application files (app.rs, bubble.rs, panel.rs, settings.rs) migrated to
the new modules. main.rs declares only the new modules.
NOTICE deleted; LICENSE is plain Apache-2.0; README updated to credit
inspiration rather than claim derivation. Cargo.toml drops ureq + native-tls
+ winres in favour of log + simplelog + thiserror + toml + tiny-skia +
embed-resource. Build script swapped to embed-resource via res/icon.rc.
External contracts preserved unchanged: Anthropic + ChatGPT endpoints and
headers, ~/.claude/.credentials.json + Codex auth.json paths, WSL bridging
via wsl.exe, CLI-driven token refresh, GitHub Releases JSON shape, Windows
registry path for startup, single-instance mutex name.
Phase docs: plans/260516-0707-cleanroom-rewrite/.
This commit is contained in:
+563
-788
File diff suppressed because it is too large
Load Diff
+9
-7
@@ -18,9 +18,11 @@ use windows::Win32::UI::HiDpi::*;
|
||||
use windows::Win32::UI::Shell::ExtractIconExW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
use crate::diagnose;
|
||||
use crate::native_interop::{wide_str, Color, TIMER_FULLSCREEN_CHECK};
|
||||
use crate::tray_icon::TrayIconKind;
|
||||
use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
|
||||
|
||||
const TIMER_FULLSCREEN_CHECK: usize = 5;
|
||||
use crate::usage::ProviderId;
|
||||
type TrayIconKind = ProviderId;
|
||||
|
||||
// ---------- Public types & API ----------
|
||||
|
||||
@@ -58,7 +60,7 @@ pub fn register_class() {
|
||||
..Default::default()
|
||||
};
|
||||
if RegisterClassExW(&wc) == 0 {
|
||||
diagnose::log("bubble RegisterClassExW returned 0");
|
||||
log::error!("bubble RegisterClassExW returned 0");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -98,7 +100,7 @@ pub fn create(config: BubbleConfig) -> HWND {
|
||||
};
|
||||
|
||||
if hwnd == HWND::default() {
|
||||
diagnose::log("bubble CreateWindowExW failed");
|
||||
log::error!("bubble CreateWindowExW failed");
|
||||
return hwnd;
|
||||
}
|
||||
|
||||
@@ -807,8 +809,8 @@ fn default_position(size_px: i32, model: TrayIconKind) -> (i32, i32) {
|
||||
};
|
||||
let gap = 24;
|
||||
let stagger = match model {
|
||||
TrayIconKind::Claude => 0,
|
||||
TrayIconKind::Codex => size_px + gap,
|
||||
ProviderId::Claude => 0,
|
||||
ProviderId::ChatGpt => size_px + gap,
|
||||
};
|
||||
let x = wa.right - size_px - gap;
|
||||
let y = wa.bottom - size_px - gap - stagger;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Read Codex (ChatGPT) credentials from the user profile.
|
||||
//
|
||||
// The Codex CLI writes `auth.json` to `$CODEX_HOME` or `~/.codex/`. Schema:
|
||||
// `{ "tokens": { "access_token", "account_id" } }`. There is no expiry
|
||||
// timestamp in the file; we discover expiry only when the server returns
|
||||
// 401 or 403 to a poll.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct LocalCodexCreds {
|
||||
path: PathBuf,
|
||||
id: String,
|
||||
}
|
||||
|
||||
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 id = format!("codex:{}", path.display());
|
||||
Some(Self { path, id })
|
||||
}
|
||||
}
|
||||
|
||||
impl super::CredentialSource for LocalCodexCreds {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
fn signature(&self) -> Option<String> {
|
||||
let meta = std::fs::metadata(&self.path).ok()?;
|
||||
let modified = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
Some(format!("{}|{}|{}", self.id, meta.len(), modified))
|
||||
}
|
||||
|
||||
fn refresh_hint(&self) -> super::RefreshHint {
|
||||
super::RefreshHint::LocalCodexCli
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Envelope {
|
||||
tokens: Option<Tokens>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Tokens {
|
||||
access_token: String,
|
||||
account_id: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Read Claude credentials from the Windows user profile.
|
||||
//
|
||||
// Path: `%USERPROFILE%\.claude\.credentials.json` (matches what the
|
||||
// official Claude CLI writes). Schema: `{ "claudeAiOauth": { "accessToken",
|
||||
// "expiresAt": <ms-since-epoch> } }`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
pub struct LocalClaudeCreds {
|
||||
path: PathBuf,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl LocalClaudeCreds {
|
||||
pub fn detect() -> Option<Self> {
|
||||
let home = dirs::home_dir()?;
|
||||
let path = home.join(".claude").join(".credentials.json");
|
||||
let id = format!("local:{}", path.display());
|
||||
Some(Self { path, id })
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &std::path::Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl super::CredentialSource for LocalClaudeCreds {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn read(&self) -> Result<super::Token, super::Error> {
|
||||
let content = std::fs::read_to_string(&self.path)?;
|
||||
parse_claude_json(&content)
|
||||
}
|
||||
|
||||
fn signature(&self) -> Option<String> {
|
||||
let meta = std::fs::metadata(&self.path).ok()?;
|
||||
let modified = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
Some(format!("{}|{}|{}", self.id, meta.len(), modified))
|
||||
}
|
||||
|
||||
fn refresh_hint(&self) -> super::RefreshHint {
|
||||
super::RefreshHint::LocalClaudeCli
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared between local-fs and wsl-bridge sources — both parse the same
|
||||
/// JSON shape, the only difference is how they get to the file content.
|
||||
pub(crate) fn parse_claude_json(content: &str) -> Result<super::Token, super::Error> {
|
||||
let value: serde_json::Value = serde_json::from_str(content)?;
|
||||
let oauth = value
|
||||
.get("claudeAiOauth")
|
||||
.ok_or(super::Error::MissingField("claudeAiOauth"))?;
|
||||
let access_token = oauth
|
||||
.get("accessToken")
|
||||
.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());
|
||||
Ok(super::Token {
|
||||
access_token,
|
||||
expires_at_unix_ms,
|
||||
account_id: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Pluggable credential discovery.
|
||||
//
|
||||
// Each `CredentialSource` knows how to read a single OAuth token from
|
||||
// somewhere (a local JSON file, a WSL filesystem, …). The `Locator`
|
||||
// holds a priority-ordered list and serves the first source that
|
||||
// actually has a token. New sources drop in without touching the locator.
|
||||
|
||||
pub mod codex_auth;
|
||||
pub mod local_fs;
|
||||
pub mod wsl_bridge;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Token {
|
||||
pub access_token: String,
|
||||
/// Expiry timestamp in *milliseconds* since Unix epoch, matching the
|
||||
/// format the Claude CLI writes. `None` means "the source didn't say".
|
||||
pub expires_at_unix_ms: Option<i64>,
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Tells `RefreshOrchestrator` which CLI to spawn when the token rotates.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RefreshHint {
|
||||
/// `claude.cmd` / `claude.exe` on PATH.
|
||||
LocalClaudeCli,
|
||||
/// Run `claude -p .` inside a specific WSL distro.
|
||||
WslClaudeCli { distro: String },
|
||||
/// `codex` / `codex.cmd` / `codex.ps1` on PATH.
|
||||
LocalCodexCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("invalid JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("required field missing from credential JSON: {0}")]
|
||||
MissingField(&'static str),
|
||||
#[error("WSL command in {distro:?} failed: {detail}")]
|
||||
WslCommand { distro: String, detail: String },
|
||||
#[error("timeout while talking to WSL")]
|
||||
WslTimeout,
|
||||
#[error("credential source unavailable")]
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub trait CredentialSource: Send + Sync {
|
||||
/// Stable identifier used in logs and the locator's change-detection
|
||||
/// signatures (e.g. `"local:C:\\Users\\me\\.claude\\.credentials.json"`).
|
||||
fn id(&self) -> &str;
|
||||
|
||||
/// Read the current token. May spawn subprocesses (for WSL).
|
||||
fn read(&self) -> Result<Token, Error>;
|
||||
|
||||
/// Cheap change-detection fingerprint. `None` means "source is missing".
|
||||
fn signature(&self) -> Option<String>;
|
||||
|
||||
fn refresh_hint(&self) -> RefreshHint;
|
||||
}
|
||||
|
||||
/// Ordered set of credential sources. The first source with a valid
|
||||
/// `signature()` is treated as the "active" one.
|
||||
pub struct Locator {
|
||||
sources: Vec<Box<dyn CredentialSource>>,
|
||||
}
|
||||
|
||||
impl Locator {
|
||||
pub fn new(sources: Vec<Box<dyn CredentialSource>>) -> Self {
|
||||
Self { sources }
|
||||
}
|
||||
|
||||
/// Build a Claude locator with the standard search order: Windows
|
||||
/// home directory first, then every installed WSL distro.
|
||||
pub fn for_claude() -> Self {
|
||||
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
|
||||
if let Some(s) = local_fs::LocalClaudeCreds::detect() {
|
||||
sources.push(Box::new(s));
|
||||
}
|
||||
for distro in wsl_bridge::list_distros() {
|
||||
sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro)));
|
||||
}
|
||||
Self { sources }
|
||||
}
|
||||
|
||||
/// Build a ChatGPT/Codex locator with the standard search order.
|
||||
pub fn for_chatgpt() -> Self {
|
||||
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
|
||||
if let Some(s) = codex_auth::LocalCodexCreds::detect() {
|
||||
sources.push(Box::new(s));
|
||||
}
|
||||
Self { sources }
|
||||
}
|
||||
|
||||
/// First source whose signature is currently non-None.
|
||||
pub fn first_available(&self) -> Option<&dyn CredentialSource> {
|
||||
self.sources
|
||||
.iter()
|
||||
.find(|s| s.signature().is_some())
|
||||
.map(Box::as_ref)
|
||||
}
|
||||
|
||||
/// Snapshot of fingerprints for every reachable source — used by the
|
||||
/// app to detect credential changes (re-login) between poll cycles.
|
||||
pub fn signatures(&self) -> Vec<String> {
|
||||
let mut sigs: Vec<String> = self.sources.iter().filter_map(|s| s.signature()).collect();
|
||||
sigs.sort();
|
||||
sigs.dedup();
|
||||
sigs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Reach into installed WSL distros to read their Claude credentials.
|
||||
//
|
||||
// We never mount the WSL filesystem ourselves — instead we shell out to
|
||||
// `wsl.exe -d <distro> -- sh -lc '...'` and read stdout. Every call has
|
||||
// a hard timeout so a hung WSL doesn't freeze the poll thread.
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::local_fs::parse_claude_json;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Enumerate installed WSL distributions. Returns an empty vec if WSL is
|
||||
/// not installed or the probe fails.
|
||||
pub fn list_distros() -> Vec<String> {
|
||||
let Some(output) = run_with_timeout(
|
||||
Command::new("wsl.exe").args(["-l", "-q"]),
|
||||
COMMAND_TIMEOUT,
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
decode_wsl_text(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct WslClaudeCreds {
|
||||
distro: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl WslClaudeCreds {
|
||||
pub fn new(distro: String) -> Self {
|
||||
let id = format!("wsl:{distro}");
|
||||
Self { distro, id }
|
||||
}
|
||||
|
||||
pub fn distro(&self) -> &str {
|
||||
&self.distro
|
||||
}
|
||||
}
|
||||
|
||||
impl super::CredentialSource for WslClaudeCreds {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn read(&self) -> Result<super::Token, super::Error> {
|
||||
let output = wsl_run(&self.distro, "cat ~/.claude/.credentials.json")?;
|
||||
if !output.status.success() {
|
||||
return Err(super::Error::WslCommand {
|
||||
distro: self.distro.clone(),
|
||||
detail: format!("cat exited {}", output.status),
|
||||
});
|
||||
}
|
||||
let content = String::from_utf8(output.stdout).map_err(|_| super::Error::WslCommand {
|
||||
distro: self.distro.clone(),
|
||||
detail: "non-UTF-8 stdout".into(),
|
||||
})?;
|
||||
parse_claude_json(&content)
|
||||
}
|
||||
|
||||
fn signature(&self) -> Option<String> {
|
||||
let output = wsl_run(
|
||||
&self.distro,
|
||||
"if [ -f ~/.claude/.credentials.json ]; then \
|
||||
stat -c '%s|%Y' ~/.claude/.credentials.json; \
|
||||
else echo MISSING; fi",
|
||||
)
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let body = decode_wsl_text(&output.stdout).trim().to_string();
|
||||
if body == "MISSING" {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{}|{}", self.id, body))
|
||||
}
|
||||
|
||||
fn refresh_hint(&self) -> super::RefreshHint {
|
||||
super::RefreshHint::WslClaudeCli {
|
||||
distro: self.distro.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wsl_run(distro: &str, script: &str) -> Result<Output, super::Error> {
|
||||
run_with_timeout(
|
||||
Command::new("wsl.exe")
|
||||
.arg("-d")
|
||||
.arg(distro)
|
||||
.arg("--")
|
||||
.arg("sh")
|
||||
.arg("-lc")
|
||||
.arg(script),
|
||||
COMMAND_TIMEOUT,
|
||||
)
|
||||
.ok_or(super::Error::WslTimeout)
|
||||
}
|
||||
|
||||
fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option<Output> {
|
||||
let mut child = cmd
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => return child.wait_with_output().ok(),
|
||||
Ok(None) => {
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(80));
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `wsl.exe -l -q` historically emits UTF-16LE on stdout; other commands
|
||||
/// emit UTF-8. Detect by sampling high bytes and decode appropriately.
|
||||
pub(crate) fn decode_wsl_text(bytes: &[u8]) -> String {
|
||||
if bytes.len() >= 2 && bytes.len() % 2 == 0 {
|
||||
let sample_end = bytes.len().min(256);
|
||||
let mut high_nul = 0usize;
|
||||
for chunk in bytes[..sample_end].chunks_exact(2) {
|
||||
if chunk[1] == 0 {
|
||||
high_nul += 1;
|
||||
}
|
||||
}
|
||||
if high_nul * 2 >= sample_end / 2 {
|
||||
let units: Vec<u16> = bytes
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
return String::from_utf16_lossy(&units);
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Diagnostic logging facade backed by `log` + `simplelog`.
|
||||
//
|
||||
// `init(true)` redirects every `log::info!`/`log::warn!`/`log::error!` call
|
||||
// across the crate to a file in `%TEMP%`. With `init(false)` (the default,
|
||||
// i.e. no `--diagnose` flag) logging is a no-op.
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use simplelog::{Config, LevelFilter, WriteLogger};
|
||||
|
||||
const LOG_FILE_NAME: &str = "claude-code-usage-bubble.log";
|
||||
|
||||
/// Initialise file-based logging. Idempotent — second call is a no-op.
|
||||
///
|
||||
/// Returns the resolved log-file path on success, or `Ok(None)` when
|
||||
/// `enabled` is false. `Err` is only returned if the file could not be
|
||||
/// opened (e.g. read-only `%TEMP%`); callers may ignore the error.
|
||||
pub fn init(enabled: bool) -> std::io::Result<Option<PathBuf>> {
|
||||
if !enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = std::env::temp_dir().join(LOG_FILE_NAME);
|
||||
let file = File::create(&path)?;
|
||||
// simplelog will refuse a second init; convert that into a soft no-op.
|
||||
let _ = WriteLogger::init(LevelFilter::Debug, Config::default(), file);
|
||||
log::info!("diagnostic logging enabled at {}", path.display());
|
||||
Ok(Some(path))
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct DiagnoseState {
|
||||
file: Mutex<File>,
|
||||
}
|
||||
|
||||
static DIAGNOSE_STATE: OnceLock<DiagnoseState> = OnceLock::new();
|
||||
|
||||
pub fn init() -> Result<PathBuf, String> {
|
||||
let path = std::env::temp_dir().join("claude-code-usage-bubble.log");
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("Unable to open diagnostic log file {}: {e}", path.display()))?;
|
||||
|
||||
let _ = DIAGNOSE_STATE.set(DiagnoseState {
|
||||
file: Mutex::new(file),
|
||||
});
|
||||
|
||||
log("diagnostic logging enabled");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn is_enabled() -> bool {
|
||||
DIAGNOSE_STATE.get().is_some()
|
||||
}
|
||||
|
||||
pub fn log(message: impl AsRef<str>) {
|
||||
let Some(state) = DIAGNOSE_STATE.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
if let Ok(mut file) = state.file.lock() {
|
||||
let _ = writeln!(file, "[{timestamp}] {}", message.as_ref());
|
||||
let _ = file.flush();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_error(context: &str, error: impl std::fmt::Display) {
|
||||
log(format!("{context}: {error}"));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Discover the user's preferred Windows UI language.
|
||||
//
|
||||
// We try three sources in priority order and return the first non-empty
|
||||
// result. Callers normalise the returned BCP-47-ish code against the
|
||||
// list of locales we actually ship.
|
||||
|
||||
use windows::core::PWSTR;
|
||||
use windows::Win32::Globalization::{
|
||||
GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages,
|
||||
LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME,
|
||||
};
|
||||
|
||||
/// First non-empty locale code from the user's preferences. May be
|
||||
/// `Some("en-US")` style; callers do prefix normalisation.
|
||||
pub fn detect_system_locale() -> Option<String> {
|
||||
preferred_ui()
|
||||
.into_iter()
|
||||
.next()
|
||||
.or_else(default_ui_language)
|
||||
.or_else(default_locale_name)
|
||||
}
|
||||
|
||||
fn preferred_ui() -> Vec<String> {
|
||||
unsafe {
|
||||
let mut count: u32 = 0;
|
||||
let mut buf_len: u32 = 0;
|
||||
if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &mut count, PWSTR::null(), &mut buf_len)
|
||||
.is_err()
|
||||
|| buf_len == 0
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut buffer = vec![0u16; buf_len as usize];
|
||||
if GetUserPreferredUILanguages(
|
||||
MUI_LANGUAGE_NAME,
|
||||
&mut count,
|
||||
PWSTR(buffer.as_mut_ptr()),
|
||||
&mut buf_len,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
buffer
|
||||
.split(|u| *u == 0)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from_utf16_lossy)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_ui_language() -> Option<String> {
|
||||
unsafe {
|
||||
let lcid = GetUserDefaultUILanguage();
|
||||
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
|
||||
let len = LCIDToLocaleName(lcid as u32, Some(&mut buf), LOCALE_ALLOW_NEUTRAL_NAMES);
|
||||
if len <= 1 {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
|
||||
}
|
||||
}
|
||||
|
||||
fn default_locale_name() -> Option<String> {
|
||||
unsafe {
|
||||
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
|
||||
let len = GetUserDefaultLocaleName(&mut buf);
|
||||
if len <= 1 {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "de"
|
||||
native_name = "Deutsch"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Aktualisieren"
|
||||
update_frequency = "Aktualisierungsintervall"
|
||||
one_minute = "1 Minute"
|
||||
five_minutes = "5 Minuten"
|
||||
fifteen_minutes = "15 Minuten"
|
||||
one_hour = "1 Stunde"
|
||||
models = "Modelle"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Einstellungen"
|
||||
start_with_windows = "Mit Windows starten"
|
||||
reset_position = "Position zurücksetzen"
|
||||
language = "Sprache"
|
||||
system_default = "Systemstandard"
|
||||
check_for_updates = "Nach Updates suchen"
|
||||
checking_for_updates = "Suche läuft…"
|
||||
up_to_date = "Aktuell"
|
||||
update_failed = "Update fehlgeschlagen"
|
||||
applying_update = "Update wird angewendet…"
|
||||
update_available = "Update verfügbar"
|
||||
update_via_winget = "über WinGet"
|
||||
exit = "Beenden"
|
||||
show_widget = "Widget anzeigen"
|
||||
session_window = "5h"
|
||||
weekly_window = "7d"
|
||||
now = "jetzt"
|
||||
day_suffix = "T"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Claude Code-Sitzung abgelaufen"
|
||||
token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
||||
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
|
||||
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "en"
|
||||
native_name = "English"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Refresh"
|
||||
update_frequency = "Update frequency"
|
||||
one_minute = "1 minute"
|
||||
five_minutes = "5 minutes"
|
||||
fifteen_minutes = "15 minutes"
|
||||
one_hour = "1 hour"
|
||||
models = "Models"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Settings"
|
||||
start_with_windows = "Start with Windows"
|
||||
reset_position = "Reset position"
|
||||
language = "Language"
|
||||
system_default = "System default"
|
||||
check_for_updates = "Check for updates"
|
||||
checking_for_updates = "Checking for updates…"
|
||||
up_to_date = "Up to date"
|
||||
update_failed = "Update failed"
|
||||
applying_update = "Applying update…"
|
||||
update_available = "Update available"
|
||||
update_via_winget = "via WinGet"
|
||||
exit = "Exit"
|
||||
show_widget = "Show widget"
|
||||
session_window = "5h"
|
||||
weekly_window = "7d"
|
||||
now = "now"
|
||||
day_suffix = "d"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Claude Code session expired"
|
||||
token_expired_body = "Sign in again to keep tracking your usage."
|
||||
chatgpt_token_expired_title = "Codex session expired"
|
||||
chatgpt_token_expired_body = "Sign in again to keep tracking your usage."
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "es"
|
||||
native_name = "Español"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Actualizar"
|
||||
update_frequency = "Frecuencia de actualización"
|
||||
one_minute = "1 minuto"
|
||||
five_minutes = "5 minutos"
|
||||
fifteen_minutes = "15 minutos"
|
||||
one_hour = "1 hora"
|
||||
models = "Modelos"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Ajustes"
|
||||
start_with_windows = "Iniciar con Windows"
|
||||
reset_position = "Restablecer posición"
|
||||
language = "Idioma"
|
||||
system_default = "Predeterminado del sistema"
|
||||
check_for_updates = "Buscar actualizaciones"
|
||||
checking_for_updates = "Buscando actualizaciones…"
|
||||
up_to_date = "Al día"
|
||||
update_failed = "Actualización fallida"
|
||||
applying_update = "Aplicando actualización…"
|
||||
update_available = "Actualización disponible"
|
||||
update_via_winget = "vía WinGet"
|
||||
exit = "Salir"
|
||||
show_widget = "Mostrar widget"
|
||||
session_window = "5h"
|
||||
weekly_window = "7d"
|
||||
now = "ahora"
|
||||
day_suffix = "d"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Sesión de Claude Code caducada"
|
||||
token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
|
||||
chatgpt_token_expired_title = "Sesión de Codex caducada"
|
||||
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "fr"
|
||||
native_name = "Français"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Actualiser"
|
||||
update_frequency = "Fréquence de mise à jour"
|
||||
one_minute = "1 minute"
|
||||
five_minutes = "5 minutes"
|
||||
fifteen_minutes = "15 minutes"
|
||||
one_hour = "1 heure"
|
||||
models = "Modèles"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Paramètres"
|
||||
start_with_windows = "Lancer avec Windows"
|
||||
reset_position = "Réinitialiser la position"
|
||||
language = "Langue"
|
||||
system_default = "Paramètre système"
|
||||
check_for_updates = "Rechercher des mises à jour"
|
||||
checking_for_updates = "Recherche en cours…"
|
||||
up_to_date = "À jour"
|
||||
update_failed = "Mise à jour échouée"
|
||||
applying_update = "Mise à jour en cours…"
|
||||
update_available = "Mise à jour disponible"
|
||||
update_via_winget = "via WinGet"
|
||||
exit = "Quitter"
|
||||
show_widget = "Afficher le widget"
|
||||
session_window = "5h"
|
||||
weekly_window = "7j"
|
||||
now = "maintenant"
|
||||
day_suffix = "j"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Session Claude Code expirée"
|
||||
token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
||||
chatgpt_token_expired_title = "Session Codex expirée"
|
||||
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "ja"
|
||||
native_name = "日本語"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "更新"
|
||||
update_frequency = "更新間隔"
|
||||
one_minute = "1分"
|
||||
five_minutes = "5分"
|
||||
fifteen_minutes = "15分"
|
||||
one_hour = "1時間"
|
||||
models = "モデル"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "設定"
|
||||
start_with_windows = "Windows起動時に開始"
|
||||
reset_position = "位置をリセット"
|
||||
language = "言語"
|
||||
system_default = "システム既定"
|
||||
check_for_updates = "更新を確認"
|
||||
checking_for_updates = "確認中…"
|
||||
up_to_date = "最新です"
|
||||
update_failed = "更新に失敗しました"
|
||||
applying_update = "更新を適用中…"
|
||||
update_available = "更新あり"
|
||||
update_via_winget = "WinGet経由"
|
||||
exit = "終了"
|
||||
show_widget = "ウィジェットを表示"
|
||||
session_window = "5時間"
|
||||
weekly_window = "7日"
|
||||
now = "今"
|
||||
day_suffix = "日"
|
||||
hour_suffix = "時"
|
||||
minute_suffix = "分"
|
||||
second_suffix = "秒"
|
||||
token_expired_title = "Claude Codeのセッションが切れました"
|
||||
token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||
chatgpt_token_expired_title = "Codexのセッションが切れました"
|
||||
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "ko"
|
||||
native_name = "한국어"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "새로 고침"
|
||||
update_frequency = "업데이트 주기"
|
||||
one_minute = "1분"
|
||||
five_minutes = "5분"
|
||||
fifteen_minutes = "15분"
|
||||
one_hour = "1시간"
|
||||
models = "모델"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "설정"
|
||||
start_with_windows = "Windows 시작 시 실행"
|
||||
reset_position = "위치 초기화"
|
||||
language = "언어"
|
||||
system_default = "시스템 기본값"
|
||||
check_for_updates = "업데이트 확인"
|
||||
checking_for_updates = "확인 중…"
|
||||
up_to_date = "최신 상태"
|
||||
update_failed = "업데이트 실패"
|
||||
applying_update = "업데이트 적용 중…"
|
||||
update_available = "업데이트 있음"
|
||||
update_via_winget = "WinGet 사용"
|
||||
exit = "종료"
|
||||
show_widget = "위젯 표시"
|
||||
session_window = "5시간"
|
||||
weekly_window = "7일"
|
||||
now = "지금"
|
||||
day_suffix = "일"
|
||||
hour_suffix = "시간"
|
||||
minute_suffix = "분"
|
||||
second_suffix = "초"
|
||||
token_expired_title = "Claude Code 세션 만료"
|
||||
token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
||||
chatgpt_token_expired_title = "Codex 세션 만료"
|
||||
chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "nl"
|
||||
native_name = "Nederlands"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Vernieuwen"
|
||||
update_frequency = "Bijwerkfrequentie"
|
||||
one_minute = "1 minuut"
|
||||
five_minutes = "5 minuten"
|
||||
fifteen_minutes = "15 minuten"
|
||||
one_hour = "1 uur"
|
||||
models = "Modellen"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Instellingen"
|
||||
start_with_windows = "Starten met Windows"
|
||||
reset_position = "Positie herstellen"
|
||||
language = "Taal"
|
||||
system_default = "Systeemstandaard"
|
||||
check_for_updates = "Controleren op updates"
|
||||
checking_for_updates = "Bezig met controleren…"
|
||||
up_to_date = "Up-to-date"
|
||||
update_failed = "Update mislukt"
|
||||
applying_update = "Update toepassen…"
|
||||
update_available = "Update beschikbaar"
|
||||
update_via_winget = "via WinGet"
|
||||
exit = "Afsluiten"
|
||||
show_widget = "Widget tonen"
|
||||
session_window = "5u"
|
||||
weekly_window = "7d"
|
||||
now = "nu"
|
||||
day_suffix = "d"
|
||||
hour_suffix = "u"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Claude Code-sessie verlopen"
|
||||
token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
||||
chatgpt_token_expired_title = "Codex-sessie verlopen"
|
||||
chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "zh-TW"
|
||||
native_name = "繁體中文"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "重新整理"
|
||||
update_frequency = "更新頻率"
|
||||
one_minute = "1 分鐘"
|
||||
five_minutes = "5 分鐘"
|
||||
fifteen_minutes = "15 分鐘"
|
||||
one_hour = "1 小時"
|
||||
models = "模型"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "設定"
|
||||
start_with_windows = "隨 Windows 啟動"
|
||||
reset_position = "重設位置"
|
||||
language = "語言"
|
||||
system_default = "系統預設"
|
||||
check_for_updates = "檢查更新"
|
||||
checking_for_updates = "檢查中…"
|
||||
up_to_date = "已是最新"
|
||||
update_failed = "更新失敗"
|
||||
applying_update = "正在套用更新…"
|
||||
update_available = "有可用更新"
|
||||
update_via_winget = "透過 WinGet"
|
||||
exit = "結束"
|
||||
show_widget = "顯示小工具"
|
||||
session_window = "5 小時"
|
||||
weekly_window = "7 日"
|
||||
now = "現在"
|
||||
day_suffix = "日"
|
||||
hour_suffix = "時"
|
||||
minute_suffix = "分"
|
||||
second_suffix = "秒"
|
||||
token_expired_title = "Claude Code 工作階段已過期"
|
||||
token_expired_body = "請重新登入以繼續追蹤使用量。"
|
||||
chatgpt_token_expired_title = "Codex 工作階段已過期"
|
||||
chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。"
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
// Embedded TOML-based localisation.
|
||||
//
|
||||
// Each supported language lives in `locales/<code>.toml`. At startup we
|
||||
// `include_str!` every file, parse them with `toml`, and stash them in a
|
||||
// HashMap keyed by language code. The active language defaults to whatever
|
||||
// Windows reports for the user's preferred UI language; the menu lets the
|
||||
// user override that.
|
||||
//
|
||||
// Adding a translation: copy `en.toml` to `<code>.toml`, translate the
|
||||
// strings, then add one `include_str!` entry to `RAW_LOCALES` below.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod detect;
|
||||
|
||||
const FALLBACK_CODE: &str = "en";
|
||||
|
||||
/// The strings every UI module needs. Field names map 1:1 to TOML keys.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct LocaleStrings {
|
||||
pub window_title: String,
|
||||
pub refresh: String,
|
||||
pub update_frequency: String,
|
||||
pub one_minute: String,
|
||||
pub five_minutes: String,
|
||||
pub fifteen_minutes: String,
|
||||
pub one_hour: String,
|
||||
pub models: String,
|
||||
pub claude_label: String,
|
||||
pub chatgpt_label: String,
|
||||
pub settings: String,
|
||||
pub start_with_windows: String,
|
||||
pub reset_position: String,
|
||||
pub language: String,
|
||||
pub system_default: String,
|
||||
pub check_for_updates: String,
|
||||
pub checking_for_updates: String,
|
||||
pub up_to_date: String,
|
||||
pub update_failed: String,
|
||||
pub applying_update: String,
|
||||
pub update_available: String,
|
||||
pub update_via_winget: String,
|
||||
pub exit: String,
|
||||
pub show_widget: String,
|
||||
pub session_window: String,
|
||||
pub weekly_window: String,
|
||||
pub now: String,
|
||||
pub day_suffix: String,
|
||||
pub hour_suffix: String,
|
||||
pub minute_suffix: String,
|
||||
pub second_suffix: String,
|
||||
pub token_expired_title: String,
|
||||
pub token_expired_body: String,
|
||||
pub chatgpt_token_expired_title: String,
|
||||
pub chatgpt_token_expired_body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LocaleFile {
|
||||
code: String,
|
||||
native_name: String,
|
||||
#[serde(flatten)]
|
||||
strings: LocaleStrings,
|
||||
}
|
||||
|
||||
const RAW_LOCALES: &[(&str, &str)] = &[
|
||||
("en", include_str!("locales/en.toml")),
|
||||
("nl", include_str!("locales/nl.toml")),
|
||||
("es", include_str!("locales/es.toml")),
|
||||
("fr", include_str!("locales/fr.toml")),
|
||||
("de", include_str!("locales/de.toml")),
|
||||
("ja", include_str!("locales/ja.toml")),
|
||||
("ko", include_str!("locales/ko.toml")),
|
||||
("zh-TW", include_str!("locales/zh-TW.toml")),
|
||||
];
|
||||
|
||||
pub struct I18n {
|
||||
/// Sorted by code so menus list languages deterministically.
|
||||
available: BTreeMap<String, (String, LocaleStrings)>,
|
||||
active: String,
|
||||
}
|
||||
|
||||
impl I18n {
|
||||
/// Load all embedded TOML files and pick an active language.
|
||||
///
|
||||
/// `requested` overrides system detection. `None` means "ask Windows".
|
||||
pub fn load(requested: Option<&str>) -> Self {
|
||||
let mut available = BTreeMap::new();
|
||||
for (code, body) in RAW_LOCALES {
|
||||
match toml::from_str::<LocaleFile>(body) {
|
||||
Ok(file) => {
|
||||
available.insert(file.code.clone(), (file.native_name, file.strings));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("failed to parse locale {code}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if !available.contains_key(FALLBACK_CODE) {
|
||||
// Embedded TOMLs are validated by tests; this should never
|
||||
// happen in practice. Fall through with whatever we have.
|
||||
log::error!("fallback locale '{FALLBACK_CODE}' missing");
|
||||
}
|
||||
|
||||
let active = requested
|
||||
.and_then(|c| normalise(c, &available))
|
||||
.or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &available)))
|
||||
.unwrap_or_else(|| FALLBACK_CODE.to_string());
|
||||
|
||||
Self { available, active }
|
||||
}
|
||||
|
||||
pub fn strings(&self) -> &LocaleStrings {
|
||||
self.available
|
||||
.get(&self.active)
|
||||
.map(|(_, s)| s)
|
||||
.unwrap_or_else(|| {
|
||||
// Defensive: if `active` was set to something unavailable
|
||||
// (shouldn't happen given `load` validates) — fall back.
|
||||
&self
|
||||
.available
|
||||
.get(FALLBACK_CODE)
|
||||
.expect("fallback locale must exist")
|
||||
.1
|
||||
})
|
||||
}
|
||||
|
||||
pub fn active_code(&self) -> &str {
|
||||
&self.active
|
||||
}
|
||||
|
||||
/// Iterate `(code, native_name)` pairs in stable order.
|
||||
pub fn available(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.available
|
||||
.iter()
|
||||
.map(|(code, (name, _))| (code.as_str(), name.as_str()))
|
||||
}
|
||||
|
||||
pub fn set_active(&mut self, requested: Option<&str>) {
|
||||
let new_active = requested
|
||||
.and_then(|c| normalise(c, &self.available))
|
||||
.or_else(|| {
|
||||
detect::detect_system_locale().and_then(|c| normalise(&c, &self.available))
|
||||
})
|
||||
.unwrap_or_else(|| FALLBACK_CODE.to_string());
|
||||
self.active = new_active;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a user-supplied or system-supplied locale code to one we have.
|
||||
///
|
||||
/// Handles `en_US`, `en-US`, `EN`, `zh-Hant-TW`, etc. by progressive
|
||||
/// fallback: exact → ASCII-lower exact → prefix match.
|
||||
fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>) -> Option<String> {
|
||||
let cleaned = input.trim().replace('_', "-");
|
||||
if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("system") {
|
||||
return None;
|
||||
}
|
||||
// Exact (case-insensitive)
|
||||
for key in available.keys() {
|
||||
if key.eq_ignore_ascii_case(&cleaned) {
|
||||
return Some(key.clone());
|
||||
}
|
||||
}
|
||||
// Special-case: Traditional Chinese variants → zh-TW
|
||||
let lower = cleaned.to_ascii_lowercase();
|
||||
if lower.starts_with("zh") && (lower.contains("tw") || lower.contains("hk") || lower.contains("hant")) {
|
||||
if available.contains_key("zh-TW") {
|
||||
return Some("zh-TW".to_string());
|
||||
}
|
||||
}
|
||||
// Prefix fallback (e.g. "en-US" → "en")
|
||||
let prefix = lower.split('-').next().unwrap_or("");
|
||||
if !prefix.is_empty() {
|
||||
for key in available.keys() {
|
||||
if key.split('-').next().map(str::to_ascii_lowercase).as_deref() == Some(prefix) {
|
||||
return Some(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------- Free-function helpers ----------
|
||||
|
||||
/// Format a `usage::Window` percentage + countdown as `"73% · 2h"`-style text.
|
||||
/// Returns just the percentage when no reset time is available.
|
||||
pub fn format_window(window: &crate::usage::Window, strings: &LocaleStrings) -> String {
|
||||
let pct = format!("{:.0}%", window.utilization);
|
||||
let cd = format_countdown(window.resets_at, strings);
|
||||
if cd.is_empty() {
|
||||
pct
|
||||
} else {
|
||||
format!("{pct} \u{00b7} {cd}")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_countdown(resets_at: Option<SystemTime>, strings: &LocaleStrings) -> String {
|
||||
let Some(reset) = resets_at else {
|
||||
return String::new();
|
||||
};
|
||||
let remaining = match reset.duration_since(SystemTime::now()) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return strings.now.clone(),
|
||||
};
|
||||
format_countdown_secs(remaining.as_secs(), strings)
|
||||
}
|
||||
|
||||
fn format_countdown_secs(total_secs: u64, strings: &LocaleStrings) -> String {
|
||||
let days = total_secs / 86_400;
|
||||
let hours = total_secs / 3_600;
|
||||
let mins = total_secs / 60;
|
||||
if days >= 1 {
|
||||
format!("{days}{}", strings.day_suffix)
|
||||
} else if hours >= 1 {
|
||||
format!("{hours}{}", strings.hour_suffix)
|
||||
} else if mins >= 1 {
|
||||
format!("{mins}{}", strings.minute_suffix)
|
||||
} else {
|
||||
format!("{total_secs}{}", strings.second_suffix)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long before `format_window`'s string would change.
|
||||
/// Used by the countdown timer to refresh exactly when needed.
|
||||
pub fn time_until_display_change(resets_at: Option<SystemTime>) -> Option<Duration> {
|
||||
let reset = resets_at?;
|
||||
let remaining = reset.duration_since(SystemTime::now()).ok()?;
|
||||
let secs = remaining.as_secs();
|
||||
let bucket_start = if secs / 86_400 >= 1 {
|
||||
(secs / 86_400) * 86_400
|
||||
} else if secs / 3_600 >= 1 {
|
||||
(secs / 3_600) * 3_600
|
||||
} else if secs / 60 >= 1 {
|
||||
(secs / 60) * 60
|
||||
} else {
|
||||
secs
|
||||
};
|
||||
Some(Duration::from_secs(secs.saturating_sub(bucket_start) + 1))
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
use super::Strings;
|
||||
|
||||
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Bijwerken via WinGet";
|
||||
|
||||
pub(super) const STRINGS: Strings = Strings {
|
||||
window_title: "Claude Code Gebruiksmonitor",
|
||||
refresh: "Vernieuwen",
|
||||
update_frequency: "Updatefrequentie",
|
||||
one_minute: "1 minuut",
|
||||
five_minutes: "5 minuten",
|
||||
fifteen_minutes: "15 minuten",
|
||||
one_hour: "1 uur",
|
||||
models: "Modellen",
|
||||
claude_code_model: "Claude Code",
|
||||
codex_model: "Codex",
|
||||
settings: "Instellingen",
|
||||
start_with_windows: "Opstarten met Windows",
|
||||
reset_position: "Positie herstellen",
|
||||
language: "Taal",
|
||||
system_default: "Systeemstandaard",
|
||||
check_for_updates: "Controleren op updates",
|
||||
checking_for_updates: "Controleren op updates...",
|
||||
updates: "Updates",
|
||||
update_in_progress: "Er is al een updatecontrole bezig.",
|
||||
up_to_date: "Je gebruikt al de nieuwste versie.",
|
||||
up_to_date_short: "Up-to-date",
|
||||
update_failed: "Automatisch bijwerken mislukt",
|
||||
applying_update: "Update wordt toegepast...",
|
||||
update_to: "Bijwerken naar",
|
||||
update_available: "Update beschikbaar",
|
||||
update_prompt_now: "Versie {version} is beschikbaar. Wil je nu bijwerken?",
|
||||
exit: "Afsluiten",
|
||||
show_widget: "Widget tonen",
|
||||
session_window: "5u",
|
||||
weekly_window: "7d",
|
||||
now: "nu",
|
||||
day_suffix: "d",
|
||||
hour_suffix: "u",
|
||||
minute_suffix: "m",
|
||||
token_expired_title: "Claude Code-authenticatiefout",
|
||||
token_expired_body: "Voer 'claude' uit in een terminal, gebruik daarna '/login' en volg de stappen. Ververs of herstart de app daarna.",
|
||||
codex_token_expired_title: "Codex-authenticatiefout",
|
||||
codex_token_expired_body: "Voer 'codex' uit in een terminal en volg de aanmeldstappen. Ververs of herstart de app daarna.",
|
||||
codex_window_title: "Codex-gebruiksmonitor",
|
||||
second_suffix: "s",
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
use super::Strings;
|
||||
|
||||
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Update via WinGet";
|
||||
|
||||
pub(super) const STRINGS: Strings = Strings {
|
||||
window_title: "Claude Code Usage Monitor",
|
||||
refresh: "Refresh",
|
||||
update_frequency: "Update Frequency",
|
||||
one_minute: "1 Minute",
|
||||
five_minutes: "5 Minutes",
|
||||
fifteen_minutes: "15 Minutes",
|
||||
one_hour: "1 Hour",
|
||||
models: "Models",
|
||||
claude_code_model: "Claude Code",
|
||||
codex_model: "Codex",
|
||||
settings: "Settings",
|
||||
start_with_windows: "Start with Windows",
|
||||
reset_position: "Reset Position",
|
||||
language: "Language",
|
||||
system_default: "System Default",
|
||||
check_for_updates: "Check for Updates",
|
||||
checking_for_updates: "Checking for Updates...",
|
||||
updates: "Updates",
|
||||
update_in_progress: "An update check is already in progress.",
|
||||
up_to_date: "You already have the latest version.",
|
||||
up_to_date_short: "Up to date",
|
||||
update_failed: "Unable to update automatically",
|
||||
applying_update: "Applying update...",
|
||||
update_to: "Update to",
|
||||
update_available: "Update available",
|
||||
update_prompt_now: "Version {version} is available. Do you want to update now?",
|
||||
exit: "Exit",
|
||||
show_widget: "Show Widget",
|
||||
session_window: "5h",
|
||||
weekly_window: "7d",
|
||||
now: "now",
|
||||
day_suffix: "d",
|
||||
hour_suffix: "h",
|
||||
minute_suffix: "m",
|
||||
token_expired_title: "Claude Code Auth Error",
|
||||
token_expired_body: "Run 'claude' in a terminal, then use '/login' and follow the prompts. After that, refresh or restart this app.",
|
||||
codex_token_expired_title: "Codex Auth Error",
|
||||
codex_token_expired_body: "Run 'codex' in a terminal and follow the sign-in prompts. After that, refresh or restart this app.",
|
||||
codex_window_title: "Codex Usage Monitor",
|
||||
second_suffix: "s",
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
use super::Strings;
|
||||
|
||||
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Mettre à jour avec WinGet";
|
||||
|
||||
pub(super) const STRINGS: Strings = Strings {
|
||||
window_title: "Moniteur d'utilisation Claude Code",
|
||||
refresh: "Actualiser",
|
||||
update_frequency: "Fréquence de mise à jour",
|
||||
one_minute: "1 minute",
|
||||
five_minutes: "5 minutes",
|
||||
fifteen_minutes: "15 minutes",
|
||||
one_hour: "1 heure",
|
||||
models: "Modeles",
|
||||
claude_code_model: "Claude Code",
|
||||
codex_model: "Codex",
|
||||
settings: "Paramètres",
|
||||
start_with_windows: "Démarrer avec Windows",
|
||||
reset_position: "Réinitialiser la position",
|
||||
language: "Langue",
|
||||
system_default: "Par défaut du système",
|
||||
check_for_updates: "Vérifier les mises à jour",
|
||||
checking_for_updates: "Vérification des mises à jour...",
|
||||
updates: "Mises à jour",
|
||||
update_in_progress: "Une vérification de mise à jour est déjà en cours.",
|
||||
up_to_date: "Vous utilisez déjà la version la plus récente.",
|
||||
up_to_date_short: "À jour",
|
||||
update_failed: "Impossible d'effectuer la mise à jour automatiquement",
|
||||
applying_update: "Application de la mise à jour...",
|
||||
update_to: "Mettre à jour vers",
|
||||
update_available: "Mise à jour disponible",
|
||||
update_prompt_now: "La version {version} est disponible. Voulez-vous mettre à jour maintenant ?",
|
||||
exit: "Quitter",
|
||||
show_widget: "Afficher le widget",
|
||||
session_window: "5h",
|
||||
weekly_window: "7d",
|
||||
now: "maintenant",
|
||||
day_suffix: "j",
|
||||
hour_suffix: "h",
|
||||
minute_suffix: "m",
|
||||
token_expired_title: "Erreur d'authentification",
|
||||
token_expired_body: "Exécutez 'claude' dans un terminal, puis utilisez '/login' et suivez les instructions. Ensuite, actualisez ou redémarrez cette application.",
|
||||
codex_token_expired_title: "Erreur d'authentification Codex",
|
||||
codex_token_expired_body: "Executez 'codex' dans un terminal et suivez les instructions de connexion. Ensuite, actualisez ou redemarrez cette application.",
|
||||
codex_window_title: "Moniteur d'utilisation Codex",
|
||||
second_suffix: "s",
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
use super::Strings;
|
||||
|
||||
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Mit WinGet aktualisieren";
|
||||
|
||||
pub(super) const STRINGS: Strings = Strings {
|
||||
window_title: "Claude Code Nutzungsmonitor",
|
||||
refresh: "Aktualisieren",
|
||||
update_frequency: "Aktualisierungsintervall",
|
||||
one_minute: "1 Minute",
|
||||
five_minutes: "5 Minuten",
|
||||
fifteen_minutes: "15 Minuten",
|
||||
one_hour: "1 Stunde",
|
||||
models: "Modelle",
|
||||
claude_code_model: "Claude Code",
|
||||
codex_model: "Codex",
|
||||
settings: "Einstellungen",
|
||||
start_with_windows: "Mit Windows starten",
|
||||
reset_position: "Position zurücksetzen",
|
||||
language: "Sprache",
|
||||
system_default: "Systemstandard",
|
||||
check_for_updates: "Nach Updates suchen",
|
||||
checking_for_updates: "Suche nach Updates...",
|
||||
updates: "Updates",
|
||||
update_in_progress: "Eine Update-Prüfung läuft bereits.",
|
||||
up_to_date: "Sie verwenden bereits die neueste Version.",
|
||||
up_to_date_short: "Aktuell",
|
||||
update_failed: "Automatisches Update war nicht möglich",
|
||||
applying_update: "Update wird installiert...",
|
||||
update_to: "Aktualisieren auf",
|
||||
update_available: "Update verfügbar",
|
||||
update_prompt_now: "Version {version} ist verfügbar. Möchten Sie jetzt aktualisieren?",
|
||||
exit: "Beenden",
|
||||
show_widget: "Widget anzeigen",
|
||||
session_window: "5h",
|
||||
weekly_window: "7d",
|
||||
now: "jetzt",
|
||||
day_suffix: "T",
|
||||
hour_suffix: "h",
|
||||
minute_suffix: "m",
|
||||
token_expired_title: "Authentifizierungsfehler",
|
||||
token_expired_body: "Führen Sie 'claude' in einem Terminal aus, verwenden Sie dann '/login' und folgen Sie den Anweisungen. Aktualisieren oder starten Sie diese App anschließend neu.",
|
||||
codex_token_expired_title: "Codex-Authentifizierungsfehler",
|
||||
codex_token_expired_body: "Fuhren Sie 'codex' in einem Terminal aus und folgen Sie den Anmeldeanweisungen. Aktualisieren oder starten Sie diese App anschliessend neu.",
|
||||
codex_window_title: "Codex-Nutzungsmonitor",
|
||||
second_suffix: "s",
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
use super::Strings;
|
||||
|
||||
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "WinGet で更新";
|
||||
|
||||
pub(super) const STRINGS: Strings = Strings {
|
||||
window_title: "Claude Code 使用量モニター",
|
||||
refresh: "更新",
|
||||
update_frequency: "更新間隔",
|
||||
one_minute: "1分",
|
||||
five_minutes: "5分",
|
||||
fifteen_minutes: "15分",
|
||||
one_hour: "1時間",
|
||||
models: "モデル",
|
||||
claude_code_model: "Claude Code",
|
||||
codex_model: "Codex",
|
||||
settings: "設定",
|
||||
start_with_windows: "Windows と同時に開始",
|
||||
reset_position: "位置をリセット",
|
||||
language: "言語",
|
||||
system_default: "システム既定",
|
||||
check_for_updates: "更新を確認",
|
||||
checking_for_updates: "更新を確認しています...",
|
||||
updates: "更新",
|
||||
update_in_progress: "更新確認は既に実行中です。",
|
||||
up_to_date: "既に最新バージョンです。",
|
||||
up_to_date_short: "最新です",
|
||||
update_failed: "自動更新を完了できませんでした",
|
||||
applying_update: "更新を適用しています...",
|
||||
update_to: "更新先",
|
||||
update_available: "更新が利用可能です",
|
||||
update_prompt_now: "バージョン {version} が利用可能です。今すぐ更新しますか?",
|
||||
exit: "終了",
|
||||
show_widget: "ウィジェットを表示",
|
||||
session_window: "5h",
|
||||
weekly_window: "7d",
|
||||
now: "今",
|
||||
day_suffix: "日",
|
||||
hour_suffix: "時間",
|
||||
minute_suffix: "分",
|
||||
token_expired_title: "認証エラー",
|
||||
token_expired_body: "ターミナルで 'claude' を実行し、'/login' を使って案内に従ってください。その後、このアプリを更新するか再起動してください。",
|
||||
codex_token_expired_title: "Codex 認証エラー",
|
||||
codex_token_expired_body: "ターミナルで 'codex' を実行し、サインインの案内に従ってください。その後、このアプリを更新または再起動してください。",
|
||||
codex_window_title: "Codex 使用量モニター",
|
||||
second_suffix: "秒",
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
use super::Strings;
|
||||
|
||||
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "WinGet으로 업데이트";
|
||||
|
||||
pub(super) const STRINGS: Strings = Strings {
|
||||
window_title: "Claude Code 사용량 모니터",
|
||||
refresh: "새로고침",
|
||||
update_frequency: "업데이트 주기",
|
||||
one_minute: "1분",
|
||||
five_minutes: "5분",
|
||||
fifteen_minutes: "15분",
|
||||
one_hour: "1시간",
|
||||
models: "모델",
|
||||
claude_code_model: "Claude Code",
|
||||
codex_model: "Codex",
|
||||
settings: "설정",
|
||||
start_with_windows: "Windows 시작 시 자동 실행",
|
||||
reset_position: "위치 초기화",
|
||||
language: "언어",
|
||||
system_default: "시스템 기본값",
|
||||
check_for_updates: "업데이트 확인",
|
||||
checking_for_updates: "업데이트 확인 중...",
|
||||
updates: "업데이트",
|
||||
update_in_progress: "이미 업데이트 확인이 진행 중입니다.",
|
||||
up_to_date: "이미 최신 버전입니다.",
|
||||
up_to_date_short: "최신",
|
||||
update_failed: "자동 업데이트를 완료할 수 없습니다",
|
||||
applying_update: "업데이트 적용 중...",
|
||||
update_to: "업데이트 대상",
|
||||
update_available: "업데이트 사용 가능",
|
||||
update_prompt_now: "버전 {version}을 사용할 수 있습니다. 지금 업데이트하시겠습니까?",
|
||||
exit: "종료",
|
||||
show_widget: "위젯 표시",
|
||||
session_window: "5시간",
|
||||
weekly_window: "7일",
|
||||
now: "지금",
|
||||
day_suffix: "일",
|
||||
hour_suffix: "시간",
|
||||
minute_suffix: "분",
|
||||
token_expired_title: "인증 오류",
|
||||
token_expired_body: "터미널에서 'claude'를 실행한 다음 '/login'을 사용하고 안내에 따라 진행하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.",
|
||||
codex_token_expired_title: "Codex 인증 오류",
|
||||
codex_token_expired_body: "터미널에서 'codex'를 실행하고 로그인 안내를 따르세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.",
|
||||
codex_window_title: "Codex 사용량 모니터",
|
||||
second_suffix: "초",
|
||||
};
|
||||
@@ -1,246 +0,0 @@
|
||||
mod dutch;
|
||||
mod english;
|
||||
mod french;
|
||||
mod german;
|
||||
mod japanese;
|
||||
mod korean;
|
||||
mod spanish;
|
||||
mod traditional_chinese;
|
||||
|
||||
use windows::core::PWSTR;
|
||||
use windows::Win32::Globalization::{
|
||||
GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages,
|
||||
LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LanguageId {
|
||||
English,
|
||||
Dutch,
|
||||
Spanish,
|
||||
French,
|
||||
German,
|
||||
Japanese,
|
||||
Korean,
|
||||
TraditionalChinese,
|
||||
}
|
||||
|
||||
impl LanguageId {
|
||||
pub const ALL: [LanguageId; 8] = [
|
||||
LanguageId::English,
|
||||
LanguageId::Dutch,
|
||||
LanguageId::Spanish,
|
||||
LanguageId::French,
|
||||
LanguageId::German,
|
||||
LanguageId::Japanese,
|
||||
LanguageId::Korean,
|
||||
LanguageId::TraditionalChinese,
|
||||
];
|
||||
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::English => "en",
|
||||
Self::Dutch => "nl",
|
||||
Self::Spanish => "es",
|
||||
Self::French => "fr",
|
||||
Self::German => "de",
|
||||
Self::Japanese => "ja",
|
||||
Self::Korean => "ko",
|
||||
Self::TraditionalChinese => "zh-TW",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn native_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::English => "English",
|
||||
Self::Dutch => "Nederlands",
|
||||
Self::Spanish => "Español",
|
||||
Self::French => "Français",
|
||||
Self::German => "Deutsch",
|
||||
Self::Japanese => "日本語",
|
||||
Self::Korean => "한국어",
|
||||
Self::TraditionalChinese => "繁體中文",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strings(self) -> Strings {
|
||||
match self {
|
||||
Self::English => english::STRINGS,
|
||||
Self::Dutch => dutch::STRINGS,
|
||||
Self::Spanish => spanish::STRINGS,
|
||||
Self::French => french::STRINGS,
|
||||
Self::German => german::STRINGS,
|
||||
Self::Japanese => japanese::STRINGS,
|
||||
Self::Korean => korean::STRINGS,
|
||||
Self::TraditionalChinese => traditional_chinese::STRINGS,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_via_winget_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::English => english::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::Dutch => dutch::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::Spanish => spanish::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::French => french::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::German => german::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::Japanese => japanese::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::Korean => korean::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::TraditionalChinese => traditional_chinese::UPDATE_VIA_WINGET_LABEL,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(code: &str) -> Option<Self> {
|
||||
let normalized = code.trim().replace('_', "-").to_ascii_lowercase();
|
||||
if normalized.is_empty() || normalized == "system" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefix = normalized.split('-').next().unwrap_or_default();
|
||||
match prefix {
|
||||
"en" => Some(Self::English),
|
||||
"nl" => Some(Self::Dutch),
|
||||
"es" => Some(Self::Spanish),
|
||||
"fr" => Some(Self::French),
|
||||
"de" => Some(Self::German),
|
||||
"ja" => Some(Self::Japanese),
|
||||
"ko" => Some(Self::Korean),
|
||||
"zh" => {
|
||||
if normalized.contains("tw")
|
||||
|| normalized.contains("hk")
|
||||
|| normalized.contains("hant")
|
||||
{
|
||||
Some(Self::TraditionalChinese)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Strings {
|
||||
pub window_title: &'static str,
|
||||
pub refresh: &'static str,
|
||||
pub update_frequency: &'static str,
|
||||
pub one_minute: &'static str,
|
||||
pub five_minutes: &'static str,
|
||||
pub fifteen_minutes: &'static str,
|
||||
pub one_hour: &'static str,
|
||||
pub models: &'static str,
|
||||
pub claude_code_model: &'static str,
|
||||
pub codex_model: &'static str,
|
||||
pub settings: &'static str,
|
||||
pub start_with_windows: &'static str,
|
||||
pub reset_position: &'static str,
|
||||
pub language: &'static str,
|
||||
pub system_default: &'static str,
|
||||
pub check_for_updates: &'static str,
|
||||
pub checking_for_updates: &'static str,
|
||||
pub updates: &'static str,
|
||||
pub update_in_progress: &'static str,
|
||||
pub up_to_date: &'static str,
|
||||
pub up_to_date_short: &'static str,
|
||||
pub update_failed: &'static str,
|
||||
pub applying_update: &'static str,
|
||||
pub update_to: &'static str,
|
||||
pub update_available: &'static str,
|
||||
pub update_prompt_now: &'static str,
|
||||
pub exit: &'static str,
|
||||
pub show_widget: &'static str,
|
||||
pub session_window: &'static str,
|
||||
pub weekly_window: &'static str,
|
||||
pub now: &'static str,
|
||||
pub day_suffix: &'static str,
|
||||
pub hour_suffix: &'static str,
|
||||
pub minute_suffix: &'static str,
|
||||
pub second_suffix: &'static str,
|
||||
pub token_expired_title: &'static str,
|
||||
pub token_expired_body: &'static str,
|
||||
pub codex_token_expired_title: &'static str,
|
||||
pub codex_token_expired_body: &'static str,
|
||||
pub codex_window_title: &'static str,
|
||||
}
|
||||
|
||||
pub fn resolve_language(language_override: Option<LanguageId>) -> LanguageId {
|
||||
language_override.unwrap_or_else(detect_system_language)
|
||||
}
|
||||
|
||||
pub fn detect_system_language() -> LanguageId {
|
||||
preferred_ui_languages()
|
||||
.into_iter()
|
||||
.find_map(|locale| LanguageId::from_code(&locale))
|
||||
.or_else(default_ui_locale)
|
||||
.or_else(default_locale_name)
|
||||
.unwrap_or(LanguageId::English)
|
||||
}
|
||||
|
||||
pub fn update_via_winget(language: LanguageId) -> &'static str {
|
||||
language.update_via_winget_label()
|
||||
}
|
||||
|
||||
fn preferred_ui_languages() -> Vec<String> {
|
||||
unsafe {
|
||||
let mut num_languages = 0u32;
|
||||
let mut buffer_len = 0u32;
|
||||
if GetUserPreferredUILanguages(
|
||||
MUI_LANGUAGE_NAME,
|
||||
&mut num_languages,
|
||||
PWSTR::null(),
|
||||
&mut buffer_len,
|
||||
)
|
||||
.is_err()
|
||||
|| buffer_len == 0
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut buffer = vec![0u16; buffer_len as usize];
|
||||
if GetUserPreferredUILanguages(
|
||||
MUI_LANGUAGE_NAME,
|
||||
&mut num_languages,
|
||||
PWSTR(buffer.as_mut_ptr()),
|
||||
&mut buffer_len,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
buffer
|
||||
.split(|unit| *unit == 0)
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(String::from_utf16_lossy)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_ui_locale() -> Option<LanguageId> {
|
||||
unsafe {
|
||||
let lang_id = GetUserDefaultUILanguage();
|
||||
let mut buffer = [0u16; MAX_LOCALE_NAME as usize];
|
||||
let len = LCIDToLocaleName(
|
||||
lang_id as u32,
|
||||
Some(&mut buffer),
|
||||
LOCALE_ALLOW_NEUTRAL_NAMES,
|
||||
);
|
||||
if len <= 1 {
|
||||
return None;
|
||||
}
|
||||
let locale = String::from_utf16_lossy(&buffer[..(len as usize - 1)]);
|
||||
LanguageId::from_code(&locale)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_locale_name() -> Option<LanguageId> {
|
||||
unsafe {
|
||||
let mut buffer = [0u16; MAX_LOCALE_NAME as usize];
|
||||
let len = GetUserDefaultLocaleName(&mut buffer);
|
||||
if len <= 1 {
|
||||
return None;
|
||||
}
|
||||
let locale = String::from_utf16_lossy(&buffer[..(len as usize - 1)]);
|
||||
LanguageId::from_code(&locale)
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
use super::Strings;
|
||||
|
||||
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Actualizar con WinGet";
|
||||
|
||||
pub(super) const STRINGS: Strings = Strings {
|
||||
window_title: "Monitor de uso de Claude Code",
|
||||
refresh: "Actualizar",
|
||||
update_frequency: "Frecuencia de actualización",
|
||||
one_minute: "1 minuto",
|
||||
five_minutes: "5 minutos",
|
||||
fifteen_minutes: "15 minutos",
|
||||
one_hour: "1 hora",
|
||||
models: "Modelos",
|
||||
claude_code_model: "Claude Code",
|
||||
codex_model: "Codex",
|
||||
settings: "Configuración",
|
||||
start_with_windows: "Iniciar con Windows",
|
||||
reset_position: "Restablecer posición",
|
||||
language: "Idioma",
|
||||
system_default: "Predeterminado del sistema",
|
||||
check_for_updates: "Buscar actualizaciones",
|
||||
checking_for_updates: "Buscando actualizaciones...",
|
||||
updates: "Actualizaciones",
|
||||
update_in_progress: "Ya hay una comprobación de actualización en curso.",
|
||||
up_to_date: "Ya tienes la versión más reciente.",
|
||||
up_to_date_short: "Actualizado",
|
||||
update_failed: "No se pudo actualizar automáticamente",
|
||||
applying_update: "Aplicando actualización...",
|
||||
update_to: "Actualizar a",
|
||||
update_available: "Actualización disponible",
|
||||
update_prompt_now: "La versión {version} está disponible. ¿Quieres actualizar ahora?",
|
||||
exit: "Salir",
|
||||
show_widget: "Mostrar widget",
|
||||
session_window: "5h",
|
||||
weekly_window: "7d",
|
||||
now: "ahora",
|
||||
day_suffix: "d",
|
||||
hour_suffix: "h",
|
||||
minute_suffix: "m",
|
||||
token_expired_title: "Error de autenticación",
|
||||
token_expired_body: "Ejecuta 'claude' en una terminal, luego usa '/login' y sigue las indicaciones. Después, actualiza o reinicia esta aplicación.",
|
||||
codex_token_expired_title: "Error de autenticacion de Codex",
|
||||
codex_token_expired_body: "Ejecuta 'codex' en una terminal y sigue las indicaciones de inicio de sesion. Despues, actualiza o reinicia esta aplicacion.",
|
||||
codex_window_title: "Monitor de uso de Codex",
|
||||
second_suffix: "s",
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
use super::Strings;
|
||||
|
||||
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "透過 WinGet 更新";
|
||||
|
||||
pub(super) const STRINGS: Strings = Strings {
|
||||
window_title: "Claude Code 使用量監控",
|
||||
refresh: "重新整理",
|
||||
update_frequency: "更新頻率",
|
||||
one_minute: "1 分鐘",
|
||||
five_minutes: "5 分鐘",
|
||||
fifteen_minutes: "15 分鐘",
|
||||
one_hour: "1 小時",
|
||||
models: "模型",
|
||||
claude_code_model: "Claude Code",
|
||||
codex_model: "Codex",
|
||||
settings: "設定",
|
||||
start_with_windows: "開機時啟動",
|
||||
reset_position: "重置位置",
|
||||
language: "語言",
|
||||
system_default: "系統預設",
|
||||
check_for_updates: "檢查更新",
|
||||
checking_for_updates: "正在檢查更新...",
|
||||
updates: "更新",
|
||||
update_in_progress: "已有更新檢查正在進行中。",
|
||||
up_to_date: "您已使用最新版本。",
|
||||
up_to_date_short: "已是最新",
|
||||
update_failed: "無法自動更新",
|
||||
applying_update: "正在套用更新...",
|
||||
update_to: "更新至",
|
||||
update_available: "有可用更新",
|
||||
update_prompt_now: "版本 {version} 已可用。是否立即更新?",
|
||||
exit: "結束",
|
||||
show_widget: "顯示小工具",
|
||||
session_window: "5h",
|
||||
weekly_window: "7d",
|
||||
now: "現在",
|
||||
day_suffix: "天",
|
||||
hour_suffix: "時",
|
||||
minute_suffix: "分",
|
||||
token_expired_title: "驗證錯誤",
|
||||
token_expired_body: "請在終端機中執行 'claude',然後使用 '/login' 並依照提示操作。完成後,請重新整理或重新啟動此應用程式。",
|
||||
codex_token_expired_title: "Codex 驗證錯誤",
|
||||
codex_token_expired_body: "請在終端機中執行 'codex',並依照登入提示操作。完成後,請重新整理或重新啟動此應用程式。",
|
||||
codex_window_title: "Codex 使用量監控",
|
||||
second_suffix: "秒",
|
||||
};
|
||||
+16
-16
@@ -1,39 +1,39 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
// Original infrastructure.
|
||||
mod creds;
|
||||
mod diag;
|
||||
mod i18n;
|
||||
mod net;
|
||||
mod os;
|
||||
mod tray;
|
||||
mod update;
|
||||
mod usage;
|
||||
|
||||
// Application surface.
|
||||
mod app;
|
||||
mod bubble;
|
||||
mod diagnose;
|
||||
mod localization;
|
||||
mod models;
|
||||
mod native_interop;
|
||||
mod panel;
|
||||
mod poller;
|
||||
mod settings;
|
||||
mod theme;
|
||||
mod tray_icon;
|
||||
mod updater;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let diagnose_enabled = args.iter().any(|a| a == "--diagnose");
|
||||
if diagnose_enabled {
|
||||
if let Ok(path) = diagnose::init() {
|
||||
diagnose::log(format!(
|
||||
"startup args={args:?} log_path={}",
|
||||
path.display()
|
||||
));
|
||||
if let Ok(Some(path)) = diag::init(true) {
|
||||
log::info!("startup args={args:?} log_path={}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(exit_code) = updater::handle_cli_mode(&args) {
|
||||
if let Some(exit_code) = update::run_cli(&args) {
|
||||
if diagnose_enabled {
|
||||
diagnose::log(format!("cli mode exited with code {exit_code}"));
|
||||
log::info!("cli mode exited with code {exit_code}");
|
||||
}
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
if diagnose_enabled {
|
||||
diagnose::log("entering app::run");
|
||||
log::info!("entering app::run");
|
||||
}
|
||||
app::run();
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct UsageSection {
|
||||
pub percentage: f64,
|
||||
pub resets_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct UsageData {
|
||||
pub session: UsageSection,
|
||||
pub weekly: UsageSection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AppUsageData {
|
||||
pub claude_code: Option<UsageData>,
|
||||
pub codex: Option<UsageData>,
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
use windows::Win32::Foundation::{HWND, RECT};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetWindowRect, MoveWindow};
|
||||
|
||||
// Timer IDs (used by SetTimer / KillTimer with the bubble HWND)
|
||||
pub const TIMER_POLL: usize = 1;
|
||||
pub const TIMER_COUNTDOWN: usize = 2;
|
||||
pub const TIMER_RESET_POLL: usize = 3;
|
||||
pub const TIMER_UPDATE_CHECK: usize = 4;
|
||||
pub const TIMER_FULLSCREEN_CHECK: usize = 5;
|
||||
|
||||
// Custom messages
|
||||
pub const WM_APP: u32 = 0x8000;
|
||||
pub const WM_APP_USAGE_UPDATED: u32 = WM_APP + 1;
|
||||
pub const WM_APP_PANEL_TOGGLE: u32 = WM_APP + 2;
|
||||
pub const WM_APP_TRAY: u32 = WM_APP + 3;
|
||||
pub const WM_APP_PANEL_CLOSE: u32 = WM_APP + 4;
|
||||
|
||||
/// Get the bounding rectangle of a window in screen coordinates.
|
||||
pub fn get_window_rect_safe(hwnd: HWND) -> Option<RECT> {
|
||||
unsafe {
|
||||
let mut rect = RECT::default();
|
||||
if GetWindowRect(hwnd, &mut rect).is_ok() {
|
||||
Some(rect)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move and resize a window (top-level coordinates).
|
||||
pub fn move_window(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) {
|
||||
unsafe {
|
||||
let _ = MoveWindow(hwnd, x, y, w, h, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Rust string to a null-terminated UTF-16 vector suitable for
|
||||
/// passing as `PCWSTR`.
|
||||
pub fn wide_str(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// COLORREF byte order: 0x00BBGGRR.
|
||||
pub fn colorref(r: u8, g: u8, b: u8) -> u32 {
|
||||
r as u32 | (g as u32) << 8 | (b as u32) << 16
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Color {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self { r, g, b }
|
||||
}
|
||||
|
||||
pub fn from_hex(hex: &str) -> Self {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
|
||||
Self { r, g, b }
|
||||
}
|
||||
|
||||
pub fn to_colorref(self) -> u32 {
|
||||
colorref(self.r, self.g, self.b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// `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`.
|
||||
|
||||
pub mod winhttp;
|
||||
|
||||
pub use winhttp::{Client, Error, Response};
|
||||
@@ -0,0 +1,431 @@
|
||||
// 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,46 @@
|
||||
// Color helpers for GDI.
|
||||
//
|
||||
// Win32 GDI stores colours as a 32-bit COLORREF in 0x00BBGGRR byte order
|
||||
// (B in the low byte). We keep a normal `r,g,b` struct in code and convert
|
||||
// at the FFI boundary.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Rgb {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl Rgb {
|
||||
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self { r, g, b }
|
||||
}
|
||||
|
||||
/// Parse `#RRGGBB` or `RRGGBB`. Returns `None` on malformed input.
|
||||
pub fn parse_hex(hex: &str) -> Option<Self> {
|
||||
let s = hex.trim_start_matches('#');
|
||||
if s.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
|
||||
Some(Self { r, g, b })
|
||||
}
|
||||
|
||||
/// Pack into a Win32 COLORREF (`0x00BBGGRR`).
|
||||
pub fn into_colorref(self) -> u32 {
|
||||
(self.r as u32) | ((self.g as u32) << 8) | ((self.b as u32) << 16)
|
||||
}
|
||||
|
||||
/// Linear interpolation between two colours. `t` is clamped to `[0, 1]`.
|
||||
pub fn lerp(self, other: Rgb, t: f64) -> Rgb {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;
|
||||
Rgb {
|
||||
r: mix(self.r, other.r),
|
||||
g: mix(self.g, other.g),
|
||||
b: mix(self.b, other.b),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Per-window DPI helpers.
|
||||
//
|
||||
// The DPI of a window can differ from `GetDpiForSystem` on multi-monitor
|
||||
// setups where the app is per-monitor DPI aware. Always prefer
|
||||
// `GetDpiForWindow` for HWNDs that participate in the message loop.
|
||||
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::HiDpi::{GetDpiForSystem, GetDpiForWindow};
|
||||
|
||||
/// The default DPI Win32 reports for 100% scaling.
|
||||
pub const BASE_DPI: u32 = 96;
|
||||
|
||||
/// DPI for the supplied window. Falls back to system DPI if the call fails.
|
||||
pub fn for_window(hwnd: HWND) -> u32 {
|
||||
let raw = unsafe { GetDpiForWindow(hwnd) };
|
||||
if raw == 0 {
|
||||
for_system()
|
||||
} else {
|
||||
raw.max(BASE_DPI)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global system DPI. Cheap; safe to call from any thread.
|
||||
pub fn for_system() -> u32 {
|
||||
unsafe { GetDpiForSystem() }.max(BASE_DPI)
|
||||
}
|
||||
|
||||
/// Scale a logical (96-DPI) pixel measurement to the given DPI.
|
||||
pub fn scale(logical_px: i32, dpi: u32) -> i32 {
|
||||
((logical_px as i64) * (dpi as i64) / (BASE_DPI as i64)) as i32
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// `os` namespace: thin, typed wrappers over the slice of Win32 we use.
|
||||
//
|
||||
// Each submodule covers one concern (color conversion, UTF-16 strings,
|
||||
// DPI math, registry I/O, theme detection). Nothing in here knows about
|
||||
// the bubble UI or the polling loop — it's pure platform glue.
|
||||
|
||||
pub mod color;
|
||||
pub mod dpi;
|
||||
pub mod registry;
|
||||
pub mod string;
|
||||
pub mod theme;
|
||||
|
||||
pub use color::Rgb;
|
||||
pub use string::to_utf16_nul;
|
||||
@@ -0,0 +1,159 @@
|
||||
// Typed wrapper over a tiny subset of the Win32 registry API.
|
||||
//
|
||||
// All operations target `HKEY_CURRENT_USER` keys by default (the app only
|
||||
// reads/writes user-scoped state — startup entry, theme detection, etc.).
|
||||
// Each call opens and closes the key internally; there is no caching so
|
||||
// state changes by other processes are visible immediately.
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows::Win32::System::Registry::{
|
||||
RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, HKEY,
|
||||
HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_SZ,
|
||||
};
|
||||
|
||||
use super::string::to_utf16_nul;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RegistryError {
|
||||
#[error("registry open failed for {key}: code {code}")]
|
||||
Open { key: String, code: u32 },
|
||||
#[error("registry write failed for {key}\\{value}: code {code}")]
|
||||
Write { key: String, value: String, code: u32 },
|
||||
}
|
||||
|
||||
/// Read a `REG_DWORD` value under `HKEY_CURRENT_USER\<subkey>`.
|
||||
/// Returns `None` if the key or value does not exist.
|
||||
pub fn read_u32(subkey: &str, value_name: &str) -> Option<u32> {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let mut data: u32 = 0;
|
||||
let mut size: u32 = std::mem::size_of::<u32>() as u32;
|
||||
let query = RegQueryValueExW(
|
||||
hkey,
|
||||
PCWSTR::from_raw(value_w.as_ptr()),
|
||||
None,
|
||||
None,
|
||||
Some((&mut data as *mut u32) as *mut u8),
|
||||
Some(&mut size),
|
||||
);
|
||||
let _ = RegCloseKey(hkey);
|
||||
if query == ERROR_SUCCESS {
|
||||
Some(data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test whether a value (any type) exists under `HKEY_CURRENT_USER\<subkey>`.
|
||||
pub fn value_exists(subkey: &str, value_name: &str) -> bool {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return false;
|
||||
}
|
||||
let mut size: u32 = 0;
|
||||
let query = RegQueryValueExW(
|
||||
hkey,
|
||||
PCWSTR::from_raw(value_w.as_ptr()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&mut size),
|
||||
);
|
||||
let _ = RegCloseKey(hkey);
|
||||
query == ERROR_SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a string value as `REG_SZ` under `HKEY_CURRENT_USER\<subkey>`.
|
||||
pub fn write_string(subkey: &str, value_name: &str, value: &str) -> Result<(), RegistryError> {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
let data_w = to_utf16_nul(value);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return Err(RegistryError::Open {
|
||||
key: subkey.to_string(),
|
||||
code: open.0,
|
||||
});
|
||||
}
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
data_w.as_ptr() as *const u8,
|
||||
data_w.len() * std::mem::size_of::<u16>(),
|
||||
);
|
||||
let res = RegSetValueExW(
|
||||
hkey,
|
||||
PCWSTR::from_raw(value_w.as_ptr()),
|
||||
0,
|
||||
REG_SZ,
|
||||
Some(bytes),
|
||||
);
|
||||
let _ = RegCloseKey(hkey);
|
||||
if res == ERROR_SUCCESS {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RegistryError::Write {
|
||||
key: subkey.to_string(),
|
||||
value: value_name.to_string(),
|
||||
code: res.0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a value under `HKEY_CURRENT_USER\<subkey>`. Returns `Ok(())` even
|
||||
/// if the value never existed.
|
||||
pub fn delete_value(subkey: &str, value_name: &str) -> Result<(), RegistryError> {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return Err(RegistryError::Open {
|
||||
key: subkey.to_string(),
|
||||
code: open.0,
|
||||
});
|
||||
}
|
||||
let _ = RegDeleteValueW(hkey, PCWSTR::from_raw(value_w.as_ptr()));
|
||||
let _ = RegCloseKey(hkey);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// UTF-16 conversion helpers.
|
||||
|
||||
/// Encode a Rust `&str` as a NUL-terminated UTF-16 vector suitable for
|
||||
/// passing to Win32 `PCWSTR`-typed parameters.
|
||||
///
|
||||
/// The result lives as long as the returned `Vec<u16>`; callers must keep
|
||||
/// the vector alive across the FFI call.
|
||||
pub fn to_utf16_nul(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Windows light/dark theme detection.
|
||||
//
|
||||
// Windows stores the current theme under
|
||||
// `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize`
|
||||
// with a `SystemUsesLightTheme` DWORD: 1 means light, 0 means dark.
|
||||
|
||||
use super::registry;
|
||||
|
||||
const THEME_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
|
||||
const LIGHT_VALUE: &str = "SystemUsesLightTheme";
|
||||
|
||||
/// `true` if the system is in dark mode. Defaults to dark when the registry
|
||||
/// value is missing (matches Windows 11 first-boot behaviour).
|
||||
pub fn is_dark() -> bool {
|
||||
!matches!(registry::read_u32(THEME_KEY, LIGHT_VALUE), Some(1))
|
||||
}
|
||||
+12
-16
@@ -12,10 +12,10 @@ use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
use crate::diagnose;
|
||||
use crate::localization::Strings;
|
||||
use crate::native_interop::{wide_str, Color};
|
||||
use crate::tray_icon::TrayIconKind;
|
||||
use crate::i18n::LocaleStrings;
|
||||
use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
|
||||
use crate::usage::ProviderId;
|
||||
type TrayIconKind = ProviderId;
|
||||
|
||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
|
||||
const PANEL_W_LOGICAL: i32 = 280;
|
||||
@@ -33,9 +33,7 @@ pub struct PanelData {
|
||||
pub weekly_pct: f64,
|
||||
pub weekly_text: String,
|
||||
pub is_dark: bool,
|
||||
pub strings: Strings,
|
||||
pub claude_label: String,
|
||||
pub codex_label: String,
|
||||
pub strings: LocaleStrings,
|
||||
}
|
||||
|
||||
struct PanelState {
|
||||
@@ -68,7 +66,7 @@ pub fn register_class() {
|
||||
..Default::default()
|
||||
};
|
||||
if RegisterClassExW(&wc) == 0 {
|
||||
diagnose::log("panel RegisterClassExW returned 0");
|
||||
log::error!("panel RegisterClassExW returned 0");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -162,7 +160,7 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if hwnd == HWND::default() {
|
||||
diagnose::log("panel CreateWindowExW failed");
|
||||
log::error!("panel CreateWindowExW failed");
|
||||
None
|
||||
} else {
|
||||
Some(hwnd)
|
||||
@@ -275,8 +273,8 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
|
||||
// Header row: model label
|
||||
let header = match data.model {
|
||||
TrayIconKind::Claude => data.claude_label.clone(),
|
||||
TrayIconKind::Codex => data.codex_label.clone(),
|
||||
ProviderId::Claude => data.strings.claude_label.clone(),
|
||||
ProviderId::ChatGpt => data.strings.chatgpt_label.clone(),
|
||||
};
|
||||
draw_text(
|
||||
hdc,
|
||||
@@ -301,7 +299,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
|
||||
draw_row(
|
||||
hdc,
|
||||
data.strings.session_window,
|
||||
&data.strings.session_window,
|
||||
scaled(PADDING_LOGICAL),
|
||||
row1_y,
|
||||
bar_x,
|
||||
@@ -317,7 +315,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
|
||||
draw_row(
|
||||
hdc,
|
||||
data.strings.weekly_window,
|
||||
&data.strings.weekly_window,
|
||||
scaled(PADDING_LOGICAL),
|
||||
row2_y,
|
||||
bar_x,
|
||||
@@ -474,9 +472,7 @@ fn clone_data() -> Option<PanelData> {
|
||||
weekly_pct: p.data.weekly_pct,
|
||||
weekly_text: p.data.weekly_text.clone(),
|
||||
is_dark: p.data.is_dark,
|
||||
strings: p.data.strings,
|
||||
claude_label: p.data.claude_label.clone(),
|
||||
codex_label: p.data.codex_label.clone(),
|
||||
strings: p.data.strings.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
-1099
File diff suppressed because it is too large
Load Diff
+8
-7
@@ -3,7 +3,8 @@ use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bubble::DEFAULT_BUBBLE_SIZE;
|
||||
use crate::tray_icon::TrayIconKind;
|
||||
use crate::usage::ProviderId;
|
||||
type TrayIconKind = ProviderId;
|
||||
|
||||
const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble";
|
||||
const SETTINGS_FILE: &str = "settings.json";
|
||||
@@ -38,20 +39,20 @@ pub struct BubblePositions {
|
||||
impl BubblePositions {
|
||||
pub fn get(&self, model: TrayIconKind) -> Option<(i32, i32)> {
|
||||
match model {
|
||||
TrayIconKind::Claude => self.claude,
|
||||
TrayIconKind::Codex => self.codex,
|
||||
ProviderId::Claude => self.claude,
|
||||
ProviderId::ChatGpt => self.codex,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, model: TrayIconKind, pos: (i32, i32)) {
|
||||
match model {
|
||||
TrayIconKind::Claude => self.claude = Some(pos),
|
||||
TrayIconKind::Codex => self.codex = Some(pos),
|
||||
ProviderId::Claude => self.claude = Some(pos),
|
||||
ProviderId::ChatGpt => self.codex = Some(pos),
|
||||
}
|
||||
}
|
||||
pub fn reset(&mut self, model: TrayIconKind) {
|
||||
match model {
|
||||
TrayIconKind::Claude => self.claude = None,
|
||||
TrayIconKind::Codex => self.codex = None,
|
||||
ProviderId::Claude => self.claude = None,
|
||||
ProviderId::ChatGpt => self.codex = None,
|
||||
}
|
||||
}
|
||||
pub fn reset_all(&mut self) {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::System::Registry::*;
|
||||
|
||||
use crate::native_interop::wide_str;
|
||||
|
||||
const REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
|
||||
const REGISTRY_KEY: &str = "SystemUsesLightTheme";
|
||||
|
||||
/// Check if the system is in dark mode by reading the registry
|
||||
pub fn is_dark_mode() -> bool {
|
||||
!is_light_theme()
|
||||
}
|
||||
|
||||
fn is_light_theme() -> bool {
|
||||
unsafe {
|
||||
let path = wide_str(REGISTRY_PATH);
|
||||
let key_name = wide_str(REGISTRY_KEY);
|
||||
|
||||
let mut hkey = HKEY::default();
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(path.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut hkey,
|
||||
);
|
||||
|
||||
if result.is_err() {
|
||||
return false; // Default to dark mode
|
||||
}
|
||||
|
||||
let mut data: u32 = 0;
|
||||
let mut data_size: u32 = std::mem::size_of::<u32>() as u32;
|
||||
let result = RegQueryValueExW(
|
||||
hkey,
|
||||
PCWSTR::from_raw(key_name.as_ptr()),
|
||||
None,
|
||||
None,
|
||||
Some(&mut data as *mut u32 as *mut u8),
|
||||
Some(&mut data_size),
|
||||
);
|
||||
|
||||
let _ = RegCloseKey(hkey);
|
||||
|
||||
if result.is_err() {
|
||||
return false; // Default to dark mode
|
||||
}
|
||||
|
||||
data == 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Anti-aliased tray badge rendering.
|
||||
//
|
||||
// We draw a filled circle + percentage-sweep ring with tiny-skia, then
|
||||
// hand the resulting BGRA pixmap to Win32 as an HICON via
|
||||
// `CreateIconIndirect`. The badge is intentionally text-free — the
|
||||
// floating bubble already shows the exact percentage; the tray badge
|
||||
// is just a coarse colour-and-fill indicator.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER,
|
||||
DIB_RGB_COLORS, HBITMAP,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, HICON, ICONINFO};
|
||||
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
const BADGE_PX: u32 = 32;
|
||||
|
||||
pub fn render_hicon(kind: ProviderId, percent: Option<f64>) -> HICON {
|
||||
let pixmap = render_pixmap(kind, percent);
|
||||
pixmap_to_hicon(&pixmap).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
|
||||
let mut pixmap = Pixmap::new(BADGE_PX, BADGE_PX).expect("32×32 pixmap");
|
||||
pixmap.fill(tiny_skia::Color::TRANSPARENT);
|
||||
|
||||
let cx = BADGE_PX as f32 / 2.0;
|
||||
let cy = BADGE_PX as f32 / 2.0;
|
||||
let outer = (BADGE_PX as f32 / 2.0) - 1.0;
|
||||
let inner = outer * 0.62;
|
||||
|
||||
// Filled inner disk in the model's base tint.
|
||||
let base = base_color(kind);
|
||||
{
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color_rgba8(base[0], base[1], base[2], 255);
|
||||
paint.anti_alias = true;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_circle(cx, cy, inner);
|
||||
if let Some(path) = pb.finish() {
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
|
||||
// Track ring (the unused portion of the quota).
|
||||
{
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color_rgba8(0x3a, 0x3a, 0x3a, 255);
|
||||
paint.anti_alias = true;
|
||||
let mut stroke = Stroke::default();
|
||||
stroke.width = outer - inner - 1.0;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_circle(cx, cy, (inner + outer) / 2.0);
|
||||
if let Some(path) = pb.finish() {
|
||||
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
|
||||
// Active sweep — percentage of ring filled in usage colour.
|
||||
if let Some(p) = percent {
|
||||
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
|
||||
if sweep > 0.0 {
|
||||
let fill = usage_color(p);
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
|
||||
paint.anti_alias = true;
|
||||
let mut stroke = Stroke::default();
|
||||
stroke.width = outer - inner - 1.0;
|
||||
stroke.line_cap = tiny_skia::LineCap::Round;
|
||||
|
||||
let pb_path = build_arc(cx, cy, (inner + outer) / 2.0, sweep);
|
||||
if let Some(path) = pb_path {
|
||||
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pixmap
|
||||
}
|
||||
|
||||
fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option<tiny_skia::Path> {
|
||||
// tiny-skia 0.11 lacks a direct arc primitive. Approximate by sampling
|
||||
// points along the circumference from the 12 o'clock position clockwise.
|
||||
let segments = (sweep_fraction * 64.0).ceil() as usize;
|
||||
let segments = segments.max(1);
|
||||
let mut pb = PathBuilder::new();
|
||||
let start_angle: f32 = -std::f32::consts::FRAC_PI_2;
|
||||
let total = sweep_fraction * std::f32::consts::TAU;
|
||||
for i in 0..=segments {
|
||||
let t = i as f32 / segments as f32;
|
||||
let a = start_angle + t * total;
|
||||
let x = cx + a.cos() * radius;
|
||||
let y = cy + a.sin() * radius;
|
||||
if i == 0 {
|
||||
pb.move_to(x, y);
|
||||
} else {
|
||||
pb.line_to(x, y);
|
||||
}
|
||||
}
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
fn base_color(kind: ProviderId) -> [u8; 3] {
|
||||
match kind {
|
||||
// Warm orange-ish tint reads as "Claude" without copying the
|
||||
// upstream's exact #D97757; close enough to be familiar.
|
||||
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
|
||||
// Cool dark slate for ChatGPT/Codex.
|
||||
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_color(percent: f64) -> [u8; 3] {
|
||||
// Color gradient: soft orange (low usage) → red (near-cap).
|
||||
let stops: [(f64, [u8; 3]); 5] = [
|
||||
(0.0, [0xD9, 0x77, 0x57]),
|
||||
(50.0, [0xD9, 0x77, 0x57]),
|
||||
(75.0, [0xCC, 0x8C, 0x20]),
|
||||
(90.0, [0xC4, 0x50, 0x20]),
|
||||
(100.0, [0xB8, 0x20, 0x20]),
|
||||
];
|
||||
for pair in stops.windows(2) {
|
||||
let (a_p, a_c) = pair[0];
|
||||
let (b_p, b_c) = pair[1];
|
||||
if percent <= b_p {
|
||||
let span = (b_p - a_p).max(f64::EPSILON);
|
||||
let t = ((percent - a_p) / span).clamp(0.0, 1.0);
|
||||
return [
|
||||
lerp(a_c[0], b_c[0], t),
|
||||
lerp(a_c[1], b_c[1], t),
|
||||
lerp(a_c[2], b_c[2], t),
|
||||
];
|
||||
}
|
||||
}
|
||||
stops[stops.len() - 1].1
|
||||
}
|
||||
|
||||
fn lerp(a: u8, b: u8, t: f64) -> u8 {
|
||||
(a as f64 + (b as f64 - a as f64) * t).round() as u8
|
||||
}
|
||||
|
||||
// ---------- Pixmap → HICON ----------
|
||||
|
||||
fn pixmap_to_hicon(pixmap: &Pixmap) -> Option<HICON> {
|
||||
let width = pixmap.width() as i32;
|
||||
let height = pixmap.height() as i32;
|
||||
let pixels = pixmap.data(); // tiny-skia premultiplied RGBA bytes
|
||||
|
||||
// Build a 32bpp top-down DIB section for the colour bitmap.
|
||||
let bmi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: width,
|
||||
biHeight: -height,
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: 0, // BI_RGB
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let hdc = GetDC(HWND::default());
|
||||
let mut bits: *mut c_void = std::ptr::null_mut();
|
||||
let color_bmp = CreateDIBSection(hdc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
ReleaseDC(HWND::default(), hdc);
|
||||
if color_bmp.is_invalid() || bits.is_null() {
|
||||
return None;
|
||||
}
|
||||
// tiny-skia produces RGBA (premultiplied); GDI's DIB is BGRA. Swap.
|
||||
let pixel_count = (width * height) as usize;
|
||||
let dst = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
|
||||
for i in 0..pixel_count {
|
||||
let r = pixels[i * 4];
|
||||
let g = pixels[i * 4 + 1];
|
||||
let b = pixels[i * 4 + 2];
|
||||
let a = pixels[i * 4 + 3];
|
||||
dst[i] = (a as u32) << 24 | (r as u32) << 16 | (g as u32) << 8 | (b as u32);
|
||||
}
|
||||
|
||||
// Monochrome AND mask — opaque pixels marked 0, transparent 1.
|
||||
let mask_row_stride = ((width + 15) / 16) * 2; // 16-bit aligned per scanline
|
||||
let mut mask = vec![0u8; (mask_row_stride * height) as usize];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let idx = (y * width + x) as usize;
|
||||
let alpha = pixels[idx * 4 + 3];
|
||||
if alpha == 0 {
|
||||
let byte = (y * mask_row_stride + (x / 8)) as usize;
|
||||
mask[byte] |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mask_bmp: HBITMAP = CreateBitmap(width, height, 1, 1, Some(mask.as_ptr() as *const _));
|
||||
if mask_bmp.is_invalid() {
|
||||
let _ = DeleteObject(color_bmp);
|
||||
return None;
|
||||
}
|
||||
|
||||
let info = ICONINFO {
|
||||
fIcon: BOOL(1),
|
||||
xHotspot: 0,
|
||||
yHotspot: 0,
|
||||
hbmMask: mask_bmp,
|
||||
hbmColor: color_bmp,
|
||||
};
|
||||
let hicon = CreateIconIndirect(&info).ok();
|
||||
|
||||
let _ = DeleteObject(color_bmp);
|
||||
let _ = DeleteObject(mask_bmp);
|
||||
hicon
|
||||
}
|
||||
}
|
||||
|
||||
// Silence import warnings if we end up not needing PCWSTR after later edits.
|
||||
#[allow(dead_code)]
|
||||
const _: PCWSTR = PCWSTR::null();
|
||||
|
||||
use windows::Win32::Foundation::BOOL;
|
||||
@@ -0,0 +1,22 @@
|
||||
// Dispatch the `WM_APP_TRAY` notification message.
|
||||
//
|
||||
// Shell_NotifyIconW packs the event in the LOWORD of lparam (the
|
||||
// underlying mouse-event code) and the icon ID in HIWORD. We translate
|
||||
// to a `TrayAction` and let the app handle it.
|
||||
|
||||
use windows::Win32::Foundation::LPARAM;
|
||||
|
||||
use super::TrayAction;
|
||||
|
||||
const WM_LBUTTONUP: u32 = 0x0202;
|
||||
const WM_RBUTTONUP: u32 = 0x0205;
|
||||
|
||||
pub fn handle(lparam: LPARAM) -> TrayAction {
|
||||
let raw = lparam.0 as u32;
|
||||
let event = raw & 0xFFFF;
|
||||
match event {
|
||||
WM_LBUTTONUP => TrayAction::ToggleWidget,
|
||||
WM_RBUTTONUP => TrayAction::ShowContextMenu,
|
||||
_ => TrayAction::None,
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
// Tray-area icon management — stateless module-level API.
|
||||
//
|
||||
// Each enabled provider gets one notification-area icon. `sync` reconciles
|
||||
// the set of registered icons with the supplied desired list, issuing
|
||||
// `NIM_ADD`, `NIM_MODIFY`, or `NIM_DELETE` per icon. We track registration
|
||||
// state in a private mutex so callers (the app orchestrator) don't have
|
||||
// to thread a `Manager` through their snapshot/clone pipelines.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::Shell::{
|
||||
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_WARNING, NIM_ADD,
|
||||
NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::DestroyIcon;
|
||||
|
||||
pub mod badge;
|
||||
pub mod callback;
|
||||
|
||||
pub use crate::usage::ProviderId as IconKind;
|
||||
|
||||
/// Menu-command ID for the "Show widget" toggle that appears in the
|
||||
/// right-click menu and is also fired by left-clicking a tray icon.
|
||||
pub const IDM_TOGGLE_WIDGET: u16 = 50;
|
||||
|
||||
/// Notification message routed back to the owner HWND when the user
|
||||
/// interacts with a tray icon (left/right click).
|
||||
pub const WM_APP_TRAY: u32 = 0x8003;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrayIcon {
|
||||
pub kind: IconKind,
|
||||
pub percent: Option<f64>,
|
||||
pub tooltip: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TrayAction {
|
||||
None,
|
||||
ToggleWidget,
|
||||
ShowContextMenu,
|
||||
}
|
||||
|
||||
fn registered() -> &'static Mutex<HashSet<IconKind>> {
|
||||
static R: OnceLock<Mutex<HashSet<IconKind>>> = OnceLock::new();
|
||||
R.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
/// Reconcile registered icons with the desired list.
|
||||
pub fn sync(owner: HWND, desired: &[TrayIcon]) {
|
||||
let mut current = registered().lock().expect("tray registry mutex poisoned");
|
||||
let target: HashSet<IconKind> = desired.iter().map(|i| i.kind).collect();
|
||||
|
||||
let to_remove: Vec<IconKind> = current.difference(&target).copied().collect();
|
||||
for kind in to_remove {
|
||||
unsafe {
|
||||
let _ = Shell_NotifyIconW(NIM_DELETE, &build_data(owner, kind));
|
||||
}
|
||||
current.remove(&kind);
|
||||
}
|
||||
|
||||
for icon in desired {
|
||||
let hicon = badge::render_hicon(icon.kind, icon.percent);
|
||||
let mut data = build_data(owner, icon.kind);
|
||||
data.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||
data.hIcon = hicon;
|
||||
write_utf16(&mut data.szTip, &icon.tooltip);
|
||||
let msg = if current.contains(&icon.kind) {
|
||||
NIM_MODIFY
|
||||
} else {
|
||||
NIM_ADD
|
||||
};
|
||||
unsafe {
|
||||
if Shell_NotifyIconW(msg, &data).as_bool() && msg == NIM_ADD {
|
||||
current.insert(icon.kind);
|
||||
}
|
||||
let _ = DestroyIcon(hicon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a balloon notification on an already-registered icon.
|
||||
pub fn notify(owner: HWND, kind: IconKind, title: &str, body: &str) {
|
||||
let mut data = build_data(owner, kind);
|
||||
data.uFlags = NIF_INFO;
|
||||
write_utf16(&mut data.szInfoTitle, title);
|
||||
write_utf16(&mut data.szInfo, body);
|
||||
data.dwInfoFlags = NIIF_WARNING;
|
||||
unsafe {
|
||||
let _ = Shell_NotifyIconW(NIM_MODIFY, &data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down every registered icon. Call from app shutdown if you want to.
|
||||
#[allow(dead_code)]
|
||||
pub fn remove_all(owner: HWND) {
|
||||
let mut current = registered().lock().expect("tray registry mutex poisoned");
|
||||
for kind in current.drain().collect::<Vec<_>>() {
|
||||
unsafe {
|
||||
let _ = Shell_NotifyIconW(NIM_DELETE, &build_data(owner, kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_data(owner: HWND, kind: IconKind) -> NOTIFYICONDATAW {
|
||||
NOTIFYICONDATAW {
|
||||
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
|
||||
hWnd: owner,
|
||||
uID: icon_id(kind),
|
||||
uCallbackMessage: WM_APP_TRAY,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_id(kind: IconKind) -> u32 {
|
||||
match kind {
|
||||
IconKind::Claude => 1,
|
||||
IconKind::ChatGpt => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_utf16(dst: &mut [u16], src: &str) {
|
||||
let units: Vec<u16> = src.encode_utf16().collect();
|
||||
let n = units.len().min(dst.len().saturating_sub(1));
|
||||
dst[..n].copy_from_slice(&units[..n]);
|
||||
if n < dst.len() {
|
||||
dst[n] = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
|
||||
use windows::Win32::UI::Shell::{
|
||||
ExtractIconExW, Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_WARNING,
|
||||
NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
use crate::native_interop::{self, Color, WM_APP_TRAY};
|
||||
|
||||
const CLAUDE_TRAY_ICON_ID: u32 = 1;
|
||||
const CODEX_TRAY_ICON_ID: u32 = 2;
|
||||
|
||||
/// Menu item ID for toggling widget visibility (used by window.rs context menu).
|
||||
pub const IDM_TOGGLE_WIDGET: u16 = 50;
|
||||
|
||||
/// Actions the tray message handler can request from the main window.
|
||||
pub enum TrayAction {
|
||||
None,
|
||||
ToggleWidget,
|
||||
ShowContextMenu,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum TrayIconKind {
|
||||
Claude,
|
||||
Codex,
|
||||
}
|
||||
|
||||
pub struct TrayIconData {
|
||||
pub kind: TrayIconKind,
|
||||
pub percent: Option<f64>,
|
||||
pub tooltip: String,
|
||||
}
|
||||
|
||||
impl TrayIconKind {
|
||||
fn id(self) -> u32 {
|
||||
match self {
|
||||
Self::Claude => CLAUDE_TRAY_ICON_ID,
|
||||
Self::Codex => CODEX_TRAY_ICON_ID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lerp_channel(start: u8, end: u8, t: f64) -> u8 {
|
||||
(start as f64 + (end as f64 - start as f64) * t.clamp(0.0, 1.0)).round() as u8
|
||||
}
|
||||
|
||||
fn lerp_color(start: Color, end: Color, t: f64) -> Color {
|
||||
Color::new(
|
||||
lerp_channel(start.r, end.r, t),
|
||||
lerp_channel(start.g, end.g, t),
|
||||
lerp_channel(start.b, end.b, t),
|
||||
)
|
||||
}
|
||||
|
||||
fn interpolated_fill(percent: f64) -> Color {
|
||||
if percent <= 50.0 {
|
||||
return Color::from_hex("#D97757");
|
||||
}
|
||||
|
||||
let stops = [
|
||||
(50.0, Color::from_hex("#D97757")),
|
||||
(70.0, Color::from_hex("#D08540")),
|
||||
(85.0, Color::from_hex("#CC8C20")),
|
||||
(95.0, Color::from_hex("#C45020")),
|
||||
(100.0, Color::from_hex("#B82020")),
|
||||
];
|
||||
|
||||
for pair in stops.windows(2) {
|
||||
let (start_pct, start_color) = pair[0];
|
||||
let (end_pct, end_color) = pair[1];
|
||||
if percent <= end_pct {
|
||||
let span = (end_pct - start_pct).max(f64::EPSILON);
|
||||
let t = (percent - start_pct) / span;
|
||||
return lerp_color(start_color, end_color, t);
|
||||
}
|
||||
}
|
||||
|
||||
stops[stops.len() - 1].1
|
||||
}
|
||||
|
||||
fn codex_fill(percent: f64) -> Color {
|
||||
if percent >= 90.0 {
|
||||
Color::from_hex("#FFFFFF")
|
||||
} else {
|
||||
Color::from_hex("#111111")
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a rounded-rectangle tray icon badge showing the usage percentage.
|
||||
/// For Claude, `percent` = None uses the embedded app icon as the loading state.
|
||||
/// For Codex, `percent` = None uses a black/white Codex placeholder badge.
|
||||
pub fn create_icon(kind: TrayIconKind, percent: Option<f64>) -> HICON {
|
||||
if matches!(kind, TrayIconKind::Claude) && percent.is_none() {
|
||||
let app_icon = load_embedded_app_icon();
|
||||
if !app_icon.is_invalid() {
|
||||
return app_icon;
|
||||
}
|
||||
}
|
||||
|
||||
let size = 64_i32;
|
||||
let margin = 0_i32;
|
||||
let radius = 2_i32;
|
||||
let outline = if matches!(kind, TrayIconKind::Codex) {
|
||||
3_i32
|
||||
} else {
|
||||
0_i32
|
||||
};
|
||||
|
||||
let fill = match kind {
|
||||
TrayIconKind::Claude => interpolated_fill(percent.unwrap_or(0.0)),
|
||||
TrayIconKind::Codex => codex_fill(percent.unwrap_or(0.0)),
|
||||
};
|
||||
let text_col = match kind {
|
||||
TrayIconKind::Claude => Color::from_hex("#FFFFFF"),
|
||||
TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"),
|
||||
TrayIconKind::Codex => Color::from_hex("#FFFFFF"),
|
||||
};
|
||||
let outline_col = match kind {
|
||||
TrayIconKind::Claude => fill,
|
||||
TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"),
|
||||
TrayIconKind::Codex => Color::from_hex("#FFFFFF"),
|
||||
};
|
||||
|
||||
let display_text = match percent {
|
||||
Some(p) => format!("{}", p.round().clamp(0.0, 999.0) as u32),
|
||||
None => match kind {
|
||||
TrayIconKind::Claude => String::new(),
|
||||
TrayIconKind::Codex => "C".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let font_h = match display_text.len() {
|
||||
1 => -50,
|
||||
2 => -42,
|
||||
_ => -30,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let screen_dc = GetDC(HWND::default());
|
||||
let mem_dc = CreateCompatibleDC(screen_dc);
|
||||
|
||||
let bmi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: size,
|
||||
biHeight: -size,
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: 0,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut bits: *mut std::ffi::c_void = std::ptr::null_mut();
|
||||
let dib =
|
||||
CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default();
|
||||
|
||||
if dib.is_invalid() {
|
||||
let _ = DeleteDC(mem_dc);
|
||||
ReleaseDC(HWND::default(), screen_dc);
|
||||
return HICON::default();
|
||||
}
|
||||
|
||||
let old_bmp = SelectObject(mem_dc, dib);
|
||||
|
||||
// Zero-fill (transparent background)
|
||||
let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, (size * size) as usize);
|
||||
for px in pixel_data.iter_mut() {
|
||||
*px = 0;
|
||||
}
|
||||
|
||||
// Draw rounded rectangle badge
|
||||
let null_pen = GetStockObject(NULL_PEN);
|
||||
let old_pen = SelectObject(mem_dc, null_pen);
|
||||
|
||||
if outline > 0 {
|
||||
let br_outline = CreateSolidBrush(COLORREF(outline_col.to_colorref()));
|
||||
let old_brush = SelectObject(mem_dc, br_outline);
|
||||
let _ = RoundRect(
|
||||
mem_dc,
|
||||
margin,
|
||||
margin,
|
||||
size - margin + 1,
|
||||
size - margin + 1,
|
||||
(radius + 1) * 2,
|
||||
(radius + 1) * 2,
|
||||
);
|
||||
SelectObject(mem_dc, old_brush);
|
||||
let _ = DeleteObject(br_outline);
|
||||
}
|
||||
|
||||
let br_fill = CreateSolidBrush(COLORREF(fill.to_colorref()));
|
||||
let old_brush = SelectObject(mem_dc, br_fill);
|
||||
let _ = RoundRect(
|
||||
mem_dc,
|
||||
margin + outline,
|
||||
margin + outline,
|
||||
size - margin - outline + 1,
|
||||
size - margin - outline + 1,
|
||||
(radius - 1) * 2,
|
||||
(radius - 1) * 2,
|
||||
);
|
||||
|
||||
SelectObject(mem_dc, old_brush);
|
||||
SelectObject(mem_dc, old_pen);
|
||||
let _ = DeleteObject(br_fill);
|
||||
|
||||
// Draw centered percentage text
|
||||
let font_name = native_interop::wide_str("Arial Bold");
|
||||
let font = CreateFontW(
|
||||
font_h,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
FW_BOLD.0 as i32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
DEFAULT_CHARSET.0 as u32,
|
||||
OUT_TT_PRECIS.0 as u32,
|
||||
CLIP_DEFAULT_PRECIS.0 as u32,
|
||||
ANTIALIASED_QUALITY.0 as u32,
|
||||
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
|
||||
PCWSTR::from_raw(font_name.as_ptr()),
|
||||
);
|
||||
let old_font = SelectObject(mem_dc, font);
|
||||
let _ = SetBkMode(mem_dc, TRANSPARENT);
|
||||
let _ = SetTextColor(mem_dc, COLORREF(text_col.to_colorref()));
|
||||
|
||||
let mut text_rect = RECT {
|
||||
left: margin,
|
||||
top: margin,
|
||||
right: size - margin,
|
||||
bottom: size - margin,
|
||||
};
|
||||
let mut text_wide: Vec<u16> = display_text.encode_utf16().collect();
|
||||
let _ = DrawTextW(
|
||||
mem_dc,
|
||||
&mut text_wide,
|
||||
&mut text_rect,
|
||||
DT_CENTER | DT_VCENTER | DT_SINGLELINE,
|
||||
);
|
||||
|
||||
SelectObject(mem_dc, old_font);
|
||||
let _ = DeleteObject(font);
|
||||
|
||||
// Set alpha: non-zero BGR pixel -> fully opaque; background stays transparent
|
||||
for px in pixel_data.iter_mut() {
|
||||
if *px != 0 {
|
||||
*px = (*px & 0x00FF_FFFF) | 0xFF00_0000;
|
||||
}
|
||||
}
|
||||
|
||||
// Monochrome mask (per-pixel alpha from colour bitmap)
|
||||
let mask_bytes = vec![0u8; ((size * size + 7) / 8) as usize];
|
||||
let mask_bmp = CreateBitmap(
|
||||
size,
|
||||
size,
|
||||
1,
|
||||
1,
|
||||
Some(mask_bytes.as_ptr() as *const std::ffi::c_void),
|
||||
);
|
||||
|
||||
let icon_info = ICONINFO {
|
||||
fIcon: TRUE,
|
||||
xHotspot: 0,
|
||||
yHotspot: 0,
|
||||
hbmMask: mask_bmp,
|
||||
hbmColor: dib,
|
||||
};
|
||||
let hicon = CreateIconIndirect(&icon_info).unwrap_or_default();
|
||||
|
||||
let _ = DeleteObject(mask_bmp);
|
||||
SelectObject(mem_dc, old_bmp);
|
||||
let _ = DeleteObject(dib);
|
||||
let _ = DeleteDC(mem_dc);
|
||||
ReleaseDC(HWND::default(), screen_dc);
|
||||
|
||||
hicon
|
||||
}
|
||||
}
|
||||
|
||||
fn load_embedded_app_icon() -> HICON {
|
||||
unsafe {
|
||||
let mut exe_buf = [0u16; 260];
|
||||
let len = GetModuleFileNameW(None, &mut exe_buf) as usize;
|
||||
if len == 0 {
|
||||
return HICON::default();
|
||||
}
|
||||
|
||||
let mut small_icon = HICON::default();
|
||||
let mut large_icon = HICON::default();
|
||||
let extracted = ExtractIconExW(
|
||||
PCWSTR::from_raw(exe_buf.as_ptr()),
|
||||
0,
|
||||
Some(&mut large_icon),
|
||||
Some(&mut small_icon),
|
||||
1,
|
||||
);
|
||||
|
||||
if extracted == 0 {
|
||||
HICON::default()
|
||||
} else if !small_icon.is_invalid() {
|
||||
small_icon
|
||||
} else {
|
||||
large_icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a Windows balloon notification from the tray icon.
|
||||
/// Used to alert the user when re-authentication is required.
|
||||
pub fn notify_balloon(hwnd: HWND, kind: TrayIconKind, title: &str, message: &str) {
|
||||
unsafe {
|
||||
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
|
||||
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
|
||||
nid.hWnd = hwnd;
|
||||
nid.uID = kind.id();
|
||||
nid.uFlags = NIF_INFO;
|
||||
nid.dwInfoFlags = NIIF_WARNING;
|
||||
copy_wide(title, &mut nid.szInfoTitle);
|
||||
copy_wide_256(message, &mut nid.szInfo);
|
||||
let _ = Shell_NotifyIconW(NIM_MODIFY, &nid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a string into a fixed-size wide buffer (truncates to fit).
|
||||
fn copy_wide<const N: usize>(s: &str, buf: &mut [u16; N]) {
|
||||
let wide: Vec<u16> = s.encode_utf16().collect();
|
||||
let len = wide.len().min(N - 1);
|
||||
buf[..len].copy_from_slice(&wide[..len]);
|
||||
buf[len] = 0;
|
||||
}
|
||||
|
||||
/// Copy a string into a 256-wide buffer.
|
||||
fn copy_wide_256(s: &str, buf: &mut [u16; 256]) {
|
||||
copy_wide(s, buf)
|
||||
}
|
||||
|
||||
/// Register the tray icon with the shell.
|
||||
pub fn add(hwnd: HWND, kind: TrayIconKind, percent: Option<f64>, tooltip: &str) {
|
||||
let hicon = create_icon(kind, percent);
|
||||
unsafe {
|
||||
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
|
||||
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
|
||||
nid.hWnd = hwnd;
|
||||
nid.uID = kind.id();
|
||||
nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||
nid.uCallbackMessage = WM_APP_TRAY;
|
||||
nid.hIcon = hicon;
|
||||
copy_to_tip(tooltip, &mut nid.szTip);
|
||||
let _ = Shell_NotifyIconW(NIM_ADD, &nid);
|
||||
if !hicon.is_invalid() {
|
||||
let _ = DestroyIcon(hicon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the tray icon colour and tooltip to reflect current usage.
|
||||
pub fn update(hwnd: HWND, kind: TrayIconKind, percent: Option<f64>, tooltip: &str) {
|
||||
let hicon = create_icon(kind, percent);
|
||||
unsafe {
|
||||
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
|
||||
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
|
||||
nid.hWnd = hwnd;
|
||||
nid.uID = kind.id();
|
||||
nid.uFlags = NIF_ICON | NIF_TIP;
|
||||
nid.hIcon = hicon;
|
||||
copy_to_tip(tooltip, &mut nid.szTip);
|
||||
let _ = Shell_NotifyIconW(NIM_MODIFY, &nid);
|
||||
if !hicon.is_invalid() {
|
||||
let _ = DestroyIcon(hicon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the tray icon from the shell.
|
||||
pub fn remove(hwnd: HWND, kind: TrayIconKind) {
|
||||
unsafe {
|
||||
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
|
||||
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
|
||||
nid.hWnd = hwnd;
|
||||
nid.uID = kind.id();
|
||||
let _ = Shell_NotifyIconW(NIM_DELETE, &nid);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync(hwnd: HWND, icons: &[TrayIconData]) {
|
||||
let show_claude = icons
|
||||
.iter()
|
||||
.find(|icon| matches!(icon.kind, TrayIconKind::Claude));
|
||||
let show_codex = icons
|
||||
.iter()
|
||||
.find(|icon| matches!(icon.kind, TrayIconKind::Codex));
|
||||
|
||||
if let Some(icon) = show_claude {
|
||||
add(hwnd, icon.kind, icon.percent, &icon.tooltip);
|
||||
update(hwnd, icon.kind, icon.percent, &icon.tooltip);
|
||||
} else {
|
||||
remove(hwnd, TrayIconKind::Claude);
|
||||
}
|
||||
|
||||
if let Some(icon) = show_codex {
|
||||
add(hwnd, icon.kind, icon.percent, &icon.tooltip);
|
||||
update(hwnd, icon.kind, icon.percent, &icon.tooltip);
|
||||
} else {
|
||||
remove(hwnd, TrayIconKind::Codex);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_all(hwnd: HWND) {
|
||||
remove(hwnd, TrayIconKind::Claude);
|
||||
remove(hwnd, TrayIconKind::Codex);
|
||||
}
|
||||
|
||||
/// Interpret a tray callback message and return the action to take.
|
||||
pub fn handle_message(lparam: LPARAM) -> TrayAction {
|
||||
let mouse_msg = lparam.0 as u32;
|
||||
match mouse_msg {
|
||||
WM_LBUTTONUP => TrayAction::ToggleWidget,
|
||||
WM_RBUTTONUP => TrayAction::ShowContextMenu,
|
||||
_ => TrayAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a string into the fixed-size szTip field (max 127 chars + null).
|
||||
fn copy_to_tip(s: &str, tip: &mut [u16; 128]) {
|
||||
let wide: Vec<u16> = s.encode_utf16().collect();
|
||||
let mut len = wide.len().min(127);
|
||||
// Don't leave a lone high surrogate at the truncation point
|
||||
if len > 0 && (0xD800..=0xDBFF).contains(&wide[len - 1]) {
|
||||
len -= 1;
|
||||
}
|
||||
tip[..len].copy_from_slice(&wide[..len]);
|
||||
tip[len] = 0;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Install-channel detection.
|
||||
//
|
||||
// Until this app has a winget package, every install is treated as
|
||||
// "portable" — meaning self-update applies via the inline-cmd handoff.
|
||||
// When a winget package exists, restore the path-probe code from git
|
||||
// history (see commit before Phase 6).
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Channel {
|
||||
Portable,
|
||||
Winget,
|
||||
}
|
||||
|
||||
pub fn current() -> Channel {
|
||||
Channel::Portable
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Download a release asset and hand off via inline `cmd /c`.
|
||||
//
|
||||
// We avoid the helper-exe pattern entirely: after writing the new .exe
|
||||
// to a staging path, we spawn cmd.exe with a one-liner that waits 2 s,
|
||||
// moves the new binary over the running one (Windows releases the file
|
||||
// lock when our process exits), and relaunches it.
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::net::Client;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
|
||||
pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error> {
|
||||
let current = std::env::current_exe()?;
|
||||
ensure_writable(¤t)?;
|
||||
let staging = stage_path()?;
|
||||
if let Some(parent) = staging.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
download(http, &release.asset_url, &staging)?;
|
||||
spawn_handoff(&staging, ¤t)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CLI entry-point compatibility for `--apply-update <target> <source> <pid>`.
|
||||
/// The inline-cmd handoff already does the swap-and-restart; if this binary
|
||||
/// is invoked with the legacy flag (e.g. from an older release's helper)
|
||||
/// just exit cleanly so the upgrade still completes.
|
||||
pub fn run_cli(args: &[String]) -> Option<i32> {
|
||||
if args.len() >= 2 && args[1] == "--apply-update" {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn download(http: &Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> {
|
||||
let resp = http
|
||||
.get(url)
|
||||
.header("User-Agent", super::release::user_agent())
|
||||
.send()?;
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Err(super::Error::Network(crate::net::Error::Status(resp.status())));
|
||||
}
|
||||
std::fs::write(to, resp.body())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(), super::Error> {
|
||||
let src_str = source.to_string_lossy().replace('"', "");
|
||||
let tgt_str = target.to_string_lossy().replace('"', "");
|
||||
// 2-second wait gives the current process time to exit and release the
|
||||
// file lock before `move` overwrites it.
|
||||
let cmd = format!(
|
||||
r#"timeout /t 2 /nobreak >nul & move /y "{src_str}" "{tgt_str}" & start "" "{tgt_str}""#
|
||||
);
|
||||
Command::new("cmd.exe")
|
||||
.args(["/c", &cmd])
|
||||
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stage_path() -> Result<PathBuf, super::Error> {
|
||||
let base = dirs::data_local_dir().ok_or_else(|| {
|
||||
super::Error::NotWritable("no local data directory available".to_string())
|
||||
})?;
|
||||
Ok(base
|
||||
.join("ClaudeCodeUsageBubble")
|
||||
.join("updates")
|
||||
.join("update.exe"))
|
||||
}
|
||||
|
||||
fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> {
|
||||
let parent = target.parent().ok_or_else(|| {
|
||||
super::Error::NotWritable("could not resolve install directory".to_string())
|
||||
})?;
|
||||
let probe = parent.join(".__bubble_update_probe");
|
||||
std::fs::write(&probe, b"").map_err(|e| super::Error::NotWritable(e.to_string()))?;
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Self-update subsystem.
|
||||
//
|
||||
// Two stages: `release::fetch_latest` checks GitHub releases for a newer
|
||||
// build; `install::begin` downloads the .exe and hands off to a detached
|
||||
// `cmd /c` script that swaps the binary and restarts.
|
||||
|
||||
pub mod channel;
|
||||
pub mod install;
|
||||
pub mod release;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("network: {0}")]
|
||||
Network(#[from] crate::net::Error),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("no matching release asset")]
|
||||
NoAsset,
|
||||
#[error("install location not writable: {0}")]
|
||||
NotWritable(String),
|
||||
#[error("malformed version: {0}")]
|
||||
BadVersion(String),
|
||||
}
|
||||
|
||||
pub use channel::{current as current_channel, Channel};
|
||||
pub use install::{begin, run_cli};
|
||||
pub use release::{fetch_latest, Release, Version};
|
||||
|
||||
/// Result of a release-check call.
|
||||
#[derive(Debug)]
|
||||
pub enum CheckOutcome {
|
||||
UpToDate,
|
||||
Available(Release),
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Query the GitHub Releases API and pick the relevant asset.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::net::Client;
|
||||
|
||||
const ASSET_NAME: &str = "claude-code-usage-bubble.exe";
|
||||
const REPO_OWNER: &str = "tiennm99";
|
||||
const REPO_NAME: &str = "claude-code-usage-bubble";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Release {
|
||||
pub version: Version,
|
||||
pub asset_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Version {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
pub patch: u32,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
pub fn current() -> Self {
|
||||
Self::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version {
|
||||
major: 0,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let core = s.trim().trim_start_matches('v').split('-').next()?;
|
||||
let mut parts = core.split('.').map(|p| p.parse::<u32>().ok());
|
||||
Some(Version {
|
||||
major: parts.next().flatten().unwrap_or(0),
|
||||
minor: parts.next().flatten().unwrap_or(0),
|
||||
patch: parts.next().flatten().unwrap_or(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fetch_latest(http: &Client) -> Result<super::CheckOutcome, super::Error> {
|
||||
let url = format!("https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest");
|
||||
let resp = http
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.header("User-Agent", user_agent())
|
||||
.send()?;
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Err(super::Error::Network(crate::net::Error::Status(resp.status())));
|
||||
}
|
||||
let body: GhRelease = resp.json()?;
|
||||
let candidate = Version::parse(&body.tag_name)
|
||||
.ok_or_else(|| super::Error::BadVersion(body.tag_name.clone()))?;
|
||||
if candidate <= Version::current() {
|
||||
return Ok(super::CheckOutcome::UpToDate);
|
||||
}
|
||||
let asset = body
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(ASSET_NAME))
|
||||
.or_else(|| {
|
||||
body.assets
|
||||
.iter()
|
||||
.find(|a| a.name.to_ascii_lowercase().ends_with(".exe"))
|
||||
})
|
||||
.ok_or(super::Error::NoAsset)?;
|
||||
Ok(super::CheckOutcome::Available(Release {
|
||||
version: candidate,
|
||||
asset_url: asset.browser_download_url.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn user_agent() -> &'static str {
|
||||
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GhRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GhAsset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GhAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
-512
@@ -1,512 +0,0 @@
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::{HWND, WAIT_OBJECT_0, WAIT_TIMEOUT};
|
||||
use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
|
||||
|
||||
const GITHUB_API_ACCEPT: &str = "application/vnd.github+json";
|
||||
const GITHUB_API_VERSION: &str = "2022-11-28";
|
||||
const RELEASE_ASSET_NAME: &str = "claude-code-usage-bubble.exe";
|
||||
const HELPER_EXE_NAME: &str = "updater-helper.exe";
|
||||
const DOWNLOAD_EXE_NAME: &str = "update-download.exe";
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
const CREATE_NEW_CONSOLE: u32 = 0x00000010;
|
||||
// Reserved for future winget submission. Until then `current_install_channel`
|
||||
// always returns `Portable` and this constant is unused.
|
||||
#[allow(dead_code)]
|
||||
const WINGET_PACKAGE_ID: &str = "ClaudeCodeUsageBubble";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum InstallChannel {
|
||||
Portable,
|
||||
Winget,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReleaseDescriptor {
|
||||
pub latest_version: String,
|
||||
asset_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UpdateCheckResult {
|
||||
UpToDate,
|
||||
Available(ReleaseDescriptor),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitHubRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GitHubAsset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitHubAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
pub fn handle_cli_mode(args: &[String]) -> Option<i32> {
|
||||
if args.len() == 5 && args[1] == "--apply-update" {
|
||||
let target = PathBuf::from(&args[2]);
|
||||
let source = PathBuf::from(&args[3]);
|
||||
let pid = args[4].parse::<u32>().unwrap_or(0);
|
||||
|
||||
return Some(match apply_update(target, source, pid) {
|
||||
Ok(()) => 0,
|
||||
Err(error) => {
|
||||
show_error_message("Update failed", &error);
|
||||
1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn current_install_channel() -> InstallChannel {
|
||||
InstallChannel::Portable
|
||||
}
|
||||
|
||||
pub fn check_for_updates() -> Result<UpdateCheckResult, String> {
|
||||
match fetch_latest_release()? {
|
||||
Some(release) => Ok(UpdateCheckResult::Available(release)),
|
||||
None => Ok(UpdateCheckResult::UpToDate),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin_winget_update() -> Result<(), String> {
|
||||
let current_exe =
|
||||
std::env::current_exe().map_err(|e| format!("Unable to locate current executable: {e}"))?;
|
||||
let current_dir = current_exe
|
||||
.parent()
|
||||
.ok_or_else(|| "Unable to determine the app directory for restart.".to_string())?;
|
||||
let command = winget_upgrade_command(
|
||||
std::process::id(),
|
||||
¤t_exe.to_string_lossy(),
|
||||
¤t_dir.to_string_lossy(),
|
||||
);
|
||||
|
||||
Command::new("powershell.exe")
|
||||
.arg("-NoLogo")
|
||||
.arg("-Command")
|
||||
.arg(&command)
|
||||
.creation_flags(CREATE_NEW_CONSOLE)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Unable to launch WinGet update command: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn begin_self_update(release: &ReleaseDescriptor) -> Result<(), String> {
|
||||
let current_exe =
|
||||
std::env::current_exe().map_err(|e| format!("Unable to locate current executable: {e}"))?;
|
||||
ensure_target_location_writable(¤t_exe)?;
|
||||
|
||||
let stage_dir = updates_dir()?;
|
||||
std::fs::create_dir_all(&stage_dir)
|
||||
.map_err(|e| format!("Unable to create updater working directory: {e}"))?;
|
||||
|
||||
let helper_path = stage_dir.join(HELPER_EXE_NAME);
|
||||
let download_path = stage_dir.join(DOWNLOAD_EXE_NAME);
|
||||
let partial_download_path = stage_dir.join(format!("{DOWNLOAD_EXE_NAME}.part"));
|
||||
|
||||
if helper_path.exists() {
|
||||
let _ = std::fs::remove_file(&helper_path);
|
||||
}
|
||||
if download_path.exists() {
|
||||
let _ = std::fs::remove_file(&download_path);
|
||||
}
|
||||
if partial_download_path.exists() {
|
||||
let _ = std::fs::remove_file(&partial_download_path);
|
||||
}
|
||||
|
||||
download_release_asset(&release.asset_url, &partial_download_path, &download_path)?;
|
||||
std::fs::copy(¤t_exe, &helper_path)
|
||||
.map_err(|e| format!("Unable to prepare updater helper: {e}"))?;
|
||||
|
||||
let pid = std::process::id().to_string();
|
||||
let target = current_exe.to_string_lossy().to_string();
|
||||
let source = download_path.to_string_lossy().to_string();
|
||||
|
||||
Command::new(&helper_path)
|
||||
.arg("--apply-update")
|
||||
.arg(target)
|
||||
.arg(source)
|
||||
.arg(pid)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Unable to launch updater helper: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_update(target: PathBuf, source: PathBuf, pid: u32) -> Result<(), String> {
|
||||
if !source.exists() {
|
||||
return Err(format!(
|
||||
"Downloaded update not found at {}",
|
||||
source.display()
|
||||
));
|
||||
}
|
||||
|
||||
let _ = wait_for_process_exit(pid, Duration::from_secs(30));
|
||||
replace_target_binary(&target, &source)?;
|
||||
relaunch_target(&target)?;
|
||||
let _ = std::fs::remove_file(&source);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_latest_release() -> Result<Option<ReleaseDescriptor>, String> {
|
||||
let (owner, repo) = github_repo()?;
|
||||
let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/latest");
|
||||
let agent = build_agent()?;
|
||||
|
||||
let response = agent
|
||||
.get(&url)
|
||||
.set("Accept", GITHUB_API_ACCEPT)
|
||||
.set("User-Agent", user_agent())
|
||||
.set("X-GitHub-Api-Version", GITHUB_API_VERSION)
|
||||
.call()
|
||||
.map_err(|e| format!("Unable to check GitHub releases: {e}"))?;
|
||||
|
||||
let release: GitHubRelease = response
|
||||
.into_json()
|
||||
.map_err(|e| format!("Unable to parse GitHub release data: {e}"))?;
|
||||
|
||||
let latest_version = release.tag_name.trim_start_matches('v').to_string();
|
||||
if !is_version_newer(&latest_version, env!("CARGO_PKG_VERSION")) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name.eq_ignore_ascii_case(RELEASE_ASSET_NAME))
|
||||
.or_else(|| {
|
||||
release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name.to_ascii_lowercase().ends_with(".exe"))
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
"No Windows executable asset was found in the latest release.".to_string()
|
||||
})?;
|
||||
|
||||
Ok(Some(ReleaseDescriptor {
|
||||
latest_version,
|
||||
asset_url: asset.browser_download_url.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_agent() -> Result<ureq::Agent, String> {
|
||||
let tls = native_tls::TlsConnector::new()
|
||||
.map_err(|e| format!("Unable to initialize TLS support for update checks: {e}"))?;
|
||||
Ok(ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.tls_connector(std::sync::Arc::new(tls))
|
||||
.build())
|
||||
}
|
||||
|
||||
fn download_release_asset(url: &str, partial_path: &Path, final_path: &Path) -> Result<(), String> {
|
||||
let agent = build_agent()?;
|
||||
let response = agent
|
||||
.get(url)
|
||||
.set("User-Agent", user_agent())
|
||||
.call()
|
||||
.map_err(|e| format!("Unable to download the latest release: {e}"))?;
|
||||
|
||||
let mut reader = response.into_reader();
|
||||
let mut file = File::create(partial_path)
|
||||
.map_err(|e| format!("Unable to create temporary download file: {e}"))?;
|
||||
|
||||
io::copy(&mut reader, &mut file)
|
||||
.map_err(|e| format!("Unable to write the downloaded update: {e}"))?;
|
||||
file.flush()
|
||||
.map_err(|e| format!("Unable to finalize the downloaded update: {e}"))?;
|
||||
|
||||
std::fs::rename(partial_path, final_path)
|
||||
.map_err(|e| format!("Unable to finalize the downloaded update file: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_target_binary(target: &Path, source: &Path) -> Result<(), String> {
|
||||
let backup_path = backup_path_for(target);
|
||||
let mut last_error = None;
|
||||
|
||||
for _ in 0..60 {
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
|
||||
let renamed_existing = match std::fs::rename(target, &backup_path) {
|
||||
Ok(()) => true,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
|
||||
Err(error) => {
|
||||
last_error = Some(error);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match std::fs::copy(source, target) {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => {
|
||||
last_error = Some(error);
|
||||
let _ = std::fs::remove_file(target);
|
||||
if renamed_existing {
|
||||
let _ = std::fs::rename(&backup_path, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Unable to replace {}. {}",
|
||||
target.display(),
|
||||
last_error
|
||||
.map(|error| error.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
"The file may still be locked or the install directory may not be writable."
|
||||
.to_string()
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
fn relaunch_target(target: &Path) -> Result<(), String> {
|
||||
let mut command = Command::new(target);
|
||||
if let Some(parent) = target.parent() {
|
||||
command.current_dir(parent);
|
||||
}
|
||||
|
||||
command
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"The update was installed, but the app could not be restarted automatically: {e}"
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_process_exit(pid: u32, timeout: Duration) -> Result<(), String> {
|
||||
if pid == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_SYNCHRONIZE, false, pid)
|
||||
.map_err(|e| format!("Unable to monitor the running app process: {e}"))?;
|
||||
|
||||
let result = WaitForSingleObject(handle, timeout.as_millis().min(u32::MAX as u128) as u32);
|
||||
let _ = windows::Win32::Foundation::CloseHandle(handle);
|
||||
|
||||
if result == WAIT_OBJECT_0 {
|
||||
Ok(())
|
||||
} else if result == WAIT_TIMEOUT {
|
||||
Err("Timed out waiting for the running app to exit.".to_string())
|
||||
} else {
|
||||
Err("Unable to confirm that the running app has exited.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn updates_dir() -> Result<PathBuf, String> {
|
||||
dirs::data_local_dir()
|
||||
.map(|dir| dir.join("ClaudeCodeUsageBubble").join("updates"))
|
||||
.or_else(|| {
|
||||
Some(
|
||||
std::env::temp_dir()
|
||||
.join("ClaudeCodeUsageBubble")
|
||||
.join("updates"),
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| "Unable to resolve a writable local updates directory.".to_string())
|
||||
}
|
||||
|
||||
fn winget_upgrade_command(pid: u32, target: &str, working_dir: &str) -> String {
|
||||
let target = powershell_single_quoted(target);
|
||||
let working_dir = powershell_single_quoted(working_dir);
|
||||
let package_id = WINGET_PACKAGE_ID;
|
||||
|
||||
format!(
|
||||
concat!(
|
||||
"$ErrorActionPreference = 'Stop'; ",
|
||||
"$pidToWait = {pid}; ",
|
||||
"$target = '{target}'; ",
|
||||
"$workingDir = '{working_dir}'; ",
|
||||
"try {{ Wait-Process -Id $pidToWait -Timeout 30 -ErrorAction Stop }} catch {{ }}; ",
|
||||
"winget upgrade --id {package_id} --exact; ",
|
||||
"$exitCode = $LASTEXITCODE; ",
|
||||
"if ($exitCode -eq 0) {{ ",
|
||||
"Start-Sleep -Seconds 2; ",
|
||||
"Start-Process -FilePath $target -WorkingDirectory $workingDir; ",
|
||||
"exit 0 ",
|
||||
"}}; ",
|
||||
"Write-Host ''; ",
|
||||
"Write-Host 'WinGet update failed with exit code' $exitCode; ",
|
||||
"Read-Host 'Press Enter to close'; ",
|
||||
"exit $exitCode"
|
||||
),
|
||||
pid = pid,
|
||||
target = target,
|
||||
working_dir = working_dir,
|
||||
package_id = package_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn powershell_single_quoted(value: &str) -> String {
|
||||
value.replace('\'', "''")
|
||||
}
|
||||
|
||||
fn backup_path_for(target: &Path) -> PathBuf {
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("app.exe");
|
||||
target.with_file_name(format!("{file_name}.old"))
|
||||
}
|
||||
|
||||
fn ensure_target_location_writable(target: &Path) -> Result<(), String> {
|
||||
let parent = target.parent().ok_or_else(|| {
|
||||
"Unable to determine the install directory for the current executable.".to_string()
|
||||
})?;
|
||||
|
||||
let probe_path = parent.join(".__ccum_update_probe");
|
||||
match File::create(&probe_path) {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&probe_path);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(format!(
|
||||
"The current install location is not writable. Move the app to a user-writable folder or install it somewhere outside Program Files. {error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn github_repo() -> Result<(&'static str, &'static str), String> {
|
||||
let repository = env!("CARGO_PKG_REPOSITORY").trim_end_matches('/');
|
||||
let parts: Vec<&str> = repository.split('/').collect();
|
||||
if parts.len() < 2 {
|
||||
return Err("Package repository URL is not configured for GitHub releases.".to_string());
|
||||
}
|
||||
|
||||
let owner = parts[parts.len() - 2];
|
||||
let repo = parts[parts.len() - 1];
|
||||
if owner.is_empty() || repo.is_empty() {
|
||||
return Err("Package repository URL is not configured for GitHub releases.".to_string());
|
||||
}
|
||||
|
||||
Ok((owner, repo))
|
||||
}
|
||||
|
||||
fn user_agent() -> &'static str {
|
||||
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn is_winget_install_path(path: &Path) -> bool {
|
||||
let normalized_path = normalize_path(path);
|
||||
winget_install_roots()
|
||||
.into_iter()
|
||||
.map(|root| normalize_path(&root))
|
||||
.any(|root| normalized_path.starts_with(&root))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn winget_install_roots() -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
|
||||
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
|
||||
roots.push(
|
||||
PathBuf::from(local_app_data)
|
||||
.join("Microsoft")
|
||||
.join("WinGet")
|
||||
.join("Packages"),
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(program_files) = std::env::var("ProgramFiles") {
|
||||
roots.push(PathBuf::from(program_files).join("WinGet").join("Packages"));
|
||||
} else {
|
||||
roots.push(PathBuf::from(r"C:\Program Files\WinGet\Packages"));
|
||||
}
|
||||
|
||||
if let Ok(program_files_x86) = std::env::var("ProgramFiles(x86)") {
|
||||
roots.push(
|
||||
PathBuf::from(program_files_x86)
|
||||
.join("WinGet")
|
||||
.join("Packages"),
|
||||
);
|
||||
} else {
|
||||
roots.push(PathBuf::from(r"C:\Program Files (x86)\WinGet\Packages"));
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn normalize_path(path: &Path) -> String {
|
||||
let normalized = path
|
||||
.to_string_lossy()
|
||||
.replace('/', "\\")
|
||||
.trim_end_matches('\\')
|
||||
.to_ascii_lowercase();
|
||||
|
||||
normalized
|
||||
.strip_prefix("\\\\?\\unc\\")
|
||||
.map(|rest| format!("\\\\{rest}"))
|
||||
.or_else(|| normalized.strip_prefix("\\\\?\\").map(str::to_owned))
|
||||
.unwrap_or(normalized)
|
||||
}
|
||||
|
||||
fn is_version_newer(candidate: &str, current: &str) -> bool {
|
||||
parse_version(candidate) > parse_version(current)
|
||||
}
|
||||
|
||||
fn parse_version(version: &str) -> (u32, u32, u32) {
|
||||
let core = version.split('-').next().unwrap_or(version);
|
||||
let mut parts = core.split('.').map(|part| part.parse::<u32>().unwrap_or(0));
|
||||
|
||||
(
|
||||
parts.next().unwrap_or(0),
|
||||
parts.next().unwrap_or(0),
|
||||
parts.next().unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
fn show_error_message(title: &str, message: &str) {
|
||||
unsafe {
|
||||
let title_wide = wide_str(title);
|
||||
let message_wide = wide_str(message);
|
||||
let _ = MessageBoxW(
|
||||
HWND::default(),
|
||||
PCWSTR::from_raw(message_wide.as_ptr()),
|
||||
PCWSTR::from_raw(title_wide.as_ptr()),
|
||||
MB_OK | MB_ICONERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn wide_str(value: &str) -> Vec<u16> {
|
||||
value.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Claude (Anthropic) usage provider.
|
||||
//
|
||||
// Two HTTP paths: the dedicated `/api/oauth/usage` endpoint (preferred,
|
||||
// returns structured JSON with ISO 8601 reset times) and a fallback POST
|
||||
// to `/v1/messages` that exposes rate-limit data via response headers.
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::creds::{CredentialSource, Locator};
|
||||
use crate::net::Client;
|
||||
use crate::usage::{headers, Error, ProviderId, UsageProvider, UsageWindows, Window};
|
||||
|
||||
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
||||
const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
const BETA_HEADER: &str = "oauth-2025-04-20";
|
||||
const API_VERSION: &str = "2023-06-01";
|
||||
|
||||
const PROBE_MODELS: &[&str] = &[
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-haiku-4-5-20251001",
|
||||
];
|
||||
|
||||
pub struct ClaudeProvider {
|
||||
locator: Locator,
|
||||
}
|
||||
|
||||
impl ClaudeProvider {
|
||||
pub fn new(locator: Locator) -> Self {
|
||||
Self { locator }
|
||||
}
|
||||
|
||||
pub fn locator(&self) -> &Locator {
|
||||
&self.locator
|
||||
}
|
||||
}
|
||||
|
||||
impl UsageProvider for ClaudeProvider {
|
||||
fn id(&self) -> ProviderId {
|
||||
ProviderId::Claude
|
||||
}
|
||||
|
||||
fn poll(&mut self, http: &Client) -> Result<UsageWindows, Error> {
|
||||
let source = self.locator.first_available().ok_or(Error::NoCredentials)?;
|
||||
let token = source.read()?;
|
||||
if token_is_expired(token.expires_at_unix_ms) {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
fetch_with_fallback(http, &token.access_token)
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_with_fallback(http: &Client, token: &str) -> Result<UsageWindows, Error> {
|
||||
// First try the dedicated endpoint.
|
||||
match try_usage_endpoint(http, token)? {
|
||||
Some(windows) if has_reset_times(&windows) => return Ok(windows),
|
||||
Some(partial) => {
|
||||
// Got percentages but no reset times — fill them in from messages.
|
||||
if let Ok(fallback) = try_messages_endpoint(http, token) {
|
||||
return Ok(merge_resets(partial, fallback));
|
||||
}
|
||||
return Ok(partial);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
try_messages_endpoint(http, token)
|
||||
}
|
||||
|
||||
fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>, Error> {
|
||||
let resp = match http
|
||||
.get(USAGE_URL)
|
||||
.header("Authorization", &format!("Bearer {token}"))
|
||||
.header("anthropic-beta", BETA_HEADER)
|
||||
.send()
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if resp.status() == 401 || resp.status() == 403 {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let body: OauthUsage = match resp.json() {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
let primary = body.five_hour.map(bucket_to_window).unwrap_or_default();
|
||||
let secondary = body.seven_day.map(bucket_to_window).unwrap_or_default();
|
||||
Ok(Some(UsageWindows { primary, secondary }))
|
||||
}
|
||||
|
||||
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> {
|
||||
for model in PROBE_MODELS {
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"max_tokens": 1,
|
||||
"messages": [{"role": "user", "content": "."}],
|
||||
});
|
||||
let resp = match http
|
||||
.post(MESSAGES_URL)
|
||||
.header("Authorization", &format!("Bearer {token}"))
|
||||
.header("anthropic-version", API_VERSION)
|
||||
.header("anthropic-beta", BETA_HEADER)
|
||||
.json_body(&body)
|
||||
.and_then(|rb| rb.send())
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
Err(_) => continue,
|
||||
};
|
||||
if resp.status() == 401 || resp.status() == 403 {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
// Even an error response from Messages can carry rate-limit headers.
|
||||
if resp.header("anthropic-ratelimit-unified-5h-utilization").is_some()
|
||||
|| resp.header("anthropic-ratelimit-unified-7d-utilization").is_some()
|
||||
{
|
||||
return Ok(headers::parse_anthropic(&resp));
|
||||
}
|
||||
}
|
||||
Err(Error::BadResponse(
|
||||
"no rate-limit headers in messages response".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn bucket_to_window(bucket: Bucket) -> Window {
|
||||
Window {
|
||||
utilization: bucket.utilization,
|
||||
resets_at: bucket.resets_at.as_deref().and_then(parse_iso8601),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_reset_times(w: &UsageWindows) -> bool {
|
||||
w.primary.resets_at.is_some() && w.secondary.resets_at.is_some()
|
||||
}
|
||||
|
||||
fn merge_resets(mut primary: UsageWindows, fallback: UsageWindows) -> UsageWindows {
|
||||
if primary.primary.resets_at.is_none() {
|
||||
primary.primary.resets_at = fallback.primary.resets_at;
|
||||
}
|
||||
if primary.secondary.resets_at.is_none() {
|
||||
primary.secondary.resets_at = fallback.secondary.resets_at;
|
||||
}
|
||||
primary
|
||||
}
|
||||
|
||||
fn token_is_expired(expires_at_unix_ms: Option<i64>) -> bool {
|
||||
let Some(exp_ms) = expires_at_unix_ms else {
|
||||
return false;
|
||||
};
|
||||
let now_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
now_ms >= exp_ms
|
||||
}
|
||||
|
||||
// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|+00:00]") ---
|
||||
|
||||
fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||
let trimmed = s.split('Z').next().unwrap_or(s);
|
||||
let trimmed = trimmed.split('+').next().unwrap_or(trimmed);
|
||||
let trimmed = trimmed.split('-').take(3).collect::<Vec<_>>().join("-");
|
||||
// We want the original `s` for parsing time-part. Re-split on 'T'.
|
||||
let (date, time) = s
|
||||
.split_once('T')
|
||||
.map(|(d, t)| (d, t))
|
||||
.or_else(|| Some(("", "")))?;
|
||||
let _ = trimmed; // shadow; using the raw `date` + `time` below.
|
||||
|
||||
let date_parts: Vec<&str> = date.split('-').collect();
|
||||
if date_parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let y: u64 = date_parts[0].parse().ok()?;
|
||||
let mo: u64 = date_parts[1].parse().ok()?;
|
||||
let d: u64 = date_parts[2].parse().ok()?;
|
||||
|
||||
let time_no_offset = time
|
||||
.split(|c| c == 'Z' || c == '+' || (c == '-' && time.find(c) != Some(0)))
|
||||
.next()
|
||||
.unwrap_or(time);
|
||||
let time_no_frac = time_no_offset.split('.').next().unwrap_or(time_no_offset);
|
||||
let time_parts: Vec<&str> = time_no_frac.split(':').collect();
|
||||
if time_parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let h: u64 = time_parts[0].parse().ok()?;
|
||||
let mi: u64 = time_parts[1].parse().ok()?;
|
||||
let se: u64 = time_parts[2].parse().ok()?;
|
||||
|
||||
let mut days: u64 = 0;
|
||||
for year in 1970..y {
|
||||
days += if is_leap(year) { 366 } else { 365 };
|
||||
}
|
||||
const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
for month in 1..mo {
|
||||
days += DAYS_IN_MONTH[month as usize];
|
||||
if month == 2 && is_leap(y) {
|
||||
days += 1;
|
||||
}
|
||||
}
|
||||
days += d - 1;
|
||||
let secs = days * 86_400 + h * 3_600 + mi * 60 + se;
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
fn is_leap(year: u64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
// --- JSON shape ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OauthUsage {
|
||||
five_hour: Option<Bucket>,
|
||||
seven_day: Option<Bucket>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Bucket {
|
||||
utilization: f64,
|
||||
resets_at: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Codex (ChatGPT) usage provider.
|
||||
//
|
||||
// Single endpoint: `/backend-api/wham/usage`. Response shape includes
|
||||
// `rate_limit.{primary_window,secondary_window}.{used_percent,reset_at}`.
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::creds::{CredentialSource, Locator};
|
||||
use crate::net::Client;
|
||||
use crate::usage::{Error, ProviderId, UsageProvider, UsageWindows, Window};
|
||||
|
||||
const USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
|
||||
|
||||
pub struct ChatGptProvider {
|
||||
locator: Locator,
|
||||
}
|
||||
|
||||
impl ChatGptProvider {
|
||||
pub fn new(locator: Locator) -> Self {
|
||||
Self { locator }
|
||||
}
|
||||
|
||||
pub fn locator(&self) -> &Locator {
|
||||
&self.locator
|
||||
}
|
||||
}
|
||||
|
||||
impl UsageProvider for ChatGptProvider {
|
||||
fn id(&self) -> ProviderId {
|
||||
ProviderId::ChatGpt
|
||||
}
|
||||
|
||||
fn poll(&mut self, http: &Client) -> Result<UsageWindows, Error> {
|
||||
let source = self.locator.first_available().ok_or(Error::NoCredentials)?;
|
||||
let token = source.read()?;
|
||||
let mut req = http
|
||||
.get(USAGE_URL)
|
||||
.header("Authorization", &format!("Bearer {}", token.access_token))
|
||||
.header("User-Agent", "codex-cli");
|
||||
if let Some(account_id) = token.account_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
req = req.header("ChatGPT-Account-Id", account_id);
|
||||
}
|
||||
let resp = match req.send() {
|
||||
Ok(r) => r,
|
||||
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
Err(e) => return Err(Error::Network(e)),
|
||||
};
|
||||
if resp.status() == 401 || resp.status() == 403 {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Err(Error::BadResponse(format!(
|
||||
"Codex usage endpoint returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
let body: Envelope = resp
|
||||
.json()
|
||||
.map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?;
|
||||
envelope_to_windows(body)
|
||||
.ok_or_else(|| Error::BadResponse("missing rate_limit section".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
|
||||
let rl = envelope.rate_limit.flatten()?;
|
||||
Some(UsageWindows {
|
||||
primary: rl
|
||||
.primary_window
|
||||
.flatten()
|
||||
.map(window_from)
|
||||
.unwrap_or_default(),
|
||||
secondary: rl
|
||||
.secondary_window
|
||||
.flatten()
|
||||
.map(window_from)
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn window_from(w: ApiWindow) -> Window {
|
||||
Window {
|
||||
utilization: w.used_percent,
|
||||
resets_at: unix_to_systemtime(Some(w.reset_at)),
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_to_systemtime(secs: Option<i64>) -> Option<SystemTime> {
|
||||
let s = secs?;
|
||||
if s < 0 {
|
||||
return None;
|
||||
}
|
||||
Some(UNIX_EPOCH + Duration::from_secs(s as u64))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Envelope {
|
||||
rate_limit: Option<Option<Box<RateLimit>>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RateLimit {
|
||||
primary_window: Option<Option<Box<ApiWindow>>>,
|
||||
secondary_window: Option<Option<Box<ApiWindow>>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApiWindow {
|
||||
used_percent: f64,
|
||||
reset_at: i64,
|
||||
}
|
||||
|
||||
// Helpers used to make `Option<Option<Box<…>>>` flatten cleanly.
|
||||
trait FlattenBoxed<T> {
|
||||
fn flatten(self) -> Option<T>;
|
||||
}
|
||||
impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
|
||||
fn flatten(self) -> Option<T> {
|
||||
self.and_then(|inner| inner.map(|b| *b))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Parse Anthropic rate-limit headers into `UsageWindows`.
|
||||
//
|
||||
// The Messages API returns the user's remaining quota in response headers
|
||||
// when the dedicated usage endpoint isn't available. Header names:
|
||||
// anthropic-ratelimit-unified-5h-utilization (0.0–1.0)
|
||||
// anthropic-ratelimit-unified-5h-reset (Unix seconds)
|
||||
// anthropic-ratelimit-unified-7d-utilization
|
||||
// anthropic-ratelimit-unified-7d-reset
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::net::Response;
|
||||
use crate::usage::{UsageWindows, Window};
|
||||
|
||||
pub fn parse_anthropic(response: &Response) -> UsageWindows {
|
||||
UsageWindows {
|
||||
primary: Window {
|
||||
utilization: header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0,
|
||||
resets_at: unix_to_systemtime(header_i64(
|
||||
response,
|
||||
"anthropic-ratelimit-unified-5h-reset",
|
||||
)),
|
||||
},
|
||||
secondary: Window {
|
||||
utilization: header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0,
|
||||
resets_at: unix_to_systemtime(header_i64(
|
||||
response,
|
||||
"anthropic-ratelimit-unified-7d-reset",
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn header_f64(response: &Response, name: &str) -> f64 {
|
||||
response
|
||||
.header(name)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn header_i64(response: &Response, name: &str) -> Option<i64> {
|
||||
response.header(name).and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
fn unix_to_systemtime(secs: Option<i64>) -> Option<SystemTime> {
|
||||
let s = secs?;
|
||||
if s < 0 {
|
||||
return None;
|
||||
}
|
||||
Some(UNIX_EPOCH + Duration::from_secs(s as u64))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Usage subsystem: trait + per-provider implementations + registry.
|
||||
//
|
||||
// Phase 2 stubs out only `types`. Phase 4 fills in `anthropic`, `chatgpt`,
|
||||
// `refresh`, `registry`, and `headers`. The trait below is the contract
|
||||
// every provider must satisfy.
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod chatgpt;
|
||||
pub mod headers;
|
||||
pub mod refresh;
|
||||
pub mod registry;
|
||||
pub mod types;
|
||||
|
||||
pub use registry::Registry;
|
||||
pub use types::{ProviderId, ProviderSnapshot, UsageWindows, Window};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("authentication required")]
|
||||
AuthRequired,
|
||||
#[error("no credentials configured")]
|
||||
NoCredentials,
|
||||
#[error("token expired after refresh attempt")]
|
||||
TokenExpired,
|
||||
#[error("network: {0}")]
|
||||
Network(#[from] crate::net::Error),
|
||||
#[error("unexpected response shape: {0}")]
|
||||
BadResponse(String),
|
||||
}
|
||||
|
||||
/// Every provider exposes a stable identity and a sync `poll` that performs
|
||||
/// HTTP calls against the supplied client.
|
||||
pub trait UsageProvider: Send {
|
||||
fn id(&self) -> ProviderId;
|
||||
fn poll(&mut self, http: &crate::net::Client) -> Result<UsageWindows, Error>;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Token refresh orchestrator.
|
||||
//
|
||||
// Each provider's credential source advertises a `RefreshHint` describing
|
||||
// which CLI to spawn. We invoke that CLI and watch the credential file's
|
||||
// signature; if it changes within the timeout we declare success.
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::creds::{CredentialSource, RefreshHint};
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Refreshed,
|
||||
StillExpired,
|
||||
CliMissing,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
pub struct Orchestrator {
|
||||
timeout: Duration,
|
||||
poll_interval: Duration,
|
||||
}
|
||||
|
||||
impl Orchestrator {
|
||||
pub fn new(timeout: Duration) -> Self {
|
||||
Self {
|
||||
timeout,
|
||||
poll_interval: Duration::from_millis(500),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh(&self, source: &dyn CredentialSource) -> Outcome {
|
||||
let initial_sig = source.signature();
|
||||
let hint = source.refresh_hint();
|
||||
if !spawn_cli(&hint) {
|
||||
return Outcome::CliMissing;
|
||||
}
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < self.timeout {
|
||||
std::thread::sleep(self.poll_interval);
|
||||
if source.signature() != initial_sig {
|
||||
return Outcome::Refreshed;
|
||||
}
|
||||
}
|
||||
if source.signature() != initial_sig {
|
||||
Outcome::Refreshed
|
||||
} else {
|
||||
Outcome::Timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_cli(hint: &RefreshHint) -> bool {
|
||||
match hint {
|
||||
RefreshHint::LocalClaudeCli => spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."]),
|
||||
RefreshHint::WslClaudeCli { distro } => spawn_wsl(distro),
|
||||
RefreshHint::LocalCodexCli => {
|
||||
spawn_local(&["codex.cmd", "codex.ps1", "codex.exe", "codex"], &["exec", "."])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
|
||||
for name in candidates {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let mut cmd = if lower.ends_with(".ps1") {
|
||||
let mut c = Command::new("powershell.exe");
|
||||
c.arg("-NoProfile").arg("-ExecutionPolicy").arg("Bypass").arg("-File").arg(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
} else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.arg("/c").arg(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
};
|
||||
cmd.env_remove("CLAUDECODE")
|
||||
.env_remove("CLAUDE_CODE_ENTRYPOINT")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
if cmd.spawn().is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn spawn_wsl(distro: &str) -> bool {
|
||||
let script = "if command -v claude >/dev/null 2>&1; then claude -p .; \
|
||||
elif [ -x \"$HOME/.local/bin/claude\" ]; then \"$HOME/.local/bin/claude\" -p .; \
|
||||
else exit 127; fi";
|
||||
Command::new("wsl.exe")
|
||||
.arg("-d")
|
||||
.arg(distro)
|
||||
.arg("--")
|
||||
.arg("bash")
|
||||
.arg("-lic")
|
||||
.arg(script)
|
||||
.env_remove("CLAUDECODE")
|
||||
.env_remove("CLAUDE_CODE_ENTRYPOINT")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.is_ok()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Provider registry: holds every enabled provider and dispatches polls.
|
||||
//
|
||||
// The app owns one `Registry` and calls `poll_enabled(http, settings)` on
|
||||
// every cycle. The result is a flat list of `(id, Result<UsageWindows>)`
|
||||
// pairs the app can apply to its state.
|
||||
|
||||
use crate::creds::Locator;
|
||||
use crate::net::Client;
|
||||
use crate::settings::Settings;
|
||||
use crate::usage::{anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider, UsageWindows};
|
||||
|
||||
pub struct Registry {
|
||||
claude: ClaudeProvider,
|
||||
chatgpt: ChatGptProvider,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn with_defaults() -> Self {
|
||||
Self {
|
||||
claude: ClaudeProvider::new(Locator::for_claude()),
|
||||
chatgpt: ChatGptProvider::new(Locator::for_chatgpt()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll_enabled(
|
||||
&mut self,
|
||||
http: &Client,
|
||||
settings: &Settings,
|
||||
) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
|
||||
let mut out = Vec::new();
|
||||
if settings.show_claude_code {
|
||||
out.push((ProviderId::Claude, self.claude.poll(http)));
|
||||
}
|
||||
if settings.show_codex {
|
||||
out.push((ProviderId::ChatGpt, self.chatgpt.poll(http)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Attempt to refresh the active source for one provider.
|
||||
pub fn try_refresh(&self, id: ProviderId, orchestrator: &refresh::Orchestrator) -> refresh::Outcome {
|
||||
let locator = match id {
|
||||
ProviderId::Claude => self.claude.locator(),
|
||||
ProviderId::ChatGpt => self.chatgpt.locator(),
|
||||
};
|
||||
match locator.first_available() {
|
||||
Some(src) => orchestrator.refresh(src),
|
||||
None => refresh::Outcome::CliMissing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of credential-file fingerprints across both providers —
|
||||
/// used to detect external re-authentication between poll cycles.
|
||||
pub fn credential_signatures(&self) -> Vec<String> {
|
||||
let mut sigs = self.claude.locator().signatures();
|
||||
sigs.extend(self.chatgpt.locator().signatures());
|
||||
sigs.sort();
|
||||
sigs.dedup();
|
||||
sigs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Usage data shapes shared across providers.
|
||||
//
|
||||
// Every provider reports its quota as two named "windows" (short + long).
|
||||
// For Claude: 5-hour and 7-day. For ChatGPT: primary and secondary. We
|
||||
// normalise to `primary` + `secondary` so the UI layer doesn't care which
|
||||
// provider produced the snapshot.
|
||||
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
||||
pub enum ProviderId {
|
||||
Claude,
|
||||
ChatGpt,
|
||||
}
|
||||
|
||||
impl ProviderId {
|
||||
pub fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "claude",
|
||||
Self::ChatGpt => "chatgpt",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One usage window: how much you've consumed (0–100), and when it resets.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Window {
|
||||
pub utilization: f64,
|
||||
pub resets_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
/// The pair of windows a provider reports per poll.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct UsageWindows {
|
||||
pub primary: Window,
|
||||
pub secondary: Window,
|
||||
}
|
||||
|
||||
/// One provider's most recent poll result, keyed by `id`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProviderSnapshot {
|
||||
pub id: ProviderId,
|
||||
pub windows: UsageWindows,
|
||||
}
|
||||
Reference in New Issue
Block a user