From bd44f5b4c84ed9c26dd819f03520d93030a480d1 Mon Sep 17 00:00:00 2001 From: Jesus Torres Date: Fri, 3 Jul 2026 12:02:40 -0600 Subject: [PATCH 1/3] Add OpenCode Go usage support with dashboard credentials and local DB fallback Adds an optional OpenCode Go model to the widget. The first gauge shows the rolling 5h window; the second gauge dynamically shows whichever of the 7-day and 30-day windows is currently more used. Two data sources are supported in priority order: - OpenCode dashboard (https://opencode.ai/workspace//go) using the user's auth cookie and workspace ID, with server-side reset times for the full subscription. - Local SQLite database (opencode.db) opened in read-only mode as a fallback that requires no extra configuration but only reflects this machine's session costs. The dashboard parsing approach is ported from opgginc/opencode-bar (Swift) and validated against its test fixtures. Localizations updated in all 10 languages. New dependency: regex 1. --- Cargo.lock | 134 ++++++ Cargo.toml | 3 + README.md | 51 +- src/localization/dutch.rs | 4 + src/localization/english.rs | 4 + src/localization/french.rs | 4 + src/localization/german.rs | 4 + src/localization/japanese.rs | 6 +- src/localization/korean.rs | 6 +- src/localization/mod.rs | 4 + src/localization/portuguese_brazil.rs | 6 +- src/localization/russian.rs | 6 +- src/localization/spanish.rs | 4 + src/localization/traditional_chinese.rs | 6 +- src/models.rs | 2 + src/poller.rs | 589 +++++++++++++++++++++++- src/tray_icon.rs | 28 ++ src/window.rs | 282 +++++++++++- 18 files changed, 1116 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0809f1..17bfea8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,27 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -41,7 +62,10 @@ name = "claude-code-usage-monitor" version = "1.4.8" dependencies = [ "dirs", + "libsqlite3-sys", "native-tls", + "regex", + "rusqlite", "serde", "serde_json", "ureq", @@ -113,6 +137,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -179,6 +215,15 @@ dependencies = [ "wasip3", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -194,6 +239,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -348,6 +402,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -511,6 +576,49 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustix" version = "1.1.4" @@ -748,6 +856,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1075,6 +1189,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 9cbc1c6..aed6c8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,9 @@ native-tls = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" +regex = "1" +rusqlite = { version = "0.32", features = ["bundled"] } +libsqlite3-sys = { version = "0.30", features = ["bundled"] } [dependencies.windows] version = "0.58" diff --git a/README.md b/README.md index f8b3c2b..a2c0164 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ ![Screenshot](.github/animation.gif) -A lightweight Windows taskbar widget for people already using Claude Code, with optional Codex and Google Antigravity usage display. +A lightweight Windows taskbar widget for people already using Claude Code, with optional Codex, Google Antigravity, and OpenCode Go usage display. -It sits in your taskbar and shows how much of your Claude Code, Codex, and/or Antigravity 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, Codex, Antigravity, and/or OpenCode Go usage window you have left, without needing to open the terminal or the provider site. ## What You Get @@ -15,6 +15,7 @@ It sits in your taskbar and shows how much of your Claude Code, Codex, and/or An - A **7d** bar for your current 7-day window - Optional Codex usage bars alongside Claude Code - Optional Antigravity model usage bars for Google's 5-hour and weekly Gemini quota windows +- Optional OpenCode Go usage bars for your local 5-hour and weekly dollar limits - 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 @@ -30,6 +31,41 @@ Codex support is optional. To show Codex usage, install and sign in to the Codex Antigravity support is optional too. To show Antigravity usage, install and sign in to Google Antigravity, then enable the **Antigravity** model from the right-click **Models** menu. +OpenCode Go support is also optional. To show OpenCode Go usage, install [OpenCode](https://opencode.ai) and sign in to a [Go](https://opencode.ai/go) subscription. Then enable **OpenCode** from the right-click **Models** menu. + +**Recommended — dashboard credentials (server-side accuracy):** the monitor reads your real Go usage (5h and weekly windows, with server-side reset times) from the OpenCode dashboard at `https://opencode.ai/workspace//go`. To do this it needs the `auth` cookie from an `opencode.ai` browser session and the workspace ID shown in the dashboard URL. + +Provide them in one of these ways (highest priority first): + +1. Environment variables: + + ```powershell + setx OPENCODE_GO_WORKSPACE_ID "wrk_01..." + setx OPENCODE_GO_AUTH_COOKIE "eyJhbGciOi..." + ``` + +2. A config file at any of these paths (the first one that exists is used): + + - `%APPDATA%\opencode-go\config.json` (Windows-friendly default) + - `%XDG_CONFIG_HOME%\opencode-bar\opencode-go.json` + - `%XDG_CONFIG_HOME%\opencode-quota\opencode-go.json` + - `~\.config\opencode-bar\opencode-go.json` + - `~\.config\opencode-quota\opencode-go.json` + - `%OPENCODE_GO_CONFIG_FILE%` (override) + + The file is JSON with these keys (aliases accepted: `workspaceId`/`workspaceID`/`workspace_id` and `authCookie`/`auth_cookie`/`cookie`): + + ```json + { + "workspaceId": "wrk_01...", + "authCookie": "eyJhbGciOi..." + } + ``` + +To grab the values: sign in to `https://opencode.ai/go` in your browser, copy the `auth` cookie (DevTools → Application → Cookies → `https://opencode.ai`), and copy the workspace ID from the URL (`/workspace//go`). + +**Fallback — local SQLite (no setup required):** if the dashboard credentials above are not configured, the monitor falls back to reading the local OpenCode SQLite database at `$OPENCODE_DB` (if set), then `%USERPROFILE%\.local\share\opencode\opencode.db`, then `%APPDATA%\opencode\opencode.db`. The database is opened in `SQLITE_OPEN_READ_ONLY` mode and never written to. This reflects only this machine's session costs, not your full Go subscription across devices, and the reset countdowns show 5h and 7d windows from the current poll time (since the local DB has no server-side reset signal). + It works best if you want a simple "how close am I to the limit?" display that is always visible. ## Requirements @@ -38,6 +74,7 @@ It works best if you want a simple "how close am I to the limit?" display that i - Claude Code (CLI or App) installed and authenticated - Optional: Codex CLI installed and authenticated, if you want Codex usage - Optional: Google Antigravity installed and authenticated, if you want Antigravity usage +- Optional: OpenCode installed and signed in to a Go subscription, if you want OpenCode Go 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. @@ -74,6 +111,7 @@ 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 - **Antigravity** can be enabled alongside the other providers or shown by itself as its own model column +- **OpenCode** can be enabled alongside the other providers. With dashboard credentials configured, the bars reflect your full Go subscription with server-side reset times. Without dashboard credentials, the monitor falls back to this machine's local OpenCode sessions (5h and weekly windows from the current poll time, since the local DB has no server-side reset signal). When multiple models are shown, each model has its own usage bar and matching usage text color. Antigravity prefers Google's Gemini quota summary when available and falls back to model quota data when needed. @@ -83,7 +121,7 @@ The tray icon shows your current 5-hour usage as a percentage badge. If multiple providers are enabled, the app shows one tray icon per provider. 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. The Antigravity tray icon uses a blue badge style. +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. The Antigravity tray icon uses a blue badge style. The OpenCode tray icon uses a green badge style. Hovering over a tray icon shows the usage values for that model. @@ -128,6 +166,7 @@ What the app reads: - 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` - If Antigravity is enabled, your local Antigravity OAuth token from Windows Credential Manager target `gemini:antigravity` +- If OpenCode Go is enabled, either your OpenCode Go usage from the `https://opencode.ai/workspace//go` dashboard (with a session cookie), or, as a fallback, your local OpenCode SQLite session database (read-only) to compute local Go spend. The dashboard path uses server-side reset times; the local path opens the database in `SQLITE_OPEN_READ_ONLY` mode and never writes to it. What the app sends over the network: @@ -154,12 +193,14 @@ What it does **not** do: - It does not collect analytics or telemetry - It does not upload your project files - It does not directly edit your Codex credentials file +- It does not write to your OpenCode account or any local files; OpenCode Go usage is fetched read-only from the public OpenCode dashboard, or read directly from the local OpenCode SQLite database in read-only mode 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. - If your Antigravity token is expired, open Antigravity and sign in again. The monitor does not write Windows Credential Manager entries itself. +- OpenCode Go usage is read from the OpenCode dashboard and reflects your full Go subscription, not just this machine's local sessions. - 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 @@ -167,8 +208,8 @@ Notes: The monitor: -1. Finds your enabled model login credentials -2. Reads your current usage from Anthropic, ChatGPT, and/or Google's Antigravity endpoints +1. Finds your enabled model login credentials (or, for OpenCode Go, your dashboard session cookie and workspace ID, or the local database) +2. Reads your current usage from Anthropic, ChatGPT, Google's Antigravity endpoints, the OpenCode Go dashboard, and/or the local OpenCode database 3. Shows the result directly in the Windows taskbar 4. Keeps the widget aligned with the selected taskbar and tray area 5. Refreshes periodically in the background diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index ed815bf..b106a90 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + opencode_model: "OpenCode", settings: "Instellingen", start_with_windows: "Opstarten met Windows", reset_position: "Positie herstellen", @@ -44,7 +45,10 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "Voer 'codex' uit in een terminal en volg de aanmeldstappen. Ververs of herstart de app daarna.", antigravity_token_expired_title: "Antigravity-authenticatiefout", antigravity_token_expired_body: "Open Antigravity en meld je opnieuw aan. Ververs of herstart de app daarna.", + opencode_token_expired_title: "OpenCode niet gevonden", + opencode_token_expired_body: "Voer OpenCode minstens één keer uit zodat de lokale database bestaat, of stel OPENCODE_GO_WORKSPACE_ID en OPENCODE_GO_AUTH_COOKIE in voor volledige abonnementsregistratie. Zie de README. Ververs of herstart de app daarna.", codex_window_title: "Codex-gebruiksmonitor", antigravity_window_title: "Antigravity-gebruiksmonitor", + opencode_window_title: "OpenCode-gebruiksmonitor", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..a4850d5 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + opencode_model: "OpenCode", settings: "Settings", start_with_windows: "Start with Windows", reset_position: "Reset Position", @@ -44,7 +45,10 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "Run 'codex' in a terminal and follow the sign-in prompts. After that, refresh or restart this app.", antigravity_token_expired_title: "Antigravity Auth Error", antigravity_token_expired_body: "Open Antigravity and sign in again. After that, refresh or restart this app.", + opencode_token_expired_title: "OpenCode Not Found", + opencode_token_expired_body: "Run OpenCode at least once so the local database exists, or set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE for full subscription tracking. See the README. After that, refresh or restart this app.", codex_window_title: "Codex Usage Monitor", antigravity_window_title: "Antigravity Usage Monitor", + opencode_window_title: "OpenCode Usage Monitor", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index 1850f41..7dad8ae 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + opencode_model: "OpenCode", settings: "Paramètres", start_with_windows: "Démarrer avec Windows", reset_position: "Réinitialiser la position", @@ -44,7 +45,10 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "Executez 'codex' dans un terminal et suivez les instructions de connexion. Ensuite, actualisez ou redemarrez cette application.", antigravity_token_expired_title: "Erreur d'authentification Antigravity", antigravity_token_expired_body: "Ouvrez Antigravity et reconnectez-vous. Ensuite, actualisez ou redemarrez cette application.", + opencode_token_expired_title: "OpenCode introuvable", + opencode_token_expired_body: "Lancez OpenCode au moins une fois pour que la base locale existe, ou definissez OPENCODE_GO_WORKSPACE_ID et OPENCODE_GO_AUTH_COOKIE pour le suivi complet de l'abonnement. Consultez le README. Ensuite, actualisez ou redemarrez cette application.", codex_window_title: "Moniteur d'utilisation Codex", antigravity_window_title: "Moniteur d'utilisation Antigravity", + opencode_window_title: "Moniteur d'utilisation OpenCode", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b91a81..1b6a463 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + opencode_model: "OpenCode", settings: "Einstellungen", start_with_windows: "Mit Windows starten", reset_position: "Position zurücksetzen", @@ -44,7 +45,10 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "Fuhren Sie 'codex' in einem Terminal aus und folgen Sie den Anmeldeanweisungen. Aktualisieren oder starten Sie diese App anschliessend neu.", antigravity_token_expired_title: "Antigravity-Authentifizierungsfehler", antigravity_token_expired_body: "Offnen Sie Antigravity und melden Sie sich erneut an. Aktualisieren oder starten Sie diese App anschliessend neu.", + opencode_token_expired_title: "OpenCode nicht gefunden", + opencode_token_expired_body: "Führen Sie OpenCode mindestens einmal aus, damit die lokale Datenbank vorhanden ist, oder legen Sie OPENCODE_GO_WORKSPACE_ID und OPENCODE_GO_AUTH_COOKIE für die vollständige Abonnement-Überwachung fest. Siehe README. Aktualisieren oder starten Sie diese App anschliessend neu.", codex_window_title: "Codex-Nutzungsmonitor", antigravity_window_title: "Antigravity-Nutzungsmonitor", + opencode_window_title: "OpenCode-Nutzungsmonitor", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 2eec041..f1c1636 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + opencode_model: "OpenCode", settings: "設定", start_with_windows: "Windows と同時に開始", reset_position: "位置をリセット", @@ -44,7 +45,10 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "ターミナルで 'codex' を実行し、サインインの案内に従ってください。その後、このアプリを更新または再起動してください。", antigravity_token_expired_title: "Antigravity 認証エラー", antigravity_token_expired_body: "Antigravity を開いて再度サインインしてください。その後、このアプリを更新するか再起動してください。", + opencode_token_expired_title: "OpenCode が見つかりません", + opencode_token_expired_body: "ローカル データベースが存在するように OpenCode を少なくとも 1 回実行するか、完全なサブスクリプション追跡のために OPENCODE_GO_WORKSPACE_ID と OPENCODE_GO_AUTH_COOKIE を設定してください。README を参照。その後、このアプリを更新するか再起動してください。", codex_window_title: "Codex 使用量モニター", antigravity_window_title: "Antigravity 使用量モニター", - second_suffix: "秒", + opencode_window_title: "OpenCode 使用量モニター", + second_suffix: "s", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 965687d..6639e6c 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + opencode_model: "OpenCode", settings: "설정", start_with_windows: "Windows 시작 시 자동 실행", reset_position: "위치 초기화", @@ -44,7 +45,10 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "터미널에서 'codex'를 실행하고 로그인 안내를 따르세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", antigravity_token_expired_title: "Antigravity 인증 오류", antigravity_token_expired_body: "Antigravity를 열고 다시 로그인하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", + opencode_token_expired_title: "OpenCode를 찾을 수 없음", + opencode_token_expired_body: "로컬 데이터베이스가 존재하도록 OpenCode를 최소 한 번 실행하거나, 전체 구독 추적을 위해 OPENCODE_GO_WORKSPACE_ID와 OPENCODE_GO_AUTH_COOKIE를 설정하세요. README를 참조하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_window_title: "Codex 사용량 모니터", antigravity_window_title: "Antigravity 사용량 모니터", - second_suffix: "초", + opencode_window_title: "OpenCode 사용량 모니터", + second_suffix: "s", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 2a06b04..bc19f6c 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -148,6 +148,7 @@ pub struct Strings { pub claude_code_model: &'static str, pub codex_model: &'static str, pub antigravity_model: &'static str, + pub opencode_model: &'static str, pub settings: &'static str, pub start_with_windows: &'static str, pub reset_position: &'static str, @@ -179,8 +180,11 @@ pub struct Strings { pub codex_token_expired_body: &'static str, pub antigravity_token_expired_title: &'static str, pub antigravity_token_expired_body: &'static str, + pub opencode_token_expired_title: &'static str, + pub opencode_token_expired_body: &'static str, pub codex_window_title: &'static str, pub antigravity_window_title: &'static str, + pub opencode_window_title: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 56cf3bf..084b75e 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + opencode_model: "OpenCode", settings: "Configurações", start_with_windows: "Iniciar com o Windows", reset_position: "Redefinir Posição", @@ -38,13 +39,16 @@ pub(super) const STRINGS: Strings = Strings { 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.", antigravity_token_expired_title: "Erro de Autenticação do Antigravity", antigravity_token_expired_body: "Abra o Antigravity e entre novamente. Depois disso, atualize ou reinicie este aplicativo.", + opencode_token_expired_title: "OpenCode não encontrado", + opencode_token_expired_body: "Execute o OpenCode pelo menos uma vez para que o banco de dados local exista, ou defina OPENCODE_GO_WORKSPACE_ID e OPENCODE_GO_AUTH_COOKIE para acompanhamento completo da assinatura. Veja o README. Depois disso, atualize ou reinicie este aplicativo.", codex_window_title: "Monitor de uso do Codex", antigravity_window_title: "Monitor de uso do Antigravity", + opencode_window_title: "Monitor de uso do OpenCode", + second_suffix: "s", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index fc7e372..b78f672 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + opencode_model: "OpenCode", settings: "Настройки", start_with_windows: "Запускать вместе с Windows", reset_position: "Сбросить позицию", @@ -38,13 +39,16 @@ pub(super) const STRINGS: Strings = Strings { 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' в терминале и следуйте инструкциям для входа. После этого обновите или перезапустите приложение.", antigravity_token_expired_title: "Ошибка авторизации Antigravity", antigravity_token_expired_body: "Откройте Antigravity и войдите снова. После этого обновите или перезапустите приложение.", + opencode_token_expired_title: "OpenCode не найден", + opencode_token_expired_body: "Запустите OpenCode хотя бы раз, чтобы существовала локальная база данных, либо задайте OPENCODE_GO_WORKSPACE_ID и OPENCODE_GO_AUTH_COOKIE для полного отслеживания подписки. См. README. После этого обновите или перезапустите приложение.", codex_window_title: "Монитор использования Codex", antigravity_window_title: "Монитор использования Antigravity", + opencode_window_title: "Монитор использования OpenCode", + second_suffix: "с", }; diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index e635771..b062e9b 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + opencode_model: "OpenCode", settings: "Configuración", start_with_windows: "Iniciar con Windows", reset_position: "Restablecer posición", @@ -44,7 +45,10 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "Ejecuta 'codex' en una terminal y sigue las indicaciones de inicio de sesion. Despues, actualiza o reinicia esta aplicacion.", antigravity_token_expired_title: "Error de autenticacion de Antigravity", antigravity_token_expired_body: "Abre Antigravity e inicia sesion otra vez. Despues, actualiza o reinicia esta aplicacion.", + opencode_token_expired_title: "OpenCode no encontrado", + opencode_token_expired_body: "Ejecuta OpenCode al menos una vez para que exista la base de datos local, o define OPENCODE_GO_WORKSPACE_ID y OPENCODE_GO_AUTH_COOKIE para el seguimiento completo de la suscripcion. Consulta el README. Despues, actualiza o reinicia esta aplicacion.", codex_window_title: "Monitor de uso de Codex", antigravity_window_title: "Monitor de uso de Antigravity", + opencode_window_title: "Monitor de uso de OpenCode", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 3eb3514..7a75866 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + opencode_model: "OpenCode", settings: "設定", start_with_windows: "開機時啟動", reset_position: "重置位置", @@ -44,7 +45,10 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "請在終端機中執行 'codex',並依照登入提示操作。完成後,請重新整理或重新啟動此應用程式。", antigravity_token_expired_title: "Antigravity 驗證錯誤", antigravity_token_expired_body: "請開啟 Antigravity 並重新登入。完成後,請重新整理或重新啟動此應用程式。", + opencode_token_expired_title: "找不到 OpenCode", + opencode_token_expired_body: "請至少執行一次 OpenCode 以便建立本機資料庫,或設定 OPENCODE_GO_WORKSPACE_ID 和 OPENCODE_GO_AUTH_COOKIE 以進行完整的訂閱追蹤。請參閱 README。完成後,請重新整理或重新啟動此應用程式。", codex_window_title: "Codex 使用量監控", antigravity_window_title: "Antigravity 使用量監控", - second_suffix: "秒", + opencode_window_title: "OpenCode 使用量監控", + second_suffix: "s", }; diff --git a/src/models.rs b/src/models.rs index da49ef1..0a32745 100644 --- a/src/models.rs +++ b/src/models.rs @@ -10,6 +10,7 @@ pub struct UsageSection { pub struct UsageData { pub session: UsageSection, pub weekly: UsageSection, + pub weekly_label: Option<&'static str>, } #[derive(Clone, Debug, Default)] @@ -17,4 +18,5 @@ pub struct AppUsageData { pub claude_code: Option, pub codex: Option, pub antigravity: Option, + pub opencode: Option, } diff --git a/src/poller.rs b/src/poller.rs index a29cd0d..9493831 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -22,6 +22,20 @@ const ANTIGRAVITY_ENDPOINTS: &[&str] = &[ "https://daily-cloudcode-pa.sandbox.googleapis.com", "https://cloudcode-pa.googleapis.com", ]; +const OPENCODE_GO_DASHBOARD_URL_PREFIX: &str = "https://opencode.ai/workspace/"; +const OPENCODE_GO_DASHBOARD_URL_SUFFIX: &str = "/go"; +const OPENCODE_GO_USER_AGENT: &str = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \ + (KHTML, like Gecko) Chrome/126.0 Safari/537.36"; +const OPENCODE_GO_WORKSPACE_ID_ENV: &str = "OPENCODE_GO_WORKSPACE_ID"; +const OPENCODE_GO_AUTH_COOKIE_ENV: &str = "OPENCODE_GO_AUTH_COOKIE"; +const OPENCODE_GO_CONFIG_FILE_ENV: &str = "OPENCODE_GO_CONFIG_FILE"; +const OPENCODE_PROVIDER_ID: &str = "opencode-go"; +const OPENCODE_FIVE_HOUR_LIMIT_USD: f64 = 12.0; +const OPENCODE_WEEKLY_LIMIT_USD: f64 = 30.0; +const OPENCODE_FIVE_HOUR_WINDOW_MS: i64 = 5 * 60 * 60 * 1000; +const OPENCODE_WEEKLY_WINDOW_MS: i64 = 7 * 24 * 60 * 60 * 1000; +const OPENCODE_DB_ENV: &str = "OPENCODE_DB"; const CREATE_NO_WINDOW: u32 = 0x08000000; const MODEL_FALLBACK_CHAIN: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"]; @@ -39,6 +53,7 @@ pub enum CredentialWatchMode { ActiveSource, AllSources, Antigravity, + Opencode, } pub type CredentialWatchSnapshot = Vec; @@ -175,14 +190,17 @@ pub fn poll( show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_opencode: bool, ) -> Result { poll_with( show_claude_code, show_codex, show_antigravity, + show_opencode, poll_claude_code, poll_codex, poll_antigravity, + poll_opencode, ) } @@ -190,13 +208,18 @@ fn poll_with( show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_opencode: bool, mut poll_claude_code: impl FnMut() -> Result, mut poll_codex: impl FnMut() -> Result, mut poll_antigravity: impl FnMut() -> Result, + mut poll_opencode: impl FnMut() -> Result, ) -> Result { let mut data = AppUsageData::default(); let mut first_error = None; - let active_provider_count = show_claude_code as u8 + show_codex as u8 + show_antigravity as u8; + let active_provider_count = show_claude_code as u8 + + show_codex as u8 + + show_antigravity as u8 + + show_opencode as u8; if show_claude_code { match poll_claude_code() { @@ -234,7 +257,23 @@ fn poll_with( } } - if data.claude_code.is_none() && data.codex.is_none() && data.antigravity.is_none() { + if show_opencode { + match poll_opencode() { + Ok(opencode) => data.opencode = Some(opencode), + Err(error) => { + if active_provider_count > 1 { + diagnose::log(format!("OpenCode usage poll failed: {error:?}")); + } + first_error.get_or_insert(error); + } + } + } + + if data.claude_code.is_none() + && data.codex.is_none() + && data.antigravity.is_none() + && data.opencode.is_none() + { Err(first_error.unwrap_or(PollError::RequestFailed)) } else { Ok(data) @@ -287,6 +326,464 @@ fn poll_antigravity() -> Result { fetch_antigravity_usage(&creds.access_token) } +fn poll_opencode() -> Result { + if let Some(credentials) = read_opencode_dashboard_credentials() { + return poll_opencode_dashboard(&credentials); + } + + if let Some(db_path) = opencode_db_path() { + return poll_opencode_local_db(&db_path); + } + + diagnose::log( + "OpenCode usage poll failed: no dashboard credentials and no local database found", + ); + Err(PollError::NoCredentials) +} + +fn poll_opencode_dashboard(credentials: &OpencodeDashboardCredentials) -> Result { + let usage = match fetch_opencode_dashboard_usage(credentials) { + Ok(usage) => usage, + Err(error) => { + diagnose::log(format!( + "OpenCode dashboard poll failed via {}: {error:?}", + credentials.source + )); + return Err(error); + } + }; + + if usage.rolling.is_none() && usage.weekly.is_none() && usage.monthly.is_none() { + diagnose::log(format!( + "OpenCode dashboard returned no usage windows from {}", + credentials.source + )); + return Err(PollError::RequestFailed); + } + + let now = SystemTime::now(); + let section = |window: &Option| -> UsageSection { + match window { + Some(w) => UsageSection { + percentage: w.usage_percent.clamp(0.0, 100.0), + resets_at: now.checked_add(Duration::from_secs(w.reset_in_sec.max(0) as u64)), + }, + None => UsageSection::default(), + } + }; + + let (second_section, second_label) = pick_opencode_second_window(&usage); + + Ok(UsageData { + session: section(&usage.rolling), + weekly: second_section, + weekly_label: second_label, + }) +} + +/// Pick which window (weekly or monthly) to surface as the second gauge. +/// +/// The OpenCode Go dashboard exposes both a 7-day and a 30-day window. The +/// second gauge shows whichever is currently more used so the user always +/// sees the most constrained limit. +fn pick_opencode_second_window(usage: &OpencodeDashboardUsage) -> (UsageSection, Option<&'static str>) { + let now = SystemTime::now(); + let to_section = |w: &OpencodeUsageWindow| UsageSection { + percentage: w.usage_percent.clamp(0.0, 100.0), + resets_at: now.checked_add(Duration::from_secs(w.reset_in_sec.max(0) as u64)), + }; + + match (&usage.weekly, &usage.monthly) { + (Some(weekly), Some(monthly)) => { + if monthly.usage_percent > weekly.usage_percent { + (to_section(monthly), Some("30d")) + } else { + (to_section(weekly), Some("7d")) + } + } + (Some(weekly), None) => (to_section(weekly), Some("7d")), + (None, Some(monthly)) => (to_section(monthly), Some("30d")), + (None, None) => (UsageSection::default(), None), + } +} + +fn poll_opencode_local_db(db_path: &std::path::Path) -> Result { + let (used_5h, used_weekly) = match fetch_opencode_local_db_usage(db_path) { + Ok(totals) => totals, + Err(error) => { + diagnose::log(format!( + "OpenCode local DB poll failed at {}: {error:?}", + db_path.display() + )); + return Err(error); + } + }; + + let pct_5h = (used_5h / OPENCODE_FIVE_HOUR_LIMIT_USD) * 100.0; + let pct_weekly = (used_weekly / OPENCODE_WEEKLY_LIMIT_USD) * 100.0; + + let now = SystemTime::now(); + let resets_5h = now.checked_add(Duration::from_millis(OPENCODE_FIVE_HOUR_WINDOW_MS as u64)); + let resets_weekly = now.checked_add(Duration::from_millis(OPENCODE_WEEKLY_WINDOW_MS as u64)); + + Ok(UsageData { + session: UsageSection { + percentage: pct_5h.clamp(0.0, 100.0), + resets_at: resets_5h, + }, + weekly: UsageSection { + percentage: pct_weekly.clamp(0.0, 100.0), + resets_at: resets_weekly, + }, + weekly_label: None, + }) +} + +type OpencodeLocalTotals = (f64, f64); + +fn fetch_opencode_local_db_usage(db_path: &std::path::Path) -> Result { + let now_ms: i64 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + let cutoff_5h = now_ms.saturating_sub(OPENCODE_FIVE_HOUR_WINDOW_MS); + let cutoff_weekly = now_ms.saturating_sub(OPENCODE_WEEKLY_WINDOW_MS); + + let conn = rusqlite::Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| { + diagnose::log_error("unable to open OpenCode database", error); + PollError::RequestFailed + })?; + + conn.busy_timeout(std::time::Duration::from_secs(5)) + .map_err(|error| { + diagnose::log_error("unable to set OpenCode busy timeout", error); + PollError::RequestFailed + })?; + + let mut stmt = match conn.prepare( + "SELECT \ + COALESCE(SUM(CASE WHEN time_updated >= ?1 THEN cost ELSE 0 END), 0), \ + COALESCE(SUM(CASE WHEN time_updated >= ?2 THEN cost ELSE 0 END), 0) \ + FROM session \ + WHERE json_extract(model, '$.providerID') = ?3 \ + AND time_archived IS NULL", + ) { + Ok(stmt) => stmt, + Err(error) => { + diagnose::log_error("unable to query OpenCode session usage", error); + return Err(PollError::RequestFailed); + } + }; + + let totals: OpencodeLocalTotals = stmt + .query_row( + rusqlite::params![cutoff_5h, cutoff_weekly, OPENCODE_PROVIDER_ID], + |row| Ok((row.get::<_, f64>(0)?, row.get::<_, f64>(1)?)), + ) + .map_err(|error| { + diagnose::log_error("unable to read OpenCode session totals", error); + PollError::RequestFailed + })?; + + Ok(totals) +} + +fn opencode_db_path() -> Option { + if let Some(env_path) = std::env::var_os(OPENCODE_DB_ENV).map(std::path::PathBuf::from) { + if env_path.exists() { + return Some(env_path); + } + } + + if let Some(xdg_path) = opencode_xdg_data_path() { + let candidate = xdg_path.join("opencode").join("opencode.db"); + if candidate.exists() { + return Some(candidate); + } + } + + let data_dir = dirs::data_dir()?; + let default_path = data_dir.join("opencode").join("opencode.db"); + if default_path.exists() { + return Some(default_path); + } + + None +} + +#[cfg(target_os = "windows")] +fn opencode_xdg_data_path() -> Option { + if let Some(xdg) = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from) { + if !xdg.as_os_str().is_empty() { + return Some(xdg); + } + } + let home = dirs::home_dir()?; + Some(home.join(".local").join("share")) +} + +#[cfg(not(target_os = "windows"))] +fn opencode_xdg_data_path() -> Option { + if let Some(xdg) = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from) { + if !xdg.as_os_str().is_empty() { + return Some(xdg); + } + } + std::env::var_os("HOME").map(PathBuf::from) +} + +#[derive(Deserialize)] +struct OpencodeAuthFile { + #[serde(rename = "opencode-go")] + opencode_go: Option, + #[serde(alias = "opencodeGo", alias = "opencode_go")] + opencode_go_alt: Option, +} + +#[derive(Deserialize)] +struct OpencodeApiKey { + key: String, +} + +#[derive(Deserialize)] +struct OpencodeDashboardConfig { + #[serde(alias = "workspaceId", alias = "workspace_id", alias = "workspaceID")] + workspace_id: String, + #[serde(alias = "auth_cookie", alias = "cookie")] + auth_cookie: String, +} + +struct OpencodeDashboardCredentials { + workspace_id: String, + auth_cookie: String, + source: String, +} + +struct OpencodeUsageWindow { + usage_percent: f64, + reset_in_sec: i64, +} + +struct OpencodeDashboardUsage { + rolling: Option, + weekly: Option, + #[allow(dead_code)] + monthly: Option, +} + +fn opencode_api_key() -> Option { + let path = opencode_auth_path()?; + let content = std::fs::read_to_string(&path).ok()?; + let auth: OpencodeAuthFile = serde_json::from_str(&content).ok()?; + let key = auth + .opencode_go + .or(auth.opencode_go_alt)? + .key + .trim() + .to_string(); + if key.is_empty() { + None + } else { + Some(key) + } +} + +fn opencode_auth_path() -> Option { + if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + { + let path = xdg.join("opencode").join("auth.json"); + if path.exists() { + return Some(path); + } + } + + let home = dirs::home_dir()?; + let path = home.join(".local").join("share").join("opencode").join("auth.json"); + if path.exists() { + return Some(path); + } + + None +} + +fn read_opencode_dashboard_credentials() -> Option { + if let (Some(workspace_id), Some(auth_cookie)) = ( + non_empty_env(OPENCODE_GO_WORKSPACE_ID_ENV), + non_empty_env(OPENCODE_GO_AUTH_COOKIE_ENV), + ) { + return Some(OpencodeDashboardCredentials { + workspace_id, + auth_cookie, + source: "Environment".to_string(), + }); + } + + for path in opencode_dashboard_config_paths() { + if let Some(creds) = read_opencode_dashboard_config_from_path(&path) { + return Some(creds); + } + } + + None +} + +fn non_empty_env(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn opencode_dashboard_config_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(p) = non_empty_env(OPENCODE_GO_CONFIG_FILE_ENV).map(PathBuf::from) { + paths.push(p); + } + + if let Some(xdg) = non_empty_env("XDG_CONFIG_HOME").map(PathBuf::from) { + paths.push(xdg.join("opencode-bar").join("opencode-go.json")); + paths.push(xdg.join("opencode-quota").join("opencode-go.json")); + } + + if let Some(home) = dirs::home_dir() { + paths.push(home.join(".config").join("opencode-bar").join("opencode-go.json")); + paths.push(home.join(".config").join("opencode-quota").join("opencode-go.json")); + } + + if let Some(appdata) = non_empty_env("APPDATA").map(PathBuf::from) { + paths.push(appdata.join("opencode-go").join("config.json")); + } + + paths +} + +fn read_opencode_dashboard_config_from_path( + path: &std::path::Path, +) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let config: OpencodeDashboardConfig = serde_json::from_str(&content).ok()?; + let workspace_id = config.workspace_id.trim().to_string(); + let auth_cookie = config.auth_cookie.trim().to_string(); + if workspace_id.is_empty() || auth_cookie.is_empty() { + return None; + } + Some(OpencodeDashboardCredentials { + workspace_id, + auth_cookie, + source: path.display().to_string(), + }) +} + +fn fetch_opencode_dashboard_usage( + credentials: &OpencodeDashboardCredentials, +) -> Result { + let url = format!( + "{}{}{}", + OPENCODE_GO_DASHBOARD_URL_PREFIX, credentials.workspace_id, OPENCODE_GO_DASHBOARD_URL_SUFFIX + ); + let cookie_header = if credentials.auth_cookie.contains("auth=") { + credentials.auth_cookie.clone() + } else { + format!("auth={}", credentials.auth_cookie) + }; + + let agent = build_agent()?; + let resp = agent + .get(&url) + .set("Accept", "text/html,application/xhtml+xml") + .set("Cookie", &cookie_header) + .set("User-Agent", OPENCODE_GO_USER_AGENT) + .call(); + + let resp = match resp { + Ok(r) => r, + Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { + diagnose::log("OpenCode Go dashboard rejected the session cookie"); + return Err(PollError::AuthRequired); + } + Err(error) => { + diagnose::log_error("OpenCode Go dashboard request failed", error); + return Err(PollError::RequestFailed); + } + }; + + let html = resp + .into_string() + .map_err(|error| { + diagnose::log_error("OpenCode Go dashboard response is not UTF-8", error); + PollError::RequestFailed + })?; + + Ok(parse_opencode_dashboard_html(&html)) +} + +fn parse_opencode_dashboard_html(html: &str) -> OpencodeDashboardUsage { + let text = normalize_opencode_dashboard_html(html); + OpencodeDashboardUsage { + rolling: parse_opencode_window("rollingUsage", &text), + weekly: parse_opencode_window("weeklyUsage", &text), + monthly: parse_opencode_window("monthlyUsage", &text), + } +} + +fn normalize_opencode_dashboard_html(html: &str) -> String { + html.replace(""", "\"") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("&", "&") + .replace("\\\"", "\"") + .replace("\\u0022", "\"") +} + +fn parse_opencode_window(field_name: &str, text: &str) -> Option { + use std::sync::OnceLock; + + static WINDOW_RE: OnceLock = OnceLock::new(); + static PCT_RE: OnceLock = OnceLock::new(); + static RESET_RE: OnceLock = OnceLock::new(); + + let window_re = WINDOW_RE.get_or_init(|| { + regex::Regex::new( + r#"["']?(rollingUsage|weeklyUsage|monthlyUsage)["']?\s*:\s*(?:\$R\[\d+\]\s*=\s*)?\{(?P[^{}]*)\}"#, + ) + .expect("OpenCode window regex") + }); + let pct_re = PCT_RE.get_or_init(|| { + regex::Regex::new(r#"["']?usagePercent["']?\s*:\s*"?(-?\d+(?:\.\d+)?)"?"#) + .expect("OpenCode usagePercent regex") + }); + let reset_re = RESET_RE.get_or_init(|| { + regex::Regex::new(r#"["']?resetInSec["']?\s*:\s*"?(-?\d+(?:\.\d+)?)"?"#) + .expect("OpenCode resetInSec regex") + }); + + let body = window_re + .captures_iter(text) + .find(|c| c.get(1).map(|m| m.as_str() == field_name).unwrap_or(false)) + .and_then(|c| c.name("body").map(|m| m.as_str()))?; + + let usage_percent: f64 = pct_re + .captures(body) + .and_then(|c| c.get(1)) + .and_then(|m| m.as_str().parse().ok())?; + let reset_in_sec: i64 = reset_re + .captures(body) + .and_then(|c| c.get(1)) + .and_then(|m| m.as_str().parse().ok())?; + + Some(OpencodeUsageWindow { + usage_percent, + reset_in_sec: reset_in_sec.max(0), + }) +} fn refresh_or_fallback(mut creds: Credentials) -> Result { loop { if !is_token_expired(creds.expires_at) { @@ -568,12 +1065,17 @@ pub fn credential_watch_snapshot(mode: CredentialWatchMode) -> CredentialWatchSn return vec![antigravity_credential_watch_signature()]; } + if mode == CredentialWatchMode::Opencode { + return vec![opencode_credential_watch_signature()]; + } + let sources = match mode { CredentialWatchMode::ActiveSource => read_first_credentials() .map(|creds| vec![creds.source]) .unwrap_or_else(all_known_credential_sources), CredentialWatchMode::AllSources => all_known_credential_sources(), CredentialWatchMode::Antigravity => unreachable!(), + CredentialWatchMode::Opencode => unreachable!(), }; let mut snapshot: CredentialWatchSnapshot = sources @@ -872,6 +1374,55 @@ fn antigravity_credential_watch_signature() -> String { ) } +fn opencode_credential_watch_signature() -> String { + let mut parts: Vec = Vec::new(); + + if let Some(api_key) = opencode_api_key() { + let mut hasher = DefaultHasher::new(); + api_key.hash(&mut hasher); + parts.push(format!("api|present|{}|{:x}", api_key.len(), hasher.finish())); + } else { + parts.push("api|missing".to_string()); + } + + match read_opencode_dashboard_credentials() { + Some(creds) => { + let mut hasher = DefaultHasher::new(); + creds.workspace_id.hash(&mut hasher); + creds.auth_cookie.hash(&mut hasher); + parts.push(format!( + "dashboard|present|{}|{}|{:x}|{}", + creds.workspace_id.len(), + creds.auth_cookie.len(), + hasher.finish(), + creds.source + )); + } + None => parts.push("dashboard|missing".to_string()), + } + + match opencode_db_path() { + Some(path) => { + let key = format!("db:{}", path.display()); + match std::fs::metadata(&path) { + Ok(metadata) => { + let modified = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_secs()) + .unwrap_or(0); + parts.push(format!("{key}|present|{}|{modified}", metadata.len())); + } + Err(_) => parts.push(format!("{key}|missing")), + } + } + None => parts.push("db|missing".to_string()), + } + + parts.join(";;") +} + fn fetch_antigravity_usage(token: &str) -> Result { let mut auth_error = false; let mut last_error = PollError::RequestFailed; @@ -909,7 +1460,11 @@ fn fetch_antigravity_usage_from_endpoint( let session = fetch_antigravity_model_quota(base_url, token, project.as_deref())?; let weekly = UsageSection::default(); - Ok(UsageData { session, weekly }) + Ok(UsageData { + session, + weekly, + weekly_label: None, + }) } fn fetch_antigravity_project(base_url: &str, token: &str) -> Result, PollError> { @@ -1598,6 +2153,7 @@ 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) || data.antigravity.as_ref().is_some_and(is_past_reset) + || data.opencode.as_ref().is_some_and(is_past_reset) } #[cfg(test)] @@ -1611,6 +2167,7 @@ mod tests { resets_at: None, }, weekly: UsageSection::default(), + weekly_label: None, } } @@ -1620,9 +2177,11 @@ mod tests { true, true, false, + false, || Err(PollError::AuthRequired), || Ok(usage_with_session_percent(42.0)), || unreachable!("antigravity is disabled"), + || unreachable!("opencode is disabled"), ) .expect("codex data should keep the poll successful"); @@ -1636,9 +2195,11 @@ mod tests { true, true, false, + false, || Ok(usage_with_session_percent(64.0)), || Err(PollError::RequestFailed), || unreachable!("antigravity is disabled"), + || unreachable!("opencode is disabled"), ) .expect("claude data should keep the poll successful"); @@ -1652,9 +2213,11 @@ mod tests { true, true, true, + false, || Err(PollError::AuthRequired), || Err(PollError::RequestFailed), || Err(PollError::NoCredentials), + || unreachable!("opencode is disabled"), ) .expect_err("all-provider failure should return an error"); @@ -1667,9 +2230,11 @@ mod tests { false, true, true, + false, || unreachable!("claude code is disabled"), || Ok(usage_with_session_percent(42.0)), || Err(PollError::NoCredentials), + || unreachable!("opencode is disabled"), ) .expect("codex data should keep the poll successful"); @@ -1677,6 +2242,24 @@ mod tests { assert_eq!(data.codex.unwrap().session.percentage, 42.0); } + #[test] + fn opencode_failure_does_not_block_codex_when_both_are_enabled() { + let data = poll_with( + false, + true, + false, + true, + || unreachable!("claude code is disabled"), + || Ok(usage_with_session_percent(42.0)), + || unreachable!("antigravity is disabled"), + || Err(PollError::NoCredentials), + ) + .expect("codex data should keep the poll successful"); + + assert!(data.opencode.is_none()); + assert_eq!(data.codex.unwrap().session.percentage, 42.0); + } + #[test] fn antigravity_summary_prefers_gemini_group() { let response: AntigravityQuotaSummaryResponse = serde_json::from_str( diff --git a/src/tray_icon.rs b/src/tray_icon.rs index e2502e2..2518adf 100644 --- a/src/tray_icon.rs +++ b/src/tray_icon.rs @@ -13,6 +13,7 @@ use crate::native_interop::{self, Color, WM_APP_TRAY}; const CLAUDE_TRAY_ICON_ID: u32 = 1; const CODEX_TRAY_ICON_ID: u32 = 2; const ANTIGRAVITY_TRAY_ICON_ID: u32 = 3; +const OPENCODE_TRAY_ICON_ID: u32 = 4; /// Menu item ID for toggling widget visibility (used by window.rs context menu). pub const IDM_TOGGLE_WIDGET: u16 = 70; @@ -29,6 +30,7 @@ pub enum TrayIconKind { Claude, Codex, Antigravity, + Opencode, } pub struct TrayIconData { @@ -43,6 +45,7 @@ impl TrayIconKind { Self::Claude => CLAUDE_TRAY_ICON_ID, Self::Codex => CODEX_TRAY_ICON_ID, Self::Antigravity => ANTIGRAVITY_TRAY_ICON_ID, + Self::Opencode => OPENCODE_TRAY_ICON_ID, } } } @@ -101,6 +104,14 @@ fn antigravity_fill(percent: f64) -> Color { } } +fn opencode_fill(percent: f64) -> Color { + if percent >= 90.0 { + Color::from_hex("#FFFFFF") + } else { + Color::from_hex("#2EBD85") + } +} + /// 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 and Antigravity, `percent` = None uses a provider placeholder badge. @@ -125,6 +136,7 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { TrayIconKind::Claude => interpolated_fill(percent.unwrap_or(0.0)), TrayIconKind::Codex => codex_fill(percent.unwrap_or(0.0)), TrayIconKind::Antigravity => antigravity_fill(percent.unwrap_or(0.0)), + TrayIconKind::Opencode => opencode_fill(percent.unwrap_or(0.0)), }; let text_col = match kind { TrayIconKind::Claude => Color::from_hex("#FFFFFF"), @@ -132,6 +144,8 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { TrayIconKind::Codex => Color::from_hex("#FFFFFF"), TrayIconKind::Antigravity if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1967D2"), TrayIconKind::Antigravity => Color::from_hex("#FFFFFF"), + TrayIconKind::Opencode if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1F8A5C"), + TrayIconKind::Opencode => Color::from_hex("#FFFFFF"), }; let outline_col = match kind { TrayIconKind::Claude => fill, @@ -139,6 +153,8 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { TrayIconKind::Codex => Color::from_hex("#FFFFFF"), TrayIconKind::Antigravity if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1967D2"), TrayIconKind::Antigravity => Color::from_hex("#FFFFFF"), + TrayIconKind::Opencode if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1F8A5C"), + TrayIconKind::Opencode => Color::from_hex("#FFFFFF"), }; let display_text = match percent { @@ -147,6 +163,7 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { TrayIconKind::Claude => String::new(), TrayIconKind::Codex => "C".to_string(), TrayIconKind::Antigravity => "A".to_string(), + TrayIconKind::Opencode => "O".to_string(), }, }; @@ -417,6 +434,9 @@ pub fn sync(hwnd: HWND, icons: &[TrayIconData]) { let show_antigravity = icons .iter() .find(|icon| matches!(icon.kind, TrayIconKind::Antigravity)); + let show_opencode = icons + .iter() + .find(|icon| matches!(icon.kind, TrayIconKind::Opencode)); if let Some(icon) = show_claude { add(hwnd, icon.kind, icon.percent, &icon.tooltip); @@ -438,12 +458,20 @@ pub fn sync(hwnd: HWND, icons: &[TrayIconData]) { } else { remove(hwnd, TrayIconKind::Antigravity); } + + if let Some(icon) = show_opencode { + add(hwnd, icon.kind, icon.percent, &icon.tooltip); + update(hwnd, icon.kind, icon.percent, &icon.tooltip); + } else { + remove(hwnd, TrayIconKind::Opencode); + } } pub fn remove_all(hwnd: HWND) { remove(hwnd, TrayIconKind::Claude); remove(hwnd, TrayIconKind::Codex); remove(hwnd, TrayIconKind::Antigravity); + remove(hwnd, TrayIconKind::Opencode); } /// Interpret a tray callback message and return the action to take. diff --git a/src/window.rs b/src/window.rs index f6d261e..b14044e 100644 --- a/src/window.rs +++ b/src/window.rs @@ -67,9 +67,15 @@ struct AppState { antigravity_session_text: String, antigravity_weekly_percent: f64, antigravity_weekly_text: String, + opencode_session_percent: f64, + opencode_session_text: String, + opencode_weekly_percent: f64, + opencode_weekly_text: String, + opencode_weekly_label: &'static str, show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_opencode: bool, data: Option, @@ -131,6 +137,7 @@ const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; const IDM_MODEL_ANTIGRAVITY: u16 = 62; +const IDM_MODEL_OPENCODE: u16 = 63; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -316,6 +323,8 @@ struct SettingsFile { show_codex: bool, #[serde(default = "default_show_antigravity")] show_antigravity: bool, + #[serde(default = "default_show_opencode")] + show_opencode: bool, } impl Default for SettingsFile { @@ -330,6 +339,7 @@ impl Default for SettingsFile { show_claude_code: true, show_codex: false, show_antigravity: false, + show_opencode: false, } } } @@ -354,13 +364,21 @@ fn default_show_antigravity() -> bool { false } +fn default_show_opencode() -> bool { + false +} + fn load_settings() -> SettingsFile { let content = match std::fs::read_to_string(settings_path()) { Ok(c) => c, Err(_) => return SettingsFile::default(), }; let mut settings: SettingsFile = serde_json::from_str(&content).unwrap_or_default(); - if !settings.show_claude_code && !settings.show_codex && !settings.show_antigravity { + if !settings.show_claude_code + && !settings.show_codex + && !settings.show_antigravity + && !settings.show_opencode + { settings.show_claude_code = true; } settings @@ -391,6 +409,7 @@ fn save_state_settings() { show_claude_code: s.show_claude_code, show_codex: s.show_codex, show_antigravity: s.show_antigravity, + show_opencode: s.show_opencode, }); } } @@ -436,6 +455,18 @@ fn tray_icon_data_from_state() -> Vec { ), }); } + if s.show_opencode { + icons.push(tray_icon::TrayIconData { + kind: tray_icon::TrayIconKind::Opencode, + percent: Some(s.opencode_session_percent), + tooltip: format!( + "{} 5h: {} | 7d: {}", + s.language.strings().opencode_model, + s.opencode_session_text, + s.opencode_weekly_text + ), + }); + } icons } Some(s) => { @@ -461,6 +492,13 @@ fn tray_icon_data_from_state() -> Vec { tooltip: s.language.strings().antigravity_window_title.to_string(), }); } + if s.show_opencode { + icons.push(tray_icon::TrayIconData { + kind: tray_icon::TrayIconKind::Opencode, + percent: None, + tooltip: s.language.strings().opencode_window_title.to_string(), + }); + } icons } None => Vec::new(), @@ -672,6 +710,22 @@ fn refresh_usage_texts(state: &mut AppState) { state.antigravity_session_text = "!".to_string(); state.antigravity_weekly_text = "!".to_string(); } + + if let Some(opencode) = data.opencode.as_ref() { + state.opencode_session_text = poller::format_line(&opencode.session, strings); + let label = opencode.weekly_label.unwrap_or(strings.weekly_window); + state.opencode_weekly_label = label; + let inner = poller::format_line(&opencode.weekly, strings); + state.opencode_weekly_text = if label == strings.weekly_window { + inner + } else { + format!("{label} {inner}") + }; + } else if state.show_opencode { + state.opencode_session_text = "!".to_string(); + state.opencode_weekly_text = "!".to_string(); + state.opencode_weekly_label = strings.weekly_window; + } } fn set_window_title(hwnd: HWND, strings: Strings) { @@ -1079,8 +1133,17 @@ fn cursor_is_on_drag_handle(hwnd: HWND) -> bool { } } -fn active_model_count(show_claude_code: bool, show_codex: bool, show_antigravity: bool) -> i32 { - (show_claude_code as i32 + show_codex as i32 + show_antigravity as i32).max(1) +fn active_model_count( + show_claude_code: bool, + show_codex: bool, + show_antigravity: bool, + show_opencode: bool, +) -> i32 { + (show_claude_code as i32 + + show_codex as i32 + + show_antigravity as i32 + + show_opencode as i32) + .max(1) } fn row_bar_segment_count(active_models: i32) -> i32 { @@ -1111,6 +1174,7 @@ fn total_widget_width_for_state(state: &AppState) -> i32 { state.show_claude_code, state.show_codex, state.show_antigravity, + state.show_opencode, )) } @@ -1119,7 +1183,14 @@ fn total_widget_width() -> i32 { let state = lock_state(); state .as_ref() - .map(|s| active_model_count(s.show_claude_code, s.show_codex, s.show_antigravity)) + .map(|s| { + active_model_count( + s.show_claude_code, + s.show_codex, + s.show_antigravity, + s.show_opencode, + ) + }) .unwrap_or(1) }; total_widget_width_for(active_models) @@ -1165,6 +1236,18 @@ fn antigravity_usage_text_color(is_dark: bool) -> Color { } } +fn opencode_accent_color() -> Color { + Color::from_hex("#2EBD85") +} + +fn opencode_usage_text_color(is_dark: bool) -> Color { + if is_dark { + Color::from_hex("#5DD6A1") + } else { + Color::from_hex("#1F8A5C") + } +} + pub fn run() { // Enable Per-Monitor DPI Awareness V2 for crisp rendering at any scale factor unsafe { @@ -1244,6 +1327,7 @@ pub fn run() { settings.show_claude_code, settings.show_codex, settings.show_antigravity, + settings.show_opencode, ); let hwnd = CreateWindowExW( WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_NOACTIVATE, @@ -1307,9 +1391,15 @@ pub fn run() { antigravity_session_text: "--".to_string(), antigravity_weekly_percent: 0.0, antigravity_weekly_text: "--".to_string(), + opencode_session_percent: 0.0, + opencode_session_text: "--".to_string(), + opencode_weekly_percent: 0.0, + opencode_weekly_text: "--".to_string(), + opencode_weekly_label: language.strings().weekly_window, show_claude_code: settings.show_claude_code, show_codex: settings.show_codex, show_antigravity: settings.show_antigravity, + show_opencode: settings.show_opencode, data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, @@ -1432,9 +1522,14 @@ fn render_layered() { antigravity_session_text, antigravity_weekly_pct, antigravity_weekly_text, + opencode_session_pct, + opencode_session_text, + opencode_weekly_pct, + opencode_weekly_text, show_claude_code, show_codex, show_antigravity, + show_opencode, ) = { let state = lock_state(); match state.as_ref() { @@ -1455,9 +1550,14 @@ fn render_layered() { s.antigravity_session_text.clone(), s.antigravity_weekly_percent, s.antigravity_weekly_text.clone(), + s.opencode_session_percent, + s.opencode_session_text.clone(), + s.opencode_weekly_percent, + s.opencode_weekly_text.clone(), s.show_claude_code, s.show_codex, s.show_antigravity, + s.show_opencode, ), None => return, } @@ -1479,6 +1579,7 @@ fn render_layered() { let accent = claude_accent_color(); let codex_accent = codex_accent_color(is_dark); let antigravity_accent = antigravity_accent_color(); + let opencode_accent = opencode_accent_color(); let track = if is_dark { Color::from_hex("#444444") } else { @@ -1550,11 +1651,17 @@ fn render_layered() { &antigravity_session_text, antigravity_weekly_pct, &antigravity_weekly_text, + opencode_session_pct, + &opencode_session_text, + opencode_weekly_pct, + &opencode_weekly_text, show_claude_code, show_codex, show_antigravity, + show_opencode, &codex_accent, &antigravity_accent, + &opencode_accent, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1626,11 +1733,17 @@ fn paint_content( antigravity_session_text: &str, antigravity_weekly_pct: f64, antigravity_weekly_text: &str, + opencode_session_pct: f64, + opencode_session_text: &str, + opencode_weekly_pct: f64, + opencode_weekly_text: &str, show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_opencode: bool, codex_accent: &Color, antigravity_accent: &Color, + opencode_accent: &Color, ) { unsafe { let client_rect = RECT { @@ -1720,12 +1833,16 @@ fn paint_content( codex_session_text, antigravity_session_pct, antigravity_session_text, + opencode_session_pct, + opencode_session_text, show_claude_code, show_codex, show_antigravity, + show_opencode, accent, codex_accent, antigravity_accent, + opencode_accent, track, ); draw_row( @@ -1741,12 +1858,16 @@ fn paint_content( codex_weekly_text, antigravity_weekly_pct, antigravity_weekly_text, + opencode_weekly_pct, + opencode_weekly_text, show_claude_code, show_codex, show_antigravity, + show_opencode, accent, codex_accent, antigravity_accent, + opencode_accent, track, ); @@ -1757,15 +1878,22 @@ fn paint_content( fn do_poll(send_hwnd: SendHwnd) { let hwnd = send_hwnd.to_hwnd(); - let (show_claude_code, show_codex, show_antigravity) = { + let (show_claude_code, show_codex, show_antigravity, show_opencode) = { let state = lock_state(); state .as_ref() - .map(|s| (s.show_claude_code, s.show_codex, s.show_antigravity)) - .unwrap_or((true, false, false)) + .map(|s| { + ( + s.show_claude_code, + s.show_codex, + s.show_antigravity, + s.show_opencode, + ) + }) + .unwrap_or((true, false, false, false)) }; - match poller::poll(show_claude_code, show_codex, show_antigravity) { + match poller::poll(show_claude_code, show_codex, show_antigravity, show_opencode) { Ok(data) => { let mut state = lock_state(); if let Some(s) = state.as_mut() { @@ -1790,6 +1918,13 @@ fn do_poll(send_hwnd: SendHwnd) { s.antigravity_session_percent = 0.0; s.antigravity_weekly_percent = 0.0; } + if let Some(opencode) = data.opencode.as_ref() { + s.opencode_session_percent = opencode.session.percentage; + s.opencode_weekly_percent = opencode.weekly.percentage; + } else if s.show_opencode { + s.opencode_session_percent = 0.0; + s.opencode_weekly_percent = 0.0; + } // Stop fast-poll if reset data is now fresh if !poller::app_is_past_reset(&data) { unsafe { @@ -1821,6 +1956,17 @@ fn do_poll(send_hwnd: SendHwnd) { } Err(e) => { let auth_watch = match e { + poller::PollError::AuthRequired | poller::PollError::TokenExpired + if show_opencode + && !show_claude_code + && !show_codex + && !show_antigravity => + { + Some(( + poller::CredentialWatchMode::Opencode, + poller::credential_watch_snapshot(poller::CredentialWatchMode::Opencode), + )) + } poller::PollError::AuthRequired | poller::PollError::TokenExpired if show_antigravity && !show_claude_code && !show_codex => { @@ -1861,6 +2007,9 @@ fn do_poll(send_hwnd: SendHwnd) { s.codex_weekly_text = "!".to_string(); s.antigravity_session_text = "!".to_string(); s.antigravity_weekly_text = "!".to_string(); + s.opencode_session_text = "!".to_string(); + s.opencode_weekly_text = "!".to_string(); + s.opencode_weekly_label = s.language.strings().weekly_window; s.retry_count = s.retry_count.saturating_add(1); unsafe { let _ = KillTimer(hwnd, TIMER_POLL); @@ -1881,6 +2030,9 @@ fn do_poll(send_hwnd: SendHwnd) { s.codex_weekly_text = "...".to_string(); s.antigravity_session_text = "...".to_string(); s.antigravity_weekly_text = "...".to_string(); + s.opencode_session_text = "...".to_string(); + s.opencode_weekly_text = "...".to_string(); + s.opencode_weekly_label = s.language.strings().weekly_window; s.retry_count = s.retry_count.saturating_add(1); let backoff = RETRY_BASE_MS.saturating_mul( 1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX), @@ -1914,13 +2066,20 @@ fn do_poll(send_hwnd: SendHwnd) { s.language.strings().codex_token_expired_title, s.language.strings().codex_token_expired_body, ) - } else { + } else if s.show_antigravity { ( s.language.strings(), tray_icon::TrayIconKind::Antigravity, s.language.strings().antigravity_token_expired_title, s.language.strings().antigravity_token_expired_body, ) + } else { + ( + s.language.strings(), + tray_icon::TrayIconKind::Opencode, + s.language.strings().opencode_token_expired_title, + s.language.strings().opencode_token_expired_body, + ) } }) }; @@ -1983,6 +2142,12 @@ fn schedule_countdown_timer() { data.antigravity .as_ref() .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at)), + data.opencode + .as_ref() + .and_then(|usage| poller::time_until_display_change(usage.session.resets_at)), + data.opencode + .as_ref() + .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at)), ]; let min_delay = delays.into_iter().flatten().min(); @@ -2512,6 +2677,11 @@ unsafe extern "system" fn wnd_proc( s.weekly_text = "...".to_string(); s.codex_session_text = "...".to_string(); s.codex_weekly_text = "...".to_string(); + s.antigravity_session_text = "...".to_string(); + s.antigravity_weekly_text = "...".to_string(); + s.opencode_session_text = "...".to_string(); + s.opencode_weekly_text = "...".to_string(); + s.opencode_weekly_label = s.language.strings().weekly_window; s.force_notify_auth_error = true; } } @@ -2594,26 +2764,46 @@ unsafe extern "system" fn wnd_proc( // Reset the poll timer with the new interval SetTimer(hwnd, TIMER_POLL, new_interval, None); } - IDM_MODEL_CLAUDE_CODE | IDM_MODEL_CODEX | IDM_MODEL_ANTIGRAVITY => { + IDM_MODEL_CLAUDE_CODE | IDM_MODEL_CODEX | IDM_MODEL_ANTIGRAVITY + | IDM_MODEL_OPENCODE => { { let mut state = lock_state(); if let Some(s) = state.as_mut() { match id { IDM_MODEL_CLAUDE_CODE => { - if s.show_codex || s.show_antigravity || !s.show_claude_code { + if s.show_codex || s.show_antigravity || s.show_opencode + || !s.show_claude_code + { s.show_claude_code = !s.show_claude_code; } } IDM_MODEL_CODEX => { - if s.show_claude_code || s.show_antigravity || !s.show_codex { + if s.show_claude_code + || s.show_antigravity + || s.show_opencode + || !s.show_codex + { s.show_codex = !s.show_codex; } } IDM_MODEL_ANTIGRAVITY => { - if s.show_claude_code || s.show_codex || !s.show_antigravity { + if s.show_claude_code + || s.show_codex + || s.show_opencode + || !s.show_antigravity + { s.show_antigravity = !s.show_antigravity; } } + IDM_MODEL_OPENCODE => { + if s.show_claude_code + || s.show_codex + || s.show_antigravity + || !s.show_opencode + { + s.show_opencode = !s.show_opencode; + } + } _ => {} } s.session_text = "...".to_string(); @@ -2622,6 +2812,9 @@ unsafe extern "system" fn wnd_proc( s.codex_weekly_text = "...".to_string(); s.antigravity_session_text = "...".to_string(); s.antigravity_weekly_text = "...".to_string(); + s.opencode_session_text = "...".to_string(); + s.opencode_weekly_text = "...".to_string(); + s.opencode_weekly_label = s.language.strings().weekly_window; } } save_state_settings(); @@ -2715,6 +2908,7 @@ fn show_context_menu(hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + show_opencode, ) = { let state = lock_state(); match state.as_ref() { @@ -2729,6 +2923,7 @@ fn show_context_menu(hwnd: HWND) { s.show_claude_code, s.show_codex, s.show_antigravity, + s.show_opencode, ), None => ( POLL_15_MIN, @@ -2741,6 +2936,7 @@ fn show_context_menu(hwnd: HWND) { true, false, false, + false, ), } }; @@ -2827,6 +3023,19 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(antigravity_model.as_ptr()), ); + let opencode_model = native_interop::wide_str(strings.opencode_model); + let opencode_flags = if show_opencode { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + models_menu, + opencode_flags, + IDM_MODEL_OPENCODE as usize, + PCWSTR::from_raw(opencode_model.as_ptr()), + ); + let models_label = native_interop::wide_str(strings.models); let _ = AppendMenuW( menu, @@ -2984,9 +3193,14 @@ fn paint(hdc: HDC, hwnd: HWND) { antigravity_session_text, antigravity_weekly_pct, antigravity_weekly_text, + opencode_session_pct, + opencode_session_text, + opencode_weekly_pct, + opencode_weekly_text, show_claude_code, show_codex, show_antigravity, + show_opencode, ) = { let state = lock_state(); match state.as_ref() { @@ -3005,9 +3219,14 @@ fn paint(hdc: HDC, hwnd: HWND) { s.antigravity_session_text.clone(), s.antigravity_weekly_percent, s.antigravity_weekly_text.clone(), + s.opencode_session_percent, + s.opencode_session_text.clone(), + s.opencode_weekly_percent, + s.opencode_weekly_text.clone(), s.show_claude_code, s.show_codex, s.show_antigravity, + s.show_opencode, ), None => return, } @@ -3016,6 +3235,7 @@ fn paint(hdc: HDC, hwnd: HWND) { let accent = claude_accent_color(); let codex_accent = codex_accent_color(is_dark); let antigravity_accent = antigravity_accent_color(); + let opencode_accent = opencode_accent_color(); let track = if is_dark { Color::from_hex("#444444") } else { @@ -3068,11 +3288,17 @@ fn paint(hdc: HDC, hwnd: HWND) { &antigravity_session_text, antigravity_weekly_pct, &antigravity_weekly_text, + opencode_session_pct, + &opencode_session_text, + opencode_weekly_pct, + &opencode_weekly_text, show_claude_code, show_codex, show_antigravity, + show_opencode, &codex_accent, &antigravity_accent, + &opencode_accent, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -3096,16 +3322,25 @@ fn draw_row( codex_text: &str, antigravity_percent: f64, antigravity_text: &str, + opencode_percent: f64, + opencode_text: &str, show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_opencode: bool, claude_accent: &Color, codex_accent: &Color, antigravity_accent: &Color, + opencode_accent: &Color, track: &Color, ) { let seg_h = sc(SEGMENT_H); - let active_models = active_model_count(show_claude_code, show_codex, show_antigravity); + let active_models = active_model_count( + show_claude_code, + show_codex, + show_antigravity, + show_opencode, + ); let segment_count = row_bar_segment_count(active_models); let use_model_text_colors = active_models > 1; let claude_value_color = if use_model_text_colors { @@ -3123,6 +3358,11 @@ fn draw_row( } else { *text_color }; + let opencode_value_color = if use_model_text_colors { + opencode_usage_text_color(is_dark) + } else { + *text_color + }; unsafe { let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); @@ -3181,6 +3421,20 @@ fn draw_row( track, &antigravity_value_color, ); + model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); + } + if show_opencode { + draw_usage_bar( + hdc, + model_x, + y, + segment_count, + opencode_percent, + opencode_text, + opencode_accent, + track, + &opencode_value_color, + ); } } } From b2d3bfdeb02a5000be8cd02421fb7cb5a1e24fe6 Mon Sep 17 00:00:00 2001 From: Jesus Torres Date: Fri, 3 Jul 2026 20:06:23 -0600 Subject: [PATCH 2/3] Add remote notifications via Messangi (SMS, email, WhatsApp) Adds an optional notifier that sends alerts to SMS, email, and WhatsApp when a configured usage threshold is crossed. Targets the Messangi Communications Platform (https://docs.messangi.com) and is provider-pluggable in code. Behavior: - Per provider x window thresholds (claude_code, codex, antigravity, opencode; session / weekly). - Rising-edge detection: alert on the first poll past the threshold, suppress while above, re-arm when usage drops below. - WhatsApp uses session messages only; user must opt in once after sending the first message from their phone. A keep-alive is sent every 23 h to keep the 24 h conversation window open. If the window closes (24 h expired), the widget clears the opt-in and shows a balloon tip. - Rate limit: one alert per 60 s (configurable) to avoid bursts when several providers cross at the same time. - New submenu under Settings > Notifications: per-channel toggles, test message, open/reload config, WhatsApp opt-in. Configuration: JSON at %APPDATA%\\claude-code-usage-monitor\\notifier.json. Created with defaults the first time Open config file is selected. API key is the Messangi JWT (from Preferences > API in the admin UI). Testing: - Unit tests for threshold rising/falling edge, no-alerts-without-key, and config file roundtrip. - Manual end-to-end: enable a channel, set thresholds, cross a threshold, verify the alert arrives on the configured destination. WhatsApp requires the one-time opt-in step. Localizations: 10 languages updated with the new menu strings. --- README.md | 102 +++ src/localization/dutch.rs | 9 + src/localization/english.rs | 9 + src/localization/french.rs | 9 + src/localization/german.rs | 9 + src/localization/japanese.rs | 9 + src/localization/korean.rs | 9 + src/localization/mod.rs | 9 + src/localization/portuguese_brazil.rs | 9 + src/localization/russian.rs | 9 + src/localization/spanish.rs | 9 + src/localization/traditional_chinese.rs | 9 + src/main.rs | 1 + src/notifier.rs | 789 ++++++++++++++++++++++++ src/window.rs | 321 ++++++++++ 15 files changed, 1312 insertions(+) create mode 100644 src/notifier.rs diff --git a/README.md b/README.md index a2c0164..e468839 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,108 @@ Settings are saved to: %APPDATA%\ClaudeCodeUsageMonitor\settings.json ``` +## Remote Notifications (Messangi) + +The widget can send alerts to **SMS**, **email**, and **WhatsApp** via +[Messangi](https://docs.messangi.com) when a configured usage threshold +is crossed. The provider is pluggable in code but the v1 implementation +targets the Messangi Communications Platform. + +### Channels + +- **SMS** — sent to the configured phone number from the configured + shortcode. +- **Email** — sent to the configured address from the configured sender. +- **WhatsApp** — sent as a **session message** to the configured number. + WhatsApp requires the user to opt in (see below) because the + destination must have messaged the configured sender within the last + 24 h to keep the conversation window open. The widget will retry every + 23 h with a short keep-alive to keep the window alive. + +### Threshold rules + +- Configured per `provider × window` (provider = `claude_code`, `codex`, + `antigravity`, `opencode`; window = `session` for 5 h, `weekly` for + 7 d / 30 d). +- An alert is sent when usage **rises past** the threshold and is + suppressed while usage stays above it. When usage **drops below** the + threshold, the suppression clears so the next rising edge fires + again. +- The widget rate-limits outgoing alerts to one every + `minAlertIntervalSecs` seconds (default 60) to avoid bursts. + +### Configuration + +The widget reads `%APPDATA%\claude-code-usage-monitor\notifier.json`. +The file is created with sensible defaults the first time you open +**Notifications → Open config file** from the context menu. + +```json +{ + "messangiApiKey": "eyJhbGciOi...", + "messangiBaseUrl": "https://elastic.messangi.me", + "sms": { + "enabled": true, + "to": "+15551234567", + "shortcode": "CLAUDEMON" + }, + "email": { + "enabled": true, + "from": "alerts@yourdomain.com", + "to": "you@yourdomain.com" + }, + "whatsapp": { + "enabled": true, + "from": "15551234567", + "to": "+15551234567" + }, + "thresholds": { + "claude_code": { "session": [80, 95], "weekly": [50, 100] }, + "codex": { "session": [90], "weekly": [100] }, + "antigravity": { "session": [80], "weekly": [100] }, + "opencode": { "session": [80], "weekly": [100] } + }, + "keepAliveHours": 23, + "minAlertIntervalSecs": 60 +} +``` + +The `messangiApiKey` is your **JWT** (Preferences → API in the Messangi +admin UI). `keepAliveHours` controls how often the widget sends a +keep-alive to your WhatsApp number to hold the 24 h session open. + +### Context menu + +Right-click the widget → **Settings → Notifications**: + +- ☑ **SMS** / **Email** / **WhatsApp** — toggle each channel on or off + independently. The toggle is persisted in the same config file. +- **Opt in to WhatsApp** — appears when WhatsApp is toggled on. Mark the + widget as opted in after sending your first message to the configured + sender from your phone. Without this, WhatsApp alerts are skipped + (the destination has not opened a session window yet). +- **Test notification** — sends a hard-coded test message through every + active channel. +- **Open config file** — opens `notifier.json` in the default `.json` + editor so you can edit thresholds, recipients, etc. +- **Reload config** — picks up the latest JSON contents without + restarting the widget. + +### WhatsApp keep-alive + +WhatsApp closes a conversation window 24 h after the user's last +message. The widget sends a short keep-alive via session message +every `keepAliveHours` (default 23) so the window stays open. To make +the widget start sending, opt in once via the menu and then send any +message from your phone to the configured sender. Once you respond to +the keep-alive, the 24 h window resets. + +If a session message fails because the 24 h window expired +(`detail: "Re-engagement message. ..."` in the Messangi response), the +widget clears the opt-in automatically and shows a balloon tip. Re-run +the opt-in from the menu after sending another first message from your +phone. + ## Account Support This app works with the same account types that Claude Code itself supports. diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index b106a90..4da92ae 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -50,5 +50,14 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex-gebruiksmonitor", antigravity_window_title: "Antigravity-gebruiksmonitor", opencode_window_title: "OpenCode-gebruiksmonitor", + notifier_menu: "Meldingen", + notifier_sms: "SMS", + notifier_email: "E-mail", + notifier_whatsapp: "WhatsApp", + notifier_whatsapp_optin: "WhatsApp activeren", + notifier_whatsapp_opted_in: "(geactiveerd)", + notifier_test: "Melding testen", + notifier_open_config: "Configuratiebestand openen", + notifier_reload: "Configuratie herladen", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index a4850d5..97ffde7 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -50,5 +50,14 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex Usage Monitor", antigravity_window_title: "Antigravity Usage Monitor", opencode_window_title: "OpenCode Usage Monitor", + notifier_menu: "Notifications", + notifier_sms: "SMS", + notifier_email: "Email", + notifier_whatsapp: "WhatsApp", + notifier_whatsapp_optin: "Opt in to WhatsApp", + notifier_whatsapp_opted_in: "(opted in)", + notifier_test: "Test notification", + notifier_open_config: "Open config file", + notifier_reload: "Reload config", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index 7dad8ae..f1e8075 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -50,5 +50,14 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Moniteur d'utilisation Codex", antigravity_window_title: "Moniteur d'utilisation Antigravity", opencode_window_title: "Moniteur d'utilisation OpenCode", + notifier_menu: "Notifications", + notifier_sms: "SMS", + notifier_email: "E-mail", + notifier_whatsapp: "WhatsApp", + notifier_whatsapp_optin: "Activer WhatsApp", + notifier_whatsapp_opted_in: "(active)", + notifier_test: "Tester la notification", + notifier_open_config: "Ouvrir le fichier de configuration", + notifier_reload: "Recharger la configuration", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 1b6a463..b18f761 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -50,5 +50,14 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex-Nutzungsmonitor", antigravity_window_title: "Antigravity-Nutzungsmonitor", opencode_window_title: "OpenCode-Nutzungsmonitor", + notifier_menu: "Benachrichtigungen", + notifier_sms: "SMS", + notifier_email: "E-Mail", + notifier_whatsapp: "WhatsApp", + notifier_whatsapp_optin: "WhatsApp aktivieren", + notifier_whatsapp_opted_in: "(aktiviert)", + notifier_test: "Benachrichtigung testen", + notifier_open_config: "Konfigurationsdatei öffnen", + notifier_reload: "Konfiguration neu laden", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index f1c1636..85c8d92 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -50,5 +50,14 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex 使用量モニター", antigravity_window_title: "Antigravity 使用量モニター", opencode_window_title: "OpenCode 使用量モニター", + notifier_menu: "通知", + notifier_sms: "SMS", + notifier_email: "メール", + notifier_whatsapp: "WhatsApp", + notifier_whatsapp_optin: "WhatsApp を有効化", + notifier_whatsapp_opted_in: "(有効化済み)", + notifier_test: "通知をテスト", + notifier_open_config: "設定ファイルを開く", + notifier_reload: "設定を再読み込み", second_suffix: "s", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 6639e6c..2bcadac 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -50,5 +50,14 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex 사용량 모니터", antigravity_window_title: "Antigravity 사용량 모니터", opencode_window_title: "OpenCode 사용량 모니터", + notifier_menu: "알림", + notifier_sms: "SMS", + notifier_email: "이메일", + notifier_whatsapp: "WhatsApp", + notifier_whatsapp_optin: "WhatsApp 활성화", + notifier_whatsapp_opted_in: "(활성화됨)", + notifier_test: "알림 테스트", + notifier_open_config: "설정 파일 열기", + notifier_reload: "설정 다시 불러오기", second_suffix: "s", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index bc19f6c..1ab5678 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -185,6 +185,15 @@ pub struct Strings { pub codex_window_title: &'static str, pub antigravity_window_title: &'static str, pub opencode_window_title: &'static str, + pub notifier_menu: &'static str, + pub notifier_sms: &'static str, + pub notifier_email: &'static str, + pub notifier_whatsapp: &'static str, + pub notifier_whatsapp_optin: &'static str, + pub notifier_whatsapp_opted_in: &'static str, + pub notifier_test: &'static str, + pub notifier_open_config: &'static str, + pub notifier_reload: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 084b75e..7d59372 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -50,5 +50,14 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Monitor de uso do Codex", antigravity_window_title: "Monitor de uso do Antigravity", opencode_window_title: "Monitor de uso do OpenCode", + notifier_menu: "Notificações", + notifier_sms: "SMS", + notifier_email: "E-mail", + notifier_whatsapp: "WhatsApp", + notifier_whatsapp_optin: "Ativar WhatsApp", + notifier_whatsapp_opted_in: "(ativado)", + notifier_test: "Testar notificação", + notifier_open_config: "Abrir arquivo de configuração", + notifier_reload: "Recarregar configuração", second_suffix: "s", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index b78f672..b7e7552 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -50,5 +50,14 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Монитор использования Codex", antigravity_window_title: "Монитор использования Antigravity", opencode_window_title: "Монитор использования OpenCode", + notifier_menu: "Уведомления", + notifier_sms: "SMS", + notifier_email: "Эл. почта", + notifier_whatsapp: "WhatsApp", + notifier_whatsapp_optin: "Включить WhatsApp", + notifier_whatsapp_opted_in: "(включено)", + notifier_test: "Проверить уведомление", + notifier_open_config: "Открыть файл конфигурации", + notifier_reload: "Перезагрузить конфигурацию", second_suffix: "с", }; diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index b062e9b..b06d5b6 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -50,5 +50,14 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Monitor de uso de Codex", antigravity_window_title: "Monitor de uso de Antigravity", opencode_window_title: "Monitor de uso de OpenCode", + notifier_menu: "Notificaciones", + notifier_sms: "SMS", + notifier_email: "Correo", + notifier_whatsapp: "WhatsApp", + notifier_whatsapp_optin: "Activar WhatsApp", + notifier_whatsapp_opted_in: "(activado)", + notifier_test: "Probar notificacion", + notifier_open_config: "Abrir archivo de configuracion", + notifier_reload: "Recargar configuracion", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 7a75866..fede726 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -50,5 +50,14 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex 使用量監控", antigravity_window_title: "Antigravity 使用量監控", opencode_window_title: "OpenCode 使用量監控", + notifier_menu: "通知", + notifier_sms: "SMS", + notifier_email: "電子郵件", + notifier_whatsapp: "WhatsApp", + notifier_whatsapp_optin: "啟用 WhatsApp", + notifier_whatsapp_opted_in: "(已啟用)", + notifier_test: "測試通知", + notifier_open_config: "開啟設定檔", + notifier_reload: "重新載入設定", second_suffix: "s", }; diff --git a/src/main.rs b/src/main.rs index a17bc0b..e07a2c7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod diagnose; mod localization; mod models; mod native_interop; +mod notifier; mod poller; mod theme; mod tray_icon; diff --git a/src/notifier.rs b/src/notifier.rs new file mode 100644 index 0000000..f3957e3 --- /dev/null +++ b/src/notifier.rs @@ -0,0 +1,789 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::diagnose; +use crate::models::{AppUsageData, UsageData}; +use crate::poller; + +const NOTIFIER_FILE: &str = "notifier.json"; +const MESSANGI_DEFAULT_BASE_URL: &str = "https://elastic.messangi.me"; +const MESSANGI_NOTIFY_PATH: &str = "/oberyn/v2/notification"; +const MESSANGI_EMAIL_PATH: &str = "/crowsnest/v2/emails"; +const MESSANGI_WHATSAPP_PATH: &str = "/balerion/v3/messages"; +const DEFAULT_KEEP_ALIVE_HOURS: u64 = 23; +const DEFAULT_MIN_ALERT_SECS: u64 = 60; +const HTTP_TIMEOUT_SECS: u64 = 15; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct NotifierConfig { + #[serde(default)] + pub messangi_api_key: Option, + #[serde(default)] + pub messangi_base_url: Option, + #[serde(default)] + pub sms: ChannelConfig, + #[serde(default)] + pub email: EmailConfig, + #[serde(default)] + pub whatsapp: WhatsAppConfig, + #[serde(default)] + pub thresholds: HashMap, + #[serde(default)] + pub keep_alive_hours: Option, + #[serde(default)] + pub min_alert_interval_secs: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct ChannelConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub to: Option, + #[serde(default)] + pub shortcode: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct EmailConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub to: Option, + #[serde(default)] + pub from: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct WhatsAppConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub from: Option, + #[serde(default)] + pub to: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct ProviderThresholds { + #[serde(default)] + pub session: Vec, + #[serde(default)] + pub weekly: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct NotifierState { + #[serde(default)] + pub whatsapp_opted_in_at: Option, + #[serde(default)] + pub whatsapp_last_message_at: Option, + #[serde(default)] + pub last_alert_at: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct NotifierFile { + #[serde(default)] + pub config: NotifierConfig, + #[serde(default)] + pub state: NotifierState, + #[serde(default)] + pub notify_sms: Option, + #[serde(default)] + pub notify_email: Option, + #[serde(default)] + pub notify_whatsapp: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct ThresholdTracker { + last_values: HashMap<(String, String), f64>, + notified: HashMap<(String, String, u8), bool>, +} + +pub struct Notifier { + pub config: NotifierConfig, + pub state: NotifierState, + pub notify_sms: bool, + pub notify_email: bool, + pub notify_whatsapp: bool, + pub tracker: ThresholdTracker, + pub file_path: PathBuf, +} + +impl Notifier { + pub fn load() -> Self { + let file_path = notifier_file_path(); + let (config, state, notify_sms, notify_email, notify_whatsapp) = + match std::fs::read_to_string(&file_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + { + Some(file) => ( + file.config, + file.state, + file.notify_sms.unwrap_or(false), + file.notify_email.unwrap_or(false), + file.notify_whatsapp.unwrap_or(false), + ), + None => Default::default(), + }; + Notifier { + config, + state, + notify_sms, + notify_email, + notify_whatsapp, + tracker: ThresholdTracker::default(), + file_path, + } + } + + pub fn save(&self) { + let file = NotifierFile { + config: self.config.clone(), + state: self.state.clone(), + notify_sms: Some(self.notify_sms), + notify_email: Some(self.notify_email), + notify_whatsapp: Some(self.notify_whatsapp), + }; + if let Ok(content) = serde_json::to_string_pretty(&file) { + if let Some(parent) = self.file_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(error) = std::fs::write(&self.file_path, content) { + diagnose::log_error("unable to write notifier config", error); + } + } + } + + pub fn reload(&mut self) { + let fresh = Self::load(); + self.config = fresh.config; + self.state = fresh.state; + self.notify_sms = fresh.notify_sms; + self.notify_email = fresh.notify_email; + self.notify_whatsapp = fresh.notify_whatsapp; + self.tracker = ThresholdTracker::default(); + } + + pub fn has_api_key(&self) -> bool { + self.config + .messangi_api_key + .as_ref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) + } + + pub fn is_whatsapp_opted_in(&self) -> bool { + self.state.whatsapp_opted_in_at.is_some() + } + + pub fn mark_whatsapp_opted_in(&mut self) { + self.state.whatsapp_opted_in_at = Some(now_unix_secs()); + self.state.whatsapp_last_message_at = Some(now_unix_secs()); + self.save(); + } + + pub fn reset_whatsapp_opt_in(&mut self) { + self.state.whatsapp_opted_in_at = None; + self.state.whatsapp_last_message_at = None; + self.save(); + } + + pub fn is_configured_for(&self, channel: Channel) -> bool { + match channel { + Channel::Sms => { + self.config.sms.enabled + && self.config.sms.to.is_some() + && self.config.sms.shortcode.is_some() + } + Channel::Email => { + self.config.email.enabled + && self.config.email.to.is_some() + && self.config.email.from.is_some() + } + Channel::WhatsApp => { + self.config.whatsapp.enabled + && self.config.whatsapp.from.is_some() + && self.config.whatsapp.to.is_some() + } + } + } + + pub fn is_channel_active(&self, channel: Channel) -> bool { + match channel { + Channel::Sms => self.notify_sms, + Channel::Email => self.notify_email, + Channel::WhatsApp => self.notify_whatsapp, + } + } + + pub fn set_channel_active(&mut self, channel: Channel, active: bool) { + match channel { + Channel::Sms => self.notify_sms = active, + Channel::Email => self.notify_email = active, + Channel::WhatsApp => self.notify_whatsapp = active, + } + self.save(); + } + + pub fn check_and_notify(&mut self, data: &AppUsageData) { + if !self.has_api_key() { + return; + } + + let providers: [(&str, Option<&UsageData>); 4] = [ + ("claude_code", data.claude_code.as_ref()), + ("codex", data.codex.as_ref()), + ("antigravity", data.antigravity.as_ref()), + ("opencode", data.opencode.as_ref()), + ]; + + for (provider_id, provider_data) in providers { + let Some(provider_data) = provider_data else { + continue; + }; + let Some(thresholds) = self.config.thresholds.get(provider_id).cloned() else { + continue; + }; + self.check_provider(provider_id, provider_data, &thresholds); + } + + if self.is_channel_active(Channel::WhatsApp) + && self.config.whatsapp.enabled + && self.is_whatsapp_opted_in() + { + self.maybe_send_whatsapp_keep_alive(); + } + } + + fn check_provider( + &mut self, + provider_id: &str, + data: &UsageData, + thresholds: &ProviderThresholds, + ) { + let windows: [(&str, f64); 2] = [("session", data.session.percentage), ("weekly", data.weekly.percentage)]; + + for (window_name, current) in windows { + let key = (provider_id.to_string(), window_name.to_string()); + let window_thresholds: &[u8] = match window_name { + "session" => &thresholds.session, + "weekly" => &thresholds.weekly, + _ => &[], + }; + + for &threshold in window_thresholds { + let notify_key = (provider_id.to_string(), window_name.to_string(), threshold); + let was_notified = self + .tracker + .notified + .get(¬ify_key) + .copied() + .unwrap_or(false); + let t = threshold as f64; + if current >= t && !was_notified { + self.tracker.notified.insert(notify_key, true); + self.maybe_dispatch_alert(provider_id, window_name, threshold, current); + } else if current < t && was_notified { + self.tracker.notified.remove(¬ify_key); + } + } + + self.tracker.last_values.insert(key, current); + } + } + + /// Dispatch an alert to all enabled channels, gated by the + /// `min_alert_interval_secs` rate limit. + fn maybe_dispatch_alert( + &mut self, + provider_id: &str, + window_name: &str, + threshold: u8, + current: f64, + ) { + let min_interval = Duration::from_secs( + self.config + .min_alert_interval_secs + .unwrap_or(DEFAULT_MIN_ALERT_SECS), + ); + if let Some(last) = self.state.last_alert_at { + let elapsed = now_unix_secs().saturating_sub(last); + if elapsed < min_interval.as_secs() as i64 { + return; + } + } + if self.dispatch_alert(provider_id, window_name, threshold, current) { + self.state.last_alert_at = Some(now_unix_secs()); + self.save(); + } + } + + /// Send a notification to all enabled channels. Returns true if at least one + /// channel accepted the alert. + fn dispatch_alert( + &mut self, + provider_id: &str, + window_name: &str, + threshold: u8, + current: f64, + ) -> bool { + let body = format!( + "{} {} crossed {:.0}% (now {:.0}%)", + humanize_provider(provider_id), + window_name, + threshold as f64, + current + ); + let subject = format!( + "Claude Code Usage Monitor: {} {} alert", + humanize_provider(provider_id), + window_name + ); + + let mut sent = false; + + if self.is_channel_active(Channel::Sms) && self.is_configured_for(Channel::Sms) { + match self.send_sms(&body) { + Ok(_) => sent = true, + Err(error) => diagnose::log(format!("SMS alert failed: {error}")), + } + } + + if self.is_channel_active(Channel::Email) && self.is_configured_for(Channel::Email) { + match self.send_email(&subject, &body) { + Ok(_) => sent = true, + Err(error) => diagnose::log(format!("Email alert failed: {error}")), + } + } + + if self.is_channel_active(Channel::WhatsApp) + && self.is_configured_for(Channel::WhatsApp) + && self.is_whatsapp_opted_in() + { + match self.send_whatsapp_session(&body) { + Ok(_) => { + sent = true; + self.state.whatsapp_last_message_at = Some(now_unix_secs()); + } + Err(NotifError::SessionExpired) => { + diagnose::log("WhatsApp session expired; clearing opt-in"); + self.reset_whatsapp_opt_in(); + } + Err(error) => diagnose::log(format!("WhatsApp alert failed: {error}")), + } + } + + sent + } + + fn maybe_send_whatsapp_keep_alive(&mut self) { + let keep_alive_hours = self + .config + .keep_alive_hours + .unwrap_or(DEFAULT_KEEP_ALIVE_HOURS); + let now = now_unix_secs(); + let last = self + .state + .whatsapp_last_message_at + .unwrap_or(self.state.whatsapp_opted_in_at.unwrap_or(now)); + let elapsed_hours = (now - last) / 3600; + if elapsed_hours < keep_alive_hours as i64 { + return; + } + let body = format!( + "Claude Code Usage Monitor keep-alive. Reply with any message to confirm you still want alerts." + ); + match self.send_whatsapp_session(&body) { + Ok(_) => { + self.state.whatsapp_last_message_at = Some(now); + self.save(); + } + Err(NotifError::SessionExpired) => { + diagnose::log("WhatsApp keep-alive: session expired; clearing opt-in"); + self.reset_whatsapp_opt_in(); + } + Err(error) => diagnose::log(format!("WhatsApp keep-alive failed: {error}")), + } + } + + pub fn send_test(&self) -> Result { + if !self.has_api_key() { + return Err(NotifError::NotConfigured); + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let body = format!("Claude Code Usage Monitor test message at unix={now}"); + let subject = "Claude Code Usage Monitor: test notification"; + let mut report = NotifReport::default(); + + if self.is_channel_active(Channel::Sms) && self.is_configured_for(Channel::Sms) { + match self.send_sms(&body) { + Ok(_) => report.sms = Some(Ok(())), + Err(error) => report.sms = Some(Err(error.to_string())), + } + } + if self.is_channel_active(Channel::Email) && self.is_configured_for(Channel::Email) { + match self.send_email(subject, &body) { + Ok(_) => report.email = Some(Ok(())), + Err(error) => report.email = Some(Err(error.to_string())), + } + } + if self.is_channel_active(Channel::WhatsApp) + && self.is_configured_for(Channel::WhatsApp) + { + if !self.is_whatsapp_opted_in() { + report.whatsapp = Some(Err( + "WhatsApp is not opted in. Use the menu to mark opt-in after sending the first message." + .to_string(), + )); + } else { + match self.send_whatsapp_session(&body) { + Ok(_) => report.whatsapp = Some(Ok(())), + Err(NotifError::SessionExpired) => { + report.whatsapp = Some(Err("WhatsApp session expired (24h window closed). Re-trigger opt-in.".to_string())); + } + Err(error) => report.whatsapp = Some(Err(error.to_string())), + } + } + } + + Ok(report) + } + + fn send_sms(&self, body: &str) -> Result<(), NotifError> { + let api_key = self.api_key()?; + let to = self + .config + .sms + .to + .as_deref() + .ok_or(NotifError::NotConfigured)?; + let shortcode = self + .config + .sms + .shortcode + .as_deref() + .ok_or(NotifError::NotConfigured)?; + + let payload = serde_json::json!({ + "channel": "SMS", + "request": { + "to": to, + "shortcode": shortcode, + "body": body, + } + }); + self.post_json(MESSANGI_NOTIFY_PATH, &api_key, &payload) + } + + fn send_email(&self, subject: &str, body: &str) -> Result<(), NotifError> { + let api_key = self.api_key()?; + let to = self + .config + .email + .to + .as_deref() + .ok_or(NotifError::NotConfigured)?; + let from = self + .config + .email + .from + .as_deref() + .ok_or(NotifError::NotConfigured)?; + let external_id = format!("ccum-{}", now_unix_secs()); + + let payload = serde_json::json!({ + "from": from, + "to": to, + "subject": subject, + "text": body, + "externalId": external_id, + }); + self.post_json(MESSANGI_EMAIL_PATH, &api_key, &payload) + } + + fn send_whatsapp_session(&self, body: &str) -> Result<(), NotifError> { + let api_key = self.api_key()?; + let from = self + .config + .whatsapp + .from + .as_deref() + .ok_or(NotifError::NotConfigured)?; + let to = self + .config + .whatsapp + .to + .as_deref() + .ok_or(NotifError::NotConfigured)?; + + let payload = serde_json::json!({ + "from": from, + "to": to, + "type": "text", + "body": body, + }); + self.post_json(MESSANGI_WHATSAPP_PATH, &api_key, &payload) + } + + fn api_key(&self) -> Result { + self.config + .messangi_api_key + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or(NotifError::NotConfigured) + } + + fn post_json(&self, path: &str, api_key: &str, payload: &serde_json::Value) -> Result<(), NotifError> { + let base = self + .config + .messangi_base_url + .as_deref() + .unwrap_or(MESSANGI_DEFAULT_BASE_URL); + let url = format!("{base}{path}"); + let agent = build_agent()?; + let response = agent + .post(&url) + .set("Authorization", &format!("Bearer {api_key}")) + .set("Content-Type", "application/json") + .send_json(payload); + match response { + Ok(_) => Ok(()), + Err(ureq::Error::Status(code, response)) => { + let detail = response + .into_string() + .unwrap_or_default(); + if is_session_expired(&detail) || code == 410 { + Err(NotifError::SessionExpired) + } else { + Err(NotifError::Http { code, detail }) + } + } + Err(error) => Err(NotifError::Network(error.to_string())), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Channel { + Sms, + Email, + WhatsApp, +} + +#[derive(Debug)] +pub enum NotifError { + NotConfigured, + SessionExpired, + Network(String), + Http { code: u16, detail: String }, +} + +impl std::fmt::Display for NotifError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NotifError::NotConfigured => f.write_str("notifier not configured"), + NotifError::SessionExpired => f.write_str("WhatsApp 24h session expired"), + NotifError::Network(s) => write!(f, "network error: {s}"), + NotifError::Http { code, detail } => { + write!(f, "HTTP {code}: {}", truncate(detail, 200)) + } + } + } +} + +#[derive(Debug, Default)] +pub struct NotifReport { + pub sms: Option>, + pub email: Option>, + pub whatsapp: Option>, +} + +fn notifier_file_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + }) + .join("claude-code-usage-monitor") + .join(NOTIFIER_FILE) +} + +fn build_agent() -> Result { + let tls = native_tls::TlsConnector::new().map_err(|e| NotifError::Network(e.to_string()))?; + Ok(ureq::AgentBuilder::new() + .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS)) + .tls_connector(std::sync::Arc::new(tls)) + .build()) +} + +fn now_unix_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}…", &s[..max]) + } +} + +fn is_session_expired(detail: &str) -> bool { + detail.to_ascii_lowercase().contains("24 hours") + || detail.to_ascii_lowercase().contains("re-engagement") + || detail.to_ascii_lowercase().contains("session window") +} + +fn humanize_provider(provider_id: &str) -> &'static str { + match provider_id { + "claude_code" => "Claude Code", + "codex" => "Codex", + "antigravity" => "Antigravity", + "opencode" => "OpenCode", + _ => "Usage", + } +} + +// Quiet the "unused" warning for the poller import alias; kept in case we +// reuse poll-level helpers later. +#[allow(dead_code)] +fn _force_link_pollar() { + let _ = poller::format_line; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::UsageData; + + fn make_data(claude_pct: f64) -> AppUsageData { + AppUsageData { + claude_code: Some(UsageData { + session: crate::models::UsageSection { + percentage: claude_pct, + resets_at: None, + }, + weekly: crate::models::UsageSection { + percentage: 0.0, + resets_at: None, + }, + weekly_label: None, + }), + ..Default::default() + } + } + + fn make_notifier_with_thresholds() -> Notifier { + let mut config = NotifierConfig::default(); + config.messangi_api_key = Some("test-key".to_string()); + let mut thresholds = std::collections::HashMap::new(); + thresholds.insert( + "claude_code".to_string(), + ProviderThresholds { + session: vec![80], + weekly: vec![], + }, + ); + config.thresholds = thresholds; + Notifier { + config, + state: NotifierState::default(), + notify_sms: false, + notify_email: false, + notify_whatsapp: false, + tracker: ThresholdTracker::default(), + file_path: std::env::temp_dir().join("ccum-notifier-test.json"), + } + } + + #[test] + fn threshold_rising_edge_marks_notified() { + let mut n = make_notifier_with_thresholds(); + n.check_and_notify(&make_data(50.0)); + // 50% < 80%, no notification + assert!(n.tracker.notified.is_empty()); + + n.check_and_notify(&make_data(85.0)); + // 85% crossed 80%, but no channels are enabled so sent=false. + // The internal tracker should still record the edge so a future + // enable won't re-notify immediately. + assert!(!n.tracker.notified.is_empty()); + } + + #[test] + fn threshold_falling_edge_clears_notified() { + let mut n = make_notifier_with_thresholds(); + n.check_and_notify(&make_data(85.0)); + assert!(!n.tracker.notified.is_empty()); + + n.check_and_notify(&make_data(70.0)); + assert!(n.tracker.notified.is_empty()); + } + + #[test] + fn no_alerts_without_api_key() { + let mut n = make_notifier_with_thresholds(); + n.config.messangi_api_key = None; + n.check_and_notify(&make_data(95.0)); + assert!(n.tracker.notified.is_empty()); + } + + #[test] + fn file_roundtrip_preserves_config_and_state() { + let path = std::env::temp_dir().join("ccum-notifier-roundtrip.json"); + let _ = std::fs::remove_file(&path); + let mut n = Notifier { + config: NotifierConfig::default(), + state: NotifierState { + whatsapp_opted_in_at: Some(1234), + whatsapp_last_message_at: Some(5678), + last_alert_at: None, + }, + notify_sms: true, + notify_email: false, + notify_whatsapp: true, + tracker: ThresholdTracker::default(), + file_path: path.clone(), + }; + n.config.messangi_api_key = Some("abc".to_string()); + n.config.thresholds.insert( + "claude_code".to_string(), + ProviderThresholds { + session: vec![80, 95], + weekly: vec![50], + }, + ); + n.save(); + + // Reload via the public API by re-opening the same path. + let content = std::fs::read_to_string(&path).expect("read file"); + let file: NotifierFile = serde_json::from_str(&content).expect("parse"); + assert_eq!(file.config.messangi_api_key.as_deref(), Some("abc")); + assert_eq!(file.state.whatsapp_opted_in_at, Some(1234)); + assert_eq!(file.notify_sms, Some(true)); + assert_eq!(file.notify_whatsapp, Some(true)); + let t = file + .config + .thresholds + .get("claude_code") + .expect("claude_code thresholds"); + assert_eq!(t.session, vec![80, 95]); + assert_eq!(t.weekly, vec![50]); + let _ = std::fs::remove_file(&path); + } +} diff --git a/src/window.rs b/src/window.rs index b14044e..05e82cc 100644 --- a/src/window.rs +++ b/src/window.rs @@ -4,6 +4,7 @@ use std::sync::{Mutex, MutexGuard}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; +use std::os::windows::process::CommandExt; use windows::core::PCWSTR; use windows::Win32::Foundation::*; use windows::Win32::Graphics::Gdi::*; @@ -23,6 +24,7 @@ use crate::native_interop::{ self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; +use crate::notifier; use crate::poller; use crate::theme; use crate::tray_icon; @@ -79,6 +81,8 @@ struct AppState { data: Option, + notifier: notifier::Notifier, + poll_interval_ms: u32, retry_count: u32, force_notify_auth_error: bool, @@ -137,6 +141,13 @@ const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; const IDM_MODEL_ANTIGRAVITY: u16 = 62; +const IDM_NOTIFIER_TOGGLE_SMS: u16 = 70; +const IDM_NOTIFIER_TOGGLE_EMAIL: u16 = 71; +const IDM_NOTIFIER_TOGGLE_WHATSAPP: u16 = 72; +const IDM_NOTIFIER_TEST: u16 = 73; +const IDM_NOTIFIER_OPEN_CONFIG: u16 = 74; +const IDM_NOTIFIER_RELOAD_CONFIG: u16 = 75; +const IDM_NOTIFIER_OPTIN_WHATSAPP: u16 = 76; const IDM_MODEL_OPENCODE: u16 = 63; const WM_DPICHANGED_MSG: u32 = 0x02E0; @@ -1401,6 +1412,7 @@ pub fn run() { show_antigravity: settings.show_antigravity, show_opencode: settings.show_opencode, data: None, + notifier: notifier::Notifier::load(), poll_interval_ms: settings.poll_interval_ms, retry_count: 0, force_notify_auth_error: false, @@ -1933,6 +1945,9 @@ fn do_poll(send_hwnd: SendHwnd) { } s.data = Some(data); + if let Some(data_ref) = s.data.as_ref() { + s.notifier.check_and_notify(data_ref); + } s.last_poll_ok = true; refresh_usage_texts(s); @@ -2225,6 +2240,21 @@ fn tray_reposition_is_suppressed() -> bool { } } +fn show_balloon(hwnd: HWND, title: &str, body: &str) { + tray_icon::notify_balloon(hwnd, tray_icon::TrayIconKind::Claude, title, body); +} + +fn default_notifier_json() -> String { + serde_json::to_string_pretty(¬ifier::NotifierFile { + config: notifier::NotifierConfig::default(), + state: notifier::NotifierState::default(), + notify_sms: Some(false), + notify_email: Some(false), + notify_whatsapp: Some(false), + }) + .unwrap_or_else(|_| "{}".to_string()) +} + fn position_at_taskbar() { refresh_dpi(); // Drop the app-state lock before any Win32 call that may synchronously @@ -2863,6 +2893,194 @@ unsafe extern "system" fn wnd_proc( id if id == tray_icon::IDM_TOGGLE_WIDGET => { toggle_widget_visibility(hwnd); } + IDM_NOTIFIER_TOGGLE_SMS => { + let new_state = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + let current = s.notifier.is_channel_active(notifier::Channel::Sms); + s.notifier + .set_channel_active(notifier::Channel::Sms, !current); + !current + } else { + false + } + }; + diagnose::log(format!("notifier SMS toggled -> {new_state}")); + } + IDM_NOTIFIER_TOGGLE_EMAIL => { + let new_state = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + let current = s.notifier.is_channel_active(notifier::Channel::Email); + s.notifier + .set_channel_active(notifier::Channel::Email, !current); + !current + } else { + false + } + }; + diagnose::log(format!("notifier email toggled -> {new_state}")); + } + IDM_NOTIFIER_TOGGLE_WHATSAPP => { + let new_state = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + let current = + s.notifier.is_channel_active(notifier::Channel::WhatsApp); + s.notifier + .set_channel_active(notifier::Channel::WhatsApp, !current); + !current + } else { + false + } + }; + diagnose::log(format!("notifier WhatsApp toggled -> {new_state}")); + } + IDM_NOTIFIER_TEST => { + let report = { + let mut state = lock_state(); + state + .as_mut() + .map(|s| s.notifier.send_test()) + }; + match report { + Some(Ok(report)) => { + let mut parts = Vec::new(); + if let Some(r) = report.sms { + parts.push(format!( + "SMS: {}", + match r { + Ok(_) => "ok".to_string(), + Err(e) => format!("error: {e}"), + } + )); + } + if let Some(r) = report.email { + parts.push(format!( + "Email: {}", + match r { + Ok(_) => "ok".to_string(), + Err(e) => format!("error: {e}"), + } + )); + } + if let Some(r) = report.whatsapp { + parts.push(format!( + "WhatsApp: {}", + match r { + Ok(_) => "ok".to_string(), + Err(e) => format!("error: {e}"), + } + )); + } + let summary = if parts.is_empty() { + "No active channels. Enable SMS/Email/WhatsApp in this menu first.".to_string() + } else { + parts.join(" | ") + }; + show_balloon(hwnd, "Notifier test", &summary); + diagnose::log(format!("notifier test: {summary}")); + } + Some(Err(error)) => { + show_balloon(hwnd, "Notifier test failed", &error.to_string()); + diagnose::log(format!("notifier test failed: {error}")); + } + None => {} + } + } + IDM_NOTIFIER_OPEN_CONFIG => { + let path = dirs::config_dir() + .unwrap_or_else(|| { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + }) + .join("claude-code-usage-monitor") + .join("notifier.json"); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, default_notifier_json()); + let _ = std::process::Command::new("cmd") + .arg("/c") + .arg("start") + .arg("") + .arg(&path) + .creation_flags(0x08000000) + .spawn(); + diagnose::log(format!( + "opened notifier config at {}", + path.display() + )); + } + IDM_NOTIFIER_RELOAD_CONFIG => { + let (had_key, sms_active, email_active, whatsapp_active) = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.notifier.reload(); + ( + s.notifier.has_api_key(), + s.notifier.is_channel_active(notifier::Channel::Sms), + s.notifier.is_channel_active(notifier::Channel::Email), + s.notifier.is_channel_active(notifier::Channel::WhatsApp), + ) + } else { + (false, false, false, false) + } + }; + let summary = format!( + "api_key={} sms={} email={} whatsapp={}", + if had_key { "yes" } else { "no" }, + sms_active, + email_active, + whatsapp_active + ); + show_balloon(hwnd, "Notifier config reloaded", &summary); + diagnose::log(format!("notifier reload: {summary}")); + } + IDM_NOTIFIER_OPTIN_WHATSAPP => { + let (api_key, configured) = { + let state = lock_state(); + if let Some(s) = state.as_ref() { + ( + s.notifier.has_api_key(), + s.notifier.is_configured_for(notifier::Channel::WhatsApp), + ) + } else { + (false, false) + } + }; + if !api_key { + show_balloon( + hwnd, + "WhatsApp opt-in", + "Set messangiApiKey in notifier.json first.", + ); + } else if !configured { + show_balloon( + hwnd, + "WhatsApp opt-in", + "Set whatsapp.from and whatsapp.to in notifier.json first.", + ); + } else { + let (msg, opted) = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.notifier.mark_whatsapp_opted_in(); + ( + "WhatsApp opted in. Send a first message from your phone to the configured sender, then keep an eye on the log to confirm delivery.".to_string(), + true, + ) + } else { + (String::new(), false) + } + }; + if opted { + show_balloon(hwnd, "WhatsApp opted in", &msg); + diagnose::log("notifier: WhatsApp opt-in marked"); + } + } + } _ => {} } LRESULT(0) @@ -3117,6 +3335,109 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(language_label.as_ptr()), ); + let notifier_menu = CreatePopupMenu().unwrap(); + + let (sms_active, email_active, whatsapp_active, whatsapp_opted_in, has_api_key) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => ( + s.notifier.is_channel_active(notifier::Channel::Sms), + s.notifier.is_channel_active(notifier::Channel::Email), + s.notifier.is_channel_active(notifier::Channel::WhatsApp), + s.notifier.is_whatsapp_opted_in(), + s.notifier.has_api_key(), + ), + None => (false, false, false, false, false), + } + }; + + let sms_label = native_interop::wide_str(strings.notifier_sms); + let sms_flags = if sms_active { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; + let _ = AppendMenuW( + notifier_menu, + sms_flags, + IDM_NOTIFIER_TOGGLE_SMS as usize, + PCWSTR::from_raw(sms_label.as_ptr()), + ); + + let email_label = native_interop::wide_str(strings.notifier_email); + let email_flags = if email_active { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; + let _ = AppendMenuW( + notifier_menu, + email_flags, + IDM_NOTIFIER_TOGGLE_EMAIL as usize, + PCWSTR::from_raw(email_label.as_ptr()), + ); + + let whatsapp_label = native_interop::wide_str(strings.notifier_whatsapp); + let whatsapp_flags = if whatsapp_active { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; + let _ = AppendMenuW( + notifier_menu, + whatsapp_flags, + IDM_NOTIFIER_TOGGLE_WHATSAPP as usize, + PCWSTR::from_raw(whatsapp_label.as_ptr()), + ); + + if whatsapp_active { + let optin_label = native_interop::wide_str(strings.notifier_whatsapp_optin); + let _ = AppendMenuW( + notifier_menu, + MENU_ITEM_FLAGS(0), + IDM_NOTIFIER_OPTIN_WHATSAPP as usize, + PCWSTR::from_raw(optin_label.as_ptr()), + ); + if whatsapp_opted_in { + let check_label = native_interop::wide_str(strings.notifier_whatsapp_opted_in); + let _ = AppendMenuW( + notifier_menu, + MF_GRAYED, + 0, + PCWSTR::from_raw(check_label.as_ptr()), + ); + } + } + + let _ = AppendMenuW(notifier_menu, MF_SEPARATOR, 0, PCWSTR::null()); + + let test_label = native_interop::wide_str(strings.notifier_test); + let test_flags = if has_api_key { + MENU_ITEM_FLAGS(0) + } else { + MF_GRAYED + }; + let _ = AppendMenuW( + notifier_menu, + test_flags, + IDM_NOTIFIER_TEST as usize, + PCWSTR::from_raw(test_label.as_ptr()), + ); + + let open_cfg_label = native_interop::wide_str(strings.notifier_open_config); + let _ = AppendMenuW( + notifier_menu, + MENU_ITEM_FLAGS(0), + IDM_NOTIFIER_OPEN_CONFIG as usize, + PCWSTR::from_raw(open_cfg_label.as_ptr()), + ); + + let reload_label = native_interop::wide_str(strings.notifier_reload); + let _ = AppendMenuW( + notifier_menu, + MENU_ITEM_FLAGS(0), + IDM_NOTIFIER_RELOAD_CONFIG as usize, + PCWSTR::from_raw(reload_label.as_ptr()), + ); + + let notifier_label = native_interop::wide_str(strings.notifier_menu); + let _ = AppendMenuW( + settings_menu, + MF_POPUP, + notifier_menu.0 as usize, + PCWSTR::from_raw(notifier_label.as_ptr()), + ); + + let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null()); + let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null()); let version_label = From 10fe480904d872aedceb442b9344772402cce57d Mon Sep 17 00:00:00 2001 From: Jesus Torres Date: Fri, 3 Jul 2026 20:31:33 -0600 Subject: [PATCH 3/3] Fix rate-limit dropping alerts and rework keep-alive to daily time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two behavioral fixes based on user feedback: 1. **Rate-limit no longer drops alerts** (the main fix the user asked for). When multiple thresholds (e.g. 80/90/100) are crossed inside the same poll, the rate limit could block dispatching the second/third. The old code marked the threshold as notified before dispatching, so a rate-limited alert was lost forever. The flag is now set only after the dispatch was actually attempted, so the next poll retries the skipped threshold. New unit test ate_limited_alert_is_retried_on_next_poll covers this. 2. **keep_alive_hours (interval) → keep_alive (time of day).** The user pointed out that 'every 23 hours' is unintuitive for a daily reminder. The new keep_alive field is a HH:MM string in local time; the widget sends the keep-alive at that time every day. Implementation: GetLocalTime (Windows API) to read the current local time, parse the HH:MM target, compare, and track whatsapp_last_keep_alive_date (year*10000+month*100+day) so we send at most once per day. Accepts H, H:MM, HH:MM, and HH:MM:SS formats. Omit the field to disable keep-alives entirely. Requires the Win32_System_SystemInformation and Win32_System_Time features on the windows crate. Docs updated. All 5 unit tests pass; build is clean. --- Cargo.toml | 2 + README.md | 11 ++-- src/notifier.rs | 150 +++++++++++++++++++++++++++++++++++++++++------- src/window.rs | 5 +- 4 files changed, 141 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index aed6c8a..44a0978 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,8 @@ features = [ "Win32_Globalization", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", + "Win32_System_SystemInformation", + "Win32_System_Time", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Accessibility", diff --git a/README.md b/README.md index e468839..28e3e81 100644 --- a/README.md +++ b/README.md @@ -206,14 +206,17 @@ The file is created with sensible defaults the first time you open "antigravity": { "session": [80], "weekly": [100] }, "opencode": { "session": [80], "weekly": [100] } }, - "keepAliveHours": 23, - "minAlertIntervalSecs": 60 + "minAlertIntervalSecs": 60, + "keepAlive": "23:00" } ``` The `messangiApiKey` is your **JWT** (Preferences → API in the Messangi -admin UI). `keepAliveHours` controls how often the widget sends a -keep-alive to your WhatsApp number to hold the 24 h session open. +admin UI). `keepAlive` is the local time of day (24-hour `HH:MM`) at +which the widget sends a daily keep-alive message to your WhatsApp +number to hold the 24 h session open. Set it to a quiet hour (e.g. +`"23:00"`) and reply to the keep-alive within 24 h to keep the window +alive. Omit the field to disable keep-alives entirely. ### Context menu diff --git a/src/notifier.rs b/src/notifier.rs index f3957e3..218a143 100644 --- a/src/notifier.rs +++ b/src/notifier.rs @@ -13,7 +13,6 @@ const MESSANGI_DEFAULT_BASE_URL: &str = "https://elastic.messangi.me"; const MESSANGI_NOTIFY_PATH: &str = "/oberyn/v2/notification"; const MESSANGI_EMAIL_PATH: &str = "/crowsnest/v2/emails"; const MESSANGI_WHATSAPP_PATH: &str = "/balerion/v3/messages"; -const DEFAULT_KEEP_ALIVE_HOURS: u64 = 23; const DEFAULT_MIN_ALERT_SECS: u64 = 60; const HTTP_TIMEOUT_SECS: u64 = 15; @@ -32,7 +31,7 @@ pub struct NotifierConfig { #[serde(default)] pub thresholds: HashMap, #[serde(default)] - pub keep_alive_hours: Option, + pub keep_alive: Option, #[serde(default)] pub min_alert_interval_secs: Option, } @@ -82,6 +81,8 @@ pub struct NotifierState { #[serde(default)] pub whatsapp_last_message_at: Option, #[serde(default)] + pub whatsapp_last_keep_alive_date: Option, + #[serde(default)] pub last_alert_at: Option, } @@ -288,8 +289,12 @@ impl Notifier { .unwrap_or(false); let t = threshold as f64; if current >= t && !was_notified { - self.tracker.notified.insert(notify_key, true); - self.maybe_dispatch_alert(provider_id, window_name, threshold, current); + // Try to dispatch, and only mark the threshold as notified if + // the dispatch was actually attempted (not rate-limited). If + // the rate limit blocked us, the next poll will retry. + if self.maybe_dispatch_alert(provider_id, window_name, threshold, current) { + self.tracker.notified.insert(notify_key, true); + } } else if current < t && was_notified { self.tracker.notified.remove(¬ify_key); } @@ -301,13 +306,18 @@ impl Notifier { /// Dispatch an alert to all enabled channels, gated by the /// `min_alert_interval_secs` rate limit. + /// + /// Returns `true` when the dispatch was actually attempted (i.e. the rate + /// limit allowed it through), so the caller can mark the threshold as + /// notified. Returns `false` when the rate limit blocked the dispatch — + /// the next poll will retry the same threshold. fn maybe_dispatch_alert( &mut self, provider_id: &str, window_name: &str, threshold: u8, current: f64, - ) { + ) -> bool { let min_interval = Duration::from_secs( self.config .min_alert_interval_secs @@ -316,13 +326,14 @@ impl Notifier { if let Some(last) = self.state.last_alert_at { let elapsed = now_unix_secs().saturating_sub(last); if elapsed < min_interval.as_secs() as i64 { - return; + return false; } } if self.dispatch_alert(provider_id, window_name, threshold, current) { self.state.last_alert_at = Some(now_unix_secs()); self.save(); } + true } /// Send a notification to all enabled channels. Returns true if at least one @@ -384,25 +395,35 @@ impl Notifier { } fn maybe_send_whatsapp_keep_alive(&mut self) { - let keep_alive_hours = self - .config - .keep_alive_hours - .unwrap_or(DEFAULT_KEEP_ALIVE_HOURS); - let now = now_unix_secs(); - let last = self - .state - .whatsapp_last_message_at - .unwrap_or(self.state.whatsapp_opted_in_at.unwrap_or(now)); - let elapsed_hours = (now - last) / 3600; - if elapsed_hours < keep_alive_hours as i64 { + let Some(keep_alive) = self.config.keep_alive.as_deref() else { + return; + }; + let Some((target_hour, target_minute)) = parse_clock_time(keep_alive) else { + diagnose::log(format!( + "WhatsApp keep-alive: invalid keep_alive value '{keep_alive}', expected HH:MM" + )); + return; + }; + + let Some((year, month, day, hour, minute)) = current_local_time() else { + diagnose::log("WhatsApp keep-alive: unable to read local time"); + return; + }; + let date_key = date_key(year, month, day); + if self.state.whatsapp_last_keep_alive_date == Some(date_key) { return; } - let body = format!( - "Claude Code Usage Monitor keep-alive. Reply with any message to confirm you still want alerts." - ); - match self.send_whatsapp_session(&body) { + let now_minutes = hour as i32 * 60 + minute as i32; + let target_minutes = target_hour as i32 * 60 + target_minute as i32; + if now_minutes < target_minutes { + return; + } + + let body = "Claude Code Usage Monitor keep-alive. Reply with any message to confirm you still want alerts."; + match self.send_whatsapp_session(body) { Ok(_) => { - self.state.whatsapp_last_message_at = Some(now); + self.state.whatsapp_last_message_at = Some(now_unix_secs()); + self.state.whatsapp_last_keep_alive_date = Some(date_key); self.save(); } Err(NotifError::SessionExpired) => { @@ -659,6 +680,48 @@ fn humanize_provider(provider_id: &str) -> &'static str { } } +/// Parse a "HH:MM" string into (hour, minute). Accepts "H", "H:MM", "HH:MM", +/// and "HH:MM:SS" (the seconds are ignored). Returns None for invalid input. +fn parse_clock_time(value: &str) -> Option<(u32, u32)> { + let mut parts = value.split(':'); + let hour: u32 = parts.next()?.trim().parse().ok()?; + let minute: u32 = parts.next()?.trim().parse().ok()?; + if parts.next().is_some() { + // Discard the seconds component if the user passed "HH:MM:SS". + } + if hour > 23 || minute > 59 { + return None; + } + Some((hour, minute)) +} + +/// Returns the current local time as (year, month, day, hour, minute). +/// Uses GetLocalTime on Windows; falls back to None on other platforms. +fn current_local_time() -> Option<(u32, u32, u32, u32, u32)> { + #[cfg(target_os = "windows")] + { + use windows::Win32::System::SystemInformation::GetLocalTime; + unsafe { + let st = GetLocalTime(); + Some(( + st.wYear as u32, + st.wMonth as u32, + st.wDay as u32, + st.wHour as u32, + st.wMinute as u32, + )) + } + } + #[cfg(not(target_os = "windows"))] + { + None + } +} + +fn date_key(year: u32, month: u32, day: u32) -> u32 { + year * 10000 + month * 100 + day +} + // Quiet the "unused" warning for the poller import alias; kept in case we // reuse poll-level helpers later. #[allow(dead_code)] @@ -735,6 +798,48 @@ mod tests { assert!(n.tracker.notified.is_empty()); } + #[test] + fn rate_limited_alert_is_retried_on_next_poll() { + // With a 60s rate limit, when a single poll crosses multiple + // thresholds, only the first one dispatches. The others stay + // un-notified and are retried on the next poll. After the rate + // limit expires, all of them should be flagged as notified. + let mut n = make_notifier_with_thresholds(); + // Set three thresholds at 80, 90, 100 with a long rate limit so + // the second/third are guaranteed to be rate-limited on the first + // poll, then allowed on the second. + n.config.min_alert_interval_secs = Some(3600); + n.config + .thresholds + .get_mut("claude_code") + .unwrap() + .session = vec![80, 90, 100]; + + // Pretend an alert was just sent so the rate limit is active. + n.state.last_alert_at = Some(now_unix_secs()); + + // Poll 1: usage jumps from 50% to 95% (crosses 80 and 90, not 100). + n.check_and_notify(&make_data(95.0)); + // Both crossings were detected but rate-limited, so neither flag is set. + assert!(n.tracker.notified.is_empty(), "rate-limited alerts must not mark notified"); + + // Simulate the rate limit expiring. + n.state.last_alert_at = Some(now_unix_secs() - 3601); + + // Poll 2: usage is still 95%. The 80 and 90 flags are still not set, + // so they should be detected again and dispatched. + n.check_and_notify(&make_data(95.0)); + // After the rate limit expires, both 80 and 90 should be marked notified. + let notified: std::collections::HashSet<(String, String, u8)> = n + .tracker + .notified + .keys() + .cloned() + .collect(); + assert!(notified.contains(&("claude_code".to_string(), "session".to_string(), 80))); + assert!(notified.contains(&("claude_code".to_string(), "session".to_string(), 90))); + } + #[test] fn no_alerts_without_api_key() { let mut n = make_notifier_with_thresholds(); @@ -752,6 +857,7 @@ mod tests { state: NotifierState { whatsapp_opted_in_at: Some(1234), whatsapp_last_message_at: Some(5678), + whatsapp_last_keep_alive_date: None, last_alert_at: None, }, notify_sms: true, diff --git a/src/window.rs b/src/window.rs index 05e82cc..306ba1c 100644 --- a/src/window.rs +++ b/src/window.rs @@ -2246,7 +2246,10 @@ fn show_balloon(hwnd: HWND, title: &str, body: &str) { fn default_notifier_json() -> String { serde_json::to_string_pretty(¬ifier::NotifierFile { - config: notifier::NotifierConfig::default(), + config: notifier::NotifierConfig { + keep_alive: Some("23:00".to_string()), + ..Default::default() + }, state: notifier::NotifierState::default(), notify_sms: Some(false), notify_email: Some(false),