Compare commits

..

No commits in common. "main" and "v1.3.7" have entirely different histories.
main ... v1.3.7

19 changed files with 171 additions and 1382 deletions

2
.gitignore vendored
View File

@ -10,7 +10,6 @@
# IDE
.idea/
.vscode/
.history/
*.swp
*.swo
*~
@ -22,7 +21,6 @@ Desktop.ini
# Local release workflow helper
workflow.cmd
AGENTS.md
# Local WinGet manifest generation output
/manifests/

2
Cargo.lock generated
View File

@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-monitor"
version = "1.4.5"
version = "1.3.7"
dependencies = [
"dirs",
"native-tls",

View File

@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-monitor"
version = "1.4.5"
version = "1.3.7"
edition = "2021"
license = "MIT"
description = "Claude Code Usage Monitor"

View File

@ -5,34 +5,30 @@
![Screenshot](.github/animation.gif)
A lightweight Windows taskbar widget for people already using Claude Code, with optional Codex usage display.
A lightweight Windows taskbar widget for people already using Claude Code.
It sits in your taskbar and shows how much of your Claude Code and/or Codex usage window you have left, without needing to open the terminal or the provider site.
It sits in your taskbar and shows how much of your Claude Code usage window you have left, without needing to open the terminal or the Claude site.
## What You Get
- A **5h** bar for your current 5-hour Claude usage window
- A **7d** bar for your current 7-day window
- Optional Codex usage bars alongside Claude Code
- A live countdown until each limit resets
- A small native widget that lives directly in the Windows taskbar
- System tray icon badges showing your enabled model usage percentage
- A **system tray icon** showing your usage percentage as a color-coded badge
- Left-click the tray icon to toggle the taskbar widget on or off
- Right-click options for refresh, displayed models, update frequency, language, startup, and updates
- Right-click options for refresh, update frequency, language, startup, and updates
## Who This Is For
This app is for Windows users who already have **Claude Code (CLI or App) installed and signed in**.
Codex support is optional. To show Codex usage, install and sign in to the Codex CLI, then enable Codex from the right-click **Models** menu.
It works best if you want a simple "how close am I to the limit?" display that is always visible.
## Requirements
- Windows 10 or Windows 11
- Claude Code (CLI or App) installed and authenticated
- Optional: Codex CLI installed and authenticated, if you want Codex usage
If you use Claude Code through WSL, that is supported too. The monitor can read your Claude Code credentials from Windows or from your WSL environment.
@ -54,31 +50,18 @@ After installing with WinGet, run:
claude-code-usage-monitor
```
Once running, it will appear in your taskbar and as one or more tray icons in the notification area.
Once running, it will appear in your taskbar and as a tray icon in the notification area.
- Drag the left divider to move the taskbar widget
- Right-click the taskbar widget or tray icon for refresh, displayed models, update frequency, Start with Windows, reset position, language, updates, and exit
- Right-click the taskbar widget or tray icon for refresh, update frequency, Start with Windows, reset position, language, updates, and exit
- Left-click the tray icon to toggle the taskbar widget on or off
- Enable `Start with Windows` from the right-click menu if you want it to launch automatically when you sign in
### Models
Use the right-click **Models** menu to choose what the widget displays:
- **Claude Code** is enabled by default
- **Codex** can be enabled alongside Claude Code or shown by itself
When both models are shown, each model has its own usage bar and matching usage text color.
### System Tray Icon
The tray icon shows your current 5-hour usage as a percentage badge.
The tray icon shows your current 5-hour usage as a color-coded percentage badge.
If both Claude Code and Codex are enabled, the app shows two tray icons: one for Claude Code and one for Codex. If only one model is enabled, it shows one tray icon.
The Claude Code tray icon uses the same warm usage colors as the Claude bar. The Codex tray icon uses a black and white badge style.
Hovering over a tray icon shows the usage values for that model.
Hovering over the tray icon shows a tooltip with both your 5h and 7d usage.
## Diagnostics
@ -119,12 +102,10 @@ What the app reads:
- Your local Claude Code OAuth credentials from `~/.claude/.credentials.json`
- If needed, the same credentials file inside an installed WSL distro
- If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json` or `~/.codex/auth.json`
What the app sends over the network:
- Requests to Anthropic's Claude endpoints to read your usage and rate-limit information
- Requests to ChatGPT's Codex usage endpoint to read your Codex usage and rate-limit information, if Codex is enabled
- Requests to GitHub only if you use the app's update check / self-update feature
- If proxy environment variables such as `HTTPS_PROXY`, `HTTP_PROXY`, or `ALL_PROXY` are set, those outbound requests may use that proxy
@ -134,7 +115,6 @@ What the app stores locally:
- Polling frequency
- Language preference
- Last update check time
- Displayed model preferences
What it does **not** do:
@ -142,21 +122,19 @@ What it does **not** do:
- It does not use a separate backend service
- It does not collect analytics or telemetry
- It does not upload your project files
- It does not directly edit your Codex credentials file
Notes:
- If your Claude Code token is expired, the app may ask the local Claude CLI to refresh it in the background
- If your Codex token is expired, the app may ask the local Codex CLI to refresh it in the background. The monitor does not write `auth.json` itself; any credential update is handled by the Codex CLI.
- Portable installs can update themselves by downloading the latest release from this repository
- Proxies should be trusted because proxied usage requests include your OAuth bearer token inside the TLS connection
- Proxies should be trusted because proxied Claude requests include your OAuth bearer token inside the TLS connection
## How It Works
The monitor:
1. Finds your enabled model login credentials
2. Reads your current usage from Anthropic and/or ChatGPT
1. Finds your Claude Code login credentials
2. Reads your current usage from Anthropic
3. Shows the result directly in the Windows taskbar
4. Refreshes periodically in the background

View File

@ -10,9 +10,6 @@ pub(super) const STRINGS: Strings = Strings {
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",
@ -39,8 +36,5 @@ pub(super) const STRINGS: Strings = Strings {
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",
};

View File

@ -10,9 +10,6 @@ pub(super) const STRINGS: Strings = Strings {
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",
@ -39,8 +36,5 @@ pub(super) const STRINGS: Strings = Strings {
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",
};

View File

@ -10,9 +10,6 @@ pub(super) const STRINGS: Strings = Strings {
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",
@ -39,8 +36,5 @@ pub(super) const STRINGS: Strings = Strings {
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",
};

View File

@ -10,9 +10,6 @@ pub(super) const STRINGS: Strings = Strings {
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",
@ -39,8 +36,5 @@ pub(super) const STRINGS: Strings = Strings {
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",
};

View File

@ -10,9 +10,6 @@ pub(super) const STRINGS: Strings = Strings {
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: "位置をリセット",
@ -39,8 +36,5 @@ pub(super) const STRINGS: Strings = Strings {
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: "秒",
};

View File

@ -10,9 +10,6 @@ pub(super) const STRINGS: Strings = Strings {
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: "위치 초기화",
@ -39,8 +36,5 @@ pub(super) const STRINGS: Strings = Strings {
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: "초",
};

View File

@ -1,13 +1,11 @@
mod dutch;
mod english;
mod dutch;
mod french;
mod german;
mod japanese;
mod korean;
mod spanish;
mod traditional_chinese;
mod russian;
mod portuguese_brazil;
use windows::core::PWSTR;
use windows::Win32::Globalization::{
@ -25,12 +23,10 @@ pub enum LanguageId {
Japanese,
Korean,
TraditionalChinese,
Russian,
PortugueseBrazil,
}
impl LanguageId {
pub const ALL: [LanguageId; 10] = [
pub const ALL: [LanguageId; 8] = [
LanguageId::English,
LanguageId::Dutch,
LanguageId::Spanish,
@ -39,8 +35,6 @@ impl LanguageId {
LanguageId::Japanese,
LanguageId::Korean,
LanguageId::TraditionalChinese,
LanguageId::Russian,
LanguageId::PortugueseBrazil,
];
pub fn code(self) -> &'static str {
@ -53,8 +47,6 @@ impl LanguageId {
Self::Japanese => "ja",
Self::Korean => "ko",
Self::TraditionalChinese => "zh-TW",
Self::Russian => "ru",
Self::PortugueseBrazil => "pt-BR",
}
}
@ -68,8 +60,6 @@ impl LanguageId {
Self::Japanese => "日本語",
Self::Korean => "한국어",
Self::TraditionalChinese => "繁體中文",
Self::Russian => "Русский",
Self::PortugueseBrazil => "Português (Brasil)",
}
}
@ -83,8 +73,6 @@ impl LanguageId {
Self::Japanese => japanese::STRINGS,
Self::Korean => korean::STRINGS,
Self::TraditionalChinese => traditional_chinese::STRINGS,
Self::Russian => russian::STRINGS,
Self::PortugueseBrazil => portuguese_brazil::STRINGS,
}
}
@ -98,8 +86,6 @@ impl LanguageId {
Self::Japanese => japanese::UPDATE_VIA_WINGET_LABEL,
Self::Korean => korean::UPDATE_VIA_WINGET_LABEL,
Self::TraditionalChinese => traditional_chinese::UPDATE_VIA_WINGET_LABEL,
Self::Russian => russian::UPDATE_VIA_WINGET_LABEL,
Self::PortugueseBrazil => portuguese_brazil::UPDATE_VIA_WINGET_LABEL,
}
}
@ -128,8 +114,6 @@ impl LanguageId {
None
}
}
"ru" => Some(Self::Russian),
"pt" => Some(Self::PortugueseBrazil),
_ => None,
}
}
@ -144,9 +128,6 @@ pub struct Strings {
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,
@ -174,9 +155,6 @@ pub struct Strings {
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 {

View File

@ -1,46 +0,0 @@
use super::Strings;
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Atualizar via WinGet";
pub(super) const STRINGS: Strings = Strings {
window_title: "Monitor de Uso do Claude Code",
refresh: "Atualizar",
update_frequency: "Frequência de Atualização",
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: "Configurações",
start_with_windows: "Iniciar com o Windows",
reset_position: "Redefinir Posição",
language: "Idioma",
system_default: "Padrão do Sistema",
check_for_updates: "Busca atualizações",
checking_for_updates: "Buscando por atualizações...",
updates: "Atualizações",
update_in_progress: "Uma verificação de atualização já está em andamento.",
up_to_date: "Você já tem a versão mais recente.",
up_to_date_short: "Atualizado",
update_failed: "Não foi possível atualizar automaticamente",
applying_update: "Aplicando atualização...",
update_to: "Atualizar para",
update_available: "Atualização disponível",
update_prompt_now: "Versão {version} está disponível. Deseja atualizar agora?",
exit: "Sair",
show_widget: "Exibir Widget",
session_window: "5h",
weekly_window: "7d",
now: "agora",
day_suffix: "d",
hour_suffix: "h",
minute_suffix: "m",
second_suffix: "s",
token_expired_title: "Erro de Autenticação do Claude Code",
token_expired_body: "Execute 'claude' em um terminal, use '/login' e siga as instruções. Depois disso, atualize ou reinicie este aplicativo.",
codex_token_expired_title: "Erro de Autenticação do Codex",
codex_token_expired_body: "Execute 'codex' em um terminal e siga as instruções de login. Depois disso, atualize ou reinicie este aplicativo.",
codex_window_title: "Monitor de uso do Codex",
};

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: "м",
second_suffix: "с",
token_expired_title: "Ошибка авторизации Claude Code",
token_expired_body: "Запустите 'claude' в терминале, затем используйте '/login' и следуйте инструкциям. После этого обновите или перезапустите приложение.",
codex_token_expired_title: "Ошибка авторизации Codex",
codex_token_expired_body: "Запустите 'codex' в терминале и следуйте инструкциям для входа. После этого обновите или перезапустите приложение.",
codex_window_title: "Монитор использования Codex",
};

View File

@ -10,9 +10,6 @@ pub(super) const STRINGS: Strings = Strings {
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",
@ -39,8 +36,5 @@ pub(super) const STRINGS: Strings = Strings {
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",
};

View File

@ -10,9 +10,6 @@ pub(super) const STRINGS: Strings = Strings {
five_minutes: "5 分鐘",
fifteen_minutes: "15 分鐘",
one_hour: "1 小時",
models: "模型",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "設定",
start_with_windows: "開機時啟動",
reset_position: "重置位置",
@ -39,8 +36,5 @@ pub(super) const STRINGS: Strings = Strings {
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: "秒",
};

View File

@ -11,9 +11,3 @@ 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>,
}

View File

@ -7,16 +7,15 @@ use std::os::windows::process::CommandExt;
use crate::diagnose;
use crate::localization::Strings;
use crate::models::{AppUsageData, UsageData, UsageSection};
use crate::models::{UsageData, UsageSection};
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
const CREATE_NO_WINDOW: u32 = 0x08000000;
const MODEL_FALLBACK_CHAIN: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Debug)]
pub enum PollError {
AuthRequired,
NoCredentials,
@ -44,80 +43,8 @@ struct UsageBucket {
resets_at: Option<String>,
}
#[derive(Deserialize)]
struct CodexAuthFile {
tokens: Option<CodexTokenData>,
}
#[derive(Clone, Deserialize)]
struct CodexTokenData {
access_token: String,
account_id: Option<String>,
}
#[derive(Deserialize)]
struct CodexUsageResponse {
rate_limit: Option<Option<Box<CodexRateLimitDetails>>>,
}
#[derive(Deserialize)]
struct CodexRateLimitDetails {
primary_window: Option<Option<Box<CodexRateLimitWindow>>>,
secondary_window: Option<Option<Box<CodexRateLimitWindow>>>,
}
#[derive(Deserialize)]
struct CodexRateLimitWindow {
used_percent: f64,
reset_at: i64,
}
pub fn poll(show_claude_code: bool, show_codex: bool) -> Result<AppUsageData, PollError> {
poll_with(show_claude_code, show_codex, poll_claude_code, poll_codex)
}
fn poll_with(
show_claude_code: bool,
show_codex: bool,
mut poll_claude_code: impl FnMut() -> Result<UsageData, PollError>,
mut poll_codex: impl FnMut() -> Result<UsageData, PollError>,
) -> Result<AppUsageData, PollError> {
let mut data = AppUsageData::default();
let mut first_error = None;
if show_claude_code {
match poll_claude_code() {
Ok(claude_code) => data.claude_code = Some(claude_code),
Err(error) => {
if show_codex {
diagnose::log(format!("Claude Code usage poll failed: {error:?}"));
}
first_error.get_or_insert(error);
}
}
}
if show_codex {
match poll_codex() {
Ok(codex) => data.codex = Some(codex),
Err(error) => {
if show_claude_code {
diagnose::log(format!("Codex usage poll failed: {error:?}"));
}
first_error.get_or_insert(error);
}
}
}
if data.claude_code.is_none() && data.codex.is_none() {
Err(first_error.unwrap_or(PollError::RequestFailed))
} else {
Ok(data)
}
}
fn poll_claude_code() -> Result<UsageData, PollError> {
let creds = match read_first_credentials() {
pub fn poll() -> Result<UsageData, PollError> {
let mut creds = match read_credentials() {
Some(c) => c,
None => {
diagnose::log("poll failed: no Claude credentials found");
@ -125,57 +52,26 @@ fn poll_claude_code() -> Result<UsageData, PollError> {
}
};
let creds = refresh_or_fallback(creds)?;
if is_token_expired(creds.expires_at) {
cli_refresh_token(&creds.source);
match read_credentials_from_source(&creds.source) {
Some(refreshed) => creds = refreshed,
None => {
diagnose::log("poll failed: credentials still unavailable after refresh attempt");
return Err(PollError::NoCredentials);
}
}
if is_token_expired(creds.expires_at) {
diagnose::log("poll failed: token is still expired after refresh attempt");
return Err(PollError::TokenExpired);
}
}
fetch_usage_with_fallback(&creds.access_token)
}
fn poll_codex() -> Result<UsageData, PollError> {
let creds = match read_codex_credentials() {
Some(creds) => creds,
None => {
diagnose::log("Codex usage poll failed: no Codex credentials found");
return Err(PollError::NoCredentials);
}
};
match fetch_codex_usage(&creds.access_token, creds.account_id.as_deref()) {
Ok(data) => Ok(data),
Err(PollError::AuthRequired) => {
cli_refresh_codex_token();
let refreshed = read_codex_credentials().ok_or(PollError::TokenExpired)?;
fetch_codex_usage(&refreshed.access_token, refreshed.account_id.as_deref())
}
Err(error) => Err(error),
}
}
fn refresh_or_fallback(mut creds: Credentials) -> Result<Credentials, PollError> {
loop {
if !is_token_expired(creds.expires_at) {
return Ok(creds);
}
let source = creds.source.clone();
cli_refresh_token(&source);
match read_credentials_from_source(&source) {
Some(refreshed) if !is_token_expired(refreshed.expires_at) => return Ok(refreshed),
Some(_) => diagnose::log(format!(
"credentials from {source:?} still expired after refresh attempt"
)),
None => diagnose::log(format!(
"credentials from {source:?} unavailable after refresh attempt"
)),
}
match read_next_credentials_after(&source) {
Some(next) => creds = next,
None => return Err(PollError::TokenExpired),
}
}
}
/// Invoke the Claude CLI with a minimal prompt to force its internal
/// OAuth token refresh.
fn cli_refresh_token(source: &CredentialSource) {
@ -264,50 +160,6 @@ fn cli_refresh_wsl_token(distro: &str) {
wait_for_refresh(&mut child);
}
fn cli_refresh_codex_token() {
let codex_path = resolve_windows_codex_path();
let is_cmd = codex_path.to_lowercase().ends_with(".cmd");
let is_ps1 = codex_path.to_lowercase().ends_with(".ps1");
diagnose::log(format!(
"attempting Windows Codex token refresh via {codex_path}"
));
let args: &[&str] = &["exec", "."];
let mut cmd = if is_cmd {
let mut c = Command::new("cmd.exe");
c.arg("/c").arg(&codex_path).args(args);
c
} else if is_ps1 {
let mut c = Command::new("powershell.exe");
c.arg("-NoProfile")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-File")
.arg(&codex_path)
.args(args);
c
} else {
let mut c = Command::new(&codex_path);
c.args(args);
c
};
cmd.creation_flags(CREATE_NO_WINDOW)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(error) => {
diagnose::log_error("unable to spawn Windows Codex token refresh", error);
return;
}
};
wait_for_refresh(&mut child);
}
/// Spawn a command and wait up to `timeout` for it to finish.
/// Returns None if the process fails to start or exceeds the deadline.
fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option<std::process::Output> {
@ -383,41 +235,6 @@ fn resolve_windows_claude_path() -> String {
"claude.cmd".to_string()
}
fn resolve_windows_codex_path() -> String {
for name in &["codex.cmd", "codex.ps1", "codex.exe", "codex"] {
if Command::new(name)
.arg("--version")
.creation_flags(CREATE_NO_WINDOW)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
{
return name.to_string();
}
}
for name in &["codex.cmd", "codex.ps1", "codex.exe", "codex"] {
if let Ok(output) = Command::new("where.exe")
.arg(name)
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(first_line) = stdout.lines().next() {
let path = first_line.trim().to_string();
if !path.is_empty() {
return path;
}
}
}
}
}
"codex.cmd".to_string()
}
fn build_agent() -> Result<ureq::Agent, PollError> {
let tls = native_tls::TlsConnector::new().map_err(|_| PollError::RequestFailed)?;
Ok(ureq::AgentBuilder::new()
@ -428,7 +245,7 @@ fn build_agent() -> Result<ureq::Agent, PollError> {
pub fn credential_watch_snapshot(mode: CredentialWatchMode) -> CredentialWatchSnapshot {
let sources = match mode {
CredentialWatchMode::ActiveSource => read_first_credentials()
CredentialWatchMode::ActiveSource => read_credentials()
.map(|creds| vec![creds.source])
.unwrap_or_else(all_known_credential_sources),
CredentialWatchMode::AllSources => all_known_credential_sources(),
@ -516,7 +333,7 @@ fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
// Try the dedicated usage endpoint first
match try_usage_endpoint(token)? {
Some(data) => {
// If reset timers are missing, fill them in from the Messages API
// If reset timers are missing, fill them in from the Messages API
if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() {
if let Ok(fallback) = fetch_usage_via_messages(token) {
let mut merged = data;
@ -658,64 +475,6 @@ fn parse_rate_limit_headers(response: &ureq::Response) -> UsageData {
data
}
fn fetch_codex_usage(token: &str, account_id: Option<&str>) -> Result<UsageData, PollError> {
let agent = build_agent()?;
let mut request = agent
.get(CODEX_USAGE_URL)
.set("Authorization", &format!("Bearer {token}"))
.set("User-Agent", "codex-cli");
if let Some(account_id) = account_id.filter(|value| !value.is_empty()) {
request = request.set("ChatGPT-Account-Id", account_id);
}
let resp = match request.call() {
Ok(resp) => resp,
Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => {
diagnose::log(format!(
"Codex usage endpoint returned auth error status {code}; refresh required"
));
return Err(PollError::AuthRequired);
}
Err(error) => {
diagnose::log_error("Codex usage endpoint request failed", error);
return Err(PollError::RequestFailed);
}
};
let response: CodexUsageResponse = match resp.into_json() {
Ok(response) => response,
Err(error) => {
diagnose::log_error("unable to parse Codex usage response", error);
return Err(PollError::RequestFailed);
}
};
codex_usage_from_response(response).ok_or(PollError::RequestFailed)
}
fn codex_usage_from_response(response: CodexUsageResponse) -> Option<UsageData> {
let details = *response.rate_limit.flatten()?;
let mut data = UsageData::default();
if let Some(window) = details.primary_window.flatten() {
data.session = codex_section_from_window(&window);
}
if let Some(window) = details.secondary_window.flatten() {
data.weekly = codex_section_from_window(&window);
}
Some(data)
}
fn codex_section_from_window(window: &CodexRateLimitWindow) -> UsageSection {
UsageSection {
percentage: window.used_percent,
resets_at: unix_to_system_time(Some(window.reset_at)),
}
}
fn get_header_f64(response: &ureq::Response, name: &str) -> f64 {
response
.header(name)
@ -747,18 +506,20 @@ enum CredentialSource {
Wsl { distro: String },
}
fn read_first_credentials() -> Option<Credentials> {
fn read_credentials() -> Option<Credentials> {
let mut candidates = Vec::new();
if let Some(creds) = read_windows_credentials() {
return Some(creds);
candidates.push(creds);
}
for distro in list_wsl_distros() {
if let Some(creds) = read_wsl_credentials(&distro) {
return Some(creds);
candidates.push(creds);
}
}
None
choose_best_credentials(candidates)
}
fn read_windows_credentials() -> Option<Credentials> {
@ -770,10 +531,7 @@ fn read_windows_credentials() -> Option<Credentials> {
Err(error) => {
if diagnose::is_enabled() {
diagnose::log_error(
&format!(
"unable to read Windows credentials at {}",
cred_path.display()
),
&format!("unable to read Windows credentials at {}", cred_path.display()),
error,
);
}
@ -793,34 +551,6 @@ fn read_credentials_from_source(source: &CredentialSource) -> Option<Credentials
}
}
fn codex_auth_path() -> Option<PathBuf> {
if let Some(codex_home) = std::env::var_os("CODEX_HOME").map(PathBuf::from) {
return Some(codex_home.join("auth.json"));
}
Some(dirs::home_dir()?.join(".codex").join("auth.json"))
}
fn read_codex_credentials() -> Option<CodexTokenData> {
let auth_path = codex_auth_path()?;
let content = match std::fs::read_to_string(&auth_path) {
Ok(content) => content,
Err(error) => {
diagnose::log_error(
&format!(
"unable to read Codex credentials at {}",
auth_path.display()
),
error,
);
return None;
}
};
let auth: CodexAuthFile = serde_json::from_str(&content).ok()?;
auth.tokens.filter(|tokens| !tokens.access_token.is_empty())
}
fn read_wsl_credentials(distro: &str) -> Option<Credentials> {
let output = run_with_timeout(
Command::new("wsl.exe")
@ -870,30 +600,13 @@ fn parse_credentials(content: &str, source: CredentialSource) -> Option<Credenti
})
}
fn read_next_credentials_after(source: &CredentialSource) -> Option<Credentials> {
match source {
CredentialSource::Windows(_) => {
for distro in list_wsl_distros() {
if let Some(creds) = read_wsl_credentials(&distro) {
return Some(creds);
}
}
}
CredentialSource::Wsl { distro } => {
let mut past_current = false;
for candidate_distro in list_wsl_distros() {
if !past_current {
past_current = candidate_distro == *distro;
continue;
}
if let Some(creds) = read_wsl_credentials(&candidate_distro) {
return Some(creds);
}
}
}
fn choose_best_credentials(mut candidates: Vec<Credentials>) -> Option<Credentials> {
if candidates.is_empty() {
return None;
}
None
candidates.sort_by_key(|creds| is_token_expired(creds.expires_at));
candidates.into_iter().next()
}
fn list_wsl_distros() -> Vec<String> {
@ -1114,64 +827,3 @@ pub fn is_past_reset(data: &UsageData) -> bool {
let past = |s: &UsageSection| matches!(s.resets_at, Some(t) if now.duration_since(t).is_ok());
past(&data.session) || past(&data.weekly)
}
pub fn app_is_past_reset(data: &AppUsageData) -> bool {
data.claude_code.as_ref().is_some_and(is_past_reset)
|| data.codex.as_ref().is_some_and(is_past_reset)
}
#[cfg(test)]
mod tests {
use super::*;
fn usage_with_session_percent(percentage: f64) -> UsageData {
UsageData {
session: UsageSection {
percentage,
resets_at: None,
},
weekly: UsageSection::default(),
}
}
#[test]
fn claude_failure_does_not_block_codex_when_both_are_enabled() {
let data = poll_with(
true,
true,
|| Err(PollError::AuthRequired),
|| Ok(usage_with_session_percent(42.0)),
)
.expect("codex data should keep the poll successful");
assert!(data.claude_code.is_none());
assert_eq!(data.codex.unwrap().session.percentage, 42.0);
}
#[test]
fn codex_failure_does_not_block_claude_when_both_are_enabled() {
let data = poll_with(
true,
true,
|| Ok(usage_with_session_percent(64.0)),
|| Err(PollError::RequestFailed),
)
.expect("claude data should keep the poll successful");
assert_eq!(data.claude_code.unwrap().session.percentage, 64.0);
assert!(data.codex.is_none());
}
#[test]
fn returns_first_error_when_no_enabled_provider_succeeds() {
let error = poll_with(
true,
true,
|| Err(PollError::AuthRequired),
|| Err(PollError::RequestFailed),
)
.expect_err("all-provider failure should return an error");
assert_eq!(error, PollError::AuthRequired);
}
}

View File

@ -1,17 +1,16 @@
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,
ExtractIconExW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY,
NIIF_WARNING, NOTIFYICONDATAW, Shell_NotifyIconW,
};
use windows::Win32::UI::WindowsAndMessaging::*;
use windows::core::PCWSTR;
use crate::native_interop::{self, Color, WM_APP_TRAY};
const CLAUDE_TRAY_ICON_ID: u32 = 1;
const CODEX_TRAY_ICON_ID: u32 = 2;
const TRAY_ICON_ID: u32 = 1;
/// Menu item ID for toggling widget visibility (used by window.rs context menu).
pub const IDM_TOGGLE_WIDGET: u16 = 50;
@ -23,27 +22,6 @@ pub enum TrayAction {
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
}
@ -82,19 +60,10 @@ fn interpolated_fill(percent: f64) -> Color {
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() {
/// `percent` = None means "no data/loading" and uses the embedded app icon.
pub fn create_icon(percent: Option<f64>) -> HICON {
if percent.is_none() {
let app_icon = load_embedded_app_icon();
if !app_icon.is_invalid() {
return app_icon;
@ -104,33 +73,14 @@ pub fn create_icon(kind: TrayIconKind, percent: Option<f64>) -> HICON {
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 outline = 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 fill = interpolated_fill(percent.unwrap_or(0.0));
let text_col = 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(),
},
None => String::new(),
};
let font_h = match display_text.len() {
@ -157,8 +107,8 @@ pub fn create_icon(kind: TrayIconKind, percent: Option<f64>) -> HICON {
};
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();
let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
.unwrap_or_default();
if dib.is_invalid() {
let _ = DeleteDC(mem_dc);
@ -169,7 +119,8 @@ pub fn create_icon(kind: TrayIconKind, percent: Option<f64>) -> HICON {
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);
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;
}
@ -178,22 +129,7 @@ pub fn create_icon(kind: TrayIconKind, percent: Option<f64>) -> HICON {
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);
}
// Fill rounded rect
let br_fill = CreateSolidBrush(COLORREF(fill.to_colorref()));
let old_brush = SelectObject(mem_dc, br_fill);
let _ = RoundRect(
@ -315,12 +251,12 @@ fn load_embedded_app_icon() -> HICON {
/// 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) {
pub fn notify_balloon(hwnd: HWND, 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.uID = TRAY_ICON_ID;
nid.uFlags = NIF_INFO;
nid.dwInfoFlags = NIIF_WARNING;
copy_wide(title, &mut nid.szInfoTitle);
@ -343,13 +279,13 @@ fn copy_wide_256(s: &str, buf: &mut [u16; 256]) {
}
/// 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);
pub fn add(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
let hicon = create_icon(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.uID = TRAY_ICON_ID;
nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
nid.uCallbackMessage = WM_APP_TRAY;
nid.hIcon = hicon;
@ -362,13 +298,13 @@ pub fn add(hwnd: HWND, kind: TrayIconKind, percent: Option<f64>, tooltip: &str)
}
/// 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);
pub fn update(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
let hicon = create_icon(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.uID = TRAY_ICON_ID;
nid.uFlags = NIF_ICON | NIF_TIP;
nid.hIcon = hicon;
copy_to_tip(tooltip, &mut nid.szTip);
@ -380,44 +316,16 @@ pub fn update(hwnd: HWND, kind: TrayIconKind, percent: Option<f64>, tooltip: &st
}
/// Remove the tray icon from the shell.
pub fn remove(hwnd: HWND, kind: TrayIconKind) {
pub fn remove(hwnd: HWND) {
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.uID = TRAY_ICON_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;

File diff suppressed because it is too large Load Diff