mirror of
https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git
synced 2026-06-20 10:19:27 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c333fdfa14 | ||
|
|
9771e118b4 | ||
|
|
b46e11fbf8 | ||
|
|
976ce04fb2 | ||
|
|
4c59e836f0 | ||
|
|
adbfaf1107 | ||
|
|
7af314996d | ||
|
|
b094bf9e0b | ||
|
|
c1056a4bd3 | ||
|
|
c4b2c18b78 | ||
|
|
286dd89a67 | ||
|
|
dc311c9406 | ||
|
|
374d94fac2 | ||
|
|
cf5b6c0741 | ||
|
|
e88ea07245 | ||
|
|
b5f038dd39 | ||
|
|
4676f6f72e | ||
|
|
d54588bf6e | ||
|
|
f2b378462d |
2
.gitignore
vendored
2
.gitignore
vendored
@ -10,6 +10,7 @@
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
.history/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@ -21,6 +22,7 @@ Desktop.ini
|
||||
|
||||
# Local release workflow helper
|
||||
workflow.cmd
|
||||
AGENTS.md
|
||||
|
||||
# Local WinGet manifest generation output
|
||||
/manifests/
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-monitor"
|
||||
version = "1.3.7"
|
||||
version = "1.4.5"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"native-tls",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-usage-monitor"
|
||||
version = "1.3.7"
|
||||
version = "1.4.5"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Claude Code Usage Monitor"
|
||||
|
||||
44
README.md
44
README.md
@ -5,30 +5,34 @@
|
||||
|
||||

