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:
2026-05-16 10:09:43 +07:00
parent c0f3e3f860
commit aa6217d2cf
72 changed files with 6265 additions and 3788 deletions
+563 -788
View File
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -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;
+76
View File
@@ -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>,
}
+72
View File
@@ -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,
})
}
+111
View File
@@ -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
}
}
+155
View File
@@ -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()
}
+29
View File
@@ -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))
}
-52
View File
@@ -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}"));
}
+73
View File
@@ -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)]))
}
}
+38
View File
@@ -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."
+38
View File
@@ -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."
+38
View File
@@ -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."
+38
View File
@@ -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."
+38
View File
@@ -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 = "使用状況の追跡を続けるには再度サインインしてください。"
+38
View File
@@ -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 = "사용량을 계속 추적하려면 다시 로그인하세요."
+38
View File
@@ -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."
+38
View File
@@ -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
View File
@@ -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))
}
-46
View File
@@ -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",
};
-46
View File
@@ -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",
};
-46
View File
@@ -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",
};
-46
View File
@@ -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",
};
-46
View File
@@ -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: "",
};
-46
View File
@@ -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: "",
};
-246
View File
@@ -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)
}
}
-46
View File
@@ -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",
};
-46
View File
@@ -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
View File
@@ -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();
}
-19
View File
@@ -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>,
}
-71
View File
@@ -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)
}
}
+9
View File
@@ -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};
+431
View File
@@ -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,
})
}
+46
View File
@@ -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),
}
}
}
+31
View File
@@ -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
}
+14
View File
@@ -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;
+159
View File
@@ -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(())
}
}
+10
View File
@@ -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()
}
+16
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -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) {
-51
View File
@@ -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
}
}
+228
View File
@@ -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;
+22
View File
@@ -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
View File
@@ -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;
}
}
-441
View File
@@ -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;
}
+16
View File
@@ -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
}
+89
View File
@@ -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(&current)?;
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, &current)?;
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(())
}
+34
View File
@@ -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),
}
+91
View File
@@ -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
View File
@@ -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(),
&current_exe.to_string_lossy(),
&current_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(&current_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(&current_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()
}
+232
View File
@@ -0,0 +1,232 @@
// Claude (Anthropic) usage provider.
//
// Two HTTP paths: the dedicated `/api/oauth/usage` endpoint (preferred,
// returns structured JSON with ISO 8601 reset times) and a fallback POST
// to `/v1/messages` that exposes rate-limit data via response headers.
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use crate::creds::{CredentialSource, Locator};
use crate::net::Client;
use crate::usage::{headers, Error, ProviderId, UsageProvider, UsageWindows, Window};
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
const BETA_HEADER: &str = "oauth-2025-04-20";
const API_VERSION: &str = "2023-06-01";
const PROBE_MODELS: &[&str] = &[
"claude-3-haiku-20240307",
"claude-haiku-4-5-20251001",
];
pub struct ClaudeProvider {
locator: Locator,
}
impl ClaudeProvider {
pub fn new(locator: Locator) -> Self {
Self { locator }
}
pub fn locator(&self) -> &Locator {
&self.locator
}
}
impl UsageProvider for ClaudeProvider {
fn id(&self) -> ProviderId {
ProviderId::Claude
}
fn poll(&mut self, http: &Client) -> Result<UsageWindows, Error> {
let source = self.locator.first_available().ok_or(Error::NoCredentials)?;
let token = source.read()?;
if token_is_expired(token.expires_at_unix_ms) {
return Err(Error::AuthRequired);
}
fetch_with_fallback(http, &token.access_token)
}
}
fn fetch_with_fallback(http: &Client, token: &str) -> Result<UsageWindows, Error> {
// First try the dedicated endpoint.
match try_usage_endpoint(http, token)? {
Some(windows) if has_reset_times(&windows) => return Ok(windows),
Some(partial) => {
// Got percentages but no reset times — fill them in from messages.
if let Ok(fallback) = try_messages_endpoint(http, token) {
return Ok(merge_resets(partial, fallback));
}
return Ok(partial);
}
None => {}
}
try_messages_endpoint(http, token)
}
fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>, Error> {
let resp = match http
.get(USAGE_URL)
.header("Authorization", &format!("Bearer {token}"))
.header("anthropic-beta", BETA_HEADER)
.send()
{
Ok(r) => r,
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
return Err(Error::AuthRequired);
}
Err(_) => return Ok(None),
};
if resp.status() == 401 || resp.status() == 403 {
return Err(Error::AuthRequired);
}
if !(200..300).contains(&resp.status()) {
return Ok(None);
}
let body: OauthUsage = match resp.json() {
Ok(b) => b,
Err(_) => return Ok(None),
};
let primary = body.five_hour.map(bucket_to_window).unwrap_or_default();
let secondary = body.seven_day.map(bucket_to_window).unwrap_or_default();
Ok(Some(UsageWindows { primary, secondary }))
}
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> {
for model in PROBE_MODELS {
let body = serde_json::json!({
"model": model,
"max_tokens": 1,
"messages": [{"role": "user", "content": "."}],
});
let resp = match http
.post(MESSAGES_URL)
.header("Authorization", &format!("Bearer {token}"))
.header("anthropic-version", API_VERSION)
.header("anthropic-beta", BETA_HEADER)
.json_body(&body)
.and_then(|rb| rb.send())
{
Ok(r) => r,
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
return Err(Error::AuthRequired);
}
Err(_) => continue,
};
if resp.status() == 401 || resp.status() == 403 {
return Err(Error::AuthRequired);
}
// Even an error response from Messages can carry rate-limit headers.
if resp.header("anthropic-ratelimit-unified-5h-utilization").is_some()
|| resp.header("anthropic-ratelimit-unified-7d-utilization").is_some()
{
return Ok(headers::parse_anthropic(&resp));
}
}
Err(Error::BadResponse(
"no rate-limit headers in messages response".into(),
))
}
fn bucket_to_window(bucket: Bucket) -> Window {
Window {
utilization: bucket.utilization,
resets_at: bucket.resets_at.as_deref().and_then(parse_iso8601),
}
}
fn has_reset_times(w: &UsageWindows) -> bool {
w.primary.resets_at.is_some() && w.secondary.resets_at.is_some()
}
fn merge_resets(mut primary: UsageWindows, fallback: UsageWindows) -> UsageWindows {
if primary.primary.resets_at.is_none() {
primary.primary.resets_at = fallback.primary.resets_at;
}
if primary.secondary.resets_at.is_none() {
primary.secondary.resets_at = fallback.secondary.resets_at;
}
primary
}
fn token_is_expired(expires_at_unix_ms: Option<i64>) -> bool {
let Some(exp_ms) = expires_at_unix_ms else {
return false;
};
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
now_ms >= exp_ms
}
// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|+00:00]") ---
fn parse_iso8601(s: &str) -> Option<SystemTime> {
let trimmed = s.split('Z').next().unwrap_or(s);
let trimmed = trimmed.split('+').next().unwrap_or(trimmed);
let trimmed = trimmed.split('-').take(3).collect::<Vec<_>>().join("-");
// We want the original `s` for parsing time-part. Re-split on 'T'.
let (date, time) = s
.split_once('T')
.map(|(d, t)| (d, t))
.or_else(|| Some(("", "")))?;
let _ = trimmed; // shadow; using the raw `date` + `time` below.
let date_parts: Vec<&str> = date.split('-').collect();
if date_parts.len() != 3 {
return None;
}
let y: u64 = date_parts[0].parse().ok()?;
let mo: u64 = date_parts[1].parse().ok()?;
let d: u64 = date_parts[2].parse().ok()?;
let time_no_offset = time
.split(|c| c == 'Z' || c == '+' || (c == '-' && time.find(c) != Some(0)))
.next()
.unwrap_or(time);
let time_no_frac = time_no_offset.split('.').next().unwrap_or(time_no_offset);
let time_parts: Vec<&str> = time_no_frac.split(':').collect();
if time_parts.len() != 3 {
return None;
}
let h: u64 = time_parts[0].parse().ok()?;
let mi: u64 = time_parts[1].parse().ok()?;
let se: u64 = time_parts[2].parse().ok()?;
let mut days: u64 = 0;
for year in 1970..y {
days += if is_leap(year) { 366 } else { 365 };
}
const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
for month in 1..mo {
days += DAYS_IN_MONTH[month as usize];
if month == 2 && is_leap(y) {
days += 1;
}
}
days += d - 1;
let secs = days * 86_400 + h * 3_600 + mi * 60 + se;
Some(UNIX_EPOCH + Duration::from_secs(secs))
}
fn is_leap(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
// --- JSON shape ---
#[derive(Deserialize)]
struct OauthUsage {
five_hour: Option<Bucket>,
seven_day: Option<Bucket>,
}
#[derive(Deserialize)]
struct Bucket {
utilization: f64,
resets_at: Option<String>,
}
+125
View File
@@ -0,0 +1,125 @@
// Codex (ChatGPT) usage provider.
//
// Single endpoint: `/backend-api/wham/usage`. Response shape includes
// `rate_limit.{primary_window,secondary_window}.{used_percent,reset_at}`.
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use crate::creds::{CredentialSource, Locator};
use crate::net::Client;
use crate::usage::{Error, ProviderId, UsageProvider, UsageWindows, Window};
const USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
pub struct ChatGptProvider {
locator: Locator,
}
impl ChatGptProvider {
pub fn new(locator: Locator) -> Self {
Self { locator }
}
pub fn locator(&self) -> &Locator {
&self.locator
}
}
impl UsageProvider for ChatGptProvider {
fn id(&self) -> ProviderId {
ProviderId::ChatGpt
}
fn poll(&mut self, http: &Client) -> Result<UsageWindows, Error> {
let source = self.locator.first_available().ok_or(Error::NoCredentials)?;
let token = source.read()?;
let mut req = http
.get(USAGE_URL)
.header("Authorization", &format!("Bearer {}", token.access_token))
.header("User-Agent", "codex-cli");
if let Some(account_id) = token.account_id.as_deref().filter(|s| !s.is_empty()) {
req = req.header("ChatGPT-Account-Id", account_id);
}
let resp = match req.send() {
Ok(r) => r,
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
return Err(Error::AuthRequired);
}
Err(e) => return Err(Error::Network(e)),
};
if resp.status() == 401 || resp.status() == 403 {
return Err(Error::AuthRequired);
}
if !(200..300).contains(&resp.status()) {
return Err(Error::BadResponse(format!(
"Codex usage endpoint returned {}",
resp.status()
)));
}
let body: Envelope = resp
.json()
.map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?;
envelope_to_windows(body)
.ok_or_else(|| Error::BadResponse("missing rate_limit section".into()))
}
}
fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
let rl = envelope.rate_limit.flatten()?;
Some(UsageWindows {
primary: rl
.primary_window
.flatten()
.map(window_from)
.unwrap_or_default(),
secondary: rl
.secondary_window
.flatten()
.map(window_from)
.unwrap_or_default(),
})
}
fn window_from(w: ApiWindow) -> Window {
Window {
utilization: w.used_percent,
resets_at: unix_to_systemtime(Some(w.reset_at)),
}
}
fn unix_to_systemtime(secs: Option<i64>) -> Option<SystemTime> {
let s = secs?;
if s < 0 {
return None;
}
Some(UNIX_EPOCH + Duration::from_secs(s as u64))
}
#[derive(Deserialize)]
struct Envelope {
rate_limit: Option<Option<Box<RateLimit>>>,
}
#[derive(Deserialize)]
struct RateLimit {
primary_window: Option<Option<Box<ApiWindow>>>,
secondary_window: Option<Option<Box<ApiWindow>>>,
}
#[derive(Deserialize)]
struct ApiWindow {
used_percent: f64,
reset_at: i64,
}
// Helpers used to make `Option<Option<Box<…>>>` flatten cleanly.
trait FlattenBoxed<T> {
fn flatten(self) -> Option<T>;
}
impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
fn flatten(self) -> Option<T> {
self.and_then(|inner| inner.map(|b| *b))
}
}
+51
View File
@@ -0,0 +1,51 @@
// Parse Anthropic rate-limit headers into `UsageWindows`.
//
// The Messages API returns the user's remaining quota in response headers
// when the dedicated usage endpoint isn't available. Header names:
// anthropic-ratelimit-unified-5h-utilization (0.01.0)
// anthropic-ratelimit-unified-5h-reset (Unix seconds)
// anthropic-ratelimit-unified-7d-utilization
// anthropic-ratelimit-unified-7d-reset
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::net::Response;
use crate::usage::{UsageWindows, Window};
pub fn parse_anthropic(response: &Response) -> UsageWindows {
UsageWindows {
primary: Window {
utilization: header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0,
resets_at: unix_to_systemtime(header_i64(
response,
"anthropic-ratelimit-unified-5h-reset",
)),
},
secondary: Window {
utilization: header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0,
resets_at: unix_to_systemtime(header_i64(
response,
"anthropic-ratelimit-unified-7d-reset",
)),
},
}
}
fn header_f64(response: &Response, name: &str) -> f64 {
response
.header(name)
.and_then(|s| s.parse().ok())
.unwrap_or(0.0)
}
fn header_i64(response: &Response, name: &str) -> Option<i64> {
response.header(name).and_then(|s| s.parse().ok())
}
fn unix_to_systemtime(secs: Option<i64>) -> Option<SystemTime> {
let s = secs?;
if s < 0 {
return None;
}
Some(UNIX_EPOCH + Duration::from_secs(s as u64))
}
+36
View File
@@ -0,0 +1,36 @@
// Usage subsystem: trait + per-provider implementations + registry.
//
// Phase 2 stubs out only `types`. Phase 4 fills in `anthropic`, `chatgpt`,
// `refresh`, `registry`, and `headers`. The trait below is the contract
// every provider must satisfy.
pub mod anthropic;
pub mod chatgpt;
pub mod headers;
pub mod refresh;
pub mod registry;
pub mod types;
pub use registry::Registry;
pub use types::{ProviderId, ProviderSnapshot, UsageWindows, Window};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("authentication required")]
AuthRequired,
#[error("no credentials configured")]
NoCredentials,
#[error("token expired after refresh attempt")]
TokenExpired,
#[error("network: {0}")]
Network(#[from] crate::net::Error),
#[error("unexpected response shape: {0}")]
BadResponse(String),
}
/// Every provider exposes a stable identity and a sync `poll` that performs
/// HTTP calls against the supplied client.
pub trait UsageProvider: Send {
fn id(&self) -> ProviderId;
fn poll(&mut self, http: &crate::net::Client) -> Result<UsageWindows, Error>;
}
+123
View File
@@ -0,0 +1,123 @@
// Token refresh orchestrator.
//
// Each provider's credential source advertises a `RefreshHint` describing
// which CLI to spawn. We invoke that CLI and watch the credential file's
// signature; if it changes within the timeout we declare success.
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::creds::{CredentialSource, RefreshHint};
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Refreshed,
StillExpired,
CliMissing,
Timeout,
}
pub struct Orchestrator {
timeout: Duration,
poll_interval: Duration,
}
impl Orchestrator {
pub fn new(timeout: Duration) -> Self {
Self {
timeout,
poll_interval: Duration::from_millis(500),
}
}
pub fn refresh(&self, source: &dyn CredentialSource) -> Outcome {
let initial_sig = source.signature();
let hint = source.refresh_hint();
if !spawn_cli(&hint) {
return Outcome::CliMissing;
}
let start = Instant::now();
while start.elapsed() < self.timeout {
std::thread::sleep(self.poll_interval);
if source.signature() != initial_sig {
return Outcome::Refreshed;
}
}
if source.signature() != initial_sig {
Outcome::Refreshed
} else {
Outcome::Timeout
}
}
}
fn spawn_cli(hint: &RefreshHint) -> bool {
match hint {
RefreshHint::LocalClaudeCli => spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."]),
RefreshHint::WslClaudeCli { distro } => spawn_wsl(distro),
RefreshHint::LocalCodexCli => {
spawn_local(&["codex.cmd", "codex.ps1", "codex.exe", "codex"], &["exec", "."])
}
}
}
fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
for name in candidates {
let lower = name.to_ascii_lowercase();
let mut cmd = if lower.ends_with(".ps1") {
let mut c = Command::new("powershell.exe");
c.arg("-NoProfile").arg("-ExecutionPolicy").arg("Bypass").arg("-File").arg(name);
for a in args {
c.arg(a);
}
c
} else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
let mut c = Command::new("cmd.exe");
c.arg("/c").arg(name);
for a in args {
c.arg(a);
}
c
} else {
let mut c = Command::new(name);
for a in args {
c.arg(a);
}
c
};
cmd.env_remove("CLAUDECODE")
.env_remove("CLAUDE_CODE_ENTRYPOINT")
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
if cmd.spawn().is_ok() {
return true;
}
}
false
}
fn spawn_wsl(distro: &str) -> bool {
let script = "if command -v claude >/dev/null 2>&1; then claude -p .; \
elif [ -x \"$HOME/.local/bin/claude\" ]; then \"$HOME/.local/bin/claude\" -p .; \
else exit 127; fi";
Command::new("wsl.exe")
.arg("-d")
.arg(distro)
.arg("--")
.arg("bash")
.arg("-lic")
.arg(script)
.env_remove("CLAUDECODE")
.env_remove("CLAUDE_CODE_ENTRYPOINT")
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.is_ok()
}
+61
View File
@@ -0,0 +1,61 @@
// Provider registry: holds every enabled provider and dispatches polls.
//
// The app owns one `Registry` and calls `poll_enabled(http, settings)` on
// every cycle. The result is a flat list of `(id, Result<UsageWindows>)`
// pairs the app can apply to its state.
use crate::creds::Locator;
use crate::net::Client;
use crate::settings::Settings;
use crate::usage::{anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider, UsageWindows};
pub struct Registry {
claude: ClaudeProvider,
chatgpt: ChatGptProvider,
}
impl Registry {
pub fn with_defaults() -> Self {
Self {
claude: ClaudeProvider::new(Locator::for_claude()),
chatgpt: ChatGptProvider::new(Locator::for_chatgpt()),
}
}
pub fn poll_enabled(
&mut self,
http: &Client,
settings: &Settings,
) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
let mut out = Vec::new();
if settings.show_claude_code {
out.push((ProviderId::Claude, self.claude.poll(http)));
}
if settings.show_codex {
out.push((ProviderId::ChatGpt, self.chatgpt.poll(http)));
}
out
}
/// Attempt to refresh the active source for one provider.
pub fn try_refresh(&self, id: ProviderId, orchestrator: &refresh::Orchestrator) -> refresh::Outcome {
let locator = match id {
ProviderId::Claude => self.claude.locator(),
ProviderId::ChatGpt => self.chatgpt.locator(),
};
match locator.first_available() {
Some(src) => orchestrator.refresh(src),
None => refresh::Outcome::CliMissing,
}
}
/// Snapshot of credential-file fingerprints across both providers —
/// used to detect external re-authentication between poll cycles.
pub fn credential_signatures(&self) -> Vec<String> {
let mut sigs = self.claude.locator().signatures();
sigs.extend(self.chatgpt.locator().signatures());
sigs.sort();
sigs.dedup();
sigs
}
}
+44
View File
@@ -0,0 +1,44 @@
// Usage data shapes shared across providers.
//
// Every provider reports its quota as two named "windows" (short + long).
// For Claude: 5-hour and 7-day. For ChatGPT: primary and secondary. We
// normalise to `primary` + `secondary` so the UI layer doesn't care which
// provider produced the snapshot.
use std::time::SystemTime;
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub enum ProviderId {
Claude,
ChatGpt,
}
impl ProviderId {
pub fn slug(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::ChatGpt => "chatgpt",
}
}
}
/// One usage window: how much you've consumed (0100), 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,
}