|
||||
|
||||
A lightweight Windows taskbar widget for people already using Claude Code.
|
||||
A lightweight Windows taskbar widget for people already using Claude Code, with optional Codex usage display.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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
|
||||
- A **system tray icon** showing your usage percentage as a color-coded badge
|
||||
- System tray icon badges showing your enabled model usage percentage
|
||||
- Left-click the tray icon to toggle the taskbar widget on or off
|
||||
- Right-click options for refresh, update frequency, language, startup, and updates
|
||||
- Right-click options for refresh, displayed models, 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.
|
||||
|
||||
@ -50,18 +54,31 @@ After installing with WinGet, run:
|
||||
claude-code-usage-monitor
|
||||
```
|
||||
|
||||
Once running, it will appear in your taskbar and as a tray icon in the notification area.
|
||||
Once running, it will appear in your taskbar and as one or more tray icons in the notification area.
|
||||
|
||||
- Drag the left divider to move the taskbar widget
|
||||
- Right-click the taskbar widget or tray icon for refresh, update frequency, Start with Windows, reset position, language, updates, and exit
|
||||
- Right-click the taskbar widget or tray icon for refresh, displayed models, 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 color-coded percentage badge.
|
||||
The tray icon shows your current 5-hour usage as a percentage badge.
|
||||
|
||||
Hovering over the tray icon shows a tooltip with both your 5h and 7d usage.
|
||||
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.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
@ -102,10 +119,12 @@ 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
|
||||
|
||||
@ -115,6 +134,7 @@ What the app stores locally:
|
||||
- Polling frequency
|
||||
- Language preference
|
||||
- Last update check time
|
||||
- Displayed model preferences
|
||||
|
||||
What it does **not** do:
|
||||
|
||||
@ -122,19 +142,21 @@ 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 Claude requests include your OAuth bearer token inside the TLS connection
|
||||
- Proxies should be trusted because proxied usage requests include your OAuth bearer token inside the TLS connection
|
||||
|
||||
## How It Works
|
||||
|
||||
The monitor:
|
||||
|
||||
1. Finds your Claude Code login credentials
|
||||
2. Reads your current usage from Anthropic
|
||||
1. Finds your enabled model login credentials
|
||||
2. Reads your current usage from Anthropic and/or ChatGPT
|
||||
3. Shows the result directly in the Windows taskbar
|
||||
4. Refreshes periodically in the background
|
||||
|
||||
|
||||
@ -10,6 +10,9 @@ 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",
|
||||
@ -36,5 +39,8 @@ 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",
|
||||
};
|
||||
|
||||
@ -10,6 +10,9 @@ 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",
|
||||
@ -36,5 +39,8 @@ 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",
|
||||
};
|
||||
|
||||
@ -10,6 +10,9 @@ 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",
|
||||
@ -36,5 +39,8 @@ 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",
|
||||
};
|
||||
|
||||
@ -10,6 +10,9 @@ 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",
|
||||
@ -36,5 +39,8 @@ 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",
|
||||
};
|
||||
|
||||
@ -10,6 +10,9 @@ 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: "位置をリセット",
|
||||
@ -36,5 +39,8 @@ 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: "秒",
|
||||
};
|
||||
|
||||
@ -10,6 +10,9 @@ 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: "위치 초기화",
|
||||
@ -36,5 +39,8 @@ 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: "초",
|
||||
};
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
mod english;
|
||||
mod dutch;
|
||||
mod english;
|
||||
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::{
|
||||
@ -23,10 +25,12 @@ pub enum LanguageId {
|
||||
Japanese,
|
||||
Korean,
|
||||
TraditionalChinese,
|
||||
Russian,
|
||||
PortugueseBrazil,
|
||||
}
|
||||
|
||||
impl LanguageId {
|
||||
pub const ALL: [LanguageId; 8] = [
|
||||
pub const ALL: [LanguageId; 10] = [
|
||||
LanguageId::English,
|
||||
LanguageId::Dutch,
|
||||
LanguageId::Spanish,
|
||||
@ -35,6 +39,8 @@ impl LanguageId {
|
||||
LanguageId::Japanese,
|
||||
LanguageId::Korean,
|
||||
LanguageId::TraditionalChinese,
|
||||
LanguageId::Russian,
|
||||
LanguageId::PortugueseBrazil,
|
||||
];
|
||||
|
||||
pub fn code(self) -> &'static str {
|
||||
@ -47,6 +53,8 @@ impl LanguageId {
|
||||
Self::Japanese => "ja",
|
||||
Self::Korean => "ko",
|
||||
Self::TraditionalChinese => "zh-TW",
|
||||
Self::Russian => "ru",
|
||||
Self::PortugueseBrazil => "pt-BR",
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,6 +68,8 @@ impl LanguageId {
|
||||
Self::Japanese => "日本語",
|
||||
Self::Korean => "한국어",
|
||||
Self::TraditionalChinese => "繁體中文",
|
||||
Self::Russian => "Русский",
|
||||
Self::PortugueseBrazil => "Português (Brasil)",
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,6 +83,8 @@ impl LanguageId {
|
||||
Self::Japanese => japanese::STRINGS,
|
||||
Self::Korean => korean::STRINGS,
|
||||
Self::TraditionalChinese => traditional_chinese::STRINGS,
|
||||
Self::Russian => russian::STRINGS,
|
||||
Self::PortugueseBrazil => portuguese_brazil::STRINGS,
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,6 +98,8 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,6 +128,8 @@ impl LanguageId {
|
||||
None
|
||||
}
|
||||
}
|
||||
"ru" => Some(Self::Russian),
|
||||
"pt" => Some(Self::PortugueseBrazil),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@ -128,6 +144,9 @@ 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,
|
||||
@ -155,6 +174,9 @@ 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 {
|
||||
|
||||
46
src/localization/portuguese_brazil.rs
Normal file
46
src/localization/portuguese_brazil.rs
Normal file
@ -0,0 +1,46 @@
|
||||
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",
|
||||
};
|
||||
46
src/localization/russian.rs
Normal file
46
src/localization/russian.rs
Normal file
@ -0,0 +1,46 @@
|
||||
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",
|
||||
};
|
||||
@ -10,6 +10,9 @@ 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",
|
||||
@ -36,5 +39,8 @@ 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",
|
||||
};
|
||||
|
||||
@ -10,6 +10,9 @@ 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: "重置位置",
|
||||
@ -36,5 +39,8 @@ 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: "秒",
|
||||
};
|
||||
|
||||
@ -11,3 +11,9 @@ 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>,
|
||||
}
|
||||
|
||||
416
src/poller.rs
416
src/poller.rs
@ -7,15 +7,16 @@ use std::os::windows::process::CommandExt;
|
||||
|
||||
use crate::diagnose;
|
||||
use crate::localization::Strings;
|
||||
use crate::models::{UsageData, UsageSection};
|
||||
use crate::models::{AppUsageData, 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(Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PollError {
|
||||
AuthRequired,
|
||||
NoCredentials,
|
||||
@ -43,8 +44,80 @@ struct UsageBucket {
|
||||
resets_at: Option<String>,
|
||||
}
|
||||
|
||||
pub fn poll() -> Result<UsageData, PollError> {
|
||||
let mut creds = match read_credentials() {
|
||||
#[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() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
diagnose::log("poll failed: no Claude credentials found");
|
||||
@ -52,26 +125,57 @@ pub fn poll() -> Result<UsageData, PollError> {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
let creds = refresh_or_fallback(creds)?;
|
||||
|
||||
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) {
|
||||
@ -160,6 +264,50 @@ 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> {
|
||||
@ -235,6 +383,41 @@ 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()
|
||||
@ -245,7 +428,7 @@ fn build_agent() -> Result<ureq::Agent, PollError> {
|
||||
|
||||
pub fn credential_watch_snapshot(mode: CredentialWatchMode) -> CredentialWatchSnapshot {
|
||||
let sources = match mode {
|
||||
CredentialWatchMode::ActiveSource => read_credentials()
|
||||
CredentialWatchMode::ActiveSource => read_first_credentials()
|
||||
.map(|creds| vec![creds.source])
|
||||
.unwrap_or_else(all_known_credential_sources),
|
||||
CredentialWatchMode::AllSources => all_known_credential_sources(),
|
||||
@ -333,7 +516,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;
|
||||
@ -475,6 +658,64 @@ 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)
|
||||
@ -506,20 +747,18 @@ enum CredentialSource {
|
||||
Wsl { distro: String },
|
||||
}
|
||||
|
||||
fn read_credentials() -> Option<Credentials> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
fn read_first_credentials() -> Option<Credentials> {
|
||||
if let Some(creds) = read_windows_credentials() {
|
||||
candidates.push(creds);
|
||||
return Some(creds);
|
||||
}
|
||||
|
||||
for distro in list_wsl_distros() {
|
||||
if let Some(creds) = read_wsl_credentials(&distro) {
|
||||
candidates.push(creds);
|
||||
return Some(creds);
|
||||
}
|
||||
}
|
||||
|
||||
choose_best_credentials(candidates)
|
||||
None
|
||||
}
|
||||
|
||||
fn read_windows_credentials() -> Option<Credentials> {
|
||||
@ -531,7 +770,10 @@ 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,
|
||||
);
|
||||
}
|
||||
@ -551,6 +793,34 @@ 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")
|
||||
@ -600,13 +870,30 @@ fn parse_credentials(content: &str, source: CredentialSource) -> Option<Credenti
|
||||
})
|
||||
}
|
||||
|
||||
fn choose_best_credentials(mut candidates: Vec<Credentials>) -> Option<Credentials> {
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort_by_key(|creds| is_token_expired(creds.expires_at));
|
||||
candidates.into_iter().next()
|
||||
None
|
||||
}
|
||||
|
||||
fn list_wsl_distros() -> Vec<String> {
|
||||
@ -827,3 +1114,64 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
144
src/tray_icon.rs
144
src/tray_icon.rs
@ -1,16 +1,17 @@
|
||||
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, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY,
|
||||
NIIF_WARNING, NOTIFYICONDATAW, Shell_NotifyIconW,
|
||||
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 windows::core::PCWSTR;
|
||||
|
||||
use crate::native_interop::{self, Color, WM_APP_TRAY};
|
||||
|
||||
const TRAY_ICON_ID: u32 = 1;
|
||||
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;
|
||||
@ -22,6 +23,27 @@ 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
|
||||
}
|
||||
@ -60,10 +82,19 @@ 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.
|
||||
/// `percent` = None means "no data/loading" and uses the embedded app icon.
|
||||
pub fn create_icon(percent: Option<f64>) -> HICON {
|
||||
if percent.is_none() {
|
||||
/// 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;
|
||||
@ -73,14 +104,33 @@ pub fn create_icon(percent: Option<f64>) -> HICON {
|
||||
let size = 64_i32;
|
||||
let margin = 0_i32;
|
||||
let radius = 2_i32;
|
||||
let outline = 0_i32;
|
||||
let outline = if matches!(kind, TrayIconKind::Codex) {
|
||||
3_i32
|
||||
} else {
|
||||
0_i32
|
||||
};
|
||||
|
||||
let fill = interpolated_fill(percent.unwrap_or(0.0));
|
||||
let text_col = Color::from_hex("#FFFFFF");
|
||||
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 => String::new(),
|
||||
None => match kind {
|
||||
TrayIconKind::Claude => String::new(),
|
||||
TrayIconKind::Codex => "C".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let font_h = match display_text.len() {
|
||||
@ -107,8 +157,8 @@ pub fn create_icon(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);
|
||||
@ -119,8 +169,7 @@ pub fn create_icon(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;
|
||||
}
|
||||
@ -129,7 +178,22 @@ pub fn create_icon(percent: Option<f64>) -> HICON {
|
||||
let null_pen = GetStockObject(NULL_PEN);
|
||||
let old_pen = SelectObject(mem_dc, null_pen);
|
||||
|
||||
// Fill rounded rect
|
||||
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(
|
||||
@ -251,12 +315,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, title: &str, message: &str) {
|
||||
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 = TRAY_ICON_ID;
|
||||
nid.uID = kind.id();
|
||||
nid.uFlags = NIF_INFO;
|
||||
nid.dwInfoFlags = NIIF_WARNING;
|
||||
copy_wide(title, &mut nid.szInfoTitle);
|
||||
@ -279,13 +343,13 @@ fn copy_wide_256(s: &str, buf: &mut [u16; 256]) {
|
||||
}
|
||||
|
||||
/// Register the tray icon with the shell.
|
||||
pub fn add(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
|
||||
let hicon = create_icon(percent);
|
||||
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 = TRAY_ICON_ID;
|
||||
nid.uID = kind.id();
|
||||
nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||
nid.uCallbackMessage = WM_APP_TRAY;
|
||||
nid.hIcon = hicon;
|
||||
@ -298,13 +362,13 @@ pub fn add(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
|
||||
}
|
||||
|
||||
/// Update the tray icon colour and tooltip to reflect current usage.
|
||||
pub fn update(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
|
||||
let hicon = create_icon(percent);
|
||||
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 = TRAY_ICON_ID;
|
||||
nid.uID = kind.id();
|
||||
nid.uFlags = NIF_ICON | NIF_TIP;
|
||||
nid.hIcon = hicon;
|
||||
copy_to_tip(tooltip, &mut nid.szTip);
|
||||
@ -316,16 +380,44 @@ pub fn update(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
|
||||
}
|
||||
|
||||
/// Remove the tray icon from the shell.
|
||||
pub fn remove(hwnd: HWND) {
|
||||
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 = TRAY_ICON_ID;
|
||||
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;
|
||||
|
||||
773
src/window.rs
773
src/window.rs
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user