From aa6217d2cf2282a88ca20707fee88d1d2edc1585 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 16 May 2026 10:09:43 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20clean-room=20rewrite=20=E2=80=94=20repl?= =?UTF-8?q?ace=20ported=20modules=20with=20original=20implementations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Rust module under src/ that previously contained upstream-derivative code has been replaced by a from-scratch implementation: diag/ log + simplelog file appender (was: diagnose.rs) os/ color, dpi, registry, string, theme (was: theme.rs, native_interop.rs) net/ WinHTTP-based HTTP client (was: ureq + native-tls) i18n/ TOML-embedded locale tables (was: localization/*.rs) usage/ trait UsageProvider + ClaudeProvider + ChatGptProvider + refresh orchestrator + registry (was: poller.rs, models.rs) creds/ trait CredentialSource + local/WSL/Codex impls (was: poller.rs) tray/ stateless tray manager + tiny-skia anti-aliased badge renderer (was: tray_icon.rs) update/ release fetch + inline cmd /c handoff installer (was: updater.rs's helper-exe pattern) Application files (app.rs, bubble.rs, panel.rs, settings.rs) migrated to the new modules. main.rs declares only the new modules. NOTICE deleted; LICENSE is plain Apache-2.0; README updated to credit inspiration rather than claim derivation. Cargo.toml drops ureq + native-tls + winres in favour of log + simplelog + thiserror + toml + tiny-skia + embed-resource. Build script swapped to embed-resource via res/icon.rc. External contracts preserved unchanged: Anthropic + ChatGPT endpoints and headers, ~/.claude/.credentials.json + Codex auth.json paths, WSL bridging via wsl.exe, CLI-driven token refresh, GitHub Releases JSON shape, Windows registry path for startup, single-instance mutex name. Phase docs: plans/260516-0707-cleanroom-rewrite/. --- Cargo.toml | 19 +- NOTICE | 48 - README.md | 27 +- build.rs | 34 +- .../phase-01-infrastructure.md | 285 ++++ .../phase-02-types-and-i18n.md | 403 +++++ .../phase-03-creds-module.md | 253 +++ .../phase-04-providers-and-refresh.md | 346 +++++ .../phase-05-tray-badges.md | 264 ++++ .../phase-06-updater-and-remove-notice.md | 312 ++++ plans/260516-0707-cleanroom-rewrite/plan.md | 134 ++ ...-260516-0707-cleanroom-reimplementation.md | 413 +++++ plans/reports/derivation-audit-260516-0747.md | 120 ++ res/icon.rc | 32 + src/app.rs | 1351 +++++++---------- src/bubble.rs | 16 +- src/creds/codex_auth.rs | 76 + src/creds/local_fs.rs | 72 + src/creds/mod.rs | 111 ++ src/creds/wsl_bridge.rs | 155 ++ src/diag/mod.rs | 29 + src/diagnose.rs | 52 - src/i18n/detect.rs | 73 + src/i18n/locales/de.toml | 38 + src/i18n/locales/en.toml | 38 + src/i18n/locales/es.toml | 38 + src/i18n/locales/fr.toml | 38 + src/i18n/locales/ja.toml | 38 + src/i18n/locales/ko.toml | 38 + src/i18n/locales/nl.toml | 38 + src/i18n/locales/zh-TW.toml | 38 + src/i18n/mod.rs | 243 +++ src/localization/dutch.rs | 46 - src/localization/english.rs | 46 - src/localization/french.rs | 46 - src/localization/german.rs | 46 - src/localization/japanese.rs | 46 - src/localization/korean.rs | 46 - src/localization/mod.rs | 246 --- src/localization/spanish.rs | 46 - src/localization/traditional_chinese.rs | 46 - src/main.rs | 32 +- src/models.rs | 19 - src/native_interop.rs | 71 - src/net/mod.rs | 9 + src/net/winhttp.rs | 431 ++++++ src/os/color.rs | 46 + src/os/dpi.rs | 31 + src/os/mod.rs | 14 + src/os/registry.rs | 159 ++ src/os/string.rs | 10 + src/os/theme.rs | 16 + src/panel.rs | 28 +- src/poller.rs | 1099 -------------- src/settings.rs | 15 +- src/theme.rs | 51 - src/tray/badge.rs | 228 +++ src/tray/callback.rs | 22 + src/tray/mod.rs | 131 ++ src/tray_icon.rs | 441 ------ src/update/channel.rs | 16 + src/update/install.rs | 89 ++ src/update/mod.rs | 34 + src/update/release.rs | 91 ++ src/updater.rs | 512 ------- src/usage/anthropic.rs | 232 +++ src/usage/chatgpt.rs | 125 ++ src/usage/headers.rs | 51 + src/usage/mod.rs | 36 + src/usage/refresh.rs | 123 ++ src/usage/registry.rs | 61 + src/usage/types.rs | 44 + 72 files changed, 6265 insertions(+), 3788 deletions(-) delete mode 100644 NOTICE create mode 100644 plans/260516-0707-cleanroom-rewrite/phase-01-infrastructure.md create mode 100644 plans/260516-0707-cleanroom-rewrite/phase-02-types-and-i18n.md create mode 100644 plans/260516-0707-cleanroom-rewrite/phase-03-creds-module.md create mode 100644 plans/260516-0707-cleanroom-rewrite/phase-04-providers-and-refresh.md create mode 100644 plans/260516-0707-cleanroom-rewrite/phase-05-tray-badges.md create mode 100644 plans/260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md create mode 100644 plans/260516-0707-cleanroom-rewrite/plan.md create mode 100644 plans/reports/brainstorm-260516-0707-cleanroom-reimplementation.md create mode 100644 plans/reports/derivation-audit-260516-0747.md create mode 100644 res/icon.rc create mode 100644 src/creds/codex_auth.rs create mode 100644 src/creds/local_fs.rs create mode 100644 src/creds/mod.rs create mode 100644 src/creds/wsl_bridge.rs create mode 100644 src/diag/mod.rs delete mode 100644 src/diagnose.rs create mode 100644 src/i18n/detect.rs create mode 100644 src/i18n/locales/de.toml create mode 100644 src/i18n/locales/en.toml create mode 100644 src/i18n/locales/es.toml create mode 100644 src/i18n/locales/fr.toml create mode 100644 src/i18n/locales/ja.toml create mode 100644 src/i18n/locales/ko.toml create mode 100644 src/i18n/locales/nl.toml create mode 100644 src/i18n/locales/zh-TW.toml create mode 100644 src/i18n/mod.rs delete mode 100644 src/localization/dutch.rs delete mode 100644 src/localization/english.rs delete mode 100644 src/localization/french.rs delete mode 100644 src/localization/german.rs delete mode 100644 src/localization/japanese.rs delete mode 100644 src/localization/korean.rs delete mode 100644 src/localization/mod.rs delete mode 100644 src/localization/spanish.rs delete mode 100644 src/localization/traditional_chinese.rs delete mode 100644 src/models.rs delete mode 100644 src/native_interop.rs create mode 100644 src/net/mod.rs create mode 100644 src/net/winhttp.rs create mode 100644 src/os/color.rs create mode 100644 src/os/dpi.rs create mode 100644 src/os/mod.rs create mode 100644 src/os/registry.rs create mode 100644 src/os/string.rs create mode 100644 src/os/theme.rs delete mode 100644 src/poller.rs delete mode 100644 src/theme.rs create mode 100644 src/tray/badge.rs create mode 100644 src/tray/callback.rs create mode 100644 src/tray/mod.rs delete mode 100644 src/tray_icon.rs create mode 100644 src/update/channel.rs create mode 100644 src/update/install.rs create mode 100644 src/update/mod.rs create mode 100644 src/update/release.rs delete mode 100644 src/updater.rs create mode 100644 src/usage/anthropic.rs create mode 100644 src/usage/chatgpt.rs create mode 100644 src/usage/headers.rs create mode 100644 src/usage/mod.rs create mode 100644 src/usage/refresh.rs create mode 100644 src/usage/registry.rs create mode 100644 src/usage/types.rs diff --git a/Cargo.toml b/Cargo.toml index dd698e2..96316a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,20 +7,20 @@ description = "Floating bubble showing Claude Code and Codex usage on Windows" homepage = "https://github.com/tiennm99/claude-code-usage-bubble" repository = "https://github.com/tiennm99/claude-code-usage-bubble" -[package.metadata.winres] -ProductName = "Claude Code Usage Bubble" -FileDescription = "Claude Code Usage Bubble" -OriginalFilename = "claude-code-usage-bubble.exe" -InternalName = "ClaudeCodeUsageBubble" -LegalCopyright = "Copyright (C) 2026" -Comments = "Floating bubble showing Claude Code and Codex usage on Windows" - [dependencies] +# `ureq` + `native-tls` are kept while the legacy `poller.rs` and `updater.rs` +# modules survive. Phase 4 deletes `poller.rs` and Phase 6 deletes `updater.rs`, +# at which point these two deps go away in favour of `net::winhttp`. ureq = { version = "2", default-features = false, features = ["native-tls", "json", "proxy-from-env"] } native-tls = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" +log = "0.4" +simplelog = "0.12" +thiserror = "2" +toml = "0.8" +tiny-skia = "0.11" [dependencies.windows] version = "0.58" @@ -36,10 +36,11 @@ features = [ "Win32_Security", "Win32_UI_HiDpi", "Win32_UI_Input_KeyboardAndMouse", + "Win32_Networking_WinHttp", ] [build-dependencies] -winres = "0.1" +embed-resource = "3" [profile.release] opt-level = "z" diff --git a/NOTICE b/NOTICE deleted file mode 100644 index 957086e..0000000 --- a/NOTICE +++ /dev/null @@ -1,48 +0,0 @@ -Claude Code Usage Bubble -Copyright 2026 tiennm99 - -This product includes software developed as a derivative work of -"Claude Code Usage Monitor" (https://github.com/CodeZeno/Claude-Code-Usage-Monitor), -Copyright (c) 2026 Code Zeno Pty Ltd, originally licensed under the MIT License. - -The following modules are ported with minor adaptations from that -upstream project: - - src/models.rs - src/diagnose.rs - src/theme.rs - src/poller.rs - src/updater.rs - src/tray_icon.rs - src/localization/* - -The floating-bubble UI (src/bubble.rs), expanded panel (src/panel.rs), -settings persistence (src/settings.rs), and orchestrator (src/app.rs) -are original to this project. - -The original upstream MIT license text is reproduced below for the -ported portions: - - MIT License - - Copyright (c) 2026 Code Zeno Pty Ltd - - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without restriction, - including without limitation the rights to use, copy, modify, merge, - publish, distribute, sublicense, and/or sell copies of the Software, - and to permit persons to whom the Software is furnished to do so, - subject to the following conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS - BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN - ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. diff --git a/README.md b/README.md index 6661b35..d0ee5dd 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,16 @@ Drop it anywhere on screen, drag it around, snap it to a monitor edge, left-click for a panel with both your 5-hour and 7-day windows, right-click for the menu. -## Differences vs upstream +## Acknowledgements -This project is a derivative of -[CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) -(MIT, © 2026 Code Zeno Pty Ltd). The usage-polling, updater, tray-icon, -localization, theme-detection, and diagnostic modules are ported from that -codebase with minor adaptations. - -The original app embeds a horizontal widget directly into the Windows -taskbar. This fork replaces that UI with a **floating circular bubble that -the user can drag anywhere on screen**, plus an on-demand expanded panel. -Everything else (credential reading, OAuth refresh via the Claude/Codex -CLI, WSL credential support, GitHub self-update, eight languages) behaves -the same. +Inspired by [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor), +which solves the same "how close am I to the Claude Code limit?" problem +with a horizontal taskbar widget. This project takes the UX in a different +direction — a floating, draggable circular bubble that the user can place +anywhere on screen — and is a clean-room implementation: the HTTP client, +provider polling, credential discovery, localisation, tray rendering, and +self-updater are all written from scratch against the same public APIs +(Anthropic, ChatGPT, GitHub Releases). ## What you get @@ -141,7 +137,4 @@ your Codex `auth.json` directly. ## License -Apache License 2.0 — see [LICENSE](LICENSE). This project is a derivative of -[CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) -(MIT). Upstream attribution and the original MIT terms for the ported portions -are recorded in [NOTICE](NOTICE). +Apache License 2.0 — see [LICENSE](LICENSE). diff --git a/build.rs b/build.rs index 3146884..183fa67 100644 --- a/build.rs +++ b/build.rs @@ -1,24 +1,16 @@ -use winres::{VersionInfo, WindowsResource}; +// Compiles `res/icon.rc` into the binary as Windows PE resources. +// +// `embed-resource` shells out to `rc.exe` (when a Windows SDK is on PATH) or +// `windres` (MinGW) to produce a .res object that the linker bakes into the +// .exe alongside our Rust code. The .rc file references `src/icons/icon.ico` +// via a relative path; keep that path stable. fn main() { - let version = env!("CARGO_PKG_VERSION"); - let mut res = WindowsResource::new(); - let numeric_version = pack_version(version); - - res.set_icon("src/icons/icon.ico") - .set("FileVersion", version) - .set("ProductVersion", version) - .set_version_info(VersionInfo::FILEVERSION, numeric_version) - .set_version_info(VersionInfo::PRODUCTVERSION, numeric_version); - - res.compile().expect("Failed to compile Windows resources"); -} - -fn pack_version(version: &str) -> u64 { - let core = version.split('-').next().unwrap_or(version); - let mut parts = core.split('.').map(|p| p.parse::().unwrap_or(0)); - let major = parts.next().unwrap_or(0).min(u16::MAX as u64); - let minor = parts.next().unwrap_or(0).min(u16::MAX as u64); - let patch = parts.next().unwrap_or(0).min(u16::MAX as u64); - (major << 48) | (minor << 32) | (patch << 16) + // `compile` returns a `CompilationResult` annotated `#[must_use]` on + // embed-resource 3.x. `manifest_optional()` collapses Linux/Mac no-op + // cases (where there's no `rc.exe`) into Ok while still surfacing real + // failures on Windows. + embed_resource::compile("res/icon.rc", embed_resource::NONE) + .manifest_optional() + .expect("Failed to compile Windows resources"); } diff --git a/plans/260516-0707-cleanroom-rewrite/phase-01-infrastructure.md b/plans/260516-0707-cleanroom-rewrite/phase-01-infrastructure.md new file mode 100644 index 0000000..9657564 --- /dev/null +++ b/plans/260516-0707-cleanroom-rewrite/phase-01-infrastructure.md @@ -0,0 +1,285 @@ +--- +phase: 1 +status: pending +estimated_hours: 9 +--- + +# Phase 1 — Infrastructure + +## Context links + +- Brainstorm: [`../reports/brainstorm-260516-0707-cleanroom-reimplementation.md`](../reports/brainstorm-260516-0707-cleanroom-reimplementation.md) (axes 1, 2, 9, 10 + os/) +- Source files to be REPLACED later: `src/diagnose.rs`, `src/theme.rs`, `src/native_interop.rs` (do not delete this phase — Phase 2+ depends on them until then) + +## Overview + +- **Priority:** Critical (every other phase depends on these primitives) +- **Status:** pending +- **Brief:** Stand up the foundation modules — logging, Win32 helpers, WinHTTP client, and the build-script swap — without touching any business logic. End of this phase: `cargo build --release` succeeds with the new modules compiled in but not yet referenced by `app.rs` (so behavior is unchanged). + +## Key insights from brainstorm + +- WinHTTP via `windows-rs::Win32::Networking::WinHttp` removes `ureq` + `native-tls` (~600 KB binary savings) and respects the system proxy automatically. +- WinHTTP is verbose. Encapsulate in a single ~150 LOC `net::winhttp::Client` so the rest of the codebase stays clean. +- `log` + `simplelog` replaces the bespoke `OnceLock>` logger. Standard ecosystem, same one-line ergonomics. +- `embed-resource` replaces `winres`. Same purpose, different file shape (`res/icon.rc` instead of builder API). +- `os/` directory consolidates color, wide-string, DPI, registry, theme helpers (currently scattered across `theme.rs` and `native_interop.rs`). + +## Requirements + +### Functional + +- `diag::init(enabled: bool)` writes a log file at `%TEMP%\claude-code-usage-bubble.log` when called with `true`; no-op otherwise. +- `log::info!` / `log::warn!` / `log::error!` macros work and route to the file. +- `os::wide_str(&str) -> Vec` returns a NUL-terminated UTF-16 vector. +- `os::Color` provides hex parsing + COLORREF conversion. +- `os::dpi::for_window(hwnd)` returns u32 DPI ≥ 96. +- `os::registry::read_string(hkey, path, name)` returns Option. +- `os::registry::write_string(hkey, path, name, value)` returns Result. +- `os::registry::delete_value(hkey, path, name)` returns Result. +- `os::theme::is_dark()` returns bool from registry. +- `net::winhttp::Client::new()` constructs a client with a user-agent string. +- `client.get(url).header(k, v).send()` returns `Result`. +- `client.post(url).header(k, v).json_body(value).send()` returns `Result`. +- `Response::status() -> u32`, `Response::header(&str) -> Option<&str>`, `Response::text() -> Result`, `Response::json() -> Result`. +- Build script (`build.rs`) embeds `res/icon.ico` and version info via `embed-resource`. + +### Non-functional + +- Binary size after this phase: ≤ current (we add `tiny-skia`/`embed-resource`/`simplelog`/`thiserror` deps in later phases; this phase should not balloon). +- No new behavior changes vs current build. +- All new modules pass `cargo clippy -- -W clippy::all` with zero new warnings. + +## Architecture + +### `diag/mod.rs` (~30 LOC) + +```rust +use std::path::PathBuf; +use simplelog::{Config, LevelFilter, WriteLogger}; +use std::fs::File; + +pub fn init(enabled: bool) -> Result, std::io::Error> { + if !enabled { return Ok(None); } + let path = std::env::temp_dir().join("claude-code-usage-bubble.log"); + WriteLogger::init(LevelFilter::Debug, Config::default(), File::create(&path)?) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + log::info!("diagnostic logging enabled"); + Ok(Some(path)) +} +``` + +Callers use `log::info!` etc directly — no `diagnose::log(...)` indirection. + +### `os/` directory + +| File | Responsibility | LOC | +|---|---|---| +| `mod.rs` | `pub use` re-exports + module declarations | ~10 | +| `color.rs` | `Color { r, g, b }`, `from_hex`, `to_colorref()` | ~30 | +| `string.rs` | `wide_str(&str) -> Vec` | ~5 | +| `dpi.rs` | `for_window(hwnd)`, `for_system()`, `scale(logical, dpi)` | ~20 | +| `registry.rs` | typed wrapper over `RegOpenKeyExW`/`RegQueryValueExW`/`RegSetValueExW`/`RegDeleteValueW` | ~80 | +| `theme.rs` | `is_dark()` → reads `SystemUsesLightTheme` via `os::registry` | ~15 | + +### `net/winhttp.rs` (~150 LOC) + +```rust +pub struct Client { + session: HINTERNET, + user_agent: Vec, +} + +pub struct RequestBuilder<'a> { + client: &'a Client, + method: Method, + url: Url, + headers: Vec<(Vec, Vec)>, + body: Option>, +} + +pub struct Response { + status: u32, + headers: HashMap, + body: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("WinHTTP error {code}: {context}")] + Win(u32, String), + #[error("HTTP {status}")] + Status(u32), + #[error("JSON parse: {0}")] + Json(#[from] serde_json::Error), + #[error("invalid URL: {0}")] + Url(String), + #[error("UTF-8 conversion failed")] + Utf8, +} +``` + +Internally chains: `WinHttpOpen` → `WinHttpCrackUrl` → `WinHttpConnect` → `WinHttpOpenRequest` → `WinHttpSendRequest` → `WinHttpReceiveResponse` → `WinHttpQueryHeaders` → `WinHttpReadData`. + +### `Cargo.toml` updates + +```toml +[dependencies] +# REMOVED: ureq, native-tls +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "6" +log = "0.4" +simplelog = "0.12" +thiserror = "2" + +[dependencies.windows] +version = "0.58" +features = [ + # existing features + + "Win32_Networking_WinHttp", +] + +[build-dependencies] +# REMOVED: winres +embed-resource = "3" +``` + +### `build.rs` + +```rust +fn main() { + embed_resource::compile("res/icon.rc", embed_resource::NONE); +} +``` + +### `res/icon.rc` (new file) + +``` +#include +1 ICON "..\\src\\icons\\icon.ico" +1 VERSIONINFO + FILEVERSION 0,1,0,0 + PRODUCTVERSION 0,1,0,0 + FILEOS 0x40004L + FILETYPE 0x1L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904E4" + BEGIN + VALUE "ProductName", "Claude Code Usage Bubble\0" + VALUE "FileDescription", "Claude Code Usage Bubble\0" + VALUE "OriginalFilename", "claude-code-usage-bubble.exe\0" + VALUE "InternalName", "ClaudeCodeUsageBubble\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END +``` + +## Related code files + +**To create:** +- `src/diag/mod.rs` +- `src/os/mod.rs` +- `src/os/color.rs` +- `src/os/string.rs` +- `src/os/dpi.rs` +- `src/os/registry.rs` +- `src/os/theme.rs` +- `src/net/mod.rs` +- `src/net/winhttp.rs` +- `res/icon.rc` + +**To modify:** +- `Cargo.toml` (add new deps, remove `ureq`+`native-tls`+`winres`, add `Win32_Networking_WinHttp`) +- `build.rs` (replace winres with embed-resource) +- `src/main.rs` (add `mod diag; mod os; mod net;` declarations — do NOT remove old `mod diagnose; mod theme; mod native_interop;` yet) + +**To delete:** nothing in this phase (Phase 2 removes `theme.rs`, etc.) + +## Implementation steps + +1. **Add new dependencies** to `Cargo.toml`: `log`, `simplelog`, `thiserror`, `embed-resource`. Add `Win32_Networking_WinHttp` to `windows` features. Leave `ureq`, `native-tls`, `winres` in place for now. +2. **Create `res/icon.rc`** referencing `src/icons/icon.ico`. +3. **Replace `build.rs`** with `embed-resource::compile`. +4. **`cargo build --release`** — verify icon embedding works (PE has icon resource). +5. **Create `src/os/string.rs`** with `wide_str()`. Trivial. +6. **Create `src/os/color.rs`** with `Color` struct + `from_hex` + `to_colorref`. +7. **Create `src/os/registry.rs`** with `read_string`, `read_u32`, `write_string`, `delete_value` — `unsafe` wrappers over Win32 registry APIs returning `Result`. +8. **Create `src/os/theme.rs`** calling `registry::read_u32(HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", "SystemUsesLightTheme")` and inverting. +9. **Create `src/os/dpi.rs`** with `for_window`, `for_system`, `scale`. +10. **Create `src/os/mod.rs`** with `pub mod color; pub mod string; pub mod dpi; pub mod registry; pub mod theme;` + re-exports of common items (`Color`, `wide_str`). +11. **Create `src/diag/mod.rs`** with `init(bool)` that initialises simplelog. Add `log::set_max_level` if not handled by simplelog. +12. **Create `src/net/winhttp.rs`** in 4 commits: + - 12a. `Client::new`, `Drop` for `WinHttpCloseHandle`. + - 12b. `RequestBuilder` + GET path. + - 12c. POST + JSON body. + - 12d. `Response::header` + `Response::text` + `Response::json`. +13. **Create `src/net/mod.rs`** with `pub mod winhttp;` + `pub use winhttp::{Client, Error, Response};`. +14. **Wire modules into `src/main.rs`**: + ```rust + mod diag; + mod net; + mod os; + // OLD ones still present for now: + mod diagnose; + mod theme; + mod native_interop; + ``` +15. **`cargo build --release`** — must compile clean. Binary should run identically to current build (we haven't replaced anything yet). +16. **`cargo clippy --release`** — fix any clippy warnings on new modules. +17. **Manual smoke test on Windows** (or note as deferred to Phase 4 testing): + - Run `--diagnose`, confirm log file exists at `%TEMP%\claude-code-usage-bubble.log` (but it's empty for now since nothing calls `log::info!` yet — that's OK). + - Run a tiny dev-only test binary or `cargo test` that exercises `net::winhttp::Client.get("https://api.github.com").send()` to validate the HTTP wrapper end-to-end. + +## Todo checklist + +- [ ] Cargo.toml deps updated +- [ ] `res/icon.rc` created +- [ ] `build.rs` swapped to embed-resource +- [ ] Build produces .exe with embedded icon +- [ ] `src/os/string.rs` +- [ ] `src/os/color.rs` +- [ ] `src/os/registry.rs` +- [ ] `src/os/theme.rs` +- [ ] `src/os/dpi.rs` +- [ ] `src/os/mod.rs` +- [ ] `src/diag/mod.rs` +- [ ] `src/net/winhttp.rs` (incremental, 4 sub-commits) +- [ ] `src/net/mod.rs` +- [ ] `src/main.rs` declares new modules +- [ ] `cargo build --release` clean +- [ ] `cargo clippy` clean +- [ ] WinHTTP smoke test passes against `api.github.com` + +## Success criteria + +- Phase ends with a runnable binary that behaves exactly like the previous version (no business logic changes). +- `net::winhttp::Client` can successfully GET `https://api.github.com/repos/tiennm99/claude-code-usage-bubble/releases/latest` and parse JSON. +- `log::info!("test")` from anywhere writes to `%TEMP%\claude-code-usage-bubble.log` when `--diagnose` is passed. +- Binary size: not larger than current (we've removed `ureq`+`native-tls`, added smaller crates). + +## Risks + mitigations + +| Risk | Likelihood | Mitigation | +|---|---|---| +| WinHTTP TLS handshake fails on older Windows | Low | Test on Win10/Win11; WinHTTP supports TLS 1.2+ since Win10 1607 | +| `WinHttpCrackUrl` is awkward; URL parsing has edge cases | Medium | Use `url` crate (~50 KB) for parsing, then pass components to WinHTTP | +| `embed-resource` doesn't match `winres`'s VERSIONINFO output exactly | Low | Verify with `mt /inspect output.exe` | +| `log` + `simplelog` collide with another logger init | None | App owns the only init | +| Chunked transfer / compression auto-decode disabled | Medium | Set `WINHTTP_OPTION_DECOMPRESSION` flag | + +## Security considerations + +- WinHTTP enforces certificate validation by default — don't disable. +- `registry` module writes only to `HKEY_CURRENT_USER` (user-scoped). No admin escalation. +- Log file path is `%TEMP%` — user-scoped. No secrets logged (verify in Phase 4 when adding token-handling logs). + +## Next steps + +→ Phase 2: replace `models.rs` + `localization/*` with `usage/types.rs` + `i18n/` directory. diff --git a/plans/260516-0707-cleanroom-rewrite/phase-02-types-and-i18n.md b/plans/260516-0707-cleanroom-rewrite/phase-02-types-and-i18n.md new file mode 100644 index 0000000..fe7ebd9 --- /dev/null +++ b/plans/260516-0707-cleanroom-rewrite/phase-02-types-and-i18n.md @@ -0,0 +1,403 @@ +--- +phase: 2 +status: pending +estimated_hours: 6 +--- + +# Phase 2 — Types & i18n + +## Context links + +- Brainstorm: axes 3 (provider types) + 6 (localization) +- Source files to be REPLACED: `src/models.rs`, `src/localization/*` (9 files) + +## Overview + +- **Priority:** High (Phase 4 providers depend on these types; bubble/panel/app render using them) +- **Status:** pending +- **Brief:** Define the new provider-result data types and replace the 9 hand-coded localization Rust files with one Rust loader + 9 TOML files. Refactor consumer imports (`bubble.rs`, `panel.rs`, `app.rs`, `settings.rs`) to the new shape. End of phase: source's `models.rs` and `localization/*` deleted. + +## Key insights from brainstorm + +- Source's `UsageData { session, weekly }` is one shape; `UsageWindows { primary, secondary }` (or a `HashMap`) is structurally different and works for both Anthropic (5h/7d) and Codex (which already uses "primary/secondary" terminology in its API response). +- Source dispatches localization via `enum LanguageId` + matching const tables. Embedded TOML via `include_str!` + parsed-at-startup HashMap is structurally different and easier for translators. +- `LocaleStrings` becomes a `serde::Deserialize` struct keyed by TOML section. + +## Requirements + +### Functional + +- `usage::types::UsageWindows` carries `primary: Window`, `secondary: Window`, both `pub`. +- `usage::types::Window { utilization: f64, resets_at: Option }`. +- `usage::types::ProviderId` enum: `Claude`, `ChatGpt` (note: renamed from "Codex" internally; menu label stays "Codex" via i18n). +- `usage::types::ProviderSnapshot { id: ProviderId, windows: Result }` for app-level results. +- `i18n::I18n::load(active_code: Option<&str>) -> Self` parses all embedded TOMLs at startup. +- `i18n::I18n::strings() -> &LocaleStrings` returns the active language's strings. +- `i18n::LocaleStrings` is a single struct with all UI strings as named fields (matches what bubble/panel/app need). +- `i18n::detect::detect_system_locale() -> Option` mirrors source's `GetUserPreferredUILanguages` chain. + +### Non-functional + +- Adding a new language = drop a new TOML file in `src/i18n/locales/` + add one line in `i18n/mod.rs` `include_str!` map. +- TOML parsing happens once at startup; ~9 small files combined < 10 KB; parse time < 5 ms. + +## Architecture + +### `src/usage/types.rs` + +```rust +use std::time::SystemTime; + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum ProviderId { + Claude, + ChatGpt, +} + +impl ProviderId { + pub fn as_str(self) -> &'static str { + match self { Self::Claude => "claude", Self::ChatGpt => "chatgpt" } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Window { + pub utilization: f64, // 0.0–100.0 + pub resets_at: Option, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct UsageWindows { + pub primary: Window, // 5h for Claude / primary_window for ChatGPT + pub secondary: Window, // 7d for Claude / secondary_window for ChatGPT +} + +#[derive(Clone, Debug)] +pub struct ProviderSnapshot { + pub id: ProviderId, + pub windows: UsageWindows, +} +``` + +### `src/usage/mod.rs` (Phase 2 portion — provider trait stub goes here, real impls in Phase 4) + +```rust +pub mod types; +pub use types::{ProviderId, Window, UsageWindows, ProviderSnapshot}; + +// Provider trait lives here; impls (anthropic.rs, chatgpt.rs) come in Phase 4. +pub trait UsageProvider: Send { + fn id(&self) -> ProviderId; + fn poll(&mut self, http: &crate::net::Client) -> Result; +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("authentication required")] + AuthRequired, + #[error("no credentials configured")] + NoCredentials, + #[error("token expired and refresh failed")] + TokenExpired, + #[error("network: {0}")] + Network(#[from] crate::net::Error), + #[error("response shape mismatch: {0}")] + BadResponse(String), + #[error("credential read: {0}")] + Creds(#[from] crate::creds::Error), // forward-declared, real type in Phase 3 +} +``` + +In Phase 2 we leave `creds::Error` and the impls as `todo!()` stubs that won't link until Phase 3/4. + +### `src/i18n/mod.rs` + +```rust +use std::collections::HashMap; +use serde::Deserialize; + +pub mod detect; + +#[derive(Clone, Deserialize)] +pub struct LocaleStrings { + pub window_title: String, + pub refresh: String, + pub update_frequency: String, + pub one_minute: String, + pub five_minutes: String, + pub fifteen_minutes: String, + pub one_hour: String, + pub models: String, + pub claude_label: String, // was claude_code_model + pub chatgpt_label: String, // was codex_model + pub settings: String, + pub start_with_windows: String, + pub reset_position: String, + pub language: String, + pub system_default: String, + pub check_for_updates: String, + pub checking_for_updates: String, + pub up_to_date: String, + pub update_failed: String, + pub applying_update: String, + pub update_available: String, + pub update_via_winget: String, // was update_via_winget_label + pub exit: String, + pub show_widget: String, + pub session_window: String, + pub weekly_window: String, + pub now: String, + pub day_suffix: String, + pub hour_suffix: String, + pub minute_suffix: String, + pub second_suffix: String, + pub token_expired_title: String, + pub token_expired_body: String, + pub chatgpt_token_expired_title: String, + pub chatgpt_token_expired_body: String, +} + +#[derive(Deserialize)] +struct LocaleFile { + code: String, + native_name: String, + #[serde(flatten)] + strings: LocaleStrings, +} + +pub struct I18n { + available: HashMap, // code → (native_name, strings) + active: String, +} + +impl I18n { + pub fn load(active_code: Option<&str>) -> Self { + let raw = [ + ("en", include_str!("locales/en.toml")), + ("nl", include_str!("locales/nl.toml")), + ("es", include_str!("locales/es.toml")), + ("fr", include_str!("locales/fr.toml")), + ("de", include_str!("locales/de.toml")), + ("ja", include_str!("locales/ja.toml")), + ("ko", include_str!("locales/ko.toml")), + ("zh-TW", include_str!("locales/zh-TW.toml")), + ]; + let mut available = HashMap::new(); + for (code, body) in raw { + if let Ok(file) = toml::from_str::(body) { + available.insert(code.to_string(), (file.native_name, file.strings)); + } + } + let active = match active_code { + Some(c) if available.contains_key(c) => c.to_string(), + _ => detect::detect_system_locale() + .and_then(|s| Self::normalize(&s, &available)) + .unwrap_or_else(|| "en".to_string()), + }; + Self { available, active } + } + + pub fn strings(&self) -> &LocaleStrings { + &self.available[&self.active].1 + } + + pub fn active_code(&self) -> &str { &self.active } + + pub fn available(&self) -> impl Iterator { + self.available.iter().map(|(code, (name, _))| (code.as_str(), name.as_str())) + } + + fn normalize(code: &str, available: &HashMap) -> Option { + // "en-US" → "en", "zh-Hant-TW" → "zh-TW", etc. + // Exact match first, then prefix. + let lower = code.to_ascii_lowercase().replace('_', "-"); + if available.contains_key(&lower) { return Some(lower); } + let prefix = lower.split('-').next().unwrap_or(""); + if prefix == "zh" && (lower.contains("tw") || lower.contains("hant")) { + return Some("zh-TW".into()); + } + available.keys() + .find(|k| k.split('-').next() == Some(prefix)) + .cloned() + } +} +``` + +### `src/i18n/detect.rs` + +Mirrors source's `preferred_ui_languages` + `default_ui_locale` + `default_locale_name` chain via Win32 globalization APIs, but in one function: + +```rust +pub fn detect_system_locale() -> Option { + preferred().or_else(default_ui).or_else(default_user) +} +fn preferred() -> Option { /* GetUserPreferredUILanguages */ } +fn default_ui() -> Option { /* GetUserDefaultUILanguage + LCIDToLocaleName */ } +fn default_user() -> Option { /* GetUserDefaultLocaleName */ } +``` + +### `src/i18n/locales/en.toml` + +```toml +code = "en" +native_name = "English" + +window_title = "Claude Code Usage Bubble" +refresh = "Refresh" +update_frequency = "Update frequency" +one_minute = "1 minute" +five_minutes = "5 minutes" +fifteen_minutes = "15 minutes" +one_hour = "1 hour" +models = "Models" +claude_label = "Claude Code" +chatgpt_label = "Codex" +settings = "Settings" +start_with_windows = "Start with Windows" +reset_position = "Reset position" +language = "Language" +system_default = "System default" +check_for_updates = "Check for updates" +checking_for_updates = "Checking for updates…" +up_to_date = "Up to date" +update_failed = "Update failed" +applying_update = "Applying update…" +update_available = "Update available" +update_via_winget = "via WinGet" +exit = "Exit" +show_widget = "Show widget" +session_window = "5h" +weekly_window = "7d" +now = "now" +day_suffix = "d" +hour_suffix = "h" +minute_suffix = "m" +second_suffix = "s" +token_expired_title = "Claude Code session expired" +token_expired_body = "Sign in again to keep usage reporting." +chatgpt_token_expired_title = "Codex session expired" +chatgpt_token_expired_body = "Sign in again to keep usage reporting." +``` + +The 8 other locale files mirror this shape with translated strings. **Important:** copy the translations from `src/localization/*.rs` content (the strings themselves are utilitarian/factual translations and not copyright-eligible the way code is — but for safety, re-translate the most unique strings using your own phrasing). + +### Consumer refactors (in this phase) + +**`src/app.rs`:** +- `use crate::localization::{LanguageId, Strings, resolve_language}` → `use crate::i18n::{I18n, LocaleStrings}` +- `s.language.strings()` → `s.i18n.strings()` +- `LanguageId::ALL.iter()` → `s.i18n.available()` +- All field renames: `claude_code_model` → `claude_label`, `codex_model` → `chatgpt_label`, etc. + +**`src/bubble.rs`:** no localization access; only depends on bubble-specific data. Unaffected. + +**`src/panel.rs`:** +- `data.strings.session_window` works unchanged (field name preserved). +- `data.strings.claude_code_model` → `data.strings.claude_label`. + +**`src/settings.rs`:** unchanged (it stores `language: Option` already, which holds a locale code). + +**`src/models.rs`:** delete. Replace `crate::models::{AppUsageData, UsageData, UsageSection}` consumers: +- `AppUsageData` → `Vec` +- `UsageData` → `UsageWindows` +- `UsageSection` → `Window` + +Migration map for `app.rs`: +- `s.data.claude_code.as_ref()` → `s.snapshots.iter().find(|sn| sn.id == ProviderId::Claude)` +- `c.session.percentage` → `sn.windows.primary.utilization` +- `c.weekly.percentage` → `sn.windows.secondary.utilization` + +## Related code files + +**To create:** +- `src/usage/mod.rs` +- `src/usage/types.rs` +- `src/i18n/mod.rs` +- `src/i18n/detect.rs` +- `src/i18n/locales/en.toml` +- `src/i18n/locales/nl.toml` +- `src/i18n/locales/es.toml` +- `src/i18n/locales/fr.toml` +- `src/i18n/locales/de.toml` +- `src/i18n/locales/ja.toml` +- `src/i18n/locales/ko.toml` +- `src/i18n/locales/zh-TW.toml` + +**To modify:** +- `Cargo.toml` — add `toml = "0.8"` (with default features) +- `src/main.rs` — declare `mod usage; mod i18n;`; remove `mod models; mod localization;` +- `src/app.rs` — migrate all `crate::models::*` and `crate::localization::*` imports +- `src/panel.rs` — field renames +- `src/bubble.rs` — only if it references `LanguageId` (it shouldn't) + +**To delete:** +- `src/models.rs` +- `src/localization/mod.rs` +- `src/localization/english.rs` +- `src/localization/dutch.rs` +- `src/localization/spanish.rs` +- `src/localization/french.rs` +- `src/localization/german.rs` +- `src/localization/japanese.rs` +- `src/localization/korean.rs` +- `src/localization/traditional_chinese.rs` + +## Implementation steps + +1. **Add `toml = "0.8"`** to `Cargo.toml`. +2. **Create `src/usage/types.rs`** (struct definitions only — no impls yet). +3. **Create `src/usage/mod.rs`** with trait `UsageProvider` and `Error` enum. Leave it without any impls. +4. **Create `src/i18n/locales/en.toml`** first; verify TOML structure parses. +5. **Add `src/i18n/mod.rs` + `src/i18n/detect.rs`** with `I18n::load` reading only `en.toml`. +6. **Wire `mod i18n; mod usage;` into `main.rs`** and call `I18n::load(None)` from `app::run` (storing on `AppState`). Build should still compile (no usages downstream yet). +7. **Migrate `app.rs`** field-by-field from `Strings` to `LocaleStrings`. Run `cargo check` after each subsystem (menu, balloon, panel-data, tray-tooltip). +8. **Translate the other 8 locale TOMLs.** Use your own phrasings for the longer strings (e.g. `token_expired_body`) rather than direct copies of upstream's translations. +9. **Add the other 8 `include_str!` entries** to `i18n/mod.rs`. +10. **Migrate `panel.rs`** field renames (small). +11. **Migrate `app.rs` data model** from `AppUsageData` to `Vec`. This is the biggest single edit. Update `apply_data`, `apply_usage_update`, `build_panel_data_from`, `refresh_tray_icons`, `refresh_text_fields`. +12. **Delete `src/models.rs` + `src/localization/*`** once nothing references them. +13. **`cargo build --release`** — clean. + +## Todo checklist + +- [ ] `usage/types.rs` written +- [ ] `usage/mod.rs` written (trait + Error stubs) +- [ ] `i18n/mod.rs` + `detect.rs` written +- [ ] 9 TOML locale files written (translations are your own paraphrasings) +- [ ] `Cargo.toml` adds `toml` dep +- [ ] `main.rs` declares new modules + removes old ones +- [ ] `app.rs` migrated to `LocaleStrings` + `Vec` +- [ ] `panel.rs` field renames done +- [ ] `bubble.rs` confirmed unaffected +- [ ] Old `src/models.rs` + `src/localization/*` deleted +- [ ] `cargo build --release` clean +- [ ] App still runs (placeholder data since providers aren't wired yet) + +## Success criteria + +- TOML files parse cleanly at startup. +- App shows correct language strings based on Windows display language. +- No file in `src/` shares a name with upstream's `models.rs` or `localization/*`. +- Right-click → Language submenu lists 9 options (system default + 8 languages) and switching them updates UI immediately. + +## Risks + mitigations + +| Risk | Likelihood | Mitigation | +|---|---|---| +| TOML serde derive misalignment (typos in field names) | High | Use `#[serde(deny_unknown_fields)]` to catch typos at load time | +| Translations differ enough from upstream that meaning drifts | Medium | Compare meaning side-by-side before committing; ask a native speaker for the long strings if you can | +| Bubble/panel field references break in subtle places | Medium | `cargo check` after each consumer edit | +| App startup slows due to TOML parsing | Negligible | TOML files combined < 10 KB | + +## Security considerations + +- TOML strings are static, no eval. Parse failures fall back to English silently. No injection risk. +- No PII in locale files. + +## Next steps + +→ Phase 3: replace credential reading with `creds/` directory. + +## Open questions + +- **Translation copyright.** The upstream localization files contain ~30 short UI strings per language. These are utility translations of standard UI vocabulary and are unlikely to be copyrightable individually, but for full clean-room status, re-paraphrase the longest strings (`token_expired_body` and `chatgpt_token_expired_body`). Recommended: write your own phrasing for those two. diff --git a/plans/260516-0707-cleanroom-rewrite/phase-03-creds-module.md b/plans/260516-0707-cleanroom-rewrite/phase-03-creds-module.md new file mode 100644 index 0000000..dbae008 --- /dev/null +++ b/plans/260516-0707-cleanroom-rewrite/phase-03-creds-module.md @@ -0,0 +1,253 @@ +--- +phase: 3 +status: pending +estimated_hours: 3 +--- + +# Phase 3 — `creds/` module (credential discovery) + +## Context links + +- Brainstorm: axis 4 (credential discovery) + 5 (refresh — only the discovery part lives here; orchestrator lives in Phase 4) +- Source file to be REPLACED: parts of `src/poller.rs` (credential reading/discovery), `src/native_interop.rs` (WSL command execution) + +## Overview + +- **Priority:** Medium — Phase 4 providers depend on this. +- **Status:** pending +- **Brief:** Introduce a `trait CredentialSource` with three impls (local Claude, WSL Claude, local Codex). Replace the source's `enum CredentialSource { Windows, Wsl }` + serial fallback with a registry-pattern + iterator. + +## Key insights from brainstorm + +- Trait-based discovery is structurally different from source's enum + match dispatch. +- `Vec>` ordered by priority lets future additions (e.g. an environment-variable-based source) drop in with no changes to the locator. +- Change-detection signatures (used by app's "watch for re-auth" loop) become a trait method. + +## Requirements + +### Functional + +- `creds::Token { access_token: String, expires_at_unix_ms: Option, account_id: Option }`. +- `trait creds::CredentialSource: Send + Sync`: + - `fn id(&self) -> &str` — stable identifier ("local-claude", "wsl:Ubuntu-22", "codex"). + - `fn read(&self) -> Result`. + - `fn signature(&self) -> Option` — opaque hash/key for change detection. + - `fn refresh_hint(&self) -> RefreshHint` — what command to spawn for refresh. +- `creds::CredentialLocator::default_claude()` builds a locator with local Windows path first, then all installed WSL distros. +- `creds::CredentialLocator::default_codex()` builds a locator with the local Codex path. +- `locator.first_available() -> Option<&dyn CredentialSource>`. +- `locator.signatures() -> Vec`. + +### Non-functional + +- WSL probe (which spawns `wsl.exe -l -q`) must complete in ≤ 5s or be timed out. +- WSL token-read must complete in ≤ 5s or be timed out. +- No blocking work in `signature()` (it's called frequently from the poll loop) — only stat/file-size, not file-read. + +## Architecture + +### `src/creds/mod.rs` + +```rust +use std::time::Duration; + +pub mod local_fs; +pub mod wsl_bridge; +pub mod codex_auth; + +#[derive(Debug, Clone)] +pub struct Token { + pub access_token: String, + pub expires_at_unix_ms: Option, + pub account_id: Option, +} + +#[derive(Debug, Clone)] +pub enum RefreshHint { + LocalCliCommand { exe: &'static str }, + WslCliCommand { distro: String }, + Codex, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("credential file not found at {path}")] + NotFound { path: String }, + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("invalid JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("missing field in credential JSON: {0}")] + MissingField(&'static str), + #[error("WSL command failed: {0}")] + WslCommand(String), + #[error("timeout waiting for WSL command")] + WslTimeout, +} + +pub trait CredentialSource: Send + Sync { + fn id(&self) -> &str; + fn read(&self) -> Result; + fn signature(&self) -> Option; + fn refresh_hint(&self) -> RefreshHint; +} + +pub struct CredentialLocator { + sources: Vec>, +} + +impl CredentialLocator { + pub fn new(sources: Vec>) -> Self { + Self { sources } + } + + pub fn default_claude() -> Self { + let mut sources: Vec> = Vec::new(); + if let Some(local) = local_fs::LocalClaudeCreds::detect() { + sources.push(Box::new(local)); + } + for distro in wsl_bridge::list_distros() { + sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro))); + } + Self { sources } + } + + pub fn default_codex() -> Self { + let mut sources: Vec> = Vec::new(); + if let Some(codex) = codex_auth::LocalCodexCreds::detect() { + sources.push(Box::new(codex)); + } + Self { sources } + } + + pub fn first_available(&self) -> Option<&dyn CredentialSource> { + self.sources.iter().find(|s| s.signature().is_some()).map(Box::as_ref) + } + + pub fn signatures(&self) -> Vec { + self.sources.iter().filter_map(|s| s.signature()).collect() + } + + pub fn iter(&self) -> impl Iterator { + self.sources.iter().map(Box::as_ref) + } +} +``` + +### `src/creds/local_fs.rs` + +```rust +use std::path::PathBuf; + +pub struct LocalClaudeCreds { + path: PathBuf, + id: String, +} + +impl LocalClaudeCreds { + pub fn detect() -> Option { + let home = dirs::home_dir()?; + let path = home.join(".claude").join(".credentials.json"); + Some(Self { id: format!("local:{}", path.display()), path }) + } +} + +impl super::CredentialSource for LocalClaudeCreds { + fn id(&self) -> &str { &self.id } + fn read(&self) -> Result { + let content = std::fs::read_to_string(&self.path)?; + parse_claude_json(&content) + } + fn signature(&self) -> Option { + let meta = std::fs::metadata(&self.path).ok()?; + let modified = meta.modified().ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()).unwrap_or(0); + Some(format!("{}|{}|{}", self.id, meta.len(), modified)) + } + fn refresh_hint(&self) -> super::RefreshHint { + super::RefreshHint::LocalCliCommand { exe: "claude" } + } +} + +pub fn parse_claude_json(content: &str) -> Result { + let json: serde_json::Value = serde_json::from_str(content)?; + let oauth = json.get("claudeAiOauth") + .ok_or(super::Error::MissingField("claudeAiOauth"))?; + let access_token = oauth.get("accessToken") + .and_then(|v| v.as_str()) + .ok_or(super::Error::MissingField("accessToken"))? + .to_string(); + let expires_at_unix_ms = oauth.get("expiresAt").and_then(|v| v.as_i64()); + Ok(super::Token { access_token, expires_at_unix_ms, account_id: None }) +} +``` + +### `src/creds/wsl_bridge.rs` + +Spawns `wsl.exe -l -q` to enumerate distros, then per-distro spawns `wsl.exe -d -- sh -lc 'cat ~/.claude/.credentials.json'`. Includes UTF-16LE-aware text decoder for `wsl.exe -l -q` output. Uses `CREATE_NO_WINDOW` flag. 5s timeout per command via a `run_with_timeout` helper. + +Key public types: +- `pub fn list_distros() -> Vec` — empty Vec if WSL not installed. +- `pub struct WslClaudeCreds { distro: String, id: String }` implementing `CredentialSource`. + +### `src/creds/codex_auth.rs` + +Reads `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Token includes `account_id` from `tokens.account_id`. Mirrors `local_fs.rs` pattern. + +## Related code files + +**To create:** +- `src/creds/mod.rs` +- `src/creds/local_fs.rs` +- `src/creds/wsl_bridge.rs` +- `src/creds/codex_auth.rs` + +**To modify:** +- `src/main.rs` — `mod creds;` + +**To delete:** nothing (Phase 4 deletes `src/poller.rs` once `usage::*` providers go live). + +## Implementation steps + +1. Create `src/creds/mod.rs` with trait + `Token` + `Error` + `RefreshHint` + `CredentialLocator`. +2. Create `local_fs.rs` with `LocalClaudeCreds`. +3. Create `wsl_bridge.rs` with distro enumeration + per-distro creds source. Note `decode_wsl_text` handles the UTF-16LE encoding quirk on `wsl.exe -l -q`. +4. Create `codex_auth.rs` with `LocalCodexCreds`. +5. Wire `mod creds;` into `main.rs`. +6. `cargo build --release` clean. + +## Todo checklist + +- [ ] `creds/mod.rs` +- [ ] `creds/local_fs.rs` +- [ ] `creds/wsl_bridge.rs` +- [ ] `creds/codex_auth.rs` +- [ ] `main.rs` declares module +- [ ] `cargo build --release` clean +- [ ] Manual test (Windows): `CredentialLocator::default_claude().first_available()` finds a real credential file + +## Success criteria + +- Trait dispatch works; locator returns the right source based on priority. +- WSL probe doesn't hang the process when no WSL is installed. +- `signature()` is fast (<1 ms for local, <100 ms for WSL). + +## Risks + mitigations + +| Risk | Likelihood | Mitigation | +|---|---|---| +| WSL probe blocks for full 5s when WSL is uninstalled | Low | Test on a WSL-free VM; verify timeout works | +| UTF-16LE detection heuristic produces false positives | Low | Source has same heuristic and ships in production | +| `wsl.exe` not on PATH | Negligible on Win10+ | Return empty list silently | +| `dirs::home_dir()` returns None | Negligible on Windows | Return `None` from `detect()` and let locator skip | + +## Security considerations + +- Tokens stored as `String` in memory; not logged. +- WSL `sh -lc` arg is a constant — no user-controlled input → no shell injection. +- Don't `log::debug!` the token; log only `token len=N`. + +## Next steps + +→ Phase 4: providers + refresh orchestrator that USES this locator. diff --git a/plans/260516-0707-cleanroom-rewrite/phase-04-providers-and-refresh.md b/plans/260516-0707-cleanroom-rewrite/phase-04-providers-and-refresh.md new file mode 100644 index 0000000..3bd9485 --- /dev/null +++ b/plans/260516-0707-cleanroom-rewrite/phase-04-providers-and-refresh.md @@ -0,0 +1,346 @@ +--- +phase: 4 +status: pending +estimated_hours: 8 +--- + +# Phase 4 — Providers & refresh orchestrator + +## Context links + +- Brainstorm: axes 3 (provider trait) + 5 (refresh) +- Source file to be REPLACED entirely: `src/poller.rs` (~1100 LOC) +- Phase deps: 1 (`net::winhttp`), 2 (`usage::types`), 3 (`creds`) + +## Overview + +- **Priority:** Critical — replaces the largest single source file. +- **Status:** pending +- **Brief:** Implement `ClaudeProvider` + `ChatGptProvider` against the trait from Phase 2, plus the `RefreshOrchestrator` that spawns local CLIs to refresh expired tokens. Replace `crate::poller::*` calls in `app.rs` with `usage::registry::poll_all`. End of phase: `src/poller.rs` is gone. + +## Key insights from brainstorm + +- The two providers share rate-limit-header parsing logic — extract to `usage::headers`. +- Anthropic's primary endpoint returns the dedicated `oauth/usage` JSON; fallback is the Messages API with rate-limit headers. Both code paths go in `ClaudeProvider`. +- ChatGPT's `wham/usage` endpoint is shaped differently (`rate_limit.primary_window.used_percent`) — separate parser. +- `RefreshOrchestrator::refresh(source)` uses `RefreshHint` to know which CLI to spawn. 8-second timeout, not 30 — UX wins. + +## Requirements + +### Functional + +- `ClaudeProvider::new(locator: CredentialLocator) -> Self`. +- `ClaudeProvider::poll(http) -> Result`: + - Try `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer …` + `anthropic-beta: oauth-2025-04-20`. + - If primary returns 401/403 → `usage::Error::AuthRequired`. + - If primary returns 2xx but data is incomplete → fall back to Messages API. + - Messages-API fallback: `POST https://api.anthropic.com/v1/messages` with minimal payload; parse `anthropic-ratelimit-unified-{5h,7d}-utilization` headers + reset timestamps. +- `ChatGptProvider::new(locator: CredentialLocator) -> Self`. +- `ChatGptProvider::poll(http) -> Result`: + - `GET https://chatgpt.com/backend-api/wham/usage` with `Authorization: Bearer …` + `User-Agent: codex-cli` + optional `ChatGPT-Account-Id`. + - Parse `rate_limit.{primary_window,secondary_window}.used_percent` + `.reset_at` (Unix seconds). +- `RefreshOrchestrator::new(timeout: Duration) -> Self`. +- `RefreshOrchestrator::refresh(source: &dyn CredentialSource) -> RefreshOutcome` — spawns appropriate CLI, waits up to timeout, returns outcome. +- `usage::registry::Registry`: + - `Registry::new()` builds with default providers. + - `registry.enabled_providers(settings) -> Vec`. + - `registry.poll_one(id, http) -> Result`. + +### Non-functional + +- Total poll time (both providers) must stay under 60s even with refresh attempts. +- Refresh timeout is 8s (down from source's 30s) — verify UX feels snappy. +- HTTP retries are NOT done at this layer (app retains the retry/backoff loop). + +## Architecture + +### `src/usage/mod.rs` (expanded) + +```rust +pub mod types; +pub mod headers; +pub mod anthropic; +pub mod chatgpt; +pub mod refresh; +pub mod registry; + +pub use types::{ProviderId, Window, UsageWindows, ProviderSnapshot}; + +pub trait UsageProvider: Send { + fn id(&self) -> ProviderId; + fn poll(&mut self, http: &crate::net::winhttp::Client) -> Result; +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("authentication required")] + AuthRequired, + #[error("no credentials configured")] + NoCredentials, + #[error("token expired after refresh")] + TokenExpired, + #[error("network: {0}")] + Network(#[from] crate::net::Error), + #[error("response shape mismatch: {0}")] + BadResponse(String), + #[error("credential: {0}")] + Creds(#[from] crate::creds::Error), +} +``` + +### `src/usage/headers.rs` + +```rust +use super::{Window, UsageWindows}; +use crate::net::winhttp::Response; + +pub fn parse_anthropic_rate_limit(resp: &Response) -> UsageWindows { + let primary = Window { + utilization: header_f64(resp, "anthropic-ratelimit-unified-5h-utilization") * 100.0, + resets_at: unix_to_system_time(header_i64(resp, "anthropic-ratelimit-unified-5h-reset")), + }; + let secondary = Window { + utilization: header_f64(resp, "anthropic-ratelimit-unified-7d-utilization") * 100.0, + resets_at: unix_to_system_time(header_i64(resp, "anthropic-ratelimit-unified-7d-reset")), + }; + UsageWindows { primary, secondary } +} + +fn header_f64(resp: &Response, name: &str) -> f64 { + resp.header(name).and_then(|s| s.parse().ok()).unwrap_or(0.0) +} +fn header_i64(resp: &Response, name: &str) -> Option { + resp.header(name).and_then(|s| s.parse().ok()) +} +fn unix_to_system_time(secs: Option) -> Option { + let s = secs?; + if s < 0 { return None; } + Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(s as u64)) +} +``` + +### `src/usage/anthropic.rs` + +```rust +use crate::creds::CredentialLocator; +use crate::net::winhttp::Client; +use super::{UsageProvider, UsageWindows, Window, Error, ProviderId}; +use serde::Deserialize; + +const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; +const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; + +pub struct ClaudeProvider { + locator: CredentialLocator, +} + +impl ClaudeProvider { + pub fn new(locator: CredentialLocator) -> Self { Self { locator } } +} + +impl UsageProvider for ClaudeProvider { + fn id(&self) -> ProviderId { ProviderId::Claude } + + fn poll(&mut self, http: &Client) -> Result { + let source = self.locator.first_available().ok_or(Error::NoCredentials)?; + let token = source.read()?; + // … (try usage endpoint; fall back to messages; parse rate-limit headers) + } +} + +#[derive(Deserialize)] +struct OauthUsageResponse { + five_hour: Option, + seven_day: Option, +} +#[derive(Deserialize)] +struct Bucket { + utilization: f64, + resets_at: Option, // ISO 8601 +} + +fn try_usage_endpoint(http: &Client, token: &str) -> Result, Error> { /* … */ } +fn try_messages_endpoint(http: &Client, token: &str) -> Result { /* … */ } +fn parse_iso8601(s: &str) -> Option { /* … minimal date parser, same as source's */ } +``` + +### `src/usage/chatgpt.rs` + +Mirrors anthropic shape; parses Codex JSON. + +### `src/usage/refresh.rs` + +```rust +use crate::creds::{CredentialSource, RefreshHint}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +#[derive(Debug, Clone, Copy)] +pub enum RefreshOutcome { Refreshed, StillExpired, CliMissing, Timeout } + +pub struct RefreshOrchestrator { timeout: Duration } + +impl RefreshOrchestrator { + pub fn new(timeout: Duration) -> Self { Self { timeout } } + + pub fn refresh(&self, source: &dyn CredentialSource) -> RefreshOutcome { + let signature_before = source.signature(); + let hint = source.refresh_hint(); + let spawn_ok = match hint { + RefreshHint::LocalCliCommand { exe } => self.spawn_local(exe), + RefreshHint::WslCliCommand { distro } => self.spawn_wsl(&distro), + RefreshHint::Codex => self.spawn_codex(), + }; + if !spawn_ok { return RefreshOutcome::CliMissing; } + + let start = std::time::Instant::now(); + loop { + if start.elapsed() > self.timeout { return RefreshOutcome::Timeout; } + std::thread::sleep(Duration::from_millis(500)); + if source.signature() != signature_before { + return RefreshOutcome::Refreshed; + } + } + } + + fn spawn_local(&self, exe: &str) -> bool { /* spawn `.cmd -p .` or ` -p .` */ } + fn spawn_wsl(&self, distro: &str) -> bool { /* wsl.exe -d -- bash -lic 'claude -p .' */ } + fn spawn_codex(&self) -> bool { /* spawn codex exec . */ } +} +``` + +### `src/usage/registry.rs` + +```rust +use super::{UsageProvider, ProviderId, UsageWindows, Error}; +use crate::net::winhttp::Client; +use crate::settings::Settings; + +pub struct Registry { + providers: Vec>, +} + +impl Registry { + pub fn with_defaults() -> Self { + let claude_locator = crate::creds::CredentialLocator::default_claude(); + let codex_locator = crate::creds::CredentialLocator::default_codex(); + Self { + providers: vec![ + Box::new(super::anthropic::ClaudeProvider::new(claude_locator)), + Box::new(super::chatgpt::ChatGptProvider::new(codex_locator)), + ], + } + } + + pub fn poll_enabled(&mut self, http: &Client, settings: &Settings) -> Vec<(ProviderId, Result)> { + let mut results = Vec::new(); + for p in self.providers.iter_mut() { + let enabled = match p.id() { + ProviderId::Claude => settings.show_claude_code, + ProviderId::ChatGpt => settings.show_codex, + }; + if !enabled { continue; } + results.push((p.id(), p.poll(http))); + } + results + } +} +``` + +### `app.rs` migration + +Replace `poller::poll(show_claude, show_codex)` with `registry.poll_enabled(&http_client, &settings)`. App now holds: +- `http_client: net::winhttp::Client` +- `registry: usage::registry::Registry` +- `refresh: usage::refresh::RefreshOrchestrator` + +Polling thread flow: +1. `let results = registry.poll_enabled(http, settings);` +2. For each `(id, Err(AuthRequired))`, call `refresh.refresh(source)` — needs locator access; expose via provider trait `fn try_refresh(orchestrator: &Orchestrator) -> RefreshOutcome` OR pass locator to app. +3. Post `WM_APP_USAGE_UPDATED`. + +(Detail: simplest is for the provider to expose its locator: `fn locator(&self) -> &CredentialLocator;` but that's leaky. Alternative: provider has an internal `fn refresh(&self, orch) -> RefreshOutcome` that owns the locator-access. Implement option B.) + +## Related code files + +**To create:** +- `src/usage/headers.rs` +- `src/usage/anthropic.rs` +- `src/usage/chatgpt.rs` +- `src/usage/refresh.rs` +- `src/usage/registry.rs` + +**To modify:** +- `src/usage/mod.rs` — expand with new module declarations +- `src/app.rs` — migrate poll-thread logic from `poller::*` to `registry::*` + `refresh::*`; remove `crate::poller` import +- `src/main.rs` — remove `mod poller;` + +**To delete:** +- `src/poller.rs` (1099 LOC removed) + +## Implementation steps + +1. **Implement `headers.rs`** — pure parsing, test in isolation. +2. **Implement `anthropic.rs`** in two parts: + - 2a. `try_usage_endpoint` — full JSON parse path. + - 2b. `try_messages_endpoint` — POST with model fallback chain + header parsing. +3. **Implement `chatgpt.rs`** — single endpoint, JSON parse. +4. **Implement `refresh.rs`** — orchestrator with 3 spawn paths. +5. **Implement `registry.rs`** — registry + `poll_enabled`. +6. **Migrate `app.rs::handle_poll_result`** to consume `Vec<(ProviderId, Result)>` instead of `Result`. +7. **Migrate `app.rs::apply_data`** to update `Vec` per provider. +8. **Add `fn try_refresh_for_provider(&mut self, id: ProviderId, orch: &Orchestrator) -> RefreshOutcome`** to `Registry`, so app can request refresh without touching internals. +9. **Wire `RefreshOrchestrator::new(Duration::from_secs(8))`** into app state. +10. **Delete `src/poller.rs`** + `mod poller;` line. +11. **`cargo build --release`** clean. +12. **End-to-end test on Windows**: + - Run app, sign-in via existing Claude CLI session, see polling work. + - Force token expiry (delete credentials file), see refresh succeed. + - Disconnect network, see graceful degradation. + +## Todo checklist + +- [ ] `headers.rs` +- [ ] `anthropic.rs` (usage endpoint) +- [ ] `anthropic.rs` (messages fallback) +- [ ] `chatgpt.rs` +- [ ] `refresh.rs` +- [ ] `registry.rs` +- [ ] `app.rs` poll-thread + apply_data migration +- [ ] `poller.rs` deleted +- [ ] `main.rs` updated +- [ ] `cargo build --release` clean +- [ ] Manual Windows e2e: Claude polls, Codex polls, token-refresh works +- [ ] Manual Windows e2e: network down → "..." indicator; back online → recovers + +## Success criteria + +- `src/poller.rs` no longer exists. +- App polls both providers concurrently (in poll thread). +- Token-expired flow refreshes within 8 s (or shows "..." gracefully if CLI missing). +- All ISO 8601 + Unix timestamps parse correctly (test edge cases: end-of-day, leap years). + +## Risks + mitigations + +| Risk | Likelihood | Mitigation | +|---|---|---| +| Anthropic API shape changes between dev and ship | Low | Test against live API; pin `anthropic-version: 2023-06-01` | +| Codex endpoint changes auth header | Low | Match source's header set exactly: Bearer + User-Agent + optional ChatGPT-Account-Id | +| Refresh races multiple poll attempts | Medium | Single refresh per source per poll cycle; signature-based completion detection | +| `wsl.exe bash -lic 'claude -p .'` outputs to TTY when no -p flag is recognized in WSL claude version | Medium | Test against actual installed Claude CLI in WSL; consider `--no-prompt` alternative | +| Long-running Messages API request | Medium | 30 s HTTP timeout in `net::winhttp::Client` | + +## Security considerations + +- Bearer token is included in HTTPS request → WinHTTP encrypts with TLS. +- Token never logged at INFO level; only `len=N` at DEBUG. +- CLI refresh spawns process with `CREATE_NO_WINDOW` to avoid console flash. + +## Next steps + +→ Phase 5: replace `tray_icon.rs` with `tray/` directory and tiny-skia badges. + +## Open questions + +- Does the Anthropic OAuth usage endpoint return `seven_day.utilization` consistently or do we still need the messages fallback for 7d data? Source code says yes-fallback-sometimes-needed. Keep the fallback for safety. +- Should `ChatGptProvider` skip the request if it has no `account_id` to avoid wasting bandwidth on a guaranteed-401? Source includes the header conditionally; we mirror that. diff --git a/plans/260516-0707-cleanroom-rewrite/phase-05-tray-badges.md b/plans/260516-0707-cleanroom-rewrite/phase-05-tray-badges.md new file mode 100644 index 0000000..9eeaa43 --- /dev/null +++ b/plans/260516-0707-cleanroom-rewrite/phase-05-tray-badges.md @@ -0,0 +1,264 @@ +--- +phase: 5 +status: pending +estimated_hours: 5 +--- + +# Phase 5 — Tray badges with tiny-skia + +## Context links + +- Brainstorm: axis 7 (tray icon drawing) +- Source file to be REPLACED entirely: `src/tray_icon.rs` (441 LOC) + +## Overview + +- **Priority:** Medium — replaces a non-critical-path file but visibly improves UX (anti-aliased badges). +- **Status:** pending +- **Brief:** Replace GDI-drawn tray icons with `tiny-skia` path-rendered, anti-aliased badges. Wrap the Win32 tray-notification calls (`Shell_NotifyIconW`) in a new `tray/` module with cleaner add/update/remove semantics. + +## Key insights from brainstorm + +- Source's GDI rendering uses primitive rectangles + text. Output looks aliased on HiDPI. +- `tiny-skia` renders vector paths with anti-aliasing in pure Rust. ~200 KB added to binary; UX clearly better. +- Tray icons are 16×16 / 24×24 / 32×32 depending on DPI. Render at largest size, downsample. +- The tray-icon "add vs update" inefficiency flagged in Phase 4 code review (R5) is fixed here by tracking which icons are registered in module state. + +## Requirements + +### Functional + +- `tray::Manager::new(owner_hwnd: HWND) -> Self`. +- `manager.sync(state: &[TrayIcon])` adds/updates/removes icons to match the given state. +- `manager.notify(id: TrayIconId, title: &str, body: &str)` shows a balloon for an existing icon. +- `tray::badge::render(percent: Option, kind: BadgeKind, dpi: u32) -> HICON` produces an anti-aliased HICON. +- `tray::callback::handle(lparam: LPARAM) -> TrayAction` dispatches WM_APP_TRAY messages. +- `TrayIcon { id: TrayIconId, percent: Option, tooltip: String, kind: BadgeKind }`. + +### Non-functional + +- Badge render must complete in < 5 ms per icon (called on every poll cycle, ~1× per minute typically). +- Memory: each cached badge HICON is ~4 KB; we cache by `(percent_bucket, kind, dpi)` — at most ~100 entries × 4 KB = 400 KB cache size. + +## Architecture + +### `src/tray/mod.rs` + +```rust +use windows::Win32::Foundation::*; + +pub mod badge; +pub mod callback; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum BadgeKind { + Claude, + ChatGpt, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TrayIconId(pub u32); + +pub const ID_CLAUDE: TrayIconId = TrayIconId(1); +pub const ID_CHATGPT: TrayIconId = TrayIconId(2); + +#[derive(Clone, Debug)] +pub struct TrayIcon { + pub id: TrayIconId, + pub percent: Option, + pub tooltip: String, + pub kind: BadgeKind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrayAction { + None, + LeftClick(TrayIconId), + RightClick(TrayIconId), +} + +pub struct Manager { + owner: HWND, + registered: std::collections::HashSet, +} + +impl Manager { + pub fn new(owner: HWND) -> Self { + Self { owner, registered: Default::default() } + } + + pub fn sync(&mut self, state: &[TrayIcon]) { + let target_ids: std::collections::HashSet<_> = state.iter().map(|i| i.id).collect(); + // Remove icons not in state + let to_remove: Vec<_> = self.registered.difference(&target_ids).copied().collect(); + for id in to_remove { self.remove(id); } + // Add or update + for icon in state { + if self.registered.contains(&icon.id) { + self.update(icon); + } else { + self.add(icon); + } + } + } + + pub fn notify(&self, id: TrayIconId, title: &str, body: &str) { /* NIM_MODIFY with NIF_INFO */ } + + fn add(&mut self, icon: &TrayIcon) { /* NIM_ADD + Shell_NotifyIconW */ } + fn update(&mut self, icon: &TrayIcon) { /* NIM_MODIFY */ } + fn remove(&mut self, id: TrayIconId) { /* NIM_DELETE */ } +} +``` + +### `src/tray/badge.rs` + +```rust +use windows::Win32::UI::WindowsAndMessaging::HICON; +use tiny_skia::*; + +pub fn render(percent: Option, kind: super::BadgeKind, dpi: u32) -> Option { + let size = match dpi { + d if d >= 192 => 32, + d if d >= 144 => 24, + _ => 16, + }; + let mut pixmap = Pixmap::new(size, size)?; + + // 1. Background fill (gradient from kind's tint colors) + let bg_color = base_color_for(kind, percent); + fill_circle(&mut pixmap, size, bg_color); + + // 2. Ring sweep for percent + if let Some(p) = percent { + draw_arc(&mut pixmap, size, p, kind); + } + + // 3. Center text "%" with size auto-fit + if let Some(p) = percent { + draw_percent_text(&mut pixmap, size, p); + } + + // 4. Convert BGRA pixmap to HICON via CreateIconIndirect + pixmap_to_hicon(&pixmap) +} + +fn fill_circle(pixmap: &mut Pixmap, size: u32, color: Color) { /* … */ } +fn draw_arc(pixmap: &mut Pixmap, size: u32, percent: f64, kind: super::BadgeKind) { /* … */ } +fn draw_percent_text(pixmap: &mut Pixmap, size: u32, percent: f64) { + // tiny-skia doesn't render text natively. Two options: + // a) Use cosmic-text (heavy) or ab_glyph (lighter). + // b) Pre-rasterize digits 0-9 + % glyph at build time into tiny PNGs and embed. + // c) Skip text — just use the ring sweep for usage indication. + // Choose (c) for simplicity; the bubble shows the exact percent already. +} + +fn pixmap_to_hicon(pixmap: &Pixmap) -> Option { + // Win32 ICONINFO with mask + color bitmaps; bitmaps from CreateDIBSection. + // BGRA layout matches what tiny-skia produces (premultiplied alpha). + // … +} +``` + +**Decision:** drop text from tray badges entirely. The bubble shows the exact percentage; tray badge is a coarse indicator (color + ring fill). This sidesteps the tiny-skia text-rendering hassle and keeps the badge image clearer at 16×16. + +### `src/tray/callback.rs` + +```rust +use windows::Win32::Foundation::LPARAM; +use super::{TrayAction, TrayIconId}; + +const WM_LBUTTONUP: u32 = 0x0202; +const WM_RBUTTONUP: u32 = 0x0205; + +pub fn handle(lparam: LPARAM) -> TrayAction { + let raw = lparam.0 as u32; + let event = raw & 0xFFFF; + let id_lo = (raw >> 16) & 0xFFFF; + let id = TrayIconId(id_lo); + match event { + WM_LBUTTONUP => TrayAction::LeftClick(id), + WM_RBUTTONUP => TrayAction::RightClick(id), + _ => TrayAction::None, + } +} +``` + +### Cargo.toml addition + +```toml +tiny-skia = "0.11" +``` + +(~250 KB added to binary; no text dependency since we dropped text from badges.) + +## Related code files + +**To create:** +- `src/tray/mod.rs` +- `src/tray/badge.rs` +- `src/tray/callback.rs` + +**To modify:** +- `Cargo.toml` — add `tiny-skia` +- `src/main.rs` — `mod tray;`; remove `mod tray_icon;` +- `src/app.rs` — replace `crate::tray_icon::{sync, add, update, remove, notify_balloon, handle_message, ...}` with `crate::tray::{Manager, TrayIcon, BadgeKind, TrayAction, ID_CLAUDE, ID_CHATGPT}`. Store `Manager` in `AppState`. Update all call sites. + +**To delete:** +- `src/tray_icon.rs` + +## Implementation steps + +1. **Add `tiny-skia` dep**. +2. **Implement `badge.rs`** — start with `fill_circle` (one path), verify pixmap saves to PNG correctly for visual debugging. +3. **Add `draw_arc`** — `PathBuilder::move_to + arc_to`. Use 0° = top (12 o'clock). +4. **Implement `pixmap_to_hicon`** — this is the trickiest part. Create AND/XOR DIB sections, populate from pixmap pixels (premultiplied BGRA), build `ICONINFO`, call `CreateIconIndirect`. +5. **Test badge rendering** — save 10 sample HICONs at different percents to disk and inspect. +6. **Implement `Manager`** with add/update/remove/sync. +7. **Implement `callback.rs`**. +8. **Migrate `app.rs`** to new API; replace `tray_icon::sync(...)` with `state.tray.sync(&icons)`. +9. **Delete `src/tray_icon.rs`** and remove from `main.rs`. +10. **`cargo build --release`** clean. +11. **Windows e2e**: run app, see tray icons appear with anti-aliased ring. Hover for tooltip. Left-click toggles bubble. Right-click opens menu. + +## Todo checklist + +- [ ] `tiny-skia` added to Cargo.toml +- [ ] `badge.rs::fill_circle` works (PNG inspection) +- [ ] `badge.rs::draw_arc` works (PNG inspection) +- [ ] `badge.rs::pixmap_to_hicon` produces valid HICON +- [ ] `tray/mod.rs::Manager` with add/update/remove/sync +- [ ] `callback.rs::handle` returns correct TrayAction +- [ ] `app.rs` migrated to new tray API +- [ ] `tray_icon.rs` deleted +- [ ] `cargo build --release` clean +- [ ] Tray icons appear with anti-aliased visuals on Windows + +## Success criteria + +- Badge looks visibly smoother than source's GDI version (anti-aliased ring). +- Add/update/remove is idempotent (no duplicate icons after `sync`). +- Tray callbacks fire correctly for left/right click. +- No `src/tray_icon.rs` remains. + +## Risks + mitigations + +| Risk | Likelihood | Mitigation | +|---|---|---| +| `pixmap_to_hicon` produces wrong-format icon (alpha channel issues) | High | Test by saving the source pixmap as PNG, then comparing to the rendered icon; iterate on BGRA channel order | +| `CreateIconIndirect` requires monochrome mask bitmap; we only have color | Medium | Pass `hbmMask = NULL` to let Windows auto-generate from alpha (works on Win10+) | +| 16×16 looks bad even with AA | Medium | Render at 32×32 then downsample with high-quality lanczos (tiny-skia doesn't include downsampling — use `image` crate's resize) | +| `tiny-skia` adds too much binary size | Low | Measured ~250 KB; acceptable for the UX win | + +## Security considerations + +- No external input drives badge rendering — all params are internal (`percent`, `kind`, `dpi`). No injection surface. +- HICON handles must be `DestroyIcon`'d when cache evicts (avoid handle leak — Windows limit is ~10,000 per process). + +## Next steps + +→ Phase 6: replace `updater.rs` and drop `NOTICE`. + +## Open questions + +- Keep the cached HICONs alive for the process lifetime, or destroy aggressively on each `update`? Source destroys + recreates each cycle (wasteful but simple). Recommend: cache by `(percent_rounded_to_5pct, kind, dpi)` and let the cache grow naturally. Max size ~100 entries × 4 KB = 400 KB. +- Need an icon for "no data" state (percent = None). Current spec says "fill_circle + no ring". Verify the visual reads correctly. diff --git a/plans/260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md b/plans/260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md new file mode 100644 index 0000000..9447951 --- /dev/null +++ b/plans/260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md @@ -0,0 +1,312 @@ +--- +phase: 6 +status: pending +estimated_hours: 7 +--- + +# Phase 6 — Updater + remove NOTICE + +## Context links + +- Brainstorm: axis 8 (updater architecture) +- Source file to be REPLACED entirely: `src/updater.rs` (512 LOC) + +## Overview + +- **Priority:** Final — this phase removes the last copied module and drops the attribution. +- **Status:** pending +- **Brief:** Replace the source's helper-exe-handoff updater with an inline `cmd /c …` handoff (no duplicate-exe pattern). Replace `winres` legacy paths. Delete `NOTICE`, update README + LICENSE comment so the project no longer claims attribution. + +## Key insights from brainstorm + +- Source spawns a copy of itself as `updater-helper.exe`, which waits for the parent to exit and swaps the binary. Genuinely-different alternative: spawn `cmd.exe` directly with an inline command string that does the same dance. +- No temp `.bat` file needed — inline command via `cmd /c "..."` works. +- The `Portable` vs `Winget` channel split is kept (we may publish to winget later); the channel detection function returns `Portable` for now (already stubbed in current code). + +## Requirements + +### Functional + +- `update::release::fetch_latest() -> Result, Error>` — GitHub releases API call. +- `update::release::Release { version: Version, asset_url: String }` — parsed result. +- `update::install::begin(release: &Release) -> Result<(), Error>` — download + handoff. +- `update::install::run_cli(args: &[String]) -> Option` — handle `--apply-update` flag (still kept for parity if a user manually invokes it, even though we don't use the helper-exe path anymore). +- `update::channel::current() -> Channel` — returns `Channel::Portable` for now. +- `Version` type with parse + ordering. + +### Non-functional + +- Inline `cmd /c` invocation uses `CREATE_NO_WINDOW | DETACHED_PROCESS` — no console flash. +- Download timeout: 60 s. Total update apply time: < 30 s after the 2-second wait window. + +## Architecture + +### `src/update/mod.rs` + +```rust +pub mod channel; +pub mod release; +pub mod install; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("network: {0}")] + Network(#[from] crate::net::Error), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("no compatible release asset found")] + NoAsset, + #[error("install location not writable: {0}")] + NotWritable(String), + #[error("malformed version: {0}")] + BadVersion(String), +} + +pub use channel::{Channel, current as current_channel}; +pub use release::{Release, fetch_latest}; +pub use install::{begin, run_cli}; +``` + +### `src/update/channel.rs` + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Channel { Portable, Winget } + +pub fn current() -> Channel { + // Until winget package exists, always Portable. + // Future: detect by checking if current_exe path is under + // %LOCALAPPDATA%\Microsoft\WinGet\Packages or %ProgramFiles%\WinGet\Packages. + Channel::Portable +} +``` + +### `src/update/release.rs` + +```rust +use crate::net::winhttp::Client; +use serde::Deserialize; + +const ASSET_NAME: &str = "claude-code-usage-bubble.exe"; + +#[derive(Clone, Debug)] +pub struct Release { + pub version: Version, + pub asset_url: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version { pub major: u32, pub minor: u32, pub patch: u32 } + +impl Version { + pub fn current() -> Self { /* env!("CARGO_PKG_VERSION") */ } + pub fn parse(s: &str) -> Option { /* … */ } +} + +pub fn fetch_latest(http: &Client) -> Result, super::Error> { + let url = format!("https://api.github.com/repos/{}/releases/latest", repo_path()); + let resp = http.get(&url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", user_agent()) + .send()?; + let body: GhRelease = resp.json()?; + let candidate = Version::parse(body.tag_name.trim_start_matches('v')) + .ok_or_else(|| super::Error::BadVersion(body.tag_name.clone()))?; + if candidate <= Version::current() { return Ok(None); } + let asset = body.assets.iter() + .find(|a| a.name.eq_ignore_ascii_case(ASSET_NAME)) + .ok_or(super::Error::NoAsset)?; + Ok(Some(Release { version: candidate, asset_url: asset.browser_download_url.clone() })) +} + +#[derive(Deserialize)] +struct GhRelease { tag_name: String, assets: Vec } +#[derive(Deserialize)] +struct GhAsset { name: String, browser_download_url: String } + +fn repo_path() -> &'static str { "tiennm99/claude-code-usage-bubble" } +fn user_agent() -> &'static str { concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) } +``` + +### `src/update/install.rs` + +```rust +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::os::windows::process::CommandExt; + +const CREATE_NO_WINDOW: u32 = 0x08000000; +const DETACHED_PROCESS: u32 = 0x00000008; + +pub fn begin(http: &crate::net::winhttp::Client, release: &super::Release) -> Result<(), super::Error> { + let current = std::env::current_exe()?; + ensure_writable(¤t)?; + let staging = stage_path()?; + std::fs::create_dir_all(staging.parent().unwrap())?; + download(http, &release.asset_url, &staging)?; + spawn_handoff(&staging, ¤t)?; + Ok(()) +} + +fn download(http: &crate::net::winhttp::Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> { + let resp = http.get(url).header("User-Agent", super::release::user_agent()).send()?; + std::fs::write(to, resp.body())?; // assume Response exposes .body() -> &[u8] + Ok(()) +} + +fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(), super::Error> { + let cmd = format!( + r#"timeout /t 2 /nobreak >nul & move /y "{src}" "{tgt}" & start "" "{tgt}""#, + src = source.to_string_lossy(), + tgt = target.to_string_lossy(), + ); + Command::new("cmd.exe") + .args(["/c", &cmd]) + .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) + .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()) + .spawn()?; + Ok(()) +} + +pub fn run_cli(args: &[String]) -> Option { + // Keep this for parity: if the user runs the binary with `--apply-update ` + // (the source's old helper signature), the inline-cmd handoff has already done the work; + // we just exit 0. + if args.len() >= 2 && args[1] == "--apply-update" { return Some(0); } + None +} + +fn stage_path() -> Result { + let base = dirs::data_local_dir() + .ok_or_else(|| super::Error::NotWritable("no data dir".into()))?; + Ok(base.join("ClaudeCodeUsageBubble").join("updates").join("update.exe")) +} + +fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> { + let parent = target.parent().ok_or_else(|| super::Error::NotWritable("no parent".into()))?; + let probe = parent.join(".probe"); + std::fs::write(&probe, b"").map_err(|e| super::Error::NotWritable(e.to_string()))?; + let _ = std::fs::remove_file(&probe); + Ok(()) +} +``` + +### Migration in `app.rs` + +Replace `updater::*` imports: +- `updater::check_for_updates()` → `update::release::fetch_latest(&http_client)` +- `updater::begin_self_update(release)` → `update::install::begin(&http_client, release)` +- `updater::current_install_channel()` → `update::current_channel()` +- `updater::handle_cli_mode(args)` → `update::run_cli(args)` +- `updater::UpdateCheckResult` → `Option` (None = up to date, Some = available) +- `updater::ReleaseDescriptor` → `update::Release` +- `updater::InstallChannel` → `update::Channel` + +### Drop NOTICE + final attribution cleanup + +After the rewrite is complete and validated: + +1. Delete `NOTICE` file. +2. Update `LICENSE`: + - Remove the "Portions ported from …" paragraph (currently at the top of LICENSE). + - Keep just the Apache-2.0 text with `Copyright 2026 tiennm99`. +3. Update `README.md`: + - Replace "Differences vs upstream" section's "derivative of … with minor adaptations" wording with "inspired by [upstream link]". + - Remove the "License" section mention of NOTICE. +4. Update `Cargo.toml`: + - `license = "Apache-2.0"` (unchanged). + +## Related code files + +**To create:** +- `src/update/mod.rs` +- `src/update/channel.rs` +- `src/update/release.rs` +- `src/update/install.rs` + +**To modify:** +- `src/main.rs` — declare `mod update;`; remove `mod updater;` +- `src/app.rs` — migrate `updater::*` call sites +- `LICENSE` — drop the upstream-attribution paragraph +- `README.md` — drop "derivative of" wording, replace with "inspired by" +- `Cargo.toml` — no functional changes + +**To delete:** +- `src/updater.rs` +- `NOTICE` + +## Implementation steps + +1. **Create `update/channel.rs`** — trivial. +2. **Create `update/release.rs`** — Version type + fetch_latest. Test by hitting GitHub API. +3. **Create `update/install.rs`** — download + inline-cmd handoff. **Test on a throwaway VM** (the handoff replaces the binary, which is risky). +4. **Create `update/mod.rs`** — re-exports. +5. **Migrate `app.rs`** — replace all `updater::*` call sites. +6. **Delete `src/updater.rs`** + `mod updater;` line. +7. **`cargo build --release`** clean. +8. **End-to-end test on Windows**: + - Stage a v0.1.1 GitHub release with a deliberately-different .exe. + - Run v0.1.0 binary, trigger update → confirm new .exe replaces old, new app launches. +9. **AFTER end-to-end test succeeds:** + - Delete `NOTICE`. + - Edit `LICENSE` — drop the upstream-attribution paragraph at the top. + - Edit `README.md` — drop "derivative of" paragraph; replace with one-line "Inspired by [CodeZeno/Claude-Code-Usage-Monitor]" (no attribution-required phrasing). +10. **Final repo audit:** + - `grep -ri "CodeZeno" src/` → must return nothing. + - `grep -ri "Claude-Code-Usage-Monitor" src/` → must return nothing. + - File names: `find src -type f -name '*.rs' | xargs -I {} basename {} | sort` and compare against upstream's file list (`models.rs`, `diagnose.rs`, `theme.rs`, `poller.rs`, `updater.rs`, `tray_icon.rs`, `native_interop.rs`, `localization/*`). **No file name should match.** + - `git log --oneline` shows the initial-port commit + 6 rewrite commits — transparent history. +11. **Commit and push:** + - Commit message: `chore: complete clean-room rewrite; drop upstream attribution` + - Push to GitHub. + +## Todo checklist + +- [ ] `update/channel.rs` +- [ ] `update/release.rs` +- [ ] `update/install.rs` +- [ ] `update/mod.rs` +- [ ] `app.rs` migrated to new updater API +- [ ] `updater.rs` deleted +- [ ] `cargo build --release` clean +- [ ] End-to-end update tested on Windows +- [ ] `NOTICE` deleted +- [ ] `LICENSE` upstream-attribution paragraph removed +- [ ] `README.md` updated to drop "derivative" wording +- [ ] Grep verifies no upstream references remain in `src/` +- [ ] File-name overlap with upstream = 0 +- [ ] Final commit + push + +## Success criteria + +- App self-updates correctly using inline-cmd handoff. +- `NOTICE` file no longer exists in repo. +- Repo passes the "no upstream references" grep test. +- GitHub's auto-license detection still reports Apache-2.0. +- The README still credits inspiration but does not claim derivative status. + +## Risks + mitigations + +| Risk | Likelihood | Mitigation | +|---|---|---| +| Inline `cmd /c` flagged by antivirus | Medium | Most AVs allow `cmd.exe` execution; if flagged, fall back to a temp `.bat` | +| `move /y` fails if exe is still loaded by Windows | Medium-High | The 2s `timeout` gives parent time to exit, fully releasing file handle | +| User has unusual `cmd.exe` path | Negligible on Windows | Use full path `C:\Windows\System32\cmd.exe` if needed | +| Drop NOTICE prematurely (before phase done) | High if rushed | Phase order: rewrite first, then drop attribution. Never reorder. | +| Legal — is "inspired by" enough? | Low (we did rewrite everything) | This is the entire point of Phases 1-5. After full rewrite, no MIT code remains; attribution is courtesy, not required | + +## Security considerations + +- The inline `cmd /c` command string is built from `std::env::current_exe()` and `stage_path()` — both internal, no user-controlled input. No shell injection. +- The downloaded asset is over HTTPS to `api.github.com` → MITM-safe. +- Update fails closed: if `move /y` fails, the old exe is still in place; user can retry. + +## Next steps + +→ Project complete. Tag v0.2.0 with the clean-room rewrite as a milestone. + +## Open questions + +- Should we sign the binary with a code-signing certificate to satisfy AV heuristics around inline-cmd updates? Out of scope for this plan; future enhancement. +- After dropping NOTICE, should we add a small "Acknowledgements" section in README that mentions inspiration from CodeZeno's project without invoking MIT attribution language? Recommended: **yes**, that's the polite move and is legally untainting since we don't claim derivation. diff --git a/plans/260516-0707-cleanroom-rewrite/plan.md b/plans/260516-0707-cleanroom-rewrite/plan.md new file mode 100644 index 0000000..fb7eed0 --- /dev/null +++ b/plans/260516-0707-cleanroom-rewrite/plan.md @@ -0,0 +1,134 @@ +--- +status: pending +created: 2026-05-16 +mode: standard (non-TDD) +brainstorm: ../reports/brainstorm-260516-0707-cleanroom-reimplementation.md +--- + +# Clean-room rewrite of ported modules + +Replace ~2,700 LOC of code copied from `CodeZeno/Claude-Code-Usage-Monitor` with genuinely-original implementations, then drop the `NOTICE` attribution file. + +## Source of truth + +All design decisions live in +[`../reports/brainstorm-260516-0707-cleanroom-reimplementation.md`](../reports/brainstorm-260516-0707-cleanroom-reimplementation.md). +Do not re-debate them during implementation. If a phase reveals a flaw, +note it in that phase's "Open questions" and ask the user before +deviating. + +## Architecture (locked) + +| Axis | Decision | +|---|---| +| HTTP | WinHTTP via `windows-rs` | +| Errors | `thiserror` per-module enums | +| Providers | `trait UsageProvider` + ClaudeProvider/ChatGptProvider | +| Credentials | `trait CredentialSource` + Vec> | +| Token refresh | `RefreshOrchestrator`, 8s timeout | +| i18n | TOML files via `include_str!` | +| Tray badges | `tiny-skia` anti-aliased | +| Updater | inline `cmd /c` handoff (no helper-exe) | +| Logging | `log` + `simplelog` | +| Build script | `embed-resource` crate | + +## New module layout + +``` +src/ + main.rs (kept — update imports only) + app.rs (kept — update imports + call-sites) + bubble.rs (kept — update imports only) + panel.rs (kept — update imports only) + settings.rs (kept — update imports only) + + diag/mod.rs — log facade + simplelog file appender + os/ — Win32 helpers (color, string, dpi, registry, theme) + net/ — WinHTTP HTTP client + usage/ — Provider trait + types + impls + refresh + creds/ — CredentialSource trait + impls + i18n/ — TOML loader + 9 locale files + tray/ — Anti-aliased tray badges + update/ — Self-updater (release check, download, handoff, channel) +``` + +## Phases + +| # | Phase | Hours | File | +|---|---|---|---| +| 1 | Infrastructure (`diag/`, `os/`, `net/winhttp.rs`, Cargo.toml) | ~9 | [`phase-01-infrastructure.md`](phase-01-infrastructure.md) | +| 2 | Types & i18n (`usage/types.rs`, `i18n/`) | ~6 | [`phase-02-types-and-i18n.md`](phase-02-types-and-i18n.md) | +| 3 | Credentials (`creds/`) | ~3 | [`phase-03-creds-module.md`](phase-03-creds-module.md) | +| 4 | Providers & refresh (`usage/anthropic.rs`, `chatgpt.rs`, `refresh.rs`) | ~8 | [`phase-04-providers-and-refresh.md`](phase-04-providers-and-refresh.md) | +| 5 | Tray badges (`tray/badge.rs`) | ~5 | [`phase-05-tray-badges.md`](phase-05-tray-badges.md) | +| 6 | Updater + remove NOTICE | ~7 | [`phase-06-updater-and-remove-notice.md`](phase-06-updater-and-remove-notice.md) | + +**Total:** ~38h core work + 4–8h Windows-side debugging. + +## Phase dependencies + +``` +Phase 1 (infra) + ├─→ Phase 2 (types + i18n) + │ └─→ Phase 4 (providers) + │ └─→ Phase 5 (tray) — needs UsageProvider results + │ + └─→ Phase 3 (creds) + └─→ Phase 4 (providers) — depends on creds API + └─→ Phase 6 (updater + cleanup) — last +``` + +Phases 2 and 3 can technically run in parallel after Phase 1, but +serial execution (1→2→3→4→5→6) is cleaner for solo work. + +## Out of scope + +- `bubble.rs`, `panel.rs`, `app.rs`, `settings.rs`, `main.rs` — these + stay; only their imports/call-sites get touched as new APIs come + online. +- Adding new features beyond what the current copied code supports. +- Changing `bubble.rs`/`panel.rs` rendering or interaction behavior. + +## External contracts that must NOT change + +- Anthropic endpoints + headers +- ChatGPT endpoint + `User-Agent: codex-cli` +- Credential file paths and JSON shapes +- WSL access via `wsl.exe` +- CLI-driven token refresh (must invoke `claude` / `codex`) +- GitHub releases JSON format +- Settings file location (`%APPDATA%\ClaudeCodeUsageBubble\settings.json`) +- Windows registry path for startup (`Software\Microsoft\Windows\CurrentVersion\Run`) +- Single-instance mutex (`Global\ClaudeCodeUsageBubble`) + +## Success criteria (cross-phase) + +After all 6 phases: + +- [ ] `cargo build --release` clean on Windows +- [ ] No file in `src/` shares a name with the upstream source's files +- [ ] `NOTICE` file removed from repo root +- [ ] `LICENSE` (Apache-2.0) header retained; copyright line updated +- [ ] `README.md` updated to drop the "derivative of" paragraph; replace + with "inspired by [upstream]" link +- [ ] App functional: bubble, panel, tray, polling, auth, updater all + working on Windows 10/11 +- [ ] No regressions vs current behavior (poll cadence, snap, click→panel, + Ctrl+Wheel resize, fullscreen auto-hide, dual-bubble) + +## Rollback strategy + +- Each phase lands as a separate commit (or PR). If a phase breaks + the build/app, `git revert` that commit to restore the prior phase's + state. +- Phase 6 is the only commit that removes upstream attribution — if any + of Phases 1–5 is incomplete or buggy at that point, **do not** drop + NOTICE; finish or revert first. + +## Open questions + +- Bump `windows-rs` from 0.58 → newer? (Brainstorm flagged this.) + Defer decision to Phase 1 — try with 0.58 first, bump only if needed. +- Keep Git history showing the initial port? Recommended: **yes**. + Transparent and consistent with the "I inspired/rewrote from X" framing + even after NOTICE is gone. diff --git a/plans/reports/brainstorm-260516-0707-cleanroom-reimplementation.md b/plans/reports/brainstorm-260516-0707-cleanroom-reimplementation.md new file mode 100644 index 0000000..f496e12 --- /dev/null +++ b/plans/reports/brainstorm-260516-0707-cleanroom-reimplementation.md @@ -0,0 +1,413 @@ +# Brainstorm: Clean-room reimplementation of copied modules + +**Date:** 2026-05-16 +**Skill:** `/ck:brainstorm` +**Goal:** Replace ~2,700 LOC of copied-from-upstream code in `claude-code-usage-bubble` with original implementations so attribution can be dropped honestly. + +--- + +## TL;DR — brutal verdict + +**Option 2 (rewrite) is worth doing IF AND ONLY IF you commit ~3–5 focused days of work.** The clean-room version is structurally better (trait-based providers, embedded TOML i18n, anti-aliased tray icons, WinHTTP instead of ureq, simpler updater) and lets you drop NOTICE. If you just want this app for personal use, Option 1 (keep NOTICE) is strictly cheaper and totally fine — MIT attribution is normal practice in OSS and nobody is going to sue you. + +Recommendation if you proceed: **adopt all 10 axis recommendations below**. Mixing-and-matching produces a hybrid that still looks derivative. + +--- + +## Scope reminder + +**OUT of scope (kept as-is — original work):** +`bubble.rs`, `panel.rs`, `app.rs`, `settings.rs`, `main.rs` (~2,900 LOC original). + +**IN scope (currently copied — to rewrite):** +`models.rs`, `diagnose.rs`, `theme.rs`, `poller.rs`, `updater.rs`, `tray_icon.rs`, `localization/*` (9 files), `native_interop.rs`, `build.rs`. Total ~2,700 LOC. + +**External contracts that CANNOT change** (would break the app): +Anthropic + ChatGPT endpoint paths/headers, credential file paths/JSON shapes, WSL access via `wsl.exe`, CLI-driven token refresh, GitHub releases JSON, Windows `Run` registry path. + +--- + +## Axis-by-axis recommendation + +Each row gives the **top pick** and runners-up, with explicit trade-offs. + +| # | Axis | Top pick | Runner-up | Why | +|---|---|---|---|---| +| 1 | HTTP / async | **WinHTTP via `windows-rs`** | attohttpc | OS-native, no TLS crate needed, respects Windows proxy, smaller binary, visibly different | +| 2 | Errors | **`thiserror` per-module enums** | anyhow | Typed errors per subsystem, no runtime cost, retry logic can match on variant | +| 3 | Providers | **`trait UsageProvider`** | enum + match | Structurally different from source's flat `poll_claude()`/`poll_codex()`; extensible for future providers | +| 4 | Credential discovery | **`trait CredentialSource` + Vec>** | flat fn chain | Pluggable, easy to add new locations | +| 5 | Token refresh | **`RefreshOrchestrator` type, brief-wait** | non-blocking spawn | State-machine in its own type; waits 5–8s then proceeds (vs source's 30s block) | +| 6 | Localization | **Embedded TOML via `include_str!`** | gettext | Each language a TOML file in `i18n/locales/*.toml`; parsed at startup; easy for translators | +| 7 | Tray badges | **`tiny-skia` for anti-aliased rendering** | pre-rendered PNG set | Pure-Rust 2D, visibly nicer than GDI primitives, +~200 KB binary | +| 8 | Updater handoff | **detached `.bat` script** | helper-exe (current) | One-off script writes, moves, restarts; no duplicate-exe pattern | +| 9 | Diagnose / logging | **`log` + `simplelog` file appender** | `tracing` | Standard ecosystem, level-aware, replaces hand-rolled OnceLock> | +| 10 | Build script | **`embed-resource`** | winres (current) | Modern replacement; identical capabilities; new Cargo.toml line | + +### Detailed trade-offs + +#### Axis 1 — WinHTTP vs ureq + +| | ureq (source) | WinHTTP (recommended) | +|---|---|---| +| Binary size | +600 KB (ureq + rustls + native-tls) | 0 (Windows ships it) | +| Proxy handling | manual via env vars | automatic (system proxy) | +| TLS | native-tls (delegates to SChannel) | SChannel directly | +| Code shape | `agent.get(url).set(header, val).call()?` | `WinHttpOpen → WinHttpConnect → WinHttpOpenRequest → WinHttpSendRequest → WinHttpReceiveResponse` | +| Verbosity | low | high (but encapsulate in `net::winhttp::Client`) | +| Clean-room win | low (same crate as source) | **high** | + +WinHTTP via `windows-rs`'s `Win32::Networking::WinHttp` module is the genuinely-different choice. Encapsulate the verbosity in one ~150-line `net::winhttp::Client` type and the rest of the code looks like normal HTTP. + +#### Axis 2 — Error handling + +Source uses `enum PollError { AuthRequired, NoCredentials, TokenExpired, RequestFailed }`. Visibly similar to thiserror enums, but: per-module thiserror is **structurally** different. Source has ONE error type for ALL of poller.rs. Clean-room splits into `usage::Error`, `creds::Error`, `update::Error`, `net::Error`, each with their own variants. This forces conversion at boundaries via `#[from]` — which is itself a different pattern. + +#### Axis 3 — Provider trait + +```rust +// New shape: +pub trait UsageProvider: Send { + fn id(&self) -> ProviderId; + fn poll(&mut self, http: &dyn HttpClient) -> Result; +} + +pub struct ClaudeProvider { creds: Box, ... } +pub struct ChatGptProvider { creds: ChatGptAuth, ... } +``` + +vs source: + +```rust +// Source shape: +pub fn poll(show_claude_code: bool, show_codex: bool) -> Result { + if show_claude_code { ... poll_claude_code()? } + if show_codex { ... poll_codex()? } +} +``` + +The trait shape supports any number of providers, returns per-provider results, and `app.rs` becomes a registry instead of a switch. **Substantially different code shape.** + +#### Axis 4 — Credential discovery + +```rust +// New: +pub trait CredentialSource { + fn id(&self) -> &str; + fn read(&self) -> Result; + fn refresh(&self) -> Result<(), creds::Error>; + fn signature(&self) -> String; // for change-detection +} + +pub struct LocalClaudeCreds { path: PathBuf } +pub struct WslClaudeCreds { distro: String } +pub struct CodexCreds { path: PathBuf } + +pub struct CredentialLocator { sources: Vec> } +impl CredentialLocator { + pub fn find_first(&self) -> Option<&dyn CredentialSource>; + pub fn all_signatures(&self) -> Vec; +} +``` + +#### Axis 5 — Token refresh + +`RefreshOrchestrator` type: + +```rust +pub struct RefreshOrchestrator { + cli_resolver: CliResolver, + timeout: Duration, +} + +pub enum RefreshOutcome { + Refreshed, + StillExpired, + CliMissing, + Timeout, +} + +impl RefreshOrchestrator { + pub fn refresh(&self, source: &dyn CredentialSource) -> RefreshOutcome; +} +``` + +Default `timeout` is 8s (vs source's 30s). Falls through faster on failure; next poll cycle catches up. UX: user sees "..." for ~5 min after token expiry if refresh truly takes too long, vs 30 s of UI hang in source. + +#### Axis 6 — Localization + +``` +i18n/locales/ + en.toml # english + vi.toml + ja.toml + ko.toml + zh-tw.toml + fr.toml + de.toml + es.toml + nl.toml +``` + +Each TOML: + +```toml +[ui] +window_title = "Claude Code Usage Bubble" +refresh = "Refresh" +update_frequency = "Update frequency" +# ... +[duration] +day_suffix = "d" +hour_suffix = "h" +# ... +``` + +At startup: `include_str!("locales/en.toml")` → parse via `toml::from_str::` → cache. Locale detection unchanged (still Win32 `GetUserPreferredUILanguages`). + +Public surface: +```rust +pub struct I18n { + strings: HashMap<&'static str, LocaleStrings>, + active: &'static str, +} +impl I18n { + pub fn load(active_code: Option<&str>) -> Self; + pub fn get(&self) -> &LocaleStrings; +} +``` + +vs source's `LanguageId::English.strings()` enum-dispatched const tables. Same outcome, completely different shape. Plus: adding a new language is `cp en.toml fr.toml && translate` — no Rust code changes. + +#### Axis 7 — Tray badges with tiny-skia + +```rust +pub fn render_badge(percent: Option, kind: BadgeKind, size: u32) -> Vec /* BGRA */ { + let mut pixmap = Pixmap::new(size, size).unwrap(); + // Anti-aliased fill via tiny-skia Path + Paint + // Drop into HICON via CreateIconFromResourceEx +} +``` + +Source draws DIB sections + GDI primitives (rectangles, text). tiny-skia uses path rendering with vector fills, anti-aliased. Result: smoother percentage badges. Trade-off: ~200 KB binary, but ergonomics for ring/arc drawing are massively better than GDI. + +#### Axis 8 — Updater via .bat handoff + +```rust +// Download new.exe to %TEMP%\bubble-update.exe +// Write %TEMP%\bubble-swap.bat: +// @echo off +// timeout /t 2 /nobreak >nul +// move /y "%~dp0bubble-update.exe" "C:\Path\To\claude-code-usage-bubble.exe" +// start "" "C:\Path\To\claude-code-usage-bubble.exe" +// del "%~f0" +// Spawn cmd /c bubble-swap.bat with DETACHED_PROCESS | CREATE_NO_WINDOW +// Exit current process +``` + +Source: copies `current.exe → updater-helper.exe`, spawns helper with `--apply-update`, helper waits for parent to exit, copies new over, restarts. + +New approach: shell script does the same dance, no duplicate exe. Simpler. Visibly different. Mild reliability risk if the user's PATH lacks `cmd.exe` (essentially impossible on Windows) or if antivirus blocks the .bat (more likely than .exe blocking, actually). Mitigation: use `cmd /c ...` directly with no temp .bat — pass commands inline. + +Cleaner inline version: +```rust +Command::new("cmd.exe") + .args(["/c", "timeout /t 2 /nobreak >nul & move /y \"new.exe\" \"target.exe\" & start \"\" \"target.exe\""]) + .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) + .spawn()?; +std::process::exit(0); +``` + +Even simpler — no .bat file at all. + +#### Axis 9 — log + simplelog + +```rust +// diag/mod.rs +pub fn init(enabled: bool) -> Result { + if !enabled { return Ok(PathBuf::new()); } + let path = std::env::temp_dir().join("claude-code-usage-bubble.log"); + WriteLogger::init(LevelFilter::Debug, Config::default(), File::create(&path)?)?; + Ok(path) +} +``` + +Then everywhere: `log::info!("...")`, `log::error!("...")`. Standard, idiomatic, visibly different from source's `diagnose::log(format!(...))`. + +#### Axis 10 — embed-resource vs winres + +```rust +// build.rs: +fn main() { + embed_resource::compile("res/icon.rc", embed_resource::NONE); +} +``` + +Plus `res/icon.rc`: +``` +1 ICON "icon.ico" +1 VERSIONINFO + FILEVERSION 0,1,0,0 + PRODUCTVERSION 0,1,0,0 + ... +END +``` + +Trade-off: explicit .rc file instead of `WindowsResource` builder calls. Slightly more verbose. Visibly different. Same output. + +--- + +## Proposed new module layout + +``` +src/ + main.rs (unchanged) + app.rs (refactor imports only) + bubble.rs (refactor imports only) + panel.rs (refactor imports only) + settings.rs (refactor imports only) + + net/ + mod.rs pub use winhttp::Client; + winhttp.rs WinHTTP wrapper (~150 LOC) + + usage/ + mod.rs Provider trait + ProviderId enum + types.rs UsageWindows, Window, Reset, ProviderId + anthropic.rs ClaudeProvider impl + chatgpt.rs ChatGptProvider impl + refresh.rs RefreshOrchestrator + headers.rs anthropic rate-limit header parsing + + creds/ + mod.rs CredentialSource trait + CredentialLocator + local_fs.rs LocalClaudeCreds (Windows path) + wsl_bridge.rs WslClaudeCreds (wsl.exe spawn) + codex_auth.rs CodexCreds (Codex auth.json) + + i18n/ + mod.rs I18n loader, LocaleStrings struct + detect.rs Win32 locale detection + locales/ + en.toml, vi.toml, ja.toml, ko.toml, zh-tw.toml, fr.toml, de.toml, es.toml, nl.toml + + update/ + mod.rs pub use install::run; + release.rs GitHub releases fetch + Version cmp + install.rs Download + inline-cmd-handoff + channel.rs Portable/Winget detection + + tray/ + mod.rs Add/Update/Remove/Sync + badge.rs tiny-skia badge renderer + DIB→HICON + callback.rs WM_APP_TRAY dispatch + + os/ + mod.rs pub use everywhere + color.rs Color, ARGB helpers + string.rs wide_str + dpi.rs DPI helpers + registry.rs Registry read/write wrapper + theme.rs Dark mode detection + + diag/ + mod.rs log facade + simplelog init +``` + +Each former flat-file module becomes a directory with clearer responsibility splits. **No file shares a name with the source repo.** + +--- + +## Risk callout — what breaks? + +1. **`app.rs` / `bubble.rs` / `panel.rs` imports** — they currently reference `crate::poller::*`, `crate::models::*`, `crate::tray_icon::*`, `crate::theme::*`, `crate::diagnose::*`, `crate::localization::*`, `crate::updater::*`. ALL of these change. Estimated: ~80 import-line edits + ~30 call-site refactors (e.g. `poller::poll(...)` → `provider_registry.poll_all(&http_client)`). Concentrated in `app.rs`. + +2. **`AppUsageData` shape** — currently `{ claude_code: Option, codex: Option }`. New shape per provider trait: `HashMap>` or `Vec<(ProviderId, ProviderResult)>`. `app.rs` orchestrator needs reshape. + +3. **WinHTTP learning curve** — first time you write WinHTTP code, expect 4–6 hours of stumbling on quirks (chunked transfer, automatic decompression, proxy bypass list, certificate validation flags). Mitigated by encapsulating in one `net::winhttp::Client` type. + +4. **tiny-skia + HICON conversion** — need a small `pixmap_to_hicon()` helper that wraps `CreateIconFromResourceEx` or `CreateIconIndirect`. Expect 2 hours including pixel-format debugging. + +5. **TOML escape edge cases** — strings with `"` or non-ASCII need proper escaping. `toml` crate handles this on read but you'll write 9 TOML files by hand. Half-day of careful copying. + +6. **Updater inline-cmd race** — if user closes the app during the 2-second `timeout`, the move/start succeeds anyway because the file lock releases on exit. Edge case: AV may flag the inline cmd as suspicious. Less likely than .bat-file flagging. + +7. **Behavior regressions** — source code is proven through real usage. Your new code is fresh. Expect first-launch bugs that the source didn't have: WSL probe order, ChatGPT-Account-Id header omission, rate-limit-header zero-default semantics, ClipBoardCheck on token-refresh. + +--- + +## Time estimate + +| Module | LOC | Hours | +|---|---|---| +| `os/` (color, string, dpi, registry, theme) | ~120 | 2 | +| `net/winhttp.rs` | ~150 | 6 | +| `usage/types.rs` + `usage/headers.rs` | ~100 | 2 | +| `usage/anthropic.rs` (+ ChatGptProvider) | ~250 | 4 | +| `usage/refresh.rs` | ~120 | 2 | +| `creds/{local_fs,wsl_bridge,codex_auth}` | ~200 | 3 | +| `i18n/` + 9 TOML files | ~250 | 4 | +| `tray/badge.rs` (tiny-skia) | ~150 | 3 | +| `tray/mod.rs` + callback | ~200 | 2 | +| `update/{release,install,channel}` | ~250 | 3 | +| `diag/` | ~30 | 0.5 | +| `app.rs` / `bubble.rs` / `panel.rs` refactor | — | 4 | +| `build.rs` + `res/icon.rc` | ~30 | 0.5 | +| Cargo.toml feature/dep updates | — | 0.5 | +| First-launch debugging on Windows | — | 4–8 | +| **Total** | ~1,850 | **40–48 hours** | + +So: **about a week of focused work** to do this properly. Note this is LESS than the 2,700 LOC currently copied, because the new code is more idiomatic and avoids some of the source's verbosity. + +--- + +## Brutal honesty: do or don't? + +| If you want to … | Do option 1 (keep NOTICE) | Do option 2 (rewrite) | +|---|---|---| +| Ship today | ✅ | ❌ | +| Use it yourself privately | ✅ | overkill | +| Open-source publish under your name | ✅ legally fine | ✅ legally cleaner | +| Maintain long-term | tolerable | **better foundation** | +| Brag about it being "yours" | morally questionable | **honest** | +| Avoid any chance of an upstream complaint | overcautious | unnecessary | + +My honest recommendation: **option 1 is fine for ~95% of users**. MIT-derivative-with-NOTICE is normal open source. The upstream's MIT license explicitly allows what you're doing. No reasonable person would object. + +Option 2 is worth it if: +- You enjoy the architecture work +- You want to learn WinHTTP / tiny-skia / log-tracing +- You plan to make this a longer-term project with translators, contributors, etc. +- You'd rather not maintain the `NOTICE` file going forward + +--- + +## If you approve option 2 — implementation order + +I'd structure the rewrite as 6 phases (one PR each): + +1. **Phase 1 — Infrastructure**: `diag/` + `os/` + `net/winhttp.rs` + Cargo.toml. No behavior changes. Compile-only. +2. **Phase 2 — Types & i18n**: `usage/types.rs`, `i18n/` + 9 TOML files. Replace source's `models.rs` + `localization/*`. Refactor consumers' imports. +3. **Phase 3 — Credentials**: `creds/`. Replace source's credential reading inside poller. Test on Windows. +4. **Phase 4 — Providers + refresh**: `usage/anthropic.rs`, `usage/chatgpt.rs`, `usage/refresh.rs`. Replace source's poller. Test full poll flow on Windows. +5. **Phase 5 — Tray badges**: `tray/badge.rs` + integration. Replace `tray_icon.rs`. +6. **Phase 6 — Updater + remove NOTICE**: `update/`, drop `NOTICE`, update README + LICENSE comment. + +After phase 6: zero source-derivative code remains. Drop NOTICE legally. + +--- + +## Decision needed + +Pick one before I touch code: + +1. **Stop here, keep NOTICE** → no further action. Repo stays as-is. +2. **Proceed with rewrite** → invoke `/ck:plan` to produce per-phase plan documents based on this brainstorm. Estimated 40–48h work, 6 PRs. +3. **Partial rewrite** → tell me which specific axes/modules you want and we draft a narrower plan. + +--- + +## Open questions + +- Do you want to bump the project to use `windows-rs 0.59+` while we're at it? Source uses 0.58. Newer versions have minor API differences. Slight extra risk but more current. +- Should the new repo's history retain the initial-port commit (so the rewrite shows up as a fresh series of commits), or should we squash-rewrite to claim authorship cleanly? I'd suggest **retain history** — it's transparent about what happened. diff --git a/plans/reports/derivation-audit-260516-0747.md b/plans/reports/derivation-audit-260516-0747.md new file mode 100644 index 0000000..e8983dd --- /dev/null +++ b/plans/reports/derivation-audit-260516-0747.md @@ -0,0 +1,120 @@ +# Derivation audit: claude-code-usage-bubble vs Claude-Code-Usage-Monitor + +**Date:** 2026-05-16 +**Source repo:** `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor` — MIT, "Copyright (c) 2025 Craig Constable" per `LICENSE` (note: NOTICE in new repo claims "2026 Code Zeno Pty Ltd"; the actual upstream LICENSE names a person) +**New repo:** `/config/workspace/tiennm99/claude-code-usage-bubble` — Apache-2.0 with NOTICE +**Method:** byte comparison (`cmp -s`), `diff -u`, longest-contiguous-identical-run analysis (Python), line-set intersection (`comm -12`). + +--- + +## Per-file classification + +| File | LOC | Category | Similarity | Notes | +|---|---|---|---|---| +| `src/poller.rs` | 1099 | **COPY** | 100% | `cmp -s` byte-identical. Zero changes. Biggest single risk surface. | +| `src/tray_icon.rs` | 441 | **COPY** | 100% | Byte-identical. | +| `src/theme.rs` | 51 | **COPY** | 100% | Byte-identical. | +| `src/models.rs` | 19 | **COPY** | 100% | Byte-identical. | +| `src/localization/mod.rs` | 246 | **COPY** | 100% | Byte-identical. | +| `src/localization/dutch.rs` | 46 | **COPY** | 100% | Byte-identical. | +| `src/localization/english.rs` | 46 | **COPY** | 100% | Byte-identical. | +| `src/localization/french.rs` | 46 | **COPY** | 100% | Byte-identical. | +| `src/localization/german.rs` | 46 | **COPY** | 100% | Byte-identical. | +| `src/localization/japanese.rs` | 46 | **COPY** | 100% | Byte-identical. | +| `src/localization/korean.rs` | 46 | **COPY** | 100% | Byte-identical. | +| `src/localization/spanish.rs` | 46 | **COPY** | 100% | Byte-identical. | +| `src/localization/traditional_chinese.rs` | 46 | **COPY** | 100% | Byte-identical. | +| `src/updater.rs` | 512 | **PATCH** | ~95% | 33-line diff. Changes: `RELEASE_ASSET_NAME`/`WINGET_PACKAGE_ID` renamed, `current_install_channel()` short-circuits to `Portable`, `ClaudeCodeUsageMonitor` -> `ClaudeCodeUsageBubble` in cache dir, three `#[allow(dead_code)]` attributes added. Algorithm and structure intact. | +| `src/diagnose.rs` | 52 | **PATCH** | ~98% | Single-line rename: log file `claude-code-usage-monitor.log` -> `claude-code-usage-bubble.log`. Otherwise identical. | +| `src/native_interop.rs` | 71 | **PATCH** | trimmed (108 LOC dropped) | Removed taskbar-embedding helpers (`find_taskbar`, `find_child_window`, `get_taskbar_rect`, `embed_in_taskbar`, WinEvent hooks). Kept `Color` struct, `colorref`, `wide_str`, `move_window`, `get_window_rect_safe`, timer/WM_APP constants — all verbatim names, signatures, bodies. Added two new timer/message consts (`TIMER_FULLSCREEN_CHECK`, `WM_APP_PANEL_TOGGLE`, `WM_APP_PANEL_CLOSE`). What remains is a verbatim subset of upstream. | +| `src/main.rs` | 49 | **PATCH** | ~50% | Same shape (parse `--diagnose`, init logging, dispatch to UI). Module list expanded; `window::run()` -> `app::run()`; an `if let Ok(path) = ...` refactor replacing `match`. Modules `app`, `bubble`, `panel`, `settings`, `diag`, `net`, `os` added; `window` removed. | +| `src/app.rs` | 1354 | **REWRITE** | ~25% line-set overlap with upstream `window.rs` | Carved out of upstream's 2847-LOC `window.rs`. Shares constants (`STARTUP_REGISTRY_PATH`, `STARTUP_VALUE_NAME`, `RETRY_BASE_MS`, `UPDATE_CHECK_INTERVAL_SECS`) and short adapted blocks (mutex setup, ~10 contiguous lines max). Structure genuinely reworked but lifts named consts, message-pump idioms, and update-scheduler logic. Fair-use derivative — *not* clean-room. | +| `src/bubble.rs` | 817 | **REWRITE** | ~23% line-set overlap | New floating-bubble window (popup + layered alpha, hit-testing). Borrows Win32 boilerplate (`SendMessageW(WM_SETICON, ...)`, `ExtractIconExW`, `GetDpiForWindow`); longest contiguous identical run is 16 lines of icon-attach plumbing. Core bubble rendering (per-pixel alpha, circle hit-test) is original. | +| `src/panel.rs` | 506 | **REWRITE** | ~24% line-set overlap | New popup panel for the expanded view. Longest identical run = 8 lines (generic GDI boilerplate). Original UI design (horizontal bars, session/weekly). | +| `src/settings.rs` | 140 | **REWRITE** | ~26% line-set overlap (mostly `serde` boilerplate) | New `Settings` struct with `BubblePositions`, persisted to JSON in `%LOCALAPPDATA%\ClaudeCodeUsageBubble`. Mostly original; longest identical run = 4 lines. | +| `src/diag/mod.rs` | 29 | **ORIGINAL** | 0% | New `log` + `simplelog` facade. No upstream counterpart. Parallels (not replaces) upstream's `diagnose.rs` which still exists in both. | +| `src/os/mod.rs` | 14 | **ORIGINAL** | 0% | New. | +| `src/os/string.rs` | 10 | **ORIGINAL** | 0% | New. | +| `src/os/color.rs` | 46 | **ORIGINAL** | 0% | New. | +| `src/os/dpi.rs` | 31 | **ORIGINAL** | 0% | New. | +| `src/os/registry.rs` | 159 | **ORIGINAL** | 0% | New typed registry wrapper. Parallel to upstream's inline `RegOpenKeyExW` calls. | +| `src/os/theme.rs` | 16 | **ORIGINAL** | 0% | New. Parallel to upstream's inline `theme.rs` logic. | +| `src/net/mod.rs` | 9 | **ORIGINAL** | 0% | New. | +| `src/net/winhttp.rs` | 431 | **ORIGINAL** | 0% | New WinHTTP-based blocking HTTP client. Replaces `ureq` once Phase 4/6 ships. | +| `res/icon.rc` | 32 | **ORIGINAL** | 0% | New. Upstream uses `winres` builder API in `build.rs` for the same effect. | +| `Cargo.toml` | 47 | **PATCH** | ~70% | Same dep list (`ureq`, `native-tls`, `serde`, `serde_json`, `dirs`, `windows`) plus 3 added (`log`, `simplelog`, `thiserror`). Pkg name/version/license/repo URLs changed; winres -> embed-resource; one `windows` feature swapped (`Win32_UI_Accessibility` dropped, `Win32_Networking_WinHttp` added). The winres `[package.metadata.winres]` block removed in favour of `res/icon.rc`. Profile-release block unchanged. | +| `build.rs` | 16 | **REWRITE** | different impl | Same purpose (compile icon into PE resources). Old uses `winres::WindowsResource` builder API + custom `pack_version` helper; new shells out to `embed-resource::compile("res/icon.rc")`. Structurally different but functionally equivalent. | +| `src/icons/*` (9 binary files) | n/a | **COPY** | 100% | All 9 icons (`16.svg`, `16x16.png`, `32.svg`, `32x32.png`, `48.svg`, `48x48.png`, `256.svg`, `256x256.png`, `icon.ico`) byte-identical to upstream. | +| `LICENSE` | n/a | swapped | n/a | Apache-2.0 (upstream is MIT). | +| `NOTICE` | n/a | new | n/a | Attribution to upstream + reproduces upstream MIT text. **BUG:** names "© 2026 Code Zeno Pty Ltd" but the actual upstream LICENSE says "Copyright (c) 2025 Craig Constable". The reproduced MIT block does not match the actual upstream MIT block verbatim — that's a defect in the attribution. | +| `README.md` | n/a | new | n/a | Documents the derivation: "This project is a derivative of CodeZeno/Claude-Code-Usage-Monitor (MIT, © 2026 Code Zeno Pty Ltd)" — same 2026/Code-Zeno error as NOTICE. | + +--- + +## Totals (LOC weighted, code only; excludes icons/LICENSE/NOTICE/README) + +| Category | LOC | % | +|---|---|---| +| **COPY** (byte-identical) | 2224 | 33.9% | +| **PATCH** (light edits) | 731 | 11.1% | +| **REWRITE** (same purpose, restructured, some idiom carryover) | 2833 | 43.2% | +| **ORIGINAL** (no upstream counterpart) | 777 | 11.8% | +| **TOTAL** | 6565 | 100% | + +- **Derivation % (COPY + PATCH only):** **45.0%** — this is the legally-attribution-required portion under the strictest reading. +- **Derivation % including REWRITE:** **88.2%** — what you'd cite under a broad reading of "derivative work" (REWRITE files are split from upstream's `window.rs` with shared constants/idioms and would not exist in their current form without the upstream codebase). +- **Wholly original code:** **11.8%** (777 LOC) — `diag/`, `os/`, `net/`, `res/icon.rc`. + +The user's expectation that "most files are no longer related" is **wrong**. Of 31 code files compared: +- 13 files (1932 LOC) are **byte-for-byte identical** to upstream. +- 4 files (684 LOC) are **trivial renames** of upstream. +- 4 files (2817 LOC) are **restructurings** that still share idioms and constants. +- 10 files (777 LOC) are **fully new**. + +Specifically `poller.rs` (1099 LOC), `tray_icon.rs` (441 LOC), and all 9 localization files (614 LOC) are unchanged. That alone is 2154 LOC = ~33% of the codebase, byte-identical. + +--- + +## Verdict + +### 1. Is the NOTICE file still legally required? +**Yes — unambiguously.** The MIT license requires *"The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software."* 13 files (2224 LOC, ~34%) are byte-identical copies; another 4 files are light renames. These are "substantial portions" by any reasonable measure. Removing the NOTICE while keeping these files would breach the MIT terms. + +### 2. Is Apache-2.0 compatible with upstream MIT for the copied portions? +**Yes.** MIT is compatible with relicensing the derivative under Apache-2.0, provided the original MIT copyright and permission notice are preserved (which is what NOTICE accomplishes). This is the standard "MIT → Apache-2.0" upgrade path. The new repo's outbound Apache-2.0 license governs the *combined* work; the MIT terms still bind the copied portions but are satisfied by NOTICE. + +### 3. Attribution defects to fix +Two material issues in the current NOTICE/README: + +a) **Wrong copyright holder name.** Upstream `LICENSE` says `Copyright (c) 2025 Craig Constable`. NOTICE and README both claim `Copyright (c) 2026 Code Zeno Pty Ltd`. The reproduced MIT block in NOTICE does not match the actual upstream MIT block. **Action:** correct NOTICE to reproduce the upstream MIT verbatim (`(c) 2025 Craig Constable`), or at minimum cite both names if Code Zeno Pty Ltd is an additional rights holder somewhere else (e.g. winres metadata in upstream `Cargo.toml` does name `Code Zeno Pty Ltd` — but that is metadata, not the LICENSE copyright line). + +b) **NOTICE understates the scope of copying.** Current NOTICE lists 7 modules as "ported with minor adaptations" and claims `app.rs`, `bubble.rs`, `panel.rs`, `settings.rs` are "original". That's defensible for `bubble.rs`/`panel.rs`/`settings.rs` but **`app.rs` is a carve-out of `window.rs`**, not original. It reuses upstream constants, the message-pump dispatch shape, and the update-check scheduler. Strictly truthful wording: *"`app.rs` was derived by splitting upstream's `window.rs` and is partly original / partly adapted"*. + +### 4. Recommendation if the user wants to drop NOTICE +**Cannot drop NOTICE today.** The minimum work to be able to drop attribution: + +1. **Phase 4** of the existing plan retires `poller.rs` (1099 LOC, 17% of total) — biggest single win. +2. **Phase 5** retires `tray_icon.rs` (441 LOC). +3. **Phase 2** retires `models.rs` (19) and `localization/*` (614 LOC). +4. **Phase 6** retires `updater.rs` (512), `diagnose.rs` (52), `theme.rs` (51), `native_interop.rs` (71). + +After all six phases execute as planned, *every* COPY and PATCH file is removed. The only remaining derivative content would be the REWRITE files (`app.rs`, `bubble.rs`, `panel.rs`, `settings.rs`), and at that point the line-set overlap is mostly Win32 idioms / common consts (mutex setup, registry path) which are not copyrightable on their own. **NOTICE can be dropped only after Phase 6 completes** — not before. The existing plan file `phase-06-updater-and-remove-notice.md` correctly sequences NOTICE removal as the *last* step. + +The plan's framing as a "clean-room rewrite" is technically inaccurate today: the current state is ~45% direct derivation, not clean-room. After Phase 6 it becomes a clean-room rewrite. Until then, NOTICE is load-bearing. + +### 5. Risk summary (blunt) +- The user's mental model "most files are no longer related" is **false**. Roughly **half of the codebase by LOC is direct copy or trivial rename** of upstream. The other half is either substantial rework (genuinely derivative) or genuinely new. +- The biggest single derivation surface is `poller.rs` — 1099 LOC, byte-identical, 17% of the entire codebase by itself. +- The NOTICE has a copyright-holder-name defect that should be corrected regardless of any future plans (this is technically a license breach today — the MIT requires the *actual* copyright notice to be preserved, not a paraphrased one). +- If the project ships before Phase 4-6 complete, the NOTICE must stay, must be correct, and must continue to ship in every release artifact (binary releases, source tarballs). + +--- + +## Unresolved questions + +1. Why does the upstream `Cargo.toml` `[package.metadata.winres]` block list `Code Zeno Pty Ltd` as `CompanyName`/`LegalCopyright` while the upstream `LICENSE` names `Craig Constable`? Two possibilities: (a) `Craig Constable` is the author who later assigned to or works for Code Zeno Pty Ltd, or (b) the upstream LICENSE file was never updated when Code Zeno took over. Either way, the new repo's NOTICE should reproduce the upstream MIT text **verbatim** — not paraphrase it with a different copyright holder. +2. Phase 1 of the existing plan introduced `os/`, `net/`, `diag/`, and `res/icon.rc` but did *not* delete any legacy code — they sit alongside the unchanged copies. Confirmed by reading `main.rs` (both module sets are mounted). The "clean-room rewrite" name is therefore aspirational for the project, not descriptive of current state. +3. Should `res/icon.rc`'s `CompanyName "tiennm99"` instead read something acknowledging the upstream icon copyright? The `.ico` is byte-identical to upstream's. The PE VERSIONINFO block is the public-facing metadata users see in Properties dialogs. + +**Status:** DONE +**Summary:** Forensic audit complete. ~34% of the new repo is byte-identical copy of upstream; another ~11% is trivially renamed; ~43% is structural derivative; only ~12% is fully original. NOTICE is legally required and has a copyright-name defect; can only be safely removed after the plan's Phase 6 retires the last COPY/PATCH files. Report written to `/config/workspace/tiennm99/claude-code-usage-bubble/plans/reports/derivation-audit-260516-0747.md`. diff --git a/res/icon.rc b/res/icon.rc new file mode 100644 index 0000000..17542c5 --- /dev/null +++ b/res/icon.rc @@ -0,0 +1,32 @@ +#include + +1 ICON "..\\src\\icons\\icon.ico" + +1 VERSIONINFO +FILEVERSION 0,1,0,0 +PRODUCTVERSION 0,1,0,0 +FILEFLAGSMASK 0x3fL +FILEFLAGS 0x0L +FILEOS 0x40004L +FILETYPE 0x1L +FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904E4" + BEGIN + VALUE "CompanyName", "tiennm99\0" + VALUE "FileDescription", "Claude Code Usage Bubble\0" + VALUE "FileVersion", "0.1.0\0" + VALUE "InternalName", "ClaudeCodeUsageBubble\0" + VALUE "LegalCopyright", "Copyright (C) 2026 tiennm99\0" + VALUE "OriginalFilename", "claude-code-usage-bubble.exe\0" + VALUE "ProductName", "Claude Code Usage Bubble\0" + VALUE "ProductVersion", "0.1.0\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END diff --git a/src/app.rs b/src/app.rs index aa47313..9178edf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,9 @@ -// Application orchestrator: single-instance mutex, settings, polling thread, -// tray icons, context menu, message-only window for cross-thread updates, and -// dispatch from bubble UI callbacks. +// App orchestrator. +// +// One `Mutex>` is the single source of truth. The UI +// thread runs the message loop; a background thread polls the provider +// registry and posts `WM_APP_USAGE_UPDATED` back via a hidden +// message-only window owned by this module. use std::collections::HashMap; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -9,59 +12,58 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use windows::core::PCWSTR; use windows::Win32::Foundation::*; use windows::Win32::System::LibraryLoader::GetModuleHandleW; -use windows::Win32::System::Registry::*; use windows::Win32::System::Threading::CreateMutexW; -use windows::Win32::UI::HiDpi::*; +use windows::Win32::UI::HiDpi::{ + SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, +}; use windows::Win32::UI::WindowsAndMessaging::*; use crate::bubble; -use crate::diagnose; -use crate::localization::{self, LanguageId, Strings}; -use crate::models::AppUsageData; -use crate::native_interop::{ - wide_str, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, WM_APP_TRAY, - WM_APP_USAGE_UPDATED, -}; +use crate::i18n::{self, I18n, LocaleStrings}; +// Win32 message + timer IDs moved inline; see constants below. +use crate::net; +use crate::os; use crate::panel::{self, PanelData}; -use crate::poller::{self, PollError}; -use crate::settings::{self, BubblePositions, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN}; -use crate::theme; -use crate::tray_icon::{self, TrayAction, TrayIconData, TrayIconKind}; -use crate::updater::{self, InstallChannel, UpdateCheckResult}; +use crate::settings::{self, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN}; +use crate::tray::{self, TrayAction, TrayIcon as TrayIconData}; +use crate::usage::ProviderId as TrayIconKind; +use crate::tray::WM_APP_TRAY; +use crate::update::{self, Channel as InstallChannel, CheckOutcome}; +use crate::usage::{self, ProviderId, Registry, UsageWindows}; -const APP_MUTEX_NAME: &str = "Global\\ClaudeCodeUsageBubble"; +// Win32 message IDs owned by this module. +pub const WM_APP_USAGE_UPDATED: u32 = 0x8001; + +// Timer IDs used with `SetTimer(msg_hwnd, …)`. +const TIMER_POLL: usize = 1; +const TIMER_COUNTDOWN: usize = 2; +const TIMER_RESET_POLL: usize = 3; +const TIMER_UPDATE_CHECK: usize = 4; + +const APP_MUTEX_NAME: &str = r"Global\ClaudeCodeUsageBubble"; const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; const STARTUP_VALUE_NAME: &str = "ClaudeCodeUsageBubble"; const APP_CLASS_NAME: &str = "ClaudeCodeUsageBubbleApp"; +const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); const UPDATE_CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; -const RETRY_BASE_MS: u32 = 30_000; +const BALLOON_COOLDOWN: Duration = Duration::from_secs(30 * 60); +const REFRESH_TIMEOUT: Duration = Duration::from_secs(8); -// ---------- Menu command IDs ---------- +// ---------- Menu IDs ---------- const IDM_REFRESH: u16 = 1; const IDM_EXIT: u16 = 2; - const IDM_FREQ_1MIN: u16 = 10; const IDM_FREQ_5MIN: u16 = 11; const IDM_FREQ_15MIN: u16 = 12; const IDM_FREQ_1HOUR: u16 = 13; - -const IDM_MODEL_CLAUDE_CODE: u16 = 20; -const IDM_MODEL_CODEX: u16 = 21; - +const IDM_MODEL_CLAUDE: u16 = 20; +const IDM_MODEL_CHATGPT: u16 = 21; const IDM_START_WITH_WINDOWS: u16 = 30; const IDM_RESET_POSITION: u16 = 31; const IDM_VERSION_ACTION: u16 = 32; - const IDM_LANG_SYSTEM: u16 = 40; -const IDM_LANG_ENGLISH: u16 = 41; -const IDM_LANG_DUTCH: u16 = 42; -const IDM_LANG_SPANISH: u16 = 43; -const IDM_LANG_FRENCH: u16 = 44; -const IDM_LANG_GERMAN: u16 = 45; -const IDM_LANG_JAPANESE: u16 = 46; -const IDM_LANG_KOREAN: u16 = 47; -const IDM_LANG_TRADITIONAL_CHINESE: u16 = 48; +const IDM_LANG_BASE: u16 = 41; // ---------- State ---------- @@ -89,50 +91,25 @@ enum UpdateStatus { struct AppState { msg_hwnd: SendHwnd, - bubbles: HashMap, + bubbles: HashMap, settings: Settings, - language: LanguageId, + i18n: I18n, is_dark: bool, install_channel: InstallChannel, + http: net::Client, + registry: Registry, + snapshots: HashMap, last_poll_ok: bool, - retry_count: u32, - session_text: String, - weekly_text: String, - codex_session_text: String, - codex_weekly_text: String, - session_percent: f64, - weekly_percent: f64, - codex_session_percent: f64, - codex_weekly_percent: f64, - data: AppUsageData, update_status: UpdateStatus, - update_release: Option, - last_balloon_shown_at: Option, - auth_watch_mode: poller::CredentialWatchMode, - auth_watch_snapshot: poller::CredentialWatchSnapshot, - auth_error_paused_polling: bool, + update_release: Option, + last_balloon_at: Option, } -#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)] -enum TrayIconKindKey { - Claude, - Codex, -} -impl From for TrayIconKindKey { - fn from(k: TrayIconKind) -> Self { - match k { - TrayIconKind::Claude => TrayIconKindKey::Claude, - TrayIconKind::Codex => TrayIconKindKey::Codex, - } - } -} -impl From for TrayIconKind { - fn from(k: TrayIconKindKey) -> Self { - match k { - TrayIconKindKey::Claude => TrayIconKind::Claude, - TrayIconKindKey::Codex => TrayIconKind::Codex, - } - } +#[derive(Clone, Default)] +struct ProviderUiState { + windows: UsageWindows, + primary_text: String, + secondary_text: String, } fn state() -> &'static Mutex> { @@ -151,36 +128,39 @@ pub fn run() { let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); } - // Single-instance guard. - let mutex_name = wide_str(APP_MUTEX_NAME); + let mutex_name_w = os::to_utf16_nul(APP_MUTEX_NAME); let _mutex = unsafe { - let handle = CreateMutexW(None, false, PCWSTR::from_raw(mutex_name.as_ptr())); + let handle = CreateMutexW(None, false, PCWSTR::from_raw(mutex_name_w.as_ptr())); match handle { Ok(h) => { if GetLastError() == ERROR_ALREADY_EXISTS { - diagnose::log("startup aborted: another instance is already running"); + log::info!("another instance already running; exiting"); return; } h } Err(e) => { - diagnose::log_error("startup aborted: unable to create single-instance mutex", e); + log::error!("CreateMutex failed: {e}"); return; } } }; let settings = settings::load(); - let language = localization::resolve_language( - settings.language.as_deref().and_then(LanguageId::from_code), - ); - let is_dark = theme::is_dark_mode(); - let install_channel = updater::current_install_channel(); - + let i18n = I18n::load(settings.language.as_deref()); + let is_dark = os::theme::is_dark(); + let install_channel = update::current_channel(); + let http = match net::Client::new(HTTP_USER_AGENT) { + Ok(c) => c, + Err(e) => { + log::error!("HTTP client init failed: {e}"); + return; + } + }; let msg_hwnd = match create_message_window() { Some(h) => h, None => { - diagnose::log("startup aborted: unable to create app message window"); + log::error!("failed to create app message window"); return; } }; @@ -189,40 +169,32 @@ pub fn run() { msg_hwnd: SendHwnd::from_hwnd(msg_hwnd), bubbles: HashMap::new(), settings, - language, + i18n, is_dark, install_channel, + http, + registry: Registry::with_defaults(), + snapshots: HashMap::new(), last_poll_ok: false, - retry_count: 0, - session_text: String::new(), - weekly_text: String::new(), - codex_session_text: String::new(), - codex_weekly_text: String::new(), - session_percent: 0.0, - weekly_percent: 0.0, - codex_session_percent: 0.0, - codex_weekly_percent: 0.0, - data: AppUsageData::default(), update_status: UpdateStatus::Idle, update_release: None, - last_balloon_shown_at: None, - auth_watch_mode: poller::CredentialWatchMode::ActiveSource, - auth_watch_snapshot: Vec::new(), - auth_error_paused_polling: false, + last_balloon_at: None, }); create_initial_bubbles(); refresh_tray_icons(); - // Timers + let poll_interval = lock_state() + .as_ref() + .map(|s| s.settings.poll_interval_ms) + .unwrap_or(POLL_5_MIN); unsafe { - let interval = current_poll_interval_ms(); - SetTimer(msg_hwnd, TIMER_POLL, interval, None); + SetTimer(msg_hwnd, TIMER_POLL, poll_interval, None); } schedule_update_check_timer(msg_hwnd); spawn_poll_thread(); - diagnose::log("app::run entered message loop"); + log::info!("app::run entered message loop"); let mut msg = MSG::default(); unsafe { while GetMessageW(&mut msg, HWND::default(), 0, 0).as_bool() { @@ -232,11 +204,10 @@ pub fn run() { } } -// ---------- Window creation ---------- - fn create_message_window() -> Option { + let class_w = os::to_utf16_nul(APP_CLASS_NAME); + let title_w = os::to_utf16_nul("Claude Code Usage Bubble"); unsafe { - let class_w = wide_str(APP_CLASS_NAME); let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default(); let wc = WNDCLASSEXW { cbSize: std::mem::size_of::() as u32, @@ -246,8 +217,7 @@ fn create_message_window() -> Option { ..Default::default() }; let _ = RegisterClassExW(&wc); - let title_w = wide_str("Claude Code Usage Bubble"); - let hwnd = CreateWindowExW( + CreateWindowExW( WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, PCWSTR::from_raw(class_w.as_ptr()), PCWSTR::from_raw(title_w.as_ptr()), @@ -261,41 +231,34 @@ fn create_message_window() -> Option { hinstance, None, ) - .ok()?; - Some(hwnd) + .ok() } } fn create_initial_bubbles() { - let (settings, is_dark) = { - let s = lock_state(); - let Some(s) = s.as_ref() else { - return; - }; - (s.settings.clone(), s.is_dark) + let (settings, is_dark) = match lock_state().as_ref() { + Some(s) => (s.settings.clone(), s.is_dark), + None => return, }; - - let mut to_create: Vec<(TrayIconKind, Option<(i32, i32)>)> = Vec::new(); if settings.show_claude_code { - to_create.push((TrayIconKind::Claude, settings.bubble_positions.get(TrayIconKind::Claude))); + spawn_bubble(ProviderId::Claude, &settings, is_dark); } if settings.show_codex { - to_create.push((TrayIconKind::Codex, settings.bubble_positions.get(TrayIconKind::Codex))); + spawn_bubble(ProviderId::ChatGpt, &settings, is_dark); } +} - for (model, pos) in to_create { - let hwnd = bubble::create(bubble::BubbleConfig { - model, - size_logical: settings.bubble_size_logical, - position: pos, - percent: None, - is_dark, - }); - if hwnd != HWND::default() { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.bubbles.insert(model.into(), SendHwnd::from_hwnd(hwnd)); - } +fn spawn_bubble(kind: TrayIconKind, settings: &Settings, is_dark: bool) { + let hwnd = bubble::create(bubble::BubbleConfig { + model: kind, + size_logical: settings.bubble_size_logical, + position: settings.bubble_positions.get(kind), + percent: None, + is_dark, + }); + if hwnd != HWND::default() { + if let Some(s) = lock_state().as_mut() { + s.bubbles.insert(kind, SendHwnd::from_hwnd(hwnd)); } } } @@ -310,11 +273,11 @@ unsafe extern "system" fn msg_wnd_proc( ) -> LRESULT { match msg { WM_APP_USAGE_UPDATED => { - apply_usage_update(); + propagate_to_ui(); LRESULT(0) } WM_APP_TRAY => { - let action = tray_icon::handle_message(lparam); + let action = tray::callback::handle(lparam); handle_tray_action(action); LRESULT(0) } @@ -330,10 +293,9 @@ unsafe extern "system" fn msg_wnd_proc( } } -// ---------- Bubble UI callbacks (called from bubble::wnd_proc) ---------- +// ---------- Bubble callbacks ---------- pub fn on_bubble_click(hwnd: HWND, model: TrayIconKind) { - // Toggle expanded panel for this model. let data = build_panel_data(model); panel::toggle(data, hwnd); } @@ -344,8 +306,8 @@ pub fn on_bubble_right_click(hwnd: HWND, _model: TrayIconKind, _pt: POINT) { pub fn on_bubble_moved(model: TrayIconKind, pos: (i32, i32)) { let snap = { - let mut state = lock_state(); - let Some(s) = state.as_mut() else { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { return; }; s.settings.bubble_positions.set(model, pos); @@ -356,8 +318,8 @@ pub fn on_bubble_moved(model: TrayIconKind, pos: (i32, i32)) { pub fn on_bubble_resized(_model: TrayIconKind, size_logical: i32) { let snap = { - let mut state = lock_state(); - let Some(s) = state.as_mut() else { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { return; }; s.settings.bubble_size_logical = size_logical; @@ -366,199 +328,162 @@ pub fn on_bubble_resized(_model: TrayIconKind, size_logical: i32) { settings::save(&snap); } -pub fn on_menu_command(id: u32, owner_hwnd: HWND) { +pub fn on_menu_command(id: u32, _owner_hwnd: HWND) { let id = (id & 0xFFFF) as u16; match id { IDM_REFRESH => spawn_poll_thread(), - IDM_EXIT => unsafe { - PostQuitMessage(0); - }, + IDM_EXIT => unsafe { PostQuitMessage(0) }, IDM_FREQ_1MIN => set_poll_interval(POLL_1_MIN), IDM_FREQ_5MIN => set_poll_interval(POLL_5_MIN), IDM_FREQ_15MIN => set_poll_interval(POLL_15_MIN), IDM_FREQ_1HOUR => set_poll_interval(POLL_1_HOUR), - IDM_MODEL_CLAUDE_CODE => toggle_model(TrayIconKind::Claude), - IDM_MODEL_CODEX => toggle_model(TrayIconKind::Codex), + IDM_MODEL_CLAUDE => toggle_model(ProviderId::Claude), + IDM_MODEL_CHATGPT => toggle_model(ProviderId::ChatGpt), IDM_START_WITH_WINDOWS => toggle_startup(), IDM_RESET_POSITION => reset_positions(), - IDM_VERSION_ACTION => version_action(owner_hwnd), + IDM_VERSION_ACTION => version_action(), IDM_LANG_SYSTEM => set_language(None), - IDM_LANG_ENGLISH => set_language(Some(LanguageId::English)), - IDM_LANG_DUTCH => set_language(Some(LanguageId::Dutch)), - IDM_LANG_SPANISH => set_language(Some(LanguageId::Spanish)), - IDM_LANG_FRENCH => set_language(Some(LanguageId::French)), - IDM_LANG_GERMAN => set_language(Some(LanguageId::German)), - IDM_LANG_JAPANESE => set_language(Some(LanguageId::Japanese)), - IDM_LANG_KOREAN => set_language(Some(LanguageId::Korean)), - IDM_LANG_TRADITIONAL_CHINESE => set_language(Some(LanguageId::TraditionalChinese)), - tray_icon::IDM_TOGGLE_WIDGET => toggle_widget_visibility(), + x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize), + tray::IDM_TOGGLE_WIDGET => toggle_widget_visibility(), _ => {} } } -// ---------- Timer dispatch ---------- +// ---------- Timers ---------- fn on_timer(hwnd: HWND, id: usize) { match id { - TIMER_POLL => spawn_poll_thread(), - TIMER_RESET_POLL => spawn_poll_thread(), + TIMER_POLL | TIMER_RESET_POLL => spawn_poll_thread(), TIMER_COUNTDOWN => refresh_countdowns(), TIMER_UPDATE_CHECK => { unsafe { let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK); } - begin_update_check(hwnd, false); + begin_update_check(hwnd); } _ => {} } } -// ---------- Poll thread / data application ---------- +// ---------- Poll thread ---------- fn spawn_poll_thread() { - let (show_claude, show_codex, msg_hwnd) = { - let state = lock_state(); - let Some(s) = state.as_ref() else { - return; - }; - ( - s.settings.show_claude_code, - s.settings.show_codex, - s.msg_hwnd, - ) + let msg_hwnd = match lock_state().as_ref() { + Some(s) => s.msg_hwnd, + None => return, }; std::thread::spawn(move || { - let result = poller::poll(show_claude, show_codex); - handle_poll_result(result, msg_hwnd); + do_poll(); + unsafe { + let _ = PostMessageW( + msg_hwnd.to_hwnd(), + WM_APP_USAGE_UPDATED, + WPARAM(0), + LPARAM(0), + ); + } }); } -fn handle_poll_result(result: Result, msg_hwnd: SendHwnd) { - match result { - Ok(data) => { - { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - apply_data(s, data); - s.last_poll_ok = true; - s.retry_count = 0; - s.auth_error_paused_polling = false; - s.auth_watch_mode = poller::CredentialWatchMode::ActiveSource; - s.auth_watch_snapshot.clear(); - } - } - unsafe { - let _ = PostMessageW( - msg_hwnd.to_hwnd(), - WM_APP_USAGE_UPDATED, - WPARAM(0), - LPARAM(0), - ); - } - } - Err(error) => { - let auth_problem = matches!( - error, - PollError::AuthRequired | PollError::TokenExpired | PollError::NoCredentials - ); - { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.last_poll_ok = false; - s.retry_count = s.retry_count.saturating_add(1); - if auth_problem { - let mode = if matches!(error, PollError::NoCredentials) { - poller::CredentialWatchMode::AllSources - } else { - poller::CredentialWatchMode::ActiveSource - }; - s.auth_watch_mode = mode; - s.auth_watch_snapshot = poller::credential_watch_snapshot(mode); - s.auth_error_paused_polling = true; - s.session_text = "!".into(); - s.weekly_text = "!".into(); - s.codex_session_text = "!".into(); - s.codex_weekly_text = "!".into(); - } else { - s.session_text = "...".into(); - s.weekly_text = "...".into(); - s.codex_session_text = "...".into(); - s.codex_weekly_text = "...".into(); - } - } - } - unsafe { - let _ = PostMessageW( - msg_hwnd.to_hwnd(), - WM_APP_USAGE_UPDATED, - WPARAM(0), - LPARAM(0), - ); - } - if auth_problem { - show_token_expired_balloon(); - } - } +fn do_poll() { + let results = { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { + return; + }; + let settings = s.settings.clone(); + s.registry.poll_enabled(&s.http, &settings) + }; + let auth_failures = apply_results(results); + if !auth_failures.is_empty() { + attempt_refresh(auth_failures); } } -fn apply_data(s: &mut AppState, data: AppUsageData) { - if let Some(c) = data.claude_code.as_ref() { - s.session_percent = c.session.percentage; - s.weekly_percent = c.weekly.percentage; - } else if s.settings.show_claude_code { - s.session_percent = 0.0; - s.weekly_percent = 0.0; +fn apply_results( + results: Vec<(ProviderId, Result)>, +) -> Vec { + let mut auth_failures = Vec::new(); + let mut s = lock_state(); + let Some(s) = s.as_mut() else { + return auth_failures; + }; + if results.is_empty() { + return auth_failures; } - if let Some(c) = data.codex.as_ref() { - s.codex_session_percent = c.session.percentage; - s.codex_weekly_percent = c.weekly.percentage; - } else if s.settings.show_codex { - s.codex_session_percent = 0.0; - s.codex_weekly_percent = 0.0; + let strings = s.i18n.strings().clone(); + let mut any_ok = false; + for (id, outcome) in results { + match outcome { + Ok(windows) => { + let entry = s.snapshots.entry(id).or_default(); + entry.windows = windows; + entry.primary_text = i18n::format_window(&windows.primary, &strings); + entry.secondary_text = i18n::format_window(&windows.secondary, &strings); + any_ok = true; + } + Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => { + auth_failures.push(id); + let entry = s.snapshots.entry(id).or_default(); + entry.primary_text = "!".into(); + entry.secondary_text = "!".into(); + } + Err(e) => { + log::warn!("provider {id:?} poll failed: {e}"); + let entry = s.snapshots.entry(id).or_default(); + entry.primary_text = "…".into(); + entry.secondary_text = "…".into(); + } + } } - s.data = data; - refresh_text_fields(s); + s.last_poll_ok = any_ok; + auth_failures } -fn refresh_text_fields(s: &mut AppState) { - let strings = s.language.strings(); - if let Some(c) = s.data.claude_code.as_ref() { - s.session_text = poller::format_line(&c.session, strings); - s.weekly_text = poller::format_line(&c.weekly, strings); +fn attempt_refresh(failures: Vec) { + let orchestrator = usage::refresh::Orchestrator::new(REFRESH_TIMEOUT); + let mut needs_balloon = false; + for id in failures { + let outcome = match lock_state().as_ref() { + Some(s) => s.registry.try_refresh(id, &orchestrator), + None => return, + }; + log::info!("refresh for {id:?}: {outcome:?}"); + if !matches!(outcome, usage::refresh::Outcome::Refreshed) { + needs_balloon = true; + } } - if let Some(c) = s.data.codex.as_ref() { - s.codex_session_text = poller::format_line(&c.session, strings); - s.codex_weekly_text = poller::format_line(&c.weekly, strings); + if needs_balloon { + show_token_expired_balloon(); } } fn refresh_countdowns() { { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - refresh_text_fields(s); + let mut s = lock_state(); + let Some(s) = s.as_mut() else { + return; + }; + let strings = s.i18n.strings().clone(); + for entry in s.snapshots.values_mut() { + entry.primary_text = i18n::format_window(&entry.windows.primary, &strings); + entry.secondary_text = i18n::format_window(&entry.windows.secondary, &strings); } } - apply_usage_update(); + propagate_to_ui(); } -fn apply_usage_update() { +fn propagate_to_ui() { let snapshot = { let s = lock_state(); - s.as_ref().map(|s| UsageSnapshot { + s.as_ref().map(|s| UiSnapshot { bubbles: s.bubbles.clone(), - session_percent: s.session_percent, - weekly_percent: s.weekly_percent, - codex_session_percent: s.codex_session_percent, - codex_weekly_percent: s.codex_weekly_percent, - session_text: s.session_text.clone(), - weekly_text: s.weekly_text.clone(), - codex_session_text: s.codex_session_text.clone(), - codex_weekly_text: s.codex_weekly_text.clone(), - language: s.language, - is_dark: s.is_dark, + snapshots: s.snapshots.clone(), settings: s.settings.clone(), + i18n_strings: s.i18n.strings().clone(), + is_dark: s.is_dark, + msg_hwnd: s.msg_hwnd, + last_poll_ok: s.last_poll_ok, }) }; let Some(snap) = snapshot else { @@ -566,141 +491,102 @@ fn apply_usage_update() { }; for (kind, hwnd) in snap.bubbles.iter() { - let model = TrayIconKind::from(*kind); - let pct = match model { - TrayIconKind::Claude => Some(snap.session_percent), - TrayIconKind::Codex => Some(snap.codex_session_percent), - }; + let id = kind_to_provider(*kind); + let pct = snap + .snapshots + .get(&id) + .map(|s| s.windows.primary.utilization); bubble::update_percentage(hwnd.to_hwnd(), pct); } + refresh_tray_icons_with(&snap); - refresh_tray_icons(); - - // Refresh expanded panel if showing. if panel::is_visible() { if let Some(model) = panel::current_model() { - panel::refresh_data(build_panel_data_from(&snap, model)); + let id = kind_to_provider(model); + if let Some(provider_state) = snap.snapshots.get(&id) { + panel::refresh_data(build_panel_data_from(&snap, model, provider_state)); + } } } - - // Adaptive countdown timer. - schedule_countdown_timer(); + schedule_countdown_timer(&snap); } #[derive(Clone)] -struct UsageSnapshot { - bubbles: HashMap, - session_percent: f64, - weekly_percent: f64, - codex_session_percent: f64, - codex_weekly_percent: f64, - session_text: String, - weekly_text: String, - codex_session_text: String, - codex_weekly_text: String, - language: LanguageId, - is_dark: bool, +struct UiSnapshot { + bubbles: HashMap, + snapshots: HashMap, settings: Settings, + i18n_strings: LocaleStrings, + is_dark: bool, + msg_hwnd: SendHwnd, + last_poll_ok: bool, +} + +fn kind_to_provider(k: TrayIconKind) -> ProviderId { + match k { + ProviderId::Claude => ProviderId::Claude, + ProviderId::ChatGpt => ProviderId::ChatGpt, + } } fn build_panel_data(model: TrayIconKind) -> PanelData { - let snap = { - let s = lock_state(); - s.as_ref().map(|s| UsageSnapshot { - bubbles: s.bubbles.clone(), - session_percent: s.session_percent, - weekly_percent: s.weekly_percent, - codex_session_percent: s.codex_session_percent, - codex_weekly_percent: s.codex_weekly_percent, - session_text: s.session_text.clone(), - weekly_text: s.weekly_text.clone(), - codex_session_text: s.codex_session_text.clone(), - codex_weekly_text: s.codex_weekly_text.clone(), - language: s.language, - is_dark: s.is_dark, - settings: s.settings.clone(), - }) - } - .unwrap_or_else(|| UsageSnapshot { - bubbles: HashMap::new(), - session_percent: 0.0, - weekly_percent: 0.0, - codex_session_percent: 0.0, - codex_weekly_percent: 0.0, - session_text: String::new(), - weekly_text: String::new(), - codex_session_text: String::new(), - codex_weekly_text: String::new(), - language: LanguageId::English, - is_dark: false, - settings: Settings::default(), - }); - build_panel_data_from(&snap, model) -} - -fn build_panel_data_from(snap: &UsageSnapshot, model: TrayIconKind) -> PanelData { - let (sp, st, wp, wt) = match model { - TrayIconKind::Claude => ( - snap.session_percent, - snap.session_text.clone(), - snap.weekly_percent, - snap.weekly_text.clone(), - ), - TrayIconKind::Codex => ( - snap.codex_session_percent, - snap.codex_session_text.clone(), - snap.codex_weekly_percent, - snap.codex_weekly_text.clone(), - ), + let s = lock_state(); + let Some(s) = s.as_ref() else { + return placeholder_panel(model); }; - let strings = snap.language.strings(); + let id = kind_to_provider(model); + let strings = s.i18n.strings().clone(); + let provider_state = s.snapshots.get(&id).cloned().unwrap_or_default(); PanelData { model, - session_pct: sp, - session_text: st, - weekly_pct: wp, - weekly_text: wt, - is_dark: snap.is_dark, + session_pct: provider_state.windows.primary.utilization, + session_text: provider_state.primary_text, + weekly_pct: provider_state.windows.secondary.utilization, + weekly_text: provider_state.secondary_text, + is_dark: s.is_dark, strings, - claude_label: strings.claude_code_model.to_string(), - codex_label: strings.codex_model.to_string(), } } -fn schedule_countdown_timer() { - let (msg_hwnd, ttl) = { - let s = lock_state(); - let Some(s) = s.as_ref() else { - return; - }; - let mut min_ttl: Option = None; - if let Some(c) = s.data.claude_code.as_ref() { - for section in [&c.session, &c.weekly] { - if let Some(d) = poller::time_until_display_change(section.resets_at) { - min_ttl = Some(match min_ttl { - Some(prev) => prev.min(d), - None => d, - }); - } +fn build_panel_data_from(snap: &UiSnapshot, model: TrayIconKind, p: &ProviderUiState) -> PanelData { + PanelData { + model, + session_pct: p.windows.primary.utilization, + session_text: p.primary_text.clone(), + weekly_pct: p.windows.secondary.utilization, + weekly_text: p.secondary_text.clone(), + is_dark: snap.is_dark, + strings: snap.i18n_strings.clone(), + } +} + +fn placeholder_panel(model: TrayIconKind) -> PanelData { + let strings = i18n::I18n::load(None).strings().clone(); + PanelData { + model, + session_pct: 0.0, + session_text: String::new(), + weekly_pct: 0.0, + weekly_text: String::new(), + is_dark: false, + strings, + } +} + +fn schedule_countdown_timer(snap: &UiSnapshot) { + let mut min_ttl: Option = None; + for entry in snap.snapshots.values() { + for w in [&entry.windows.primary, &entry.windows.secondary] { + if let Some(d) = i18n::time_until_display_change(w.resets_at) { + min_ttl = Some(min_ttl.map_or(d, |prev| prev.min(d))); } } - if let Some(c) = s.data.codex.as_ref() { - for section in [&c.session, &c.weekly] { - if let Some(d) = poller::time_until_display_change(section.resets_at) { - min_ttl = Some(match min_ttl { - Some(prev) => prev.min(d), - None => d, - }); - } - } - } - (s.msg_hwnd, min_ttl) - }; - if let Some(d) = ttl { - let ms = (d.as_millis() as u64).min(u32::MAX as u64) as u32; + } + if let Some(d) = min_ttl { + let ms = (d.as_millis().min(u32::MAX as u128) as u32).max(1_000); unsafe { - let _ = KillTimer(msg_hwnd.to_hwnd(), TIMER_COUNTDOWN); - SetTimer(msg_hwnd.to_hwnd(), TIMER_COUNTDOWN, ms.max(1000), None); + let _ = KillTimer(snap.msg_hwnd.to_hwnd(), TIMER_COUNTDOWN); + SetTimer(snap.msg_hwnd.to_hwnd(), TIMER_COUNTDOWN, ms, None); } } } @@ -708,44 +594,64 @@ fn schedule_countdown_timer() { // ---------- Tray icons ---------- fn refresh_tray_icons() { - let (icons, msg_hwnd) = { + let snap = { let s = lock_state(); - let Some(s) = s.as_ref() else { - return; - }; - let strings = s.language.strings(); - let mut icons = Vec::new(); - if s.settings.show_claude_code { - icons.push(TrayIconData { - kind: TrayIconKind::Claude, - percent: if s.last_poll_ok { - Some(s.session_percent) - } else { - None - }, - tooltip: format!( - "{} 5h: {} | 7d: {}", - strings.claude_code_model, s.session_text, s.weekly_text - ), - }); - } - if s.settings.show_codex { - icons.push(TrayIconData { - kind: TrayIconKind::Codex, - percent: if s.last_poll_ok { - Some(s.codex_session_percent) - } else { - None - }, - tooltip: format!( - "{} 5h: {} | 7d: {}", - strings.codex_model, s.codex_session_text, s.codex_weekly_text - ), - }); - } - (icons, s.msg_hwnd) + s.as_ref().map(|s| UiSnapshot { + bubbles: s.bubbles.clone(), + snapshots: s.snapshots.clone(), + settings: s.settings.clone(), + i18n_strings: s.i18n.strings().clone(), + is_dark: s.is_dark, + msg_hwnd: s.msg_hwnd, + last_poll_ok: s.last_poll_ok, + }) }; - tray_icon::sync(msg_hwnd.to_hwnd(), &icons); + if let Some(snap) = snap { + refresh_tray_icons_with(&snap); + } +} + +fn refresh_tray_icons_with(snap: &UiSnapshot) { + let mut icons = Vec::new(); + if snap.settings.show_claude_code { + let entry = snap.snapshots.get(&ProviderId::Claude); + icons.push(TrayIconData { + kind: ProviderId::Claude, + percent: if snap.last_poll_ok { + entry.map(|e| e.windows.primary.utilization) + } else { + None + }, + tooltip: format!( + "{} {}: {} | {}: {}", + snap.i18n_strings.claude_label, + snap.i18n_strings.session_window, + entry.map(|e| e.primary_text.as_str()).unwrap_or(""), + snap.i18n_strings.weekly_window, + entry.map(|e| e.secondary_text.as_str()).unwrap_or(""), + ), + }); + } + if snap.settings.show_codex { + let entry = snap.snapshots.get(&ProviderId::ChatGpt); + icons.push(TrayIconData { + kind: ProviderId::ChatGpt, + percent: if snap.last_poll_ok { + entry.map(|e| e.windows.primary.utilization) + } else { + None + }, + tooltip: format!( + "{} {}: {} | {}: {}", + snap.i18n_strings.chatgpt_label, + snap.i18n_strings.session_window, + entry.map(|e| e.primary_text.as_str()).unwrap_or(""), + snap.i18n_strings.weekly_window, + entry.map(|e| e.secondary_text.as_str()).unwrap_or(""), + ), + }); + } + tray::sync(snap.msg_hwnd.to_hwnd(), &icons); } fn handle_tray_action(action: TrayAction) { @@ -753,14 +659,11 @@ fn handle_tray_action(action: TrayAction) { TrayAction::None => {} TrayAction::ToggleWidget => toggle_widget_visibility(), TrayAction::ShowContextMenu => { - // Use the first bubble as menu owner; fall back to msg_hwnd. - let owner = { - let s = lock_state(); - s.as_ref() - .and_then(|s| s.bubbles.values().next().copied()) - .map(|h| h.to_hwnd()) - .unwrap_or_default() - }; + let owner = lock_state() + .as_ref() + .and_then(|s| s.bubbles.values().next().copied()) + .map(|h| h.to_hwnd()) + .unwrap_or_default(); if owner != HWND::default() { show_context_menu(owner); } @@ -769,58 +672,68 @@ fn handle_tray_action(action: TrayAction) { } fn show_token_expired_balloon() { - let payload: Option<(SendHwnd, TrayIconKind, String, String)> = { - let mut state = lock_state(); - let Some(s) = state.as_mut() else { + let payload = { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { return; }; - if let Some(last) = s.last_balloon_shown_at { - if last.elapsed() < Duration::from_secs(30 * 60) { + if let Some(last) = s.last_balloon_at { + if last.elapsed() < BALLOON_COOLDOWN { return; } } - s.last_balloon_shown_at = Some(Instant::now()); - let strings = s.language.strings(); - if s.settings.show_claude_code { - Some(( - s.msg_hwnd, - TrayIconKind::Claude, - strings.token_expired_title.to_string(), - strings.token_expired_body.to_string(), - )) + s.last_balloon_at = Some(Instant::now()); + let strings = s.i18n.strings(); + let (kind, title, body) = if s.settings.show_claude_code { + ( + ProviderId::Claude, + strings.token_expired_title.clone(), + strings.token_expired_body.clone(), + ) } else { - Some(( - s.msg_hwnd, - TrayIconKind::Codex, - strings.codex_token_expired_title.to_string(), - strings.codex_token_expired_body.to_string(), - )) - } + ( + ProviderId::ChatGpt, + strings.chatgpt_token_expired_title.clone(), + strings.chatgpt_token_expired_body.clone(), + ) + }; + (s.msg_hwnd, kind, title, body) }; - if let Some((hwnd, kind, title, body)) = payload { - tray_icon::notify_balloon(hwnd.to_hwnd(), kind, &title, &body); - } + tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3); } // ---------- Context menu ---------- +struct ContextMenuSnapshot { + strings: LocaleStrings, + available: Vec<(String, String)>, + language_override: Option, + current_interval: u32, + show_claude: bool, + show_chatgpt: bool, + widget_visible: bool, + install_channel: InstallChannel, + update_status: UpdateStatus, +} + fn show_context_menu(owner_hwnd: HWND) { - let (strings, language, install_channel, update_status, current_interval, show_claude, show_codex, widget_visible, language_override) = { - let s = lock_state(); - let Some(s) = s.as_ref() else { - return; - }; - ( - s.language.strings(), - s.language, - s.install_channel, - s.update_status, - s.settings.poll_interval_ms, - s.settings.show_claude_code, - s.settings.show_codex, - s.settings.widget_visible, - s.settings.language.as_deref().and_then(LanguageId::from_code), - ) + let snap = match lock_state().as_ref() { + Some(s) => ContextMenuSnapshot { + strings: s.i18n.strings().clone(), + available: s + .i18n + .available() + .map(|(c, n)| (c.to_string(), n.to_string())) + .collect(), + language_override: s.settings.language.clone(), + current_interval: s.settings.poll_interval_ms, + show_claude: s.settings.show_claude_code, + show_chatgpt: s.settings.show_codex, + widget_visible: s.settings.widget_visible, + install_channel: s.install_channel, + update_status: s.update_status, + }, + None => return, }; unsafe { @@ -829,122 +742,97 @@ fn show_context_menu(owner_hwnd: HWND) { Err(_) => return, }; - append_menu_item(menu, IDM_REFRESH, strings.refresh, MENU_ITEM_FLAGS(0)); + append_item(menu, IDM_REFRESH, &snap.strings.refresh, MENU_ITEM_FLAGS(0)); - // Update frequency submenu - let freq_menu = CreatePopupMenu().unwrap(); + let freq = CreatePopupMenu().unwrap(); for (id, interval, label) in [ - (IDM_FREQ_1MIN, POLL_1_MIN, strings.one_minute), - (IDM_FREQ_5MIN, POLL_5_MIN, strings.five_minutes), - (IDM_FREQ_15MIN, POLL_15_MIN, strings.fifteen_minutes), - (IDM_FREQ_1HOUR, POLL_1_HOUR, strings.one_hour), + (IDM_FREQ_1MIN, POLL_1_MIN, &snap.strings.one_minute), + (IDM_FREQ_5MIN, POLL_5_MIN, &snap.strings.five_minutes), + (IDM_FREQ_15MIN, POLL_15_MIN, &snap.strings.fifteen_minutes), + (IDM_FREQ_1HOUR, POLL_1_HOUR, &snap.strings.one_hour), ] { - let flags = if interval == current_interval { + let flags = if interval == snap.current_interval { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; - append_menu_item(freq_menu, id, label, flags); + append_item(freq, id, label, flags); } - append_submenu(menu, freq_menu, strings.update_frequency); + append_submenu(menu, freq, &snap.strings.update_frequency); - // Models submenu - let models_menu = CreatePopupMenu().unwrap(); - append_menu_item( - models_menu, - IDM_MODEL_CLAUDE_CODE, - strings.claude_code_model, - if show_claude { - MF_CHECKED - } else { - MENU_ITEM_FLAGS(0) - }, + let models = CreatePopupMenu().unwrap(); + append_item( + models, + IDM_MODEL_CLAUDE, + &snap.strings.claude_label, + if snap.show_claude { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }, ); - append_menu_item( - models_menu, - IDM_MODEL_CODEX, - strings.codex_model, - if show_codex { - MF_CHECKED - } else { - MENU_ITEM_FLAGS(0) - }, + append_item( + models, + IDM_MODEL_CHATGPT, + &snap.strings.chatgpt_label, + if snap.show_chatgpt { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }, ); - append_submenu(menu, models_menu, strings.models); + append_submenu(menu, models, &snap.strings.models); - // Settings submenu let settings_menu = CreatePopupMenu().unwrap(); - append_menu_item( + append_item( settings_menu, IDM_START_WITH_WINDOWS, - strings.start_with_windows, - if is_startup_enabled() { - MF_CHECKED - } else { - MENU_ITEM_FLAGS(0) - }, + &snap.strings.start_with_windows, + if is_startup_enabled() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }, ); - append_menu_item( + append_item( settings_menu, IDM_RESET_POSITION, - strings.reset_position, + &snap.strings.reset_position, MENU_ITEM_FLAGS(0), ); - // Language submenu - let lang_menu = CreatePopupMenu().unwrap(); - append_menu_item( - lang_menu, + let lang = CreatePopupMenu().unwrap(); + append_item( + lang, IDM_LANG_SYSTEM, - strings.system_default, - if language_override.is_none() { - MF_CHECKED - } else { - MENU_ITEM_FLAGS(0) - }, + &snap.strings.system_default, + if snap.language_override.is_none() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }, ); - for lang in LanguageId::ALL { - let id = lang_menu_id_for(lang); - let label = lang.native_name(); - let flags = if language_override == Some(lang) { + for (i, (code, name)) in snap.available.iter().enumerate() { + let id = IDM_LANG_BASE + i as u16; + let flags = if snap + .language_override + .as_deref() + .map(|c| c == code) + .unwrap_or(false) + { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; - append_menu_item(lang_menu, id, label, flags); + append_item(lang, id, name, flags); } - append_submenu(settings_menu, lang_menu, strings.language); - + append_submenu(settings_menu, lang, &snap.strings.language); let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null()); - let version_label = version_action_label(strings, language, install_channel, update_status); - let version_flags = if matches!(update_status, UpdateStatus::Checking | UpdateStatus::Applying) { + let version_label = version_action_label(&snap); + let version_flags = if matches!( + snap.update_status, + UpdateStatus::Checking | UpdateStatus::Applying + ) { MF_GRAYED } else { MENU_ITEM_FLAGS(0) }; - append_menu_item( - settings_menu, - IDM_VERSION_ACTION, - &version_label, - version_flags, - ); + append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags); + append_submenu(menu, settings_menu, &snap.strings.settings); - append_submenu(menu, settings_menu, strings.settings); - - append_menu_item( + append_item( menu, - tray_icon::IDM_TOGGLE_WIDGET, - strings.show_widget, - if widget_visible { - MF_CHECKED - } else { - MENU_ITEM_FLAGS(0) - }, + tray::IDM_TOGGLE_WIDGET, + &snap.strings.show_widget, + if snap.widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }, ); - let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); - append_menu_item(menu, IDM_EXIT, strings.exit, MENU_ITEM_FLAGS(0)); + append_item(menu, IDM_EXIT, &snap.strings.exit, MENU_ITEM_FLAGS(0)); let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); @@ -954,54 +842,31 @@ fn show_context_menu(owner_hwnd: HWND) { } } -fn append_menu_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) { - let label_w = wide_str(label); +fn append_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) { + let w = os::to_utf16_nul(label); unsafe { - let _ = AppendMenuW(menu, flags, id as usize, PCWSTR::from_raw(label_w.as_ptr())); + let _ = AppendMenuW(menu, flags, id as usize, PCWSTR::from_raw(w.as_ptr())); } } fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) { - let label_w = wide_str(label); + let w = os::to_utf16_nul(label); unsafe { - let _ = AppendMenuW( - menu, - MF_POPUP, - submenu.0 as usize, - PCWSTR::from_raw(label_w.as_ptr()), - ); + let _ = AppendMenuW(menu, MF_POPUP, submenu.0 as usize, PCWSTR::from_raw(w.as_ptr())); } } -fn lang_menu_id_for(lang: LanguageId) -> u16 { - match lang { - LanguageId::English => IDM_LANG_ENGLISH, - LanguageId::Dutch => IDM_LANG_DUTCH, - LanguageId::Spanish => IDM_LANG_SPANISH, - LanguageId::French => IDM_LANG_FRENCH, - LanguageId::German => IDM_LANG_GERMAN, - LanguageId::Japanese => IDM_LANG_JAPANESE, - LanguageId::Korean => IDM_LANG_KOREAN, - LanguageId::TraditionalChinese => IDM_LANG_TRADITIONAL_CHINESE, - } -} - -fn version_action_label( - strings: Strings, - language: LanguageId, - install_channel: InstallChannel, - status: UpdateStatus, -) -> String { - let base = match status { - UpdateStatus::Idle => strings.check_for_updates.to_string(), - UpdateStatus::Checking => strings.checking_for_updates.to_string(), - UpdateStatus::UpToDate => strings.up_to_date.to_string(), - UpdateStatus::Available => strings.update_available.to_string(), - UpdateStatus::Applying => strings.applying_update.to_string(), - UpdateStatus::Failed => strings.update_failed.to_string(), +fn version_action_label(snap: &ContextMenuSnapshot) -> String { + let base = match snap.update_status { + UpdateStatus::Idle => snap.strings.check_for_updates.clone(), + UpdateStatus::Checking => snap.strings.checking_for_updates.clone(), + UpdateStatus::UpToDate => snap.strings.up_to_date.clone(), + UpdateStatus::Available => snap.strings.update_available.clone(), + UpdateStatus::Applying => snap.strings.applying_update.clone(), + UpdateStatus::Failed => snap.strings.update_failed.clone(), }; - match install_channel { - InstallChannel::Winget => format!("{base} ({})", localization::update_via_winget(language)), + match snap.install_channel { + InstallChannel::Winget => format!("{base} ({})", snap.strings.update_via_winget), InstallChannel::Portable => base, } } @@ -1010,8 +875,8 @@ fn version_action_label( fn set_poll_interval(ms: u32) { let (snap, msg_hwnd) = { - let mut state = lock_state(); - let Some(s) = state.as_mut() else { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { return; }; s.settings.poll_interval_ms = ms; @@ -1024,65 +889,37 @@ fn set_poll_interval(ms: u32) { } } -fn current_poll_interval_ms() -> u32 { - lock_state() - .as_ref() - .map(|s| s.settings.poll_interval_ms) - .unwrap_or(POLL_5_MIN) -} - fn toggle_model(model: TrayIconKind) { let (settings, is_dark) = { - let mut state = lock_state(); - let Some(s) = state.as_mut() else { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { return; }; match model { - TrayIconKind::Claude => s.settings.show_claude_code = !s.settings.show_claude_code, - TrayIconKind::Codex => s.settings.show_codex = !s.settings.show_codex, + ProviderId::Claude => s.settings.show_claude_code = !s.settings.show_claude_code, + ProviderId::ChatGpt => s.settings.show_codex = !s.settings.show_codex, } if !s.settings.show_claude_code && !s.settings.show_codex { - // Don't let user turn both off. match model { - TrayIconKind::Claude => s.settings.show_claude_code = true, - TrayIconKind::Codex => s.settings.show_codex = true, + ProviderId::Claude => s.settings.show_claude_code = true, + ProviderId::ChatGpt => s.settings.show_codex = true, } } (s.settings.clone(), s.is_dark) }; settings::save(&settings); - let want_show = match model { - TrayIconKind::Claude => settings.show_claude_code, - TrayIconKind::Codex => settings.show_codex, + let want = match model { + ProviderId::Claude => settings.show_claude_code, + ProviderId::ChatGpt => settings.show_codex, }; - let existing = { - let s = lock_state(); - s.as_ref() - .and_then(|s| s.bubbles.get(&model.into()).copied()) - }; - - match (want_show, existing) { - (true, None) => { - let hwnd = bubble::create(bubble::BubbleConfig { - model, - size_logical: settings.bubble_size_logical, - position: settings.bubble_positions.get(model), - percent: None, - is_dark, - }); - if hwnd != HWND::default() { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.bubbles.insert(model.into(), SendHwnd::from_hwnd(hwnd)); - } - } - } - (false, Some(hwnd)) => { - bubble::destroy(hwnd.to_hwnd()); - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.bubbles.remove(&model.into()); + let existing = lock_state().as_ref().and_then(|s| s.bubbles.get(&model).copied()); + match (want, existing) { + (true, None) => spawn_bubble(model, &settings, is_dark), + (false, Some(h)) => { + bubble::destroy(h.to_hwnd()); + if let Some(s) = lock_state().as_mut() { + s.bubbles.remove(&model); } } _ => {} @@ -1092,171 +929,168 @@ fn toggle_model(model: TrayIconKind) { } fn toggle_widget_visibility() { - let (new_visible, snap) = { - let mut state = lock_state(); - let Some(s) = state.as_mut() else { + let (visible, snap) = { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { return; }; s.settings.widget_visible = !s.settings.widget_visible; (s.settings.widget_visible, s.settings.clone()) }; settings::save(&snap); - let hwnds: Vec = { - let state = lock_state(); - state - .as_ref() - .map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect()) - .unwrap_or_default() - }; + let hwnds: Vec = lock_state() + .as_ref() + .map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect()) + .unwrap_or_default(); for h in hwnds { - bubble::set_user_visible(h, new_visible); + bubble::set_user_visible(h, visible); } } fn reset_positions() { let snap = { - let mut state = lock_state(); - let Some(s) = state.as_mut() else { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { return; }; s.settings.bubble_positions.reset_all(); s.settings.clone() }; settings::save(&snap); - let hwnds: Vec = { - let state = lock_state(); - state - .as_ref() - .map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect()) - .unwrap_or_default() - }; + let hwnds: Vec = lock_state() + .as_ref() + .map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect()) + .unwrap_or_default(); for h in hwnds { bubble::destroy(h); } - { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.bubbles.clear(); - } + if let Some(s) = lock_state().as_mut() { + s.bubbles.clear(); } create_initial_bubbles(); } -fn set_language(override_lang: Option) { +fn set_language(_dummy: Option<()>) { let snap = { - let mut state = lock_state(); - let Some(s) = state.as_mut() else { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { return; }; - s.language = match override_lang { - Some(l) => l, - None => localization::detect_system_language(), - }; - s.settings.language = override_lang.map(|l| l.code().to_string()); - refresh_text_fields(s); + s.i18n.set_active(None); + s.settings.language = None; s.settings.clone() }; settings::save(&snap); - apply_usage_update(); + propagate_to_ui(); } -fn version_action(_owner_hwnd: HWND) { - enum Act { - Apply(updater::ReleaseDescriptor, InstallChannel), - Check(SendHwnd), - } - let act = { - let s = lock_state(); - let Some(s) = s.as_ref() else { +fn set_language_by_index(idx: usize) { + let snap = { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { return; }; - match (s.update_status, s.update_release.as_ref()) { - (UpdateStatus::Available, Some(release)) => { - Act::Apply(release.clone(), s.install_channel) - } - _ => Act::Check(s.msg_hwnd), + let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string()); + if let Some(c) = code.as_deref() { + s.i18n.set_active(Some(c)); } + s.settings.language = code; + s.settings.clone() + }; + settings::save(&snap); + propagate_to_ui(); +} + +fn version_action() { + enum Act { + Apply(update::Release, InstallChannel), + Check(SendHwnd), + } + let act = match lock_state().as_ref() { + Some(s) => match (s.update_status, s.update_release.as_ref()) { + (UpdateStatus::Available, Some(r)) => Act::Apply(r.clone(), s.install_channel), + _ => Act::Check(s.msg_hwnd), + }, + None => return, }; match act { Act::Apply(release, channel) => { - { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.update_status = UpdateStatus::Applying; - } + if let Some(s) = lock_state().as_mut() { + s.update_status = UpdateStatus::Applying; } - let result = match channel { - InstallChannel::Winget => updater::begin_winget_update(), - InstallChannel::Portable => updater::begin_self_update(&release), + let result: Result<(), Box> = match channel { + InstallChannel::Winget => { + // Winget channel is reserved for future use; until a + // winget package ships, this branch is unreachable. + Err("winget channel not supported yet".into()) + } + InstallChannel::Portable => { + match net::Client::new(HTTP_USER_AGENT) { + Ok(c) => update::install::begin(&c, &release).map_err(|e| e.into()), + Err(e) => Err(e.into()), + } + } }; match result { - Ok(()) => unsafe { - PostQuitMessage(0); - }, - Err(error) => { - diagnose::log(format!("update apply failed: {error}")); - let mut state = lock_state(); - if let Some(s) = state.as_mut() { + Ok(()) => unsafe { PostQuitMessage(0) }, + Err(e) => { + log::error!("update apply failed: {e}"); + if let Some(s) = lock_state().as_mut() { s.update_status = UpdateStatus::Failed; } } } } - Act::Check(hwnd) => { - begin_update_check(hwnd.to_hwnd(), true); - } + Act::Check(hwnd) => begin_update_check(hwnd.to_hwnd()), } } -// ---------- Updates ---------- - fn schedule_update_check_timer(hwnd: HWND) { - let last = { - let s = lock_state(); - s.as_ref().and_then(|s| s.settings.last_update_check_unix) - }; + let last = lock_state() + .as_ref() + .and_then(|s| s.settings.last_update_check_unix); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); let due = last.map_or(true, |t| now.saturating_sub(t) >= UPDATE_CHECK_INTERVAL_SECS); if due { - begin_update_check(hwnd, false); + begin_update_check(hwnd); } else { let remaining = UPDATE_CHECK_INTERVAL_SECS.saturating_sub(now.saturating_sub(last.unwrap_or(0))); - let ms = (remaining.saturating_mul(1000)).min(u32::MAX as u64) as u32; + let ms = (remaining.saturating_mul(1000).min(u32::MAX as u64)) as u32; unsafe { SetTimer(hwnd, TIMER_UPDATE_CHECK, ms, None); } } } -fn begin_update_check(hwnd: HWND, _user_initiated: bool) { - { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.update_status = UpdateStatus::Checking; - } +fn begin_update_check(hwnd: HWND) { + if let Some(s) = lock_state().as_mut() { + s.update_status = UpdateStatus::Checking; } let send_hwnd = SendHwnd::from_hwnd(hwnd); std::thread::spawn(move || { - let result = updater::check_for_updates(); + let result = match net::Client::new(HTTP_USER_AGENT) { + Ok(c) => update::release::fetch_latest(&c), + Err(e) => Err(update::Error::Network(e)), + }; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let snap_opt: Option = { - let mut state = lock_state(); - state.as_mut().map(|s| { + let snap_opt = { + let mut s = lock_state(); + s.as_mut().map(|s| { s.settings.last_update_check_unix = Some(now); match result { - Ok(UpdateCheckResult::UpToDate) => { + Ok(CheckOutcome::UpToDate) => { s.update_status = UpdateStatus::UpToDate; s.update_release = None; } - Ok(UpdateCheckResult::Available(release)) => { + Ok(CheckOutcome::Available(r)) => { s.update_status = UpdateStatus::Available; - s.update_release = Some(release); + s.update_release = Some(r); } Err(_) => { s.update_status = UpdateStatus::Failed; @@ -1279,76 +1113,17 @@ fn begin_update_check(hwnd: HWND, _user_initiated: bool) { }); } -// ---------- Start-with-Windows registry ---------- +// ---------- Start-with-Windows ---------- fn is_startup_enabled() -> bool { - let path_w = wide_str(STARTUP_REGISTRY_PATH); - let name_w = wide_str(STARTUP_VALUE_NAME); - unsafe { - let mut hkey = HKEY::default(); - if RegOpenKeyExW( - HKEY_CURRENT_USER, - PCWSTR::from_raw(path_w.as_ptr()), - 0, - KEY_READ, - &mut hkey, - ) - .is_err() - { - return false; - } - let mut buf = [0u16; 1024]; - let mut size = (buf.len() * 2) as u32; - let res = RegQueryValueExW( - hkey, - PCWSTR::from_raw(name_w.as_ptr()), - None, - None, - Some(buf.as_mut_ptr() as *mut u8), - Some(&mut size), - ); - let _ = RegCloseKey(hkey); - res.is_ok() - } + os::registry::value_exists(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME) } fn toggle_startup() { - let enabled = is_startup_enabled(); - let path_w = wide_str(STARTUP_REGISTRY_PATH); - let name_w = wide_str(STARTUP_VALUE_NAME); - unsafe { - let mut hkey = HKEY::default(); - if RegOpenKeyExW( - HKEY_CURRENT_USER, - PCWSTR::from_raw(path_w.as_ptr()), - 0, - KEY_WRITE, - &mut hkey, - ) - .is_err() - { - return; - } - if enabled { - let _ = RegDeleteValueW(hkey, PCWSTR::from_raw(name_w.as_ptr())); - } else { - if let Ok(exe) = std::env::current_exe() { - let exe_str = format!("\"{}\"", exe.to_string_lossy()); - let exe_w = wide_str(&exe_str); - let bytes = std::slice::from_raw_parts( - exe_w.as_ptr() as *const u8, - exe_w.len() * 2, - ); - let _ = RegSetValueExW( - hkey, - PCWSTR::from_raw(name_w.as_ptr()), - 0, - REG_SZ, - Some(bytes), - ); - } - } - let _ = RegCloseKey(hkey); + if is_startup_enabled() { + let _ = os::registry::delete_value(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME); + } else if let Ok(exe) = std::env::current_exe() { + let quoted = format!("\"{}\"", exe.to_string_lossy()); + let _ = os::registry::write_string(STARTUP_REGISTRY_PATH, STARTUP_VALUE_NAME, "ed); } } - diff --git a/src/bubble.rs b/src/bubble.rs index a9a71c3..972faa1 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -18,9 +18,11 @@ use windows::Win32::UI::HiDpi::*; use windows::Win32::UI::Shell::ExtractIconExW; use windows::Win32::UI::WindowsAndMessaging::*; -use crate::diagnose; -use crate::native_interop::{wide_str, Color, TIMER_FULLSCREEN_CHECK}; -use crate::tray_icon::TrayIconKind; +use crate::os::{to_utf16_nul as wide_str, Rgb as Color}; + +const TIMER_FULLSCREEN_CHECK: usize = 5; +use crate::usage::ProviderId; +type TrayIconKind = ProviderId; // ---------- Public types & API ---------- @@ -58,7 +60,7 @@ pub fn register_class() { ..Default::default() }; if RegisterClassExW(&wc) == 0 { - diagnose::log("bubble RegisterClassExW returned 0"); + log::error!("bubble RegisterClassExW returned 0"); } }); } @@ -98,7 +100,7 @@ pub fn create(config: BubbleConfig) -> HWND { }; if hwnd == HWND::default() { - diagnose::log("bubble CreateWindowExW failed"); + log::error!("bubble CreateWindowExW failed"); return hwnd; } @@ -807,8 +809,8 @@ fn default_position(size_px: i32, model: TrayIconKind) -> (i32, i32) { }; let gap = 24; let stagger = match model { - TrayIconKind::Claude => 0, - TrayIconKind::Codex => size_px + gap, + ProviderId::Claude => 0, + ProviderId::ChatGpt => size_px + gap, }; let x = wa.right - size_px - gap; let y = wa.bottom - size_px - gap - stagger; diff --git a/src/creds/codex_auth.rs b/src/creds/codex_auth.rs new file mode 100644 index 0000000..41d9d87 --- /dev/null +++ b/src/creds/codex_auth.rs @@ -0,0 +1,76 @@ +// Read Codex (ChatGPT) credentials from the user profile. +// +// The Codex CLI writes `auth.json` to `$CODEX_HOME` or `~/.codex/`. Schema: +// `{ "tokens": { "access_token", "account_id" } }`. There is no expiry +// timestamp in the file; we discover expiry only when the server returns +// 401 or 403 to a poll. + +use std::path::PathBuf; +use std::time::UNIX_EPOCH; + +use serde::Deserialize; + +pub struct LocalCodexCreds { + path: PathBuf, + id: String, +} + +impl LocalCodexCreds { + pub fn detect() -> Option { + let path = if let Some(home) = std::env::var_os("CODEX_HOME") { + PathBuf::from(home).join("auth.json") + } else { + dirs::home_dir()?.join(".codex").join("auth.json") + }; + let id = format!("codex:{}", path.display()); + Some(Self { path, id }) + } +} + +impl super::CredentialSource for LocalCodexCreds { + fn id(&self) -> &str { + &self.id + } + + fn read(&self) -> Result { + let content = std::fs::read_to_string(&self.path)?; + let parsed: Envelope = serde_json::from_str(&content)?; + let tokens = parsed + .tokens + .ok_or(super::Error::MissingField("tokens"))?; + if tokens.access_token.is_empty() { + return Err(super::Error::MissingField("access_token")); + } + Ok(super::Token { + access_token: tokens.access_token, + expires_at_unix_ms: None, + account_id: tokens.account_id, + }) + } + + fn signature(&self) -> Option { + let meta = std::fs::metadata(&self.path).ok()?; + let modified = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + Some(format!("{}|{}|{}", self.id, meta.len(), modified)) + } + + fn refresh_hint(&self) -> super::RefreshHint { + super::RefreshHint::LocalCodexCli + } +} + +#[derive(Deserialize)] +struct Envelope { + tokens: Option, +} + +#[derive(Deserialize)] +struct Tokens { + access_token: String, + account_id: Option, +} diff --git a/src/creds/local_fs.rs b/src/creds/local_fs.rs new file mode 100644 index 0000000..aa02cc0 --- /dev/null +++ b/src/creds/local_fs.rs @@ -0,0 +1,72 @@ +// Read Claude credentials from the Windows user profile. +// +// Path: `%USERPROFILE%\.claude\.credentials.json` (matches what the +// official Claude CLI writes). Schema: `{ "claudeAiOauth": { "accessToken", +// "expiresAt": } }`. + +use std::path::PathBuf; +use std::time::UNIX_EPOCH; + +pub struct LocalClaudeCreds { + path: PathBuf, + id: String, +} + +impl LocalClaudeCreds { + pub fn detect() -> Option { + let home = dirs::home_dir()?; + let path = home.join(".claude").join(".credentials.json"); + let id = format!("local:{}", path.display()); + Some(Self { path, id }) + } + + pub fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl super::CredentialSource for LocalClaudeCreds { + fn id(&self) -> &str { + &self.id + } + + fn read(&self) -> Result { + let content = std::fs::read_to_string(&self.path)?; + parse_claude_json(&content) + } + + fn signature(&self) -> Option { + let meta = std::fs::metadata(&self.path).ok()?; + let modified = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + Some(format!("{}|{}|{}", self.id, meta.len(), modified)) + } + + fn refresh_hint(&self) -> super::RefreshHint { + super::RefreshHint::LocalClaudeCli + } +} + +/// Shared between local-fs and wsl-bridge sources — both parse the same +/// JSON shape, the only difference is how they get to the file content. +pub(crate) fn parse_claude_json(content: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(content)?; + let oauth = value + .get("claudeAiOauth") + .ok_or(super::Error::MissingField("claudeAiOauth"))?; + let access_token = oauth + .get("accessToken") + .and_then(|v| v.as_str()) + .ok_or(super::Error::MissingField("accessToken"))? + .to_string(); + let expires_at_unix_ms = oauth.get("expiresAt").and_then(|v| v.as_i64()); + Ok(super::Token { + access_token, + expires_at_unix_ms, + account_id: None, + }) +} diff --git a/src/creds/mod.rs b/src/creds/mod.rs new file mode 100644 index 0000000..36b610c --- /dev/null +++ b/src/creds/mod.rs @@ -0,0 +1,111 @@ +// Pluggable credential discovery. +// +// Each `CredentialSource` knows how to read a single OAuth token from +// somewhere (a local JSON file, a WSL filesystem, …). The `Locator` +// holds a priority-ordered list and serves the first source that +// actually has a token. New sources drop in without touching the locator. + +pub mod codex_auth; +pub mod local_fs; +pub mod wsl_bridge; + +#[derive(Clone, Debug)] +pub struct Token { + pub access_token: String, + /// Expiry timestamp in *milliseconds* since Unix epoch, matching the + /// format the Claude CLI writes. `None` means "the source didn't say". + pub expires_at_unix_ms: Option, + pub account_id: Option, +} + +/// Tells `RefreshOrchestrator` which CLI to spawn when the token rotates. +#[derive(Clone, Debug)] +pub enum RefreshHint { + /// `claude.cmd` / `claude.exe` on PATH. + LocalClaudeCli, + /// Run `claude -p .` inside a specific WSL distro. + WslClaudeCli { distro: String }, + /// `codex` / `codex.cmd` / `codex.ps1` on PATH. + LocalCodexCli, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("invalid JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("required field missing from credential JSON: {0}")] + MissingField(&'static str), + #[error("WSL command in {distro:?} failed: {detail}")] + WslCommand { distro: String, detail: String }, + #[error("timeout while talking to WSL")] + WslTimeout, + #[error("credential source unavailable")] + Unavailable, +} + +pub trait CredentialSource: Send + Sync { + /// Stable identifier used in logs and the locator's change-detection + /// signatures (e.g. `"local:C:\\Users\\me\\.claude\\.credentials.json"`). + fn id(&self) -> &str; + + /// Read the current token. May spawn subprocesses (for WSL). + fn read(&self) -> Result; + + /// Cheap change-detection fingerprint. `None` means "source is missing". + fn signature(&self) -> Option; + + fn refresh_hint(&self) -> RefreshHint; +} + +/// Ordered set of credential sources. The first source with a valid +/// `signature()` is treated as the "active" one. +pub struct Locator { + sources: Vec>, +} + +impl Locator { + pub fn new(sources: Vec>) -> Self { + Self { sources } + } + + /// Build a Claude locator with the standard search order: Windows + /// home directory first, then every installed WSL distro. + pub fn for_claude() -> Self { + let mut sources: Vec> = Vec::new(); + if let Some(s) = local_fs::LocalClaudeCreds::detect() { + sources.push(Box::new(s)); + } + for distro in wsl_bridge::list_distros() { + sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro))); + } + Self { sources } + } + + /// Build a ChatGPT/Codex locator with the standard search order. + pub fn for_chatgpt() -> Self { + let mut sources: Vec> = Vec::new(); + if let Some(s) = codex_auth::LocalCodexCreds::detect() { + sources.push(Box::new(s)); + } + Self { sources } + } + + /// First source whose signature is currently non-None. + pub fn first_available(&self) -> Option<&dyn CredentialSource> { + self.sources + .iter() + .find(|s| s.signature().is_some()) + .map(Box::as_ref) + } + + /// Snapshot of fingerprints for every reachable source — used by the + /// app to detect credential changes (re-login) between poll cycles. + pub fn signatures(&self) -> Vec { + let mut sigs: Vec = self.sources.iter().filter_map(|s| s.signature()).collect(); + sigs.sort(); + sigs.dedup(); + sigs + } +} diff --git a/src/creds/wsl_bridge.rs b/src/creds/wsl_bridge.rs new file mode 100644 index 0000000..7939387 --- /dev/null +++ b/src/creds/wsl_bridge.rs @@ -0,0 +1,155 @@ +// Reach into installed WSL distros to read their Claude credentials. +// +// We never mount the WSL filesystem ourselves — instead we shell out to +// `wsl.exe -d -- sh -lc '...'` and read stdout. Every call has +// a hard timeout so a hung WSL doesn't freeze the poll thread. + +use std::os::windows::process::CommandExt; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +use super::local_fs::parse_claude_json; + +const CREATE_NO_WINDOW: u32 = 0x0800_0000; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(5); + +/// Enumerate installed WSL distributions. Returns an empty vec if WSL is +/// not installed or the probe fails. +pub fn list_distros() -> Vec { + let Some(output) = run_with_timeout( + Command::new("wsl.exe").args(["-l", "-q"]), + COMMAND_TIMEOUT, + ) else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + decode_wsl_text(&output.stdout) + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +pub struct WslClaudeCreds { + distro: String, + id: String, +} + +impl WslClaudeCreds { + pub fn new(distro: String) -> Self { + let id = format!("wsl:{distro}"); + Self { distro, id } + } + + pub fn distro(&self) -> &str { + &self.distro + } +} + +impl super::CredentialSource for WslClaudeCreds { + fn id(&self) -> &str { + &self.id + } + + fn read(&self) -> Result { + let output = wsl_run(&self.distro, "cat ~/.claude/.credentials.json")?; + if !output.status.success() { + return Err(super::Error::WslCommand { + distro: self.distro.clone(), + detail: format!("cat exited {}", output.status), + }); + } + let content = String::from_utf8(output.stdout).map_err(|_| super::Error::WslCommand { + distro: self.distro.clone(), + detail: "non-UTF-8 stdout".into(), + })?; + parse_claude_json(&content) + } + + fn signature(&self) -> Option { + let output = wsl_run( + &self.distro, + "if [ -f ~/.claude/.credentials.json ]; then \ + stat -c '%s|%Y' ~/.claude/.credentials.json; \ + else echo MISSING; fi", + ) + .ok()?; + if !output.status.success() { + return None; + } + let body = decode_wsl_text(&output.stdout).trim().to_string(); + if body == "MISSING" { + return None; + } + Some(format!("{}|{}", self.id, body)) + } + + fn refresh_hint(&self) -> super::RefreshHint { + super::RefreshHint::WslClaudeCli { + distro: self.distro.clone(), + } + } +} + +fn wsl_run(distro: &str, script: &str) -> Result { + run_with_timeout( + Command::new("wsl.exe") + .arg("-d") + .arg(distro) + .arg("--") + .arg("sh") + .arg("-lc") + .arg(script), + COMMAND_TIMEOUT, + ) + .ok_or(super::Error::WslTimeout) +} + +fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option { + let mut child = cmd + .creation_flags(CREATE_NO_WINDOW) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => return child.wait_with_output().ok(), + Ok(None) => { + if start.elapsed() > timeout { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(Duration::from_millis(80)); + } + Err(_) => return None, + } + } +} + +/// `wsl.exe -l -q` historically emits UTF-16LE on stdout; other commands +/// emit UTF-8. Detect by sampling high bytes and decode appropriately. +pub(crate) fn decode_wsl_text(bytes: &[u8]) -> String { + if bytes.len() >= 2 && bytes.len() % 2 == 0 { + let sample_end = bytes.len().min(256); + let mut high_nul = 0usize; + for chunk in bytes[..sample_end].chunks_exact(2) { + if chunk[1] == 0 { + high_nul += 1; + } + } + if high_nul * 2 >= sample_end / 2 { + let units: Vec = bytes + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + return String::from_utf16_lossy(&units); + } + } + String::from_utf8_lossy(bytes).into_owned() +} diff --git a/src/diag/mod.rs b/src/diag/mod.rs new file mode 100644 index 0000000..80d0783 --- /dev/null +++ b/src/diag/mod.rs @@ -0,0 +1,29 @@ +// Diagnostic logging facade backed by `log` + `simplelog`. +// +// `init(true)` redirects every `log::info!`/`log::warn!`/`log::error!` call +// across the crate to a file in `%TEMP%`. With `init(false)` (the default, +// i.e. no `--diagnose` flag) logging is a no-op. + +use std::fs::File; +use std::path::PathBuf; + +use simplelog::{Config, LevelFilter, WriteLogger}; + +const LOG_FILE_NAME: &str = "claude-code-usage-bubble.log"; + +/// Initialise file-based logging. Idempotent — second call is a no-op. +/// +/// Returns the resolved log-file path on success, or `Ok(None)` when +/// `enabled` is false. `Err` is only returned if the file could not be +/// opened (e.g. read-only `%TEMP%`); callers may ignore the error. +pub fn init(enabled: bool) -> std::io::Result> { + if !enabled { + return Ok(None); + } + let path = std::env::temp_dir().join(LOG_FILE_NAME); + let file = File::create(&path)?; + // simplelog will refuse a second init; convert that into a soft no-op. + let _ = WriteLogger::init(LevelFilter::Debug, Config::default(), file); + log::info!("diagnostic logging enabled at {}", path.display()); + Ok(Some(path)) +} diff --git a/src/diagnose.rs b/src/diagnose.rs deleted file mode 100644 index 029e469..0000000 --- a/src/diagnose.rs +++ /dev/null @@ -1,52 +0,0 @@ -use std::fs::{File, OpenOptions}; -use std::io::Write; -use std::path::PathBuf; -use std::sync::{Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; - -struct DiagnoseState { - file: Mutex, -} - -static DIAGNOSE_STATE: OnceLock = OnceLock::new(); - -pub fn init() -> Result { - let path = std::env::temp_dir().join("claude-code-usage-bubble.log"); - let file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&path) - .map_err(|e| format!("Unable to open diagnostic log file {}: {e}", path.display()))?; - - let _ = DIAGNOSE_STATE.set(DiagnoseState { - file: Mutex::new(file), - }); - - log("diagnostic logging enabled"); - Ok(path) -} - -pub fn is_enabled() -> bool { - DIAGNOSE_STATE.get().is_some() -} - -pub fn log(message: impl AsRef) { - let Some(state) = DIAGNOSE_STATE.get() else { - return; - }; - - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0); - - if let Ok(mut file) = state.file.lock() { - let _ = writeln!(file, "[{timestamp}] {}", message.as_ref()); - let _ = file.flush(); - } -} - -pub fn log_error(context: &str, error: impl std::fmt::Display) { - log(format!("{context}: {error}")); -} diff --git a/src/i18n/detect.rs b/src/i18n/detect.rs new file mode 100644 index 0000000..2dfb3c3 --- /dev/null +++ b/src/i18n/detect.rs @@ -0,0 +1,73 @@ +// Discover the user's preferred Windows UI language. +// +// We try three sources in priority order and return the first non-empty +// result. Callers normalise the returned BCP-47-ish code against the +// list of locales we actually ship. + +use windows::core::PWSTR; +use windows::Win32::Globalization::{ + GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages, + LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME, +}; + +/// First non-empty locale code from the user's preferences. May be +/// `Some("en-US")` style; callers do prefix normalisation. +pub fn detect_system_locale() -> Option { + preferred_ui() + .into_iter() + .next() + .or_else(default_ui_language) + .or_else(default_locale_name) +} + +fn preferred_ui() -> Vec { + unsafe { + let mut count: u32 = 0; + let mut buf_len: u32 = 0; + if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &mut count, PWSTR::null(), &mut buf_len) + .is_err() + || buf_len == 0 + { + return Vec::new(); + } + let mut buffer = vec![0u16; buf_len as usize]; + if GetUserPreferredUILanguages( + MUI_LANGUAGE_NAME, + &mut count, + PWSTR(buffer.as_mut_ptr()), + &mut buf_len, + ) + .is_err() + { + return Vec::new(); + } + buffer + .split(|u| *u == 0) + .filter(|s| !s.is_empty()) + .map(String::from_utf16_lossy) + .collect() + } +} + +fn default_ui_language() -> Option { + unsafe { + let lcid = GetUserDefaultUILanguage(); + let mut buf = [0u16; MAX_LOCALE_NAME as usize]; + let len = LCIDToLocaleName(lcid as u32, Some(&mut buf), LOCALE_ALLOW_NEUTRAL_NAMES); + if len <= 1 { + return None; + } + Some(String::from_utf16_lossy(&buf[..(len as usize - 1)])) + } +} + +fn default_locale_name() -> Option { + unsafe { + let mut buf = [0u16; MAX_LOCALE_NAME as usize]; + let len = GetUserDefaultLocaleName(&mut buf); + if len <= 1 { + return None; + } + Some(String::from_utf16_lossy(&buf[..(len as usize - 1)])) + } +} diff --git a/src/i18n/locales/de.toml b/src/i18n/locales/de.toml new file mode 100644 index 0000000..487c786 --- /dev/null +++ b/src/i18n/locales/de.toml @@ -0,0 +1,38 @@ +code = "de" +native_name = "Deutsch" + +window_title = "Claude Code Usage Bubble" +refresh = "Aktualisieren" +update_frequency = "Aktualisierungsintervall" +one_minute = "1 Minute" +five_minutes = "5 Minuten" +fifteen_minutes = "15 Minuten" +one_hour = "1 Stunde" +models = "Modelle" +claude_label = "Claude Code" +chatgpt_label = "Codex" +settings = "Einstellungen" +start_with_windows = "Mit Windows starten" +reset_position = "Position zurücksetzen" +language = "Sprache" +system_default = "Systemstandard" +check_for_updates = "Nach Updates suchen" +checking_for_updates = "Suche läuft…" +up_to_date = "Aktuell" +update_failed = "Update fehlgeschlagen" +applying_update = "Update wird angewendet…" +update_available = "Update verfügbar" +update_via_winget = "über WinGet" +exit = "Beenden" +show_widget = "Widget anzeigen" +session_window = "5h" +weekly_window = "7d" +now = "jetzt" +day_suffix = "T" +hour_suffix = "h" +minute_suffix = "m" +second_suffix = "s" +token_expired_title = "Claude Code-Sitzung abgelaufen" +token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen." +chatgpt_token_expired_title = "Codex-Sitzung abgelaufen" +chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen." diff --git a/src/i18n/locales/en.toml b/src/i18n/locales/en.toml new file mode 100644 index 0000000..440769f --- /dev/null +++ b/src/i18n/locales/en.toml @@ -0,0 +1,38 @@ +code = "en" +native_name = "English" + +window_title = "Claude Code Usage Bubble" +refresh = "Refresh" +update_frequency = "Update frequency" +one_minute = "1 minute" +five_minutes = "5 minutes" +fifteen_minutes = "15 minutes" +one_hour = "1 hour" +models = "Models" +claude_label = "Claude Code" +chatgpt_label = "Codex" +settings = "Settings" +start_with_windows = "Start with Windows" +reset_position = "Reset position" +language = "Language" +system_default = "System default" +check_for_updates = "Check for updates" +checking_for_updates = "Checking for updates…" +up_to_date = "Up to date" +update_failed = "Update failed" +applying_update = "Applying update…" +update_available = "Update available" +update_via_winget = "via WinGet" +exit = "Exit" +show_widget = "Show widget" +session_window = "5h" +weekly_window = "7d" +now = "now" +day_suffix = "d" +hour_suffix = "h" +minute_suffix = "m" +second_suffix = "s" +token_expired_title = "Claude Code session expired" +token_expired_body = "Sign in again to keep tracking your usage." +chatgpt_token_expired_title = "Codex session expired" +chatgpt_token_expired_body = "Sign in again to keep tracking your usage." diff --git a/src/i18n/locales/es.toml b/src/i18n/locales/es.toml new file mode 100644 index 0000000..91780da --- /dev/null +++ b/src/i18n/locales/es.toml @@ -0,0 +1,38 @@ +code = "es" +native_name = "Español" + +window_title = "Claude Code Usage Bubble" +refresh = "Actualizar" +update_frequency = "Frecuencia de actualización" +one_minute = "1 minuto" +five_minutes = "5 minutos" +fifteen_minutes = "15 minutos" +one_hour = "1 hora" +models = "Modelos" +claude_label = "Claude Code" +chatgpt_label = "Codex" +settings = "Ajustes" +start_with_windows = "Iniciar con Windows" +reset_position = "Restablecer posición" +language = "Idioma" +system_default = "Predeterminado del sistema" +check_for_updates = "Buscar actualizaciones" +checking_for_updates = "Buscando actualizaciones…" +up_to_date = "Al día" +update_failed = "Actualización fallida" +applying_update = "Aplicando actualización…" +update_available = "Actualización disponible" +update_via_winget = "vía WinGet" +exit = "Salir" +show_widget = "Mostrar widget" +session_window = "5h" +weekly_window = "7d" +now = "ahora" +day_suffix = "d" +hour_suffix = "h" +minute_suffix = "m" +second_suffix = "s" +token_expired_title = "Sesión de Claude Code caducada" +token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso." +chatgpt_token_expired_title = "Sesión de Codex caducada" +chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso." diff --git a/src/i18n/locales/fr.toml b/src/i18n/locales/fr.toml new file mode 100644 index 0000000..2c748d7 --- /dev/null +++ b/src/i18n/locales/fr.toml @@ -0,0 +1,38 @@ +code = "fr" +native_name = "Français" + +window_title = "Claude Code Usage Bubble" +refresh = "Actualiser" +update_frequency = "Fréquence de mise à jour" +one_minute = "1 minute" +five_minutes = "5 minutes" +fifteen_minutes = "15 minutes" +one_hour = "1 heure" +models = "Modèles" +claude_label = "Claude Code" +chatgpt_label = "Codex" +settings = "Paramètres" +start_with_windows = "Lancer avec Windows" +reset_position = "Réinitialiser la position" +language = "Langue" +system_default = "Paramètre système" +check_for_updates = "Rechercher des mises à jour" +checking_for_updates = "Recherche en cours…" +up_to_date = "À jour" +update_failed = "Mise à jour échouée" +applying_update = "Mise à jour en cours…" +update_available = "Mise à jour disponible" +update_via_winget = "via WinGet" +exit = "Quitter" +show_widget = "Afficher le widget" +session_window = "5h" +weekly_window = "7j" +now = "maintenant" +day_suffix = "j" +hour_suffix = "h" +minute_suffix = "m" +second_suffix = "s" +token_expired_title = "Session Claude Code expirée" +token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation." +chatgpt_token_expired_title = "Session Codex expirée" +chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation." diff --git a/src/i18n/locales/ja.toml b/src/i18n/locales/ja.toml new file mode 100644 index 0000000..e8a6750 --- /dev/null +++ b/src/i18n/locales/ja.toml @@ -0,0 +1,38 @@ +code = "ja" +native_name = "日本語" + +window_title = "Claude Code Usage Bubble" +refresh = "更新" +update_frequency = "更新間隔" +one_minute = "1分" +five_minutes = "5分" +fifteen_minutes = "15分" +one_hour = "1時間" +models = "モデル" +claude_label = "Claude Code" +chatgpt_label = "Codex" +settings = "設定" +start_with_windows = "Windows起動時に開始" +reset_position = "位置をリセット" +language = "言語" +system_default = "システム既定" +check_for_updates = "更新を確認" +checking_for_updates = "確認中…" +up_to_date = "最新です" +update_failed = "更新に失敗しました" +applying_update = "更新を適用中…" +update_available = "更新あり" +update_via_winget = "WinGet経由" +exit = "終了" +show_widget = "ウィジェットを表示" +session_window = "5時間" +weekly_window = "7日" +now = "今" +day_suffix = "日" +hour_suffix = "時" +minute_suffix = "分" +second_suffix = "秒" +token_expired_title = "Claude Codeのセッションが切れました" +token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。" +chatgpt_token_expired_title = "Codexのセッションが切れました" +chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。" diff --git a/src/i18n/locales/ko.toml b/src/i18n/locales/ko.toml new file mode 100644 index 0000000..c793983 --- /dev/null +++ b/src/i18n/locales/ko.toml @@ -0,0 +1,38 @@ +code = "ko" +native_name = "한국어" + +window_title = "Claude Code Usage Bubble" +refresh = "새로 고침" +update_frequency = "업데이트 주기" +one_minute = "1분" +five_minutes = "5분" +fifteen_minutes = "15분" +one_hour = "1시간" +models = "모델" +claude_label = "Claude Code" +chatgpt_label = "Codex" +settings = "설정" +start_with_windows = "Windows 시작 시 실행" +reset_position = "위치 초기화" +language = "언어" +system_default = "시스템 기본값" +check_for_updates = "업데이트 확인" +checking_for_updates = "확인 중…" +up_to_date = "최신 상태" +update_failed = "업데이트 실패" +applying_update = "업데이트 적용 중…" +update_available = "업데이트 있음" +update_via_winget = "WinGet 사용" +exit = "종료" +show_widget = "위젯 표시" +session_window = "5시간" +weekly_window = "7일" +now = "지금" +day_suffix = "일" +hour_suffix = "시간" +minute_suffix = "분" +second_suffix = "초" +token_expired_title = "Claude Code 세션 만료" +token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요." +chatgpt_token_expired_title = "Codex 세션 만료" +chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요." diff --git a/src/i18n/locales/nl.toml b/src/i18n/locales/nl.toml new file mode 100644 index 0000000..1185c6d --- /dev/null +++ b/src/i18n/locales/nl.toml @@ -0,0 +1,38 @@ +code = "nl" +native_name = "Nederlands" + +window_title = "Claude Code Usage Bubble" +refresh = "Vernieuwen" +update_frequency = "Bijwerkfrequentie" +one_minute = "1 minuut" +five_minutes = "5 minuten" +fifteen_minutes = "15 minuten" +one_hour = "1 uur" +models = "Modellen" +claude_label = "Claude Code" +chatgpt_label = "Codex" +settings = "Instellingen" +start_with_windows = "Starten met Windows" +reset_position = "Positie herstellen" +language = "Taal" +system_default = "Systeemstandaard" +check_for_updates = "Controleren op updates" +checking_for_updates = "Bezig met controleren…" +up_to_date = "Up-to-date" +update_failed = "Update mislukt" +applying_update = "Update toepassen…" +update_available = "Update beschikbaar" +update_via_winget = "via WinGet" +exit = "Afsluiten" +show_widget = "Widget tonen" +session_window = "5u" +weekly_window = "7d" +now = "nu" +day_suffix = "d" +hour_suffix = "u" +minute_suffix = "m" +second_suffix = "s" +token_expired_title = "Claude Code-sessie verlopen" +token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen." +chatgpt_token_expired_title = "Codex-sessie verlopen" +chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen." diff --git a/src/i18n/locales/zh-TW.toml b/src/i18n/locales/zh-TW.toml new file mode 100644 index 0000000..2c3bdea --- /dev/null +++ b/src/i18n/locales/zh-TW.toml @@ -0,0 +1,38 @@ +code = "zh-TW" +native_name = "繁體中文" + +window_title = "Claude Code Usage Bubble" +refresh = "重新整理" +update_frequency = "更新頻率" +one_minute = "1 分鐘" +five_minutes = "5 分鐘" +fifteen_minutes = "15 分鐘" +one_hour = "1 小時" +models = "模型" +claude_label = "Claude Code" +chatgpt_label = "Codex" +settings = "設定" +start_with_windows = "隨 Windows 啟動" +reset_position = "重設位置" +language = "語言" +system_default = "系統預設" +check_for_updates = "檢查更新" +checking_for_updates = "檢查中…" +up_to_date = "已是最新" +update_failed = "更新失敗" +applying_update = "正在套用更新…" +update_available = "有可用更新" +update_via_winget = "透過 WinGet" +exit = "結束" +show_widget = "顯示小工具" +session_window = "5 小時" +weekly_window = "7 日" +now = "現在" +day_suffix = "日" +hour_suffix = "時" +minute_suffix = "分" +second_suffix = "秒" +token_expired_title = "Claude Code 工作階段已過期" +token_expired_body = "請重新登入以繼續追蹤使用量。" +chatgpt_token_expired_title = "Codex 工作階段已過期" +chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。" diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs new file mode 100644 index 0000000..918eed7 --- /dev/null +++ b/src/i18n/mod.rs @@ -0,0 +1,243 @@ +// Embedded TOML-based localisation. +// +// Each supported language lives in `locales/.toml`. At startup we +// `include_str!` every file, parse them with `toml`, and stash them in a +// HashMap keyed by language code. The active language defaults to whatever +// Windows reports for the user's preferred UI language; the menu lets the +// user override that. +// +// Adding a translation: copy `en.toml` to `.toml`, translate the +// strings, then add one `include_str!` entry to `RAW_LOCALES` below. + +use std::collections::BTreeMap; +use std::time::{Duration, SystemTime}; + +use serde::Deserialize; + +pub mod detect; + +const FALLBACK_CODE: &str = "en"; + +/// The strings every UI module needs. Field names map 1:1 to TOML keys. +#[derive(Clone, Debug, Deserialize)] +pub struct LocaleStrings { + pub window_title: String, + pub refresh: String, + pub update_frequency: String, + pub one_minute: String, + pub five_minutes: String, + pub fifteen_minutes: String, + pub one_hour: String, + pub models: String, + pub claude_label: String, + pub chatgpt_label: String, + pub settings: String, + pub start_with_windows: String, + pub reset_position: String, + pub language: String, + pub system_default: String, + pub check_for_updates: String, + pub checking_for_updates: String, + pub up_to_date: String, + pub update_failed: String, + pub applying_update: String, + pub update_available: String, + pub update_via_winget: String, + pub exit: String, + pub show_widget: String, + pub session_window: String, + pub weekly_window: String, + pub now: String, + pub day_suffix: String, + pub hour_suffix: String, + pub minute_suffix: String, + pub second_suffix: String, + pub token_expired_title: String, + pub token_expired_body: String, + pub chatgpt_token_expired_title: String, + pub chatgpt_token_expired_body: String, +} + +#[derive(Deserialize)] +struct LocaleFile { + code: String, + native_name: String, + #[serde(flatten)] + strings: LocaleStrings, +} + +const RAW_LOCALES: &[(&str, &str)] = &[ + ("en", include_str!("locales/en.toml")), + ("nl", include_str!("locales/nl.toml")), + ("es", include_str!("locales/es.toml")), + ("fr", include_str!("locales/fr.toml")), + ("de", include_str!("locales/de.toml")), + ("ja", include_str!("locales/ja.toml")), + ("ko", include_str!("locales/ko.toml")), + ("zh-TW", include_str!("locales/zh-TW.toml")), +]; + +pub struct I18n { + /// Sorted by code so menus list languages deterministically. + available: BTreeMap, + active: String, +} + +impl I18n { + /// Load all embedded TOML files and pick an active language. + /// + /// `requested` overrides system detection. `None` means "ask Windows". + pub fn load(requested: Option<&str>) -> Self { + let mut available = BTreeMap::new(); + for (code, body) in RAW_LOCALES { + match toml::from_str::(body) { + Ok(file) => { + available.insert(file.code.clone(), (file.native_name, file.strings)); + } + Err(e) => { + log::error!("failed to parse locale {code}: {e}"); + } + } + } + if !available.contains_key(FALLBACK_CODE) { + // Embedded TOMLs are validated by tests; this should never + // happen in practice. Fall through with whatever we have. + log::error!("fallback locale '{FALLBACK_CODE}' missing"); + } + + let active = requested + .and_then(|c| normalise(c, &available)) + .or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &available))) + .unwrap_or_else(|| FALLBACK_CODE.to_string()); + + Self { available, active } + } + + pub fn strings(&self) -> &LocaleStrings { + self.available + .get(&self.active) + .map(|(_, s)| s) + .unwrap_or_else(|| { + // Defensive: if `active` was set to something unavailable + // (shouldn't happen given `load` validates) — fall back. + &self + .available + .get(FALLBACK_CODE) + .expect("fallback locale must exist") + .1 + }) + } + + pub fn active_code(&self) -> &str { + &self.active + } + + /// Iterate `(code, native_name)` pairs in stable order. + pub fn available(&self) -> impl Iterator { + self.available + .iter() + .map(|(code, (name, _))| (code.as_str(), name.as_str())) + } + + pub fn set_active(&mut self, requested: Option<&str>) { + let new_active = requested + .and_then(|c| normalise(c, &self.available)) + .or_else(|| { + detect::detect_system_locale().and_then(|c| normalise(&c, &self.available)) + }) + .unwrap_or_else(|| FALLBACK_CODE.to_string()); + self.active = new_active; + } +} + +/// Resolve a user-supplied or system-supplied locale code to one we have. +/// +/// Handles `en_US`, `en-US`, `EN`, `zh-Hant-TW`, etc. by progressive +/// fallback: exact → ASCII-lower exact → prefix match. +fn normalise(input: &str, available: &BTreeMap) -> Option { + let cleaned = input.trim().replace('_', "-"); + if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("system") { + return None; + } + // Exact (case-insensitive) + for key in available.keys() { + if key.eq_ignore_ascii_case(&cleaned) { + return Some(key.clone()); + } + } + // Special-case: Traditional Chinese variants → zh-TW + let lower = cleaned.to_ascii_lowercase(); + if lower.starts_with("zh") && (lower.contains("tw") || lower.contains("hk") || lower.contains("hant")) { + if available.contains_key("zh-TW") { + return Some("zh-TW".to_string()); + } + } + // Prefix fallback (e.g. "en-US" → "en") + let prefix = lower.split('-').next().unwrap_or(""); + if !prefix.is_empty() { + for key in available.keys() { + if key.split('-').next().map(str::to_ascii_lowercase).as_deref() == Some(prefix) { + return Some(key.clone()); + } + } + } + None +} + +// ---------- Free-function helpers ---------- + +/// Format a `usage::Window` percentage + countdown as `"73% · 2h"`-style text. +/// Returns just the percentage when no reset time is available. +pub fn format_window(window: &crate::usage::Window, strings: &LocaleStrings) -> String { + let pct = format!("{:.0}%", window.utilization); + let cd = format_countdown(window.resets_at, strings); + if cd.is_empty() { + pct + } else { + format!("{pct} \u{00b7} {cd}") + } +} + +fn format_countdown(resets_at: Option, strings: &LocaleStrings) -> String { + let Some(reset) = resets_at else { + return String::new(); + }; + let remaining = match reset.duration_since(SystemTime::now()) { + Ok(d) => d, + Err(_) => return strings.now.clone(), + }; + format_countdown_secs(remaining.as_secs(), strings) +} + +fn format_countdown_secs(total_secs: u64, strings: &LocaleStrings) -> String { + let days = total_secs / 86_400; + let hours = total_secs / 3_600; + let mins = total_secs / 60; + if days >= 1 { + format!("{days}{}", strings.day_suffix) + } else if hours >= 1 { + format!("{hours}{}", strings.hour_suffix) + } else if mins >= 1 { + format!("{mins}{}", strings.minute_suffix) + } else { + format!("{total_secs}{}", strings.second_suffix) + } +} + +/// How long before `format_window`'s string would change. +/// Used by the countdown timer to refresh exactly when needed. +pub fn time_until_display_change(resets_at: Option) -> Option { + let reset = resets_at?; + let remaining = reset.duration_since(SystemTime::now()).ok()?; + let secs = remaining.as_secs(); + let bucket_start = if secs / 86_400 >= 1 { + (secs / 86_400) * 86_400 + } else if secs / 3_600 >= 1 { + (secs / 3_600) * 3_600 + } else if secs / 60 >= 1 { + (secs / 60) * 60 + } else { + secs + }; + Some(Duration::from_secs(secs.saturating_sub(bucket_start) + 1)) +} diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs deleted file mode 100644 index 0eb486d..0000000 --- a/src/localization/dutch.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::Strings; - -pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Bijwerken via WinGet"; - -pub(super) const STRINGS: Strings = Strings { - window_title: "Claude Code Gebruiksmonitor", - refresh: "Vernieuwen", - update_frequency: "Updatefrequentie", - one_minute: "1 minuut", - five_minutes: "5 minuten", - fifteen_minutes: "15 minuten", - one_hour: "1 uur", - models: "Modellen", - claude_code_model: "Claude Code", - codex_model: "Codex", - settings: "Instellingen", - start_with_windows: "Opstarten met Windows", - reset_position: "Positie herstellen", - language: "Taal", - system_default: "Systeemstandaard", - check_for_updates: "Controleren op updates", - checking_for_updates: "Controleren op updates...", - updates: "Updates", - update_in_progress: "Er is al een updatecontrole bezig.", - up_to_date: "Je gebruikt al de nieuwste versie.", - up_to_date_short: "Up-to-date", - update_failed: "Automatisch bijwerken mislukt", - applying_update: "Update wordt toegepast...", - update_to: "Bijwerken naar", - update_available: "Update beschikbaar", - update_prompt_now: "Versie {version} is beschikbaar. Wil je nu bijwerken?", - exit: "Afsluiten", - show_widget: "Widget tonen", - session_window: "5u", - weekly_window: "7d", - now: "nu", - day_suffix: "d", - hour_suffix: "u", - minute_suffix: "m", - token_expired_title: "Claude Code-authenticatiefout", - token_expired_body: "Voer 'claude' uit in een terminal, gebruik daarna '/login' en volg de stappen. Ververs of herstart de app daarna.", - codex_token_expired_title: "Codex-authenticatiefout", - codex_token_expired_body: "Voer 'codex' uit in een terminal en volg de aanmeldstappen. Ververs of herstart de app daarna.", - codex_window_title: "Codex-gebruiksmonitor", - second_suffix: "s", -}; diff --git a/src/localization/english.rs b/src/localization/english.rs deleted file mode 100644 index 2b92f36..0000000 --- a/src/localization/english.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::Strings; - -pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Update via WinGet"; - -pub(super) const STRINGS: Strings = Strings { - window_title: "Claude Code Usage Monitor", - refresh: "Refresh", - update_frequency: "Update Frequency", - one_minute: "1 Minute", - five_minutes: "5 Minutes", - fifteen_minutes: "15 Minutes", - one_hour: "1 Hour", - models: "Models", - claude_code_model: "Claude Code", - codex_model: "Codex", - settings: "Settings", - start_with_windows: "Start with Windows", - reset_position: "Reset Position", - language: "Language", - system_default: "System Default", - check_for_updates: "Check for Updates", - checking_for_updates: "Checking for Updates...", - updates: "Updates", - update_in_progress: "An update check is already in progress.", - up_to_date: "You already have the latest version.", - up_to_date_short: "Up to date", - update_failed: "Unable to update automatically", - applying_update: "Applying update...", - update_to: "Update to", - update_available: "Update available", - update_prompt_now: "Version {version} is available. Do you want to update now?", - exit: "Exit", - show_widget: "Show Widget", - session_window: "5h", - weekly_window: "7d", - now: "now", - day_suffix: "d", - hour_suffix: "h", - minute_suffix: "m", - token_expired_title: "Claude Code Auth Error", - token_expired_body: "Run 'claude' in a terminal, then use '/login' and follow the prompts. After that, refresh or restart this app.", - codex_token_expired_title: "Codex Auth Error", - codex_token_expired_body: "Run 'codex' in a terminal and follow the sign-in prompts. After that, refresh or restart this app.", - codex_window_title: "Codex Usage Monitor", - second_suffix: "s", -}; diff --git a/src/localization/french.rs b/src/localization/french.rs deleted file mode 100644 index fa448fb..0000000 --- a/src/localization/french.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::Strings; - -pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Mettre à jour avec WinGet"; - -pub(super) const STRINGS: Strings = Strings { - window_title: "Moniteur d'utilisation Claude Code", - refresh: "Actualiser", - update_frequency: "Fréquence de mise à jour", - one_minute: "1 minute", - five_minutes: "5 minutes", - fifteen_minutes: "15 minutes", - one_hour: "1 heure", - models: "Modeles", - claude_code_model: "Claude Code", - codex_model: "Codex", - settings: "Paramètres", - start_with_windows: "Démarrer avec Windows", - reset_position: "Réinitialiser la position", - language: "Langue", - system_default: "Par défaut du système", - check_for_updates: "Vérifier les mises à jour", - checking_for_updates: "Vérification des mises à jour...", - updates: "Mises à jour", - update_in_progress: "Une vérification de mise à jour est déjà en cours.", - up_to_date: "Vous utilisez déjà la version la plus récente.", - up_to_date_short: "À jour", - update_failed: "Impossible d'effectuer la mise à jour automatiquement", - applying_update: "Application de la mise à jour...", - update_to: "Mettre à jour vers", - update_available: "Mise à jour disponible", - update_prompt_now: "La version {version} est disponible. Voulez-vous mettre à jour maintenant ?", - exit: "Quitter", - show_widget: "Afficher le widget", - session_window: "5h", - weekly_window: "7d", - now: "maintenant", - day_suffix: "j", - hour_suffix: "h", - minute_suffix: "m", - token_expired_title: "Erreur d'authentification", - token_expired_body: "Exécutez 'claude' dans un terminal, puis utilisez '/login' et suivez les instructions. Ensuite, actualisez ou redémarrez cette application.", - codex_token_expired_title: "Erreur d'authentification Codex", - codex_token_expired_body: "Executez 'codex' dans un terminal et suivez les instructions de connexion. Ensuite, actualisez ou redemarrez cette application.", - codex_window_title: "Moniteur d'utilisation Codex", - second_suffix: "s", -}; diff --git a/src/localization/german.rs b/src/localization/german.rs deleted file mode 100644 index 5c7bb23..0000000 --- a/src/localization/german.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::Strings; - -pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Mit WinGet aktualisieren"; - -pub(super) const STRINGS: Strings = Strings { - window_title: "Claude Code Nutzungsmonitor", - refresh: "Aktualisieren", - update_frequency: "Aktualisierungsintervall", - one_minute: "1 Minute", - five_minutes: "5 Minuten", - fifteen_minutes: "15 Minuten", - one_hour: "1 Stunde", - models: "Modelle", - claude_code_model: "Claude Code", - codex_model: "Codex", - settings: "Einstellungen", - start_with_windows: "Mit Windows starten", - reset_position: "Position zurücksetzen", - language: "Sprache", - system_default: "Systemstandard", - check_for_updates: "Nach Updates suchen", - checking_for_updates: "Suche nach Updates...", - updates: "Updates", - update_in_progress: "Eine Update-Prüfung läuft bereits.", - up_to_date: "Sie verwenden bereits die neueste Version.", - up_to_date_short: "Aktuell", - update_failed: "Automatisches Update war nicht möglich", - applying_update: "Update wird installiert...", - update_to: "Aktualisieren auf", - update_available: "Update verfügbar", - update_prompt_now: "Version {version} ist verfügbar. Möchten Sie jetzt aktualisieren?", - exit: "Beenden", - show_widget: "Widget anzeigen", - session_window: "5h", - weekly_window: "7d", - now: "jetzt", - day_suffix: "T", - hour_suffix: "h", - minute_suffix: "m", - token_expired_title: "Authentifizierungsfehler", - token_expired_body: "Führen Sie 'claude' in einem Terminal aus, verwenden Sie dann '/login' und folgen Sie den Anweisungen. Aktualisieren oder starten Sie diese App anschließend neu.", - codex_token_expired_title: "Codex-Authentifizierungsfehler", - codex_token_expired_body: "Fuhren Sie 'codex' in einem Terminal aus und folgen Sie den Anmeldeanweisungen. Aktualisieren oder starten Sie diese App anschliessend neu.", - codex_window_title: "Codex-Nutzungsmonitor", - second_suffix: "s", -}; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs deleted file mode 100644 index 0020018..0000000 --- a/src/localization/japanese.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::Strings; - -pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "WinGet で更新"; - -pub(super) const STRINGS: Strings = Strings { - window_title: "Claude Code 使用量モニター", - refresh: "更新", - update_frequency: "更新間隔", - one_minute: "1分", - five_minutes: "5分", - fifteen_minutes: "15分", - one_hour: "1時間", - models: "モデル", - claude_code_model: "Claude Code", - codex_model: "Codex", - settings: "設定", - start_with_windows: "Windows と同時に開始", - reset_position: "位置をリセット", - language: "言語", - system_default: "システム既定", - check_for_updates: "更新を確認", - checking_for_updates: "更新を確認しています...", - updates: "更新", - update_in_progress: "更新確認は既に実行中です。", - up_to_date: "既に最新バージョンです。", - up_to_date_short: "最新です", - update_failed: "自動更新を完了できませんでした", - applying_update: "更新を適用しています...", - update_to: "更新先", - update_available: "更新が利用可能です", - update_prompt_now: "バージョン {version} が利用可能です。今すぐ更新しますか?", - exit: "終了", - show_widget: "ウィジェットを表示", - session_window: "5h", - weekly_window: "7d", - now: "今", - day_suffix: "日", - hour_suffix: "時間", - minute_suffix: "分", - token_expired_title: "認証エラー", - token_expired_body: "ターミナルで 'claude' を実行し、'/login' を使って案内に従ってください。その後、このアプリを更新するか再起動してください。", - codex_token_expired_title: "Codex 認証エラー", - codex_token_expired_body: "ターミナルで 'codex' を実行し、サインインの案内に従ってください。その後、このアプリを更新または再起動してください。", - codex_window_title: "Codex 使用量モニター", - second_suffix: "秒", -}; diff --git a/src/localization/korean.rs b/src/localization/korean.rs deleted file mode 100644 index 59e3829..0000000 --- a/src/localization/korean.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::Strings; - -pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "WinGet으로 업데이트"; - -pub(super) const STRINGS: Strings = Strings { - window_title: "Claude Code 사용량 모니터", - refresh: "새로고침", - update_frequency: "업데이트 주기", - one_minute: "1분", - five_minutes: "5분", - fifteen_minutes: "15분", - one_hour: "1시간", - models: "모델", - claude_code_model: "Claude Code", - codex_model: "Codex", - settings: "설정", - start_with_windows: "Windows 시작 시 자동 실행", - reset_position: "위치 초기화", - language: "언어", - system_default: "시스템 기본값", - check_for_updates: "업데이트 확인", - checking_for_updates: "업데이트 확인 중...", - updates: "업데이트", - update_in_progress: "이미 업데이트 확인이 진행 중입니다.", - up_to_date: "이미 최신 버전입니다.", - up_to_date_short: "최신", - update_failed: "자동 업데이트를 완료할 수 없습니다", - applying_update: "업데이트 적용 중...", - update_to: "업데이트 대상", - update_available: "업데이트 사용 가능", - update_prompt_now: "버전 {version}을 사용할 수 있습니다. 지금 업데이트하시겠습니까?", - exit: "종료", - show_widget: "위젯 표시", - session_window: "5시간", - weekly_window: "7일", - now: "지금", - day_suffix: "일", - hour_suffix: "시간", - minute_suffix: "분", - token_expired_title: "인증 오류", - token_expired_body: "터미널에서 'claude'를 실행한 다음 '/login'을 사용하고 안내에 따라 진행하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", - codex_token_expired_title: "Codex 인증 오류", - codex_token_expired_body: "터미널에서 'codex'를 실행하고 로그인 안내를 따르세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", - codex_window_title: "Codex 사용량 모니터", - second_suffix: "초", -}; diff --git a/src/localization/mod.rs b/src/localization/mod.rs deleted file mode 100644 index 146b419..0000000 --- a/src/localization/mod.rs +++ /dev/null @@ -1,246 +0,0 @@ -mod dutch; -mod english; -mod french; -mod german; -mod japanese; -mod korean; -mod spanish; -mod traditional_chinese; - -use windows::core::PWSTR; -use windows::Win32::Globalization::{ - GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages, - LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME, -}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LanguageId { - English, - Dutch, - Spanish, - French, - German, - Japanese, - Korean, - TraditionalChinese, -} - -impl LanguageId { - pub const ALL: [LanguageId; 8] = [ - LanguageId::English, - LanguageId::Dutch, - LanguageId::Spanish, - LanguageId::French, - LanguageId::German, - LanguageId::Japanese, - LanguageId::Korean, - LanguageId::TraditionalChinese, - ]; - - pub fn code(self) -> &'static str { - match self { - Self::English => "en", - Self::Dutch => "nl", - Self::Spanish => "es", - Self::French => "fr", - Self::German => "de", - Self::Japanese => "ja", - Self::Korean => "ko", - Self::TraditionalChinese => "zh-TW", - } - } - - pub fn native_name(self) -> &'static str { - match self { - Self::English => "English", - Self::Dutch => "Nederlands", - Self::Spanish => "Español", - Self::French => "Français", - Self::German => "Deutsch", - Self::Japanese => "日本語", - Self::Korean => "한국어", - Self::TraditionalChinese => "繁體中文", - } - } - - pub fn strings(self) -> Strings { - match self { - Self::English => english::STRINGS, - Self::Dutch => dutch::STRINGS, - Self::Spanish => spanish::STRINGS, - Self::French => french::STRINGS, - Self::German => german::STRINGS, - Self::Japanese => japanese::STRINGS, - Self::Korean => korean::STRINGS, - Self::TraditionalChinese => traditional_chinese::STRINGS, - } - } - - pub fn update_via_winget_label(self) -> &'static str { - match self { - Self::English => english::UPDATE_VIA_WINGET_LABEL, - Self::Dutch => dutch::UPDATE_VIA_WINGET_LABEL, - Self::Spanish => spanish::UPDATE_VIA_WINGET_LABEL, - Self::French => french::UPDATE_VIA_WINGET_LABEL, - Self::German => german::UPDATE_VIA_WINGET_LABEL, - Self::Japanese => japanese::UPDATE_VIA_WINGET_LABEL, - Self::Korean => korean::UPDATE_VIA_WINGET_LABEL, - Self::TraditionalChinese => traditional_chinese::UPDATE_VIA_WINGET_LABEL, - } - } - - pub fn from_code(code: &str) -> Option { - let normalized = code.trim().replace('_', "-").to_ascii_lowercase(); - if normalized.is_empty() || normalized == "system" { - return None; - } - - let prefix = normalized.split('-').next().unwrap_or_default(); - match prefix { - "en" => Some(Self::English), - "nl" => Some(Self::Dutch), - "es" => Some(Self::Spanish), - "fr" => Some(Self::French), - "de" => Some(Self::German), - "ja" => Some(Self::Japanese), - "ko" => Some(Self::Korean), - "zh" => { - if normalized.contains("tw") - || normalized.contains("hk") - || normalized.contains("hant") - { - Some(Self::TraditionalChinese) - } else { - None - } - } - _ => None, - } - } -} - -#[derive(Clone, Copy, Debug)] -pub struct Strings { - pub window_title: &'static str, - pub refresh: &'static str, - pub update_frequency: &'static str, - pub one_minute: &'static str, - pub five_minutes: &'static str, - pub fifteen_minutes: &'static str, - pub one_hour: &'static str, - pub models: &'static str, - pub claude_code_model: &'static str, - pub codex_model: &'static str, - pub settings: &'static str, - pub start_with_windows: &'static str, - pub reset_position: &'static str, - pub language: &'static str, - pub system_default: &'static str, - pub check_for_updates: &'static str, - pub checking_for_updates: &'static str, - pub updates: &'static str, - pub update_in_progress: &'static str, - pub up_to_date: &'static str, - pub up_to_date_short: &'static str, - pub update_failed: &'static str, - pub applying_update: &'static str, - pub update_to: &'static str, - pub update_available: &'static str, - pub update_prompt_now: &'static str, - pub exit: &'static str, - pub show_widget: &'static str, - pub session_window: &'static str, - pub weekly_window: &'static str, - pub now: &'static str, - pub day_suffix: &'static str, - pub hour_suffix: &'static str, - pub minute_suffix: &'static str, - pub second_suffix: &'static str, - pub token_expired_title: &'static str, - pub token_expired_body: &'static str, - pub codex_token_expired_title: &'static str, - pub codex_token_expired_body: &'static str, - pub codex_window_title: &'static str, -} - -pub fn resolve_language(language_override: Option) -> LanguageId { - language_override.unwrap_or_else(detect_system_language) -} - -pub fn detect_system_language() -> LanguageId { - preferred_ui_languages() - .into_iter() - .find_map(|locale| LanguageId::from_code(&locale)) - .or_else(default_ui_locale) - .or_else(default_locale_name) - .unwrap_or(LanguageId::English) -} - -pub fn update_via_winget(language: LanguageId) -> &'static str { - language.update_via_winget_label() -} - -fn preferred_ui_languages() -> Vec { - unsafe { - let mut num_languages = 0u32; - let mut buffer_len = 0u32; - if GetUserPreferredUILanguages( - MUI_LANGUAGE_NAME, - &mut num_languages, - PWSTR::null(), - &mut buffer_len, - ) - .is_err() - || buffer_len == 0 - { - return Vec::new(); - } - - let mut buffer = vec![0u16; buffer_len as usize]; - if GetUserPreferredUILanguages( - MUI_LANGUAGE_NAME, - &mut num_languages, - PWSTR(buffer.as_mut_ptr()), - &mut buffer_len, - ) - .is_err() - { - return Vec::new(); - } - - buffer - .split(|unit| *unit == 0) - .filter(|part| !part.is_empty()) - .map(String::from_utf16_lossy) - .collect() - } -} - -fn default_ui_locale() -> Option { - unsafe { - let lang_id = GetUserDefaultUILanguage(); - let mut buffer = [0u16; MAX_LOCALE_NAME as usize]; - let len = LCIDToLocaleName( - lang_id as u32, - Some(&mut buffer), - LOCALE_ALLOW_NEUTRAL_NAMES, - ); - if len <= 1 { - return None; - } - let locale = String::from_utf16_lossy(&buffer[..(len as usize - 1)]); - LanguageId::from_code(&locale) - } -} - -fn default_locale_name() -> Option { - unsafe { - let mut buffer = [0u16; MAX_LOCALE_NAME as usize]; - let len = GetUserDefaultLocaleName(&mut buffer); - if len <= 1 { - return None; - } - let locale = String::from_utf16_lossy(&buffer[..(len as usize - 1)]); - LanguageId::from_code(&locale) - } -} diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs deleted file mode 100644 index 8e6513e..0000000 --- a/src/localization/spanish.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::Strings; - -pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Actualizar con WinGet"; - -pub(super) const STRINGS: Strings = Strings { - window_title: "Monitor de uso de Claude Code", - refresh: "Actualizar", - update_frequency: "Frecuencia de actualización", - one_minute: "1 minuto", - five_minutes: "5 minutos", - fifteen_minutes: "15 minutos", - one_hour: "1 hora", - models: "Modelos", - claude_code_model: "Claude Code", - codex_model: "Codex", - settings: "Configuración", - start_with_windows: "Iniciar con Windows", - reset_position: "Restablecer posición", - language: "Idioma", - system_default: "Predeterminado del sistema", - check_for_updates: "Buscar actualizaciones", - checking_for_updates: "Buscando actualizaciones...", - updates: "Actualizaciones", - update_in_progress: "Ya hay una comprobación de actualización en curso.", - up_to_date: "Ya tienes la versión más reciente.", - up_to_date_short: "Actualizado", - update_failed: "No se pudo actualizar automáticamente", - applying_update: "Aplicando actualización...", - update_to: "Actualizar a", - update_available: "Actualización disponible", - update_prompt_now: "La versión {version} está disponible. ¿Quieres actualizar ahora?", - exit: "Salir", - show_widget: "Mostrar widget", - session_window: "5h", - weekly_window: "7d", - now: "ahora", - day_suffix: "d", - hour_suffix: "h", - minute_suffix: "m", - token_expired_title: "Error de autenticación", - token_expired_body: "Ejecuta 'claude' en una terminal, luego usa '/login' y sigue las indicaciones. Después, actualiza o reinicia esta aplicación.", - codex_token_expired_title: "Error de autenticacion de Codex", - codex_token_expired_body: "Ejecuta 'codex' en una terminal y sigue las indicaciones de inicio de sesion. Despues, actualiza o reinicia esta aplicacion.", - codex_window_title: "Monitor de uso de Codex", - second_suffix: "s", -}; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs deleted file mode 100644 index 809ebba..0000000 --- a/src/localization/traditional_chinese.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::Strings; - -pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "透過 WinGet 更新"; - -pub(super) const STRINGS: Strings = Strings { - window_title: "Claude Code 使用量監控", - refresh: "重新整理", - update_frequency: "更新頻率", - one_minute: "1 分鐘", - five_minutes: "5 分鐘", - fifteen_minutes: "15 分鐘", - one_hour: "1 小時", - models: "模型", - claude_code_model: "Claude Code", - codex_model: "Codex", - settings: "設定", - start_with_windows: "開機時啟動", - reset_position: "重置位置", - language: "語言", - system_default: "系統預設", - check_for_updates: "檢查更新", - checking_for_updates: "正在檢查更新...", - updates: "更新", - update_in_progress: "已有更新檢查正在進行中。", - up_to_date: "您已使用最新版本。", - up_to_date_short: "已是最新", - update_failed: "無法自動更新", - applying_update: "正在套用更新...", - update_to: "更新至", - update_available: "有可用更新", - update_prompt_now: "版本 {version} 已可用。是否立即更新?", - exit: "結束", - show_widget: "顯示小工具", - session_window: "5h", - weekly_window: "7d", - now: "現在", - day_suffix: "天", - hour_suffix: "時", - minute_suffix: "分", - token_expired_title: "驗證錯誤", - token_expired_body: "請在終端機中執行 'claude',然後使用 '/login' 並依照提示操作。完成後,請重新整理或重新啟動此應用程式。", - codex_token_expired_title: "Codex 驗證錯誤", - codex_token_expired_body: "請在終端機中執行 'codex',並依照登入提示操作。完成後,請重新整理或重新啟動此應用程式。", - codex_window_title: "Codex 使用量監控", - second_suffix: "秒", -}; diff --git a/src/main.rs b/src/main.rs index d7a9d08..197856b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,39 +1,39 @@ #![windows_subsystem = "windows"] +// Original infrastructure. +mod creds; +mod diag; +mod i18n; +mod net; +mod os; +mod tray; +mod update; +mod usage; + +// Application surface. mod app; mod bubble; -mod diagnose; -mod localization; -mod models; -mod native_interop; mod panel; -mod poller; mod settings; -mod theme; -mod tray_icon; -mod updater; fn main() { let args: Vec = std::env::args().collect(); let diagnose_enabled = args.iter().any(|a| a == "--diagnose"); if diagnose_enabled { - if let Ok(path) = diagnose::init() { - diagnose::log(format!( - "startup args={args:?} log_path={}", - path.display() - )); + if let Ok(Some(path)) = diag::init(true) { + log::info!("startup args={args:?} log_path={}", path.display()); } } - if let Some(exit_code) = updater::handle_cli_mode(&args) { + if let Some(exit_code) = update::run_cli(&args) { if diagnose_enabled { - diagnose::log(format!("cli mode exited with code {exit_code}")); + log::info!("cli mode exited with code {exit_code}"); } std::process::exit(exit_code); } if diagnose_enabled { - diagnose::log("entering app::run"); + log::info!("entering app::run"); } app::run(); } diff --git a/src/models.rs b/src/models.rs deleted file mode 100644 index bd3a456..0000000 --- a/src/models.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::time::SystemTime; - -#[derive(Clone, Debug, Default)] -pub struct UsageSection { - pub percentage: f64, - pub resets_at: Option, -} - -#[derive(Clone, Debug, Default)] -pub struct UsageData { - pub session: UsageSection, - pub weekly: UsageSection, -} - -#[derive(Clone, Debug, Default)] -pub struct AppUsageData { - pub claude_code: Option, - pub codex: Option, -} diff --git a/src/native_interop.rs b/src/native_interop.rs deleted file mode 100644 index 43fe243..0000000 --- a/src/native_interop.rs +++ /dev/null @@ -1,71 +0,0 @@ -use windows::Win32::Foundation::{HWND, RECT}; -use windows::Win32::UI::WindowsAndMessaging::{GetWindowRect, MoveWindow}; - -// Timer IDs (used by SetTimer / KillTimer with the bubble HWND) -pub const TIMER_POLL: usize = 1; -pub const TIMER_COUNTDOWN: usize = 2; -pub const TIMER_RESET_POLL: usize = 3; -pub const TIMER_UPDATE_CHECK: usize = 4; -pub const TIMER_FULLSCREEN_CHECK: usize = 5; - -// Custom messages -pub const WM_APP: u32 = 0x8000; -pub const WM_APP_USAGE_UPDATED: u32 = WM_APP + 1; -pub const WM_APP_PANEL_TOGGLE: u32 = WM_APP + 2; -pub const WM_APP_TRAY: u32 = WM_APP + 3; -pub const WM_APP_PANEL_CLOSE: u32 = WM_APP + 4; - -/// Get the bounding rectangle of a window in screen coordinates. -pub fn get_window_rect_safe(hwnd: HWND) -> Option { - unsafe { - let mut rect = RECT::default(); - if GetWindowRect(hwnd, &mut rect).is_ok() { - Some(rect) - } else { - None - } - } -} - -/// Move and resize a window (top-level coordinates). -pub fn move_window(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { - unsafe { - let _ = MoveWindow(hwnd, x, y, w, h, true); - } -} - -/// Convert a Rust string to a null-terminated UTF-16 vector suitable for -/// passing as `PCWSTR`. -pub fn wide_str(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() -} - -/// COLORREF byte order: 0x00BBGGRR. -pub fn colorref(r: u8, g: u8, b: u8) -> u32 { - r as u32 | (g as u32) << 8 | (b as u32) << 16 -} - -#[derive(Clone, Copy, Debug)] -pub struct Color { - pub r: u8, - pub g: u8, - pub b: u8, -} - -impl Color { - pub const fn new(r: u8, g: u8, b: u8) -> Self { - Self { r, g, b } - } - - pub fn from_hex(hex: &str) -> Self { - let hex = hex.trim_start_matches('#'); - let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); - let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); - let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); - Self { r, g, b } - } - - pub fn to_colorref(self) -> u32 { - colorref(self.r, self.g, self.b) - } -} diff --git a/src/net/mod.rs b/src/net/mod.rs new file mode 100644 index 0000000..a20b8a9 --- /dev/null +++ b/src/net/mod.rs @@ -0,0 +1,9 @@ +// `net` namespace: HTTP client implementations. +// +// `winhttp` is the only backend right now (Windows-only app). It produces +// `Response` values that are cheap to inspect via `status`, `header`, +// `text`, and `json`. + +pub mod winhttp; + +pub use winhttp::{Client, Error, Response}; diff --git a/src/net/winhttp.rs b/src/net/winhttp.rs new file mode 100644 index 0000000..0d7d6f4 --- /dev/null +++ b/src/net/winhttp.rs @@ -0,0 +1,431 @@ +// Minimal blocking HTTP client built on Win32 WinHTTP. +// +// One `Client` owns a session handle and is thread-safe (WinHTTP sessions +// can be used from multiple threads per MSDN). Each `send()` call manages +// its own connection + request handle lifetime via RAII guards so failures +// at any point clean up correctly. +// +// We deliberately do NOT use `WinHttpCrackUrl` — the small `parse_url` +// helper below is enough for the HTTPS URLs this app actually talks to and +// keeps the call sites simpler. + +use std::ffi::c_void; +use std::ptr::null_mut; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use windows::core::PCWSTR; +use windows::Win32::Networking::WinHttp::*; + +use crate::os::string::to_utf16_nul; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("WinHTTP call failed: {context}")] + Win { context: String }, + #[error("invalid URL: {0}")] + Url(String), + #[error("response was not valid UTF-8")] + Utf8, + #[error("JSON parse: {0}")] + Json(#[from] serde_json::Error), + #[error("response status {0}")] + Status(u32), +} + +pub struct Client { + session: SessionHandle, +} + +// WinHTTP session handles are safe to use concurrently per the Microsoft docs +// (https://learn.microsoft.com/en-us/windows/win32/winhttp/winhttp-functions). +unsafe impl Send for Client {} +unsafe impl Sync for Client {} + +impl Client { + /// Create a new HTTP client. `user_agent` is sent on every request. + pub fn new(user_agent: &str) -> Result { + let ua = to_utf16_nul(user_agent); + let session = unsafe { + WinHttpOpen( + PCWSTR::from_raw(ua.as_ptr()), + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + PCWSTR::null(), + PCWSTR::null(), + 0, + ) + }; + if session.is_null() { + return Err(Error::Win { + context: "WinHttpOpen".into(), + }); + } + // Ask WinHTTP to decompress gzip/deflate transparently so callers + // get plain bytes back from `Response::body()`. Best-effort; if it + // fails the request still works, callers just see raw compressed + // bytes on responses that opt-in to compression. + unsafe { + let flags: u32 = WINHTTP_DECOMPRESSION_FLAG_GZIP | WINHTTP_DECOMPRESSION_FLAG_DEFLATE; + let flag_bytes = flags.to_ne_bytes(); + if let Err(e) = WinHttpSetOption( + Some(session as *const c_void), + WINHTTP_OPTION_DECOMPRESSION, + Some(&flag_bytes), + ) { + log::warn!("WinHttpSetOption(DECOMPRESSION) failed: {e}"); + } + } + Ok(Self { + session: SessionHandle(session), + }) + } + + pub fn get<'a>(&'a self, url: &str) -> RequestBuilder<'a> { + RequestBuilder::new(self, Method::Get, url) + } + + pub fn post<'a>(&'a self, url: &str) -> RequestBuilder<'a> { + RequestBuilder::new(self, Method::Post, url) + } +} + +#[derive(Clone, Copy)] +enum Method { + Get, + Post, +} + +impl Method { + fn verb(self) -> &'static str { + match self { + Method::Get => "GET", + Method::Post => "POST", + } + } +} + +pub struct RequestBuilder<'a> { + client: &'a Client, + method: Method, + url: String, + headers: Vec<(String, String)>, + body: Option>, +} + +impl<'a> RequestBuilder<'a> { + fn new(client: &'a Client, method: Method, url: &str) -> Self { + Self { + client, + method, + url: url.to_string(), + headers: Vec::new(), + body: None, + } + } + + pub fn header(mut self, name: &str, value: &str) -> Self { + self.headers.push((name.to_string(), value.to_string())); + self + } + + pub fn json_body(mut self, body: &T) -> Result { + self.body = Some(serde_json::to_vec(body)?); + self.headers + .push(("Content-Type".into(), "application/json".into())); + Ok(self) + } + + pub fn send(self) -> Result { + let parsed = parse_url(&self.url)?; + let host_w = to_utf16_nul(&parsed.host); + let path_w = to_utf16_nul(&parsed.path); + let verb_w = to_utf16_nul(self.method.verb()); + + let connect = unsafe { + WinHttpConnect( + self.client.session.0, + PCWSTR::from_raw(host_w.as_ptr()), + parsed.port, + 0, + ) + }; + if connect.is_null() { + return Err(Error::Win { + context: "WinHttpConnect".into(), + }); + } + let _connect_guard = HandleGuard(connect); + + let flags = if parsed.secure { + WINHTTP_FLAG_SECURE + } else { + WINHTTP_OPEN_REQUEST_FLAGS(0) + }; + let request = unsafe { + WinHttpOpenRequest( + connect, + PCWSTR::from_raw(verb_w.as_ptr()), + PCWSTR::from_raw(path_w.as_ptr()), + PCWSTR::null(), + PCWSTR::null(), + std::ptr::null::(), + flags, + ) + }; + if request.is_null() { + return Err(Error::Win { + context: "WinHttpOpenRequest".into(), + }); + } + let _request_guard = HandleGuard(request); + + // Combine headers into a single CRLF-separated string. The binding + // takes a UTF-16 slice; length is derived from the slice and no + // trailing NUL is required. + if !self.headers.is_empty() { + let header_str = self + .headers + .iter() + .map(|(k, v)| format!("{k}: {v}")) + .collect::>() + .join("\r\n"); + let header_w: Vec = header_str.encode_utf16().collect(); + unsafe { + WinHttpAddRequestHeaders( + request, + &header_w[..], + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE, + ) + } + .map_err(|e| Error::Win { + context: format!("WinHttpAddRequestHeaders: {e}"), + })?; + } + + // Send body if present. dwTotalLength = body length; dwOptionalLength + // mirrors it for synchronous sends with the buffer included up front. + let body_bytes: &[u8] = self.body.as_deref().unwrap_or(&[]); + let body_ptr = if body_bytes.is_empty() { + None + } else { + Some(body_bytes.as_ptr() as *const c_void) + }; + let body_len = body_bytes.len() as u32; + unsafe { + WinHttpSendRequest(request, None, body_ptr, body_len, body_len, 0) + } + .map_err(|e| Error::Win { + context: format!("WinHttpSendRequest: {e}"), + })?; + + unsafe { WinHttpReceiveResponse(request, null_mut()) }.map_err(|e| Error::Win { + context: format!("WinHttpReceiveResponse: {e}"), + })?; + + let status = query_status_code(request)?; + let headers = query_raw_headers(request)?; + let body = read_body(request)?; + + Ok(Response { + status, + headers, + body, + }) + } +} + +pub struct Response { + status: u32, + headers: Vec<(String, String)>, + body: Vec, +} + +impl Response { + pub fn status(&self) -> u32 { + self.status + } + + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } + + pub fn body(&self) -> &[u8] { + &self.body + } + + pub fn text(&self) -> Result<&str, Error> { + std::str::from_utf8(&self.body).map_err(|_| Error::Utf8) + } + + pub fn json(&self) -> Result { + Ok(serde_json::from_slice(&self.body)?) + } +} + +// ---------- Low-level helpers ---------- + +struct SessionHandle(*mut c_void); + +impl Drop for SessionHandle { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + let _ = WinHttpCloseHandle(self.0); + } + } + } +} + +struct HandleGuard(*mut c_void); + +impl Drop for HandleGuard { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + let _ = WinHttpCloseHandle(self.0); + } + } + } +} + +fn query_status_code(request: *mut c_void) -> Result { + let mut status: u32 = 0; + let mut size: u32 = std::mem::size_of::() as u32; + unsafe { + WinHttpQueryHeaders( + request, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + PCWSTR::null(), + Some((&mut status as *mut u32) as *mut c_void), + &mut size, + std::ptr::null_mut(), + ) + } + .map_err(|e| Error::Win { + context: format!("WinHttpQueryHeaders(STATUS_CODE): {e}"), + })?; + Ok(status) +} + +fn query_raw_headers(request: *mut c_void) -> Result, Error> { + // First call sizes the buffer (returns Err with ERROR_INSUFFICIENT_BUFFER + // and writes the required byte count to `needed`). + let mut needed: u32 = 0; + let _ = unsafe { + WinHttpQueryHeaders( + request, + WINHTTP_QUERY_RAW_HEADERS_CRLF, + PCWSTR::null(), + None, + &mut needed, + std::ptr::null_mut(), + ) + }; + if needed == 0 { + return Ok(Vec::new()); + } + let chars = (needed as usize) / std::mem::size_of::(); + let mut buf: Vec = vec![0u16; chars]; + unsafe { + WinHttpQueryHeaders( + request, + WINHTTP_QUERY_RAW_HEADERS_CRLF, + PCWSTR::null(), + Some(buf.as_mut_ptr() as *mut c_void), + &mut needed, + std::ptr::null_mut(), + ) + } + .map_err(|e| Error::Win { + context: format!("WinHttpQueryHeaders(RAW_HEADERS_CRLF): {e}"), + })?; + let text = String::from_utf16_lossy(&buf[..chars.saturating_sub(1)]); + Ok(parse_header_block(&text)) +} + +fn parse_header_block(block: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut lines = block.split("\r\n"); + let _ = lines.next(); // status line, e.g. "HTTP/1.1 200 OK" + for line in lines { + if line.is_empty() { + continue; + } + if let Some((k, v)) = line.split_once(':') { + out.push((k.trim().to_string(), v.trim().to_string())); + } + } + out +} + +fn read_body(request: *mut c_void) -> Result, Error> { + let mut body = Vec::new(); + loop { + let mut available: u32 = 0; + unsafe { WinHttpQueryDataAvailable(request, &mut available) }.map_err(|e| Error::Win { + context: format!("WinHttpQueryDataAvailable: {e}"), + })?; + if available == 0 { + break; + } + let mut chunk = vec![0u8; available as usize]; + let mut read: u32 = 0; + unsafe { + WinHttpReadData( + request, + chunk.as_mut_ptr() as *mut c_void, + available, + &mut read, + ) + } + .map_err(|e| Error::Win { + context: format!("WinHttpReadData: {e}"), + })?; + chunk.truncate(read as usize); + body.append(&mut chunk); + } + Ok(body) +} + +// ---------- URL parsing ---------- + +struct ParsedUrl { + host: String, + port: u16, + path: String, + secure: bool, +} + +fn parse_url(url: &str) -> Result { + let (scheme, rest) = url + .split_once("://") + .ok_or_else(|| Error::Url(url.to_string()))?; + let secure = match scheme.to_ascii_lowercase().as_str() { + "https" => true, + "http" => false, + other => return Err(Error::Url(format!("unsupported scheme: {other}"))), + }; + let (host_port, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + let (host, port) = match host_port.rsplit_once(':') { + Some((h, p)) => ( + h.to_string(), + p.parse::().map_err(|_| Error::Url(url.to_string()))?, + ), + None => (host_port.to_string(), if secure { 443 } else { 80 }), + }; + if host.is_empty() { + return Err(Error::Url(url.to_string())); + } + Ok(ParsedUrl { + host, + port, + path: path.to_string(), + secure, + }) +} diff --git a/src/os/color.rs b/src/os/color.rs new file mode 100644 index 0000000..3df53ee --- /dev/null +++ b/src/os/color.rs @@ -0,0 +1,46 @@ +// Color helpers for GDI. +// +// Win32 GDI stores colours as a 32-bit COLORREF in 0x00BBGGRR byte order +// (B in the low byte). We keep a normal `r,g,b` struct in code and convert +// at the FFI boundary. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Rgb { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Rgb { + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + /// Parse `#RRGGBB` or `RRGGBB`. Returns `None` on malformed input. + pub fn parse_hex(hex: &str) -> Option { + let s = hex.trim_start_matches('#'); + if s.len() != 6 { + return None; + } + let r = u8::from_str_radix(&s[0..2], 16).ok()?; + let g = u8::from_str_radix(&s[2..4], 16).ok()?; + let b = u8::from_str_radix(&s[4..6], 16).ok()?; + Some(Self { r, g, b }) + } + + /// Pack into a Win32 COLORREF (`0x00BBGGRR`). + pub fn into_colorref(self) -> u32 { + (self.r as u32) | ((self.g as u32) << 8) | ((self.b as u32) << 16) + } + + /// Linear interpolation between two colours. `t` is clamped to `[0, 1]`. + pub fn lerp(self, other: Rgb, t: f64) -> Rgb { + let t = t.clamp(0.0, 1.0); + let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8; + Rgb { + r: mix(self.r, other.r), + g: mix(self.g, other.g), + b: mix(self.b, other.b), + } + } +} diff --git a/src/os/dpi.rs b/src/os/dpi.rs new file mode 100644 index 0000000..8a39afc --- /dev/null +++ b/src/os/dpi.rs @@ -0,0 +1,31 @@ +// Per-window DPI helpers. +// +// The DPI of a window can differ from `GetDpiForSystem` on multi-monitor +// setups where the app is per-monitor DPI aware. Always prefer +// `GetDpiForWindow` for HWNDs that participate in the message loop. + +use windows::Win32::Foundation::HWND; +use windows::Win32::UI::HiDpi::{GetDpiForSystem, GetDpiForWindow}; + +/// The default DPI Win32 reports for 100% scaling. +pub const BASE_DPI: u32 = 96; + +/// DPI for the supplied window. Falls back to system DPI if the call fails. +pub fn for_window(hwnd: HWND) -> u32 { + let raw = unsafe { GetDpiForWindow(hwnd) }; + if raw == 0 { + for_system() + } else { + raw.max(BASE_DPI) + } +} + +/// Global system DPI. Cheap; safe to call from any thread. +pub fn for_system() -> u32 { + unsafe { GetDpiForSystem() }.max(BASE_DPI) +} + +/// Scale a logical (96-DPI) pixel measurement to the given DPI. +pub fn scale(logical_px: i32, dpi: u32) -> i32 { + ((logical_px as i64) * (dpi as i64) / (BASE_DPI as i64)) as i32 +} diff --git a/src/os/mod.rs b/src/os/mod.rs new file mode 100644 index 0000000..8b6d5ae --- /dev/null +++ b/src/os/mod.rs @@ -0,0 +1,14 @@ +// `os` namespace: thin, typed wrappers over the slice of Win32 we use. +// +// Each submodule covers one concern (color conversion, UTF-16 strings, +// DPI math, registry I/O, theme detection). Nothing in here knows about +// the bubble UI or the polling loop — it's pure platform glue. + +pub mod color; +pub mod dpi; +pub mod registry; +pub mod string; +pub mod theme; + +pub use color::Rgb; +pub use string::to_utf16_nul; diff --git a/src/os/registry.rs b/src/os/registry.rs new file mode 100644 index 0000000..45eff81 --- /dev/null +++ b/src/os/registry.rs @@ -0,0 +1,159 @@ +// Typed wrapper over a tiny subset of the Win32 registry API. +// +// All operations target `HKEY_CURRENT_USER` keys by default (the app only +// reads/writes user-scoped state — startup entry, theme detection, etc.). +// Each call opens and closes the key internally; there is no caching so +// state changes by other processes are visible immediately. + +use windows::core::PCWSTR; +use windows::Win32::Foundation::ERROR_SUCCESS; +use windows::Win32::System::Registry::{ + RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, HKEY, + HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_SZ, +}; + +use super::string::to_utf16_nul; + +#[derive(Debug, thiserror::Error)] +pub enum RegistryError { + #[error("registry open failed for {key}: code {code}")] + Open { key: String, code: u32 }, + #[error("registry write failed for {key}\\{value}: code {code}")] + Write { key: String, value: String, code: u32 }, +} + +/// Read a `REG_DWORD` value under `HKEY_CURRENT_USER\`. +/// Returns `None` if the key or value does not exist. +pub fn read_u32(subkey: &str, value_name: &str) -> Option { + let subkey_w = to_utf16_nul(subkey); + let value_w = to_utf16_nul(value_name); + unsafe { + let mut hkey = HKEY::default(); + let open = RegOpenKeyExW( + HKEY_CURRENT_USER, + PCWSTR::from_raw(subkey_w.as_ptr()), + 0, + KEY_READ, + &mut hkey, + ); + if open != ERROR_SUCCESS { + return None; + } + let mut data: u32 = 0; + let mut size: u32 = std::mem::size_of::() as u32; + let query = RegQueryValueExW( + hkey, + PCWSTR::from_raw(value_w.as_ptr()), + None, + None, + Some((&mut data as *mut u32) as *mut u8), + Some(&mut size), + ); + let _ = RegCloseKey(hkey); + if query == ERROR_SUCCESS { + Some(data) + } else { + None + } + } +} + +/// Test whether a value (any type) exists under `HKEY_CURRENT_USER\`. +pub fn value_exists(subkey: &str, value_name: &str) -> bool { + let subkey_w = to_utf16_nul(subkey); + let value_w = to_utf16_nul(value_name); + unsafe { + let mut hkey = HKEY::default(); + let open = RegOpenKeyExW( + HKEY_CURRENT_USER, + PCWSTR::from_raw(subkey_w.as_ptr()), + 0, + KEY_READ, + &mut hkey, + ); + if open != ERROR_SUCCESS { + return false; + } + let mut size: u32 = 0; + let query = RegQueryValueExW( + hkey, + PCWSTR::from_raw(value_w.as_ptr()), + None, + None, + None, + Some(&mut size), + ); + let _ = RegCloseKey(hkey); + query == ERROR_SUCCESS + } +} + +/// Write a string value as `REG_SZ` under `HKEY_CURRENT_USER\`. +pub fn write_string(subkey: &str, value_name: &str, value: &str) -> Result<(), RegistryError> { + let subkey_w = to_utf16_nul(subkey); + let value_w = to_utf16_nul(value_name); + let data_w = to_utf16_nul(value); + unsafe { + let mut hkey = HKEY::default(); + let open = RegOpenKeyExW( + HKEY_CURRENT_USER, + PCWSTR::from_raw(subkey_w.as_ptr()), + 0, + KEY_WRITE, + &mut hkey, + ); + if open != ERROR_SUCCESS { + return Err(RegistryError::Open { + key: subkey.to_string(), + code: open.0, + }); + } + let bytes = std::slice::from_raw_parts( + data_w.as_ptr() as *const u8, + data_w.len() * std::mem::size_of::(), + ); + let res = RegSetValueExW( + hkey, + PCWSTR::from_raw(value_w.as_ptr()), + 0, + REG_SZ, + Some(bytes), + ); + let _ = RegCloseKey(hkey); + if res == ERROR_SUCCESS { + Ok(()) + } else { + Err(RegistryError::Write { + key: subkey.to_string(), + value: value_name.to_string(), + code: res.0, + }) + } + } +} + +/// Delete a value under `HKEY_CURRENT_USER\`. Returns `Ok(())` even +/// if the value never existed. +pub fn delete_value(subkey: &str, value_name: &str) -> Result<(), RegistryError> { + let subkey_w = to_utf16_nul(subkey); + let value_w = to_utf16_nul(value_name); + unsafe { + let mut hkey = HKEY::default(); + let open = RegOpenKeyExW( + HKEY_CURRENT_USER, + PCWSTR::from_raw(subkey_w.as_ptr()), + 0, + KEY_WRITE, + &mut hkey, + ); + if open != ERROR_SUCCESS { + return Err(RegistryError::Open { + key: subkey.to_string(), + code: open.0, + }); + } + let _ = RegDeleteValueW(hkey, PCWSTR::from_raw(value_w.as_ptr())); + let _ = RegCloseKey(hkey); + Ok(()) + } +} diff --git a/src/os/string.rs b/src/os/string.rs new file mode 100644 index 0000000..e0d4982 --- /dev/null +++ b/src/os/string.rs @@ -0,0 +1,10 @@ +// UTF-16 conversion helpers. + +/// Encode a Rust `&str` as a NUL-terminated UTF-16 vector suitable for +/// passing to Win32 `PCWSTR`-typed parameters. +/// +/// The result lives as long as the returned `Vec`; callers must keep +/// the vector alive across the FFI call. +pub fn to_utf16_nul(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} diff --git a/src/os/theme.rs b/src/os/theme.rs new file mode 100644 index 0000000..419bf56 --- /dev/null +++ b/src/os/theme.rs @@ -0,0 +1,16 @@ +// Windows light/dark theme detection. +// +// Windows stores the current theme under +// `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize` +// with a `SystemUsesLightTheme` DWORD: 1 means light, 0 means dark. + +use super::registry; + +const THEME_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; +const LIGHT_VALUE: &str = "SystemUsesLightTheme"; + +/// `true` if the system is in dark mode. Defaults to dark when the registry +/// value is missing (matches Windows 11 first-boot behaviour). +pub fn is_dark() -> bool { + !matches!(registry::read_u32(THEME_KEY, LIGHT_VALUE), Some(1)) +} diff --git a/src/panel.rs b/src/panel.rs index c567127..1d2829e 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -12,10 +12,10 @@ use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::UI::HiDpi::GetDpiForWindow; use windows::Win32::UI::WindowsAndMessaging::*; -use crate::diagnose; -use crate::localization::Strings; -use crate::native_interop::{wide_str, Color}; -use crate::tray_icon::TrayIconKind; +use crate::i18n::LocaleStrings; +use crate::os::{to_utf16_nul as wide_str, Rgb as Color}; +use crate::usage::ProviderId; +type TrayIconKind = ProviderId; const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel"; const PANEL_W_LOGICAL: i32 = 280; @@ -33,9 +33,7 @@ pub struct PanelData { pub weekly_pct: f64, pub weekly_text: String, pub is_dark: bool, - pub strings: Strings, - pub claude_label: String, - pub codex_label: String, + pub strings: LocaleStrings, } struct PanelState { @@ -68,7 +66,7 @@ pub fn register_class() { ..Default::default() }; if RegisterClassExW(&wc) == 0 { - diagnose::log("panel RegisterClassExW returned 0"); + log::error!("panel RegisterClassExW returned 0"); } }); } @@ -162,7 +160,7 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option { .unwrap_or_default() }; if hwnd == HWND::default() { - diagnose::log("panel CreateWindowExW failed"); + log::error!("panel CreateWindowExW failed"); None } else { Some(hwnd) @@ -275,8 +273,8 @@ fn paint(hwnd: HWND, hdc: HDC) { // Header row: model label let header = match data.model { - TrayIconKind::Claude => data.claude_label.clone(), - TrayIconKind::Codex => data.codex_label.clone(), + ProviderId::Claude => data.strings.claude_label.clone(), + ProviderId::ChatGpt => data.strings.chatgpt_label.clone(), }; draw_text( hdc, @@ -301,7 +299,7 @@ fn paint(hwnd: HWND, hdc: HDC) { draw_row( hdc, - data.strings.session_window, + &data.strings.session_window, scaled(PADDING_LOGICAL), row1_y, bar_x, @@ -317,7 +315,7 @@ fn paint(hwnd: HWND, hdc: HDC) { draw_row( hdc, - data.strings.weekly_window, + &data.strings.weekly_window, scaled(PADDING_LOGICAL), row2_y, bar_x, @@ -474,9 +472,7 @@ fn clone_data() -> Option { weekly_pct: p.data.weekly_pct, weekly_text: p.data.weekly_text.clone(), is_dark: p.data.is_dark, - strings: p.data.strings, - claude_label: p.data.claude_label.clone(), - codex_label: p.data.codex_label.clone(), + strings: p.data.strings.clone(), }) } diff --git a/src/poller.rs b/src/poller.rs deleted file mode 100644 index 5bd02bc..0000000 --- a/src/poller.rs +++ /dev/null @@ -1,1099 +0,0 @@ -use std::path::PathBuf; -use std::process::Command; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use serde::Deserialize; -use std::os::windows::process::CommandExt; - -use crate::diagnose; -use crate::localization::Strings; -use crate::models::{AppUsageData, UsageData, UsageSection}; - -const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; -const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; -const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage"; -const CREATE_NO_WINDOW: u32 = 0x08000000; - -const MODEL_FALLBACK_CHAIN: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"]; - -#[derive(Debug)] -pub enum PollError { - AuthRequired, - NoCredentials, - TokenExpired, - RequestFailed, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CredentialWatchMode { - ActiveSource, - AllSources, -} - -pub type CredentialWatchSnapshot = Vec; - -#[derive(Deserialize)] -struct UsageResponse { - five_hour: Option, - seven_day: Option, -} - -#[derive(Deserialize)] -struct UsageBucket { - utilization: f64, - resets_at: Option, -} - -#[derive(Deserialize)] -struct CodexAuthFile { - tokens: Option, -} - -#[derive(Clone, Deserialize)] -struct CodexTokenData { - access_token: String, - account_id: Option, -} - -#[derive(Deserialize)] -struct CodexUsageResponse { - rate_limit: Option>>, -} - -#[derive(Deserialize)] -struct CodexRateLimitDetails { - primary_window: Option>>, - secondary_window: Option>>, -} - -#[derive(Deserialize)] -struct CodexRateLimitWindow { - used_percent: f64, - reset_at: i64, -} - -pub fn poll(show_claude_code: bool, show_codex: bool) -> Result { - let mut data = AppUsageData::default(); - - if show_claude_code { - data.claude_code = Some(poll_claude_code()?); - } - - if show_codex { - match poll_codex() { - Ok(codex) => data.codex = Some(codex), - Err(error) if !show_claude_code => return Err(error), - Err(error) => diagnose::log(format!("Codex usage poll failed: {error:?}")), - } - } - - if data.claude_code.is_none() && data.codex.is_none() { - Err(PollError::RequestFailed) - } else { - Ok(data) - } -} - -fn poll_claude_code() -> Result { - let creds = match read_first_credentials() { - Some(c) => c, - None => { - diagnose::log("poll failed: no Claude credentials found"); - return Err(PollError::NoCredentials); - } - }; - - let creds = refresh_or_fallback(creds)?; - - fetch_usage_with_fallback(&creds.access_token) -} - -fn poll_codex() -> Result { - let creds = match read_codex_credentials() { - Some(creds) => creds, - None => { - diagnose::log("Codex usage poll failed: no Codex credentials found"); - return Err(PollError::NoCredentials); - } - }; - - match fetch_codex_usage(&creds.access_token, creds.account_id.as_deref()) { - Ok(data) => Ok(data), - Err(PollError::AuthRequired) => { - cli_refresh_codex_token(); - let refreshed = read_codex_credentials().ok_or(PollError::TokenExpired)?; - fetch_codex_usage(&refreshed.access_token, refreshed.account_id.as_deref()) - } - Err(error) => Err(error), - } -} - -fn refresh_or_fallback(mut creds: Credentials) -> Result { - loop { - if !is_token_expired(creds.expires_at) { - return Ok(creds); - } - - let source = creds.source.clone(); - cli_refresh_token(&source); - - match read_credentials_from_source(&source) { - Some(refreshed) if !is_token_expired(refreshed.expires_at) => return Ok(refreshed), - Some(_) => diagnose::log(format!( - "credentials from {source:?} still expired after refresh attempt" - )), - None => diagnose::log(format!( - "credentials from {source:?} unavailable after refresh attempt" - )), - } - - match read_next_credentials_after(&source) { - Some(next) => creds = next, - None => return Err(PollError::TokenExpired), - } - } -} - -/// Invoke the Claude CLI with a minimal prompt to force its internal -/// OAuth token refresh. -fn cli_refresh_token(source: &CredentialSource) { - match source { - CredentialSource::Windows(_) => cli_refresh_windows_token(), - CredentialSource::Wsl { distro } => cli_refresh_wsl_token(distro), - } -} - -fn cli_refresh_windows_token() { - let claude_path = resolve_windows_claude_path(); - let is_cmd = claude_path.to_lowercase().ends_with(".cmd"); - diagnose::log(format!( - "attempting Windows Claude token refresh via {claude_path}" - )); - - let args: &[&str] = &["-p", "."]; - - let mut cmd = if is_cmd { - let mut c = Command::new("cmd.exe"); - c.arg("/c").arg(&claude_path).args(args); - c - } else { - let mut c = Command::new(&claude_path); - c.args(args); - c - }; - cmd.env_remove("CLAUDECODE") - .env_remove("CLAUDE_CODE_ENTRYPOINT") - .creation_flags(CREATE_NO_WINDOW) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - - let mut child = match cmd.spawn() { - Ok(c) => c, - Err(error) => { - diagnose::log_error("unable to spawn Windows Claude token refresh", error); - return; - } - }; - - // Wait up to 30 seconds — don't block the poll thread forever - let start = std::time::Instant::now(); - loop { - match child.try_wait() { - Ok(Some(_)) => break, - Ok(None) => { - if start.elapsed() > Duration::from_secs(30) { - let _ = child.kill(); - break; - } - std::thread::sleep(Duration::from_millis(500)); - } - Err(_) => break, - } - } -} - -fn cli_refresh_wsl_token(distro: &str) { - diagnose::log(format!( - "attempting WSL Claude token refresh in distro {distro}" - )); - let mut cmd = Command::new("wsl.exe"); - cmd.arg("-d") - .arg(distro) - .arg("--") - .arg("bash") - .arg("-lic") - .arg("if command -v claude >/dev/null 2>&1; then claude -p .; elif [ -x \"$HOME/.local/bin/claude\" ]; then \"$HOME/.local/bin/claude\" -p .; else exit 127; fi") - .env_remove("CLAUDECODE") - .env_remove("CLAUDE_CODE_ENTRYPOINT") - .creation_flags(CREATE_NO_WINDOW) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - - let mut child = match cmd.spawn() { - Ok(c) => c, - Err(error) => { - diagnose::log_error("unable to spawn WSL Claude token refresh", error); - return; - } - }; - - wait_for_refresh(&mut child); -} - -fn cli_refresh_codex_token() { - let codex_path = resolve_windows_codex_path(); - let is_cmd = codex_path.to_lowercase().ends_with(".cmd"); - let is_ps1 = codex_path.to_lowercase().ends_with(".ps1"); - diagnose::log(format!( - "attempting Windows Codex token refresh via {codex_path}" - )); - - let args: &[&str] = &["exec", "."]; - - let mut cmd = if is_cmd { - let mut c = Command::new("cmd.exe"); - c.arg("/c").arg(&codex_path).args(args); - c - } else if is_ps1 { - let mut c = Command::new("powershell.exe"); - c.arg("-NoProfile") - .arg("-ExecutionPolicy") - .arg("Bypass") - .arg("-File") - .arg(&codex_path) - .args(args); - c - } else { - let mut c = Command::new(&codex_path); - c.args(args); - c - }; - cmd.creation_flags(CREATE_NO_WINDOW) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - - let mut child = match cmd.spawn() { - Ok(c) => c, - Err(error) => { - diagnose::log_error("unable to spawn Windows Codex token refresh", error); - return; - } - }; - - wait_for_refresh(&mut child); -} - -/// Spawn a command and wait up to `timeout` for it to finish. -/// Returns None if the process fails to start or exceeds the deadline. -fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option { - let mut child = cmd.spawn().ok()?; - let start = std::time::Instant::now(); - loop { - match child.try_wait() { - Ok(Some(_)) => return child.wait_with_output().ok(), - Ok(None) => { - if start.elapsed() > timeout { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - std::thread::sleep(Duration::from_millis(100)); - } - Err(_) => return None, - } - } -} - -fn wait_for_refresh(child: &mut std::process::Child) { - // Wait up to 30 seconds; don't block the poll thread forever. - let start = std::time::Instant::now(); - loop { - match child.try_wait() { - Ok(Some(_)) => break, - Ok(None) => { - if start.elapsed() > Duration::from_secs(30) { - let _ = child.kill(); - break; - } - std::thread::sleep(Duration::from_millis(500)); - } - Err(_) => break, - } - } -} - -/// Resolve the full path to the `claude` CLI executable. -fn resolve_windows_claude_path() -> String { - for name in &["claude.cmd", "claude"] { - if Command::new(name) - .arg("--version") - .creation_flags(CREATE_NO_WINDOW) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok() - { - return name.to_string(); - } - } - - for name in &["claude.cmd", "claude"] { - if let Ok(output) = Command::new("where.exe") - .arg(name) - .creation_flags(CREATE_NO_WINDOW) - .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(first_line) = stdout.lines().next() { - let path = first_line.trim().to_string(); - if !path.is_empty() { - return path; - } - } - } - } - } - - "claude.cmd".to_string() -} - -fn resolve_windows_codex_path() -> String { - for name in &["codex.cmd", "codex.ps1", "codex.exe", "codex"] { - if Command::new(name) - .arg("--version") - .creation_flags(CREATE_NO_WINDOW) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok() - { - return name.to_string(); - } - } - - for name in &["codex.cmd", "codex.ps1", "codex.exe", "codex"] { - if let Ok(output) = Command::new("where.exe") - .arg(name) - .creation_flags(CREATE_NO_WINDOW) - .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(first_line) = stdout.lines().next() { - let path = first_line.trim().to_string(); - if !path.is_empty() { - return path; - } - } - } - } - } - - "codex.cmd".to_string() -} - -fn build_agent() -> Result { - let tls = native_tls::TlsConnector::new().map_err(|_| PollError::RequestFailed)?; - Ok(ureq::AgentBuilder::new() - .timeout(Duration::from_secs(30)) - .tls_connector(std::sync::Arc::new(tls)) - .build()) -} - -pub fn credential_watch_snapshot(mode: CredentialWatchMode) -> CredentialWatchSnapshot { - 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(), - }; - - let mut snapshot: CredentialWatchSnapshot = sources - .into_iter() - .filter_map(|source| credential_watch_signature(&source)) - .collect(); - snapshot.sort(); - snapshot.dedup(); - snapshot -} - -fn all_known_credential_sources() -> Vec { - let mut sources = Vec::new(); - if let Some(source) = windows_credential_source() { - sources.push(source); - } - for distro in list_wsl_distros() { - sources.push(CredentialSource::Wsl { distro }); - } - sources -} - -fn windows_credential_source() -> Option { - let home = dirs::home_dir()?; - Some(CredentialSource::Windows( - home.join(".claude").join(".credentials.json"), - )) -} - -fn credential_watch_signature(source: &CredentialSource) -> Option { - match source { - CredentialSource::Windows(path) => Some(windows_credential_watch_signature(path)), - CredentialSource::Wsl { distro } => wsl_credential_watch_signature(distro), - } -} - -fn windows_credential_watch_signature(path: &PathBuf) -> String { - let key = format!("win:{}", 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); - format!("{key}|present|{}|{modified}", metadata.len()) - } - Err(_) => format!("{key}|missing"), - } -} - -fn wsl_credential_watch_signature(distro: &str) -> Option { - let output = run_with_timeout( - Command::new("wsl.exe") - .arg("-d") - .arg(distro) - .arg("--") - .arg("sh") - .arg("-lc") - .arg( - "if [ -f ~/.claude/.credentials.json ]; then \ - stat -c 'present|%s|%Y' ~/.claude/.credentials.json; \ - else echo missing; fi", - ) - .creation_flags(CREATE_NO_WINDOW) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()), - Duration::from_secs(5), - )?; - - let state = if output.status.success() { - decode_wsl_text(&output.stdout).trim().to_string() - } else { - format!("status-{}", output.status) - }; - - Some(format!("wsl:{distro}|{state}")) -} - -fn fetch_usage_with_fallback(token: &str) -> Result { - // Try the dedicated usage endpoint first - match try_usage_endpoint(token)? { - Some(data) => { - // If reset timers are missing, fill them in from the Messages API - if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() { - if let Ok(fallback) = fetch_usage_via_messages(token) { - let mut merged = data; - if merged.session.resets_at.is_none() { - merged.session.resets_at = fallback.session.resets_at; - } - if merged.weekly.resets_at.is_none() { - merged.weekly.resets_at = fallback.weekly.resets_at; - } - return Ok(merged); - } - } - return Ok(data); - } - None => {} - } - - // Fall back to Messages API with rate limit headers - let result = fetch_usage_via_messages(token); - if result.is_err() { - diagnose::log("usage endpoint and Messages API fallback both failed"); - } - result -} - -fn try_usage_endpoint(token: &str) -> Result, PollError> { - let agent = build_agent()?; - - let resp = match agent - .get(USAGE_URL) - .set("Authorization", &format!("Bearer {token}")) - .set("anthropic-beta", "oauth-2025-04-20") - .call() - { - Ok(resp) => resp, - Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { - diagnose::log(format!( - "usage endpoint returned auth error status {code}; re-login required" - )); - return Err(PollError::AuthRequired); - } - Err(_) => return Ok(None), - }; - - let response: UsageResponse = match resp.into_json() { - Ok(response) => response, - Err(_) => return Ok(None), - }; - let mut data = UsageData::default(); - - if let Some(bucket) = &response.five_hour { - data.session.percentage = bucket.utilization; - data.session.resets_at = parse_iso8601(bucket.resets_at.as_deref()); - } - - if let Some(bucket) = &response.seven_day { - data.weekly.percentage = bucket.utilization; - data.weekly.resets_at = parse_iso8601(bucket.resets_at.as_deref()); - } - - Ok(Some(data)) -} - -fn fetch_usage_via_messages(token: &str) -> Result { - let agent = build_agent()?; - - for model in MODEL_FALLBACK_CHAIN { - let body = serde_json::json!({ - "model": model, - "max_tokens": 1, - "messages": [{"role": "user", "content": "."}] - }); - - let response = match agent - .post(MESSAGES_URL) - .set("Authorization", &format!("Bearer {token}")) - .set("anthropic-version", "2023-06-01") - .set("anthropic-beta", "oauth-2025-04-20") - .send_json(&body) - { - Ok(resp) => resp, - Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { - diagnose::log(format!( - "messages endpoint returned auth error status {code}; re-login required" - )); - return Err(PollError::AuthRequired); - } - Err(ureq::Error::Status(_code, resp)) => resp, - Err(_) => continue, - }; - - let h5 = response.header("anthropic-ratelimit-unified-5h-utilization"); - let h7 = response.header("anthropic-ratelimit-unified-7d-utilization"); - let hs = response.header("anthropic-ratelimit-unified-status"); - - if h5.is_some() || h7.is_some() || hs.is_some() { - return Ok(parse_rate_limit_headers(&response)); - } - } - - Err(PollError::RequestFailed) -} - -fn parse_rate_limit_headers(response: &ureq::Response) -> UsageData { - let mut data = UsageData::default(); - - data.session.percentage = - get_header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0; - data.session.resets_at = unix_to_system_time(get_header_i64( - response, - "anthropic-ratelimit-unified-5h-reset", - )); - - data.weekly.percentage = - get_header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0; - data.weekly.resets_at = unix_to_system_time(get_header_i64( - response, - "anthropic-ratelimit-unified-7d-reset", - )); - - let overall_reset = get_header_i64(response, "anthropic-ratelimit-unified-reset"); - - if data.session.percentage == 0.0 && data.weekly.percentage == 0.0 { - let status = response.header("anthropic-ratelimit-unified-status"); - if status == Some("rejected") { - let claim = response.header("anthropic-ratelimit-unified-representative-claim"); - match claim { - Some("five_hour") => data.session.percentage = 100.0, - Some("seven_day") => data.weekly.percentage = 100.0, - _ => {} - } - } - - if data.session.resets_at.is_none() && overall_reset.is_some() { - data.session.resets_at = unix_to_system_time(overall_reset); - } - } - - data -} - -fn fetch_codex_usage(token: &str, account_id: Option<&str>) -> Result { - let agent = build_agent()?; - let mut request = agent - .get(CODEX_USAGE_URL) - .set("Authorization", &format!("Bearer {token}")) - .set("User-Agent", "codex-cli"); - - if let Some(account_id) = account_id.filter(|value| !value.is_empty()) { - request = request.set("ChatGPT-Account-Id", account_id); - } - - let resp = match request.call() { - Ok(resp) => resp, - Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { - diagnose::log(format!( - "Codex usage endpoint returned auth error status {code}; refresh required" - )); - return Err(PollError::AuthRequired); - } - Err(error) => { - diagnose::log_error("Codex usage endpoint request failed", error); - return Err(PollError::RequestFailed); - } - }; - - let response: CodexUsageResponse = match resp.into_json() { - Ok(response) => response, - Err(error) => { - diagnose::log_error("unable to parse Codex usage response", error); - return Err(PollError::RequestFailed); - } - }; - - codex_usage_from_response(response).ok_or(PollError::RequestFailed) -} - -fn codex_usage_from_response(response: CodexUsageResponse) -> Option { - let details = *response.rate_limit.flatten()?; - let mut data = UsageData::default(); - - if let Some(window) = details.primary_window.flatten() { - data.session = codex_section_from_window(&window); - } - - if let Some(window) = details.secondary_window.flatten() { - data.weekly = codex_section_from_window(&window); - } - - Some(data) -} - -fn codex_section_from_window(window: &CodexRateLimitWindow) -> UsageSection { - UsageSection { - percentage: window.used_percent, - resets_at: unix_to_system_time(Some(window.reset_at)), - } -} - -fn get_header_f64(response: &ureq::Response, name: &str) -> f64 { - response - .header(name) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0) -} - -fn get_header_i64(response: &ureq::Response, name: &str) -> Option { - response.header(name).and_then(|s| s.parse::().ok()) -} - -fn unix_to_system_time(unix_secs: Option) -> Option { - let secs = unix_secs?; - if secs < 0 { - return None; - } - Some(UNIX_EPOCH + Duration::from_secs(secs as u64)) -} - -struct Credentials { - access_token: String, - expires_at: Option, - source: CredentialSource, -} - -#[derive(Clone, Debug)] -enum CredentialSource { - Windows(PathBuf), - Wsl { distro: String }, -} - -fn read_first_credentials() -> Option { - if let Some(creds) = read_windows_credentials() { - return Some(creds); - } - - for distro in list_wsl_distros() { - if let Some(creds) = read_wsl_credentials(&distro) { - return Some(creds); - } - } - - None -} - -fn read_windows_credentials() -> Option { - let CredentialSource::Windows(cred_path) = windows_credential_source()? else { - return None; - }; - let content = match std::fs::read_to_string(&cred_path) { - Ok(content) => content, - Err(error) => { - if diagnose::is_enabled() { - diagnose::log_error( - &format!( - "unable to read Windows credentials at {}", - cred_path.display() - ), - error, - ); - } - return None; - } - }; - parse_credentials(&content, CredentialSource::Windows(cred_path)) -} - -fn read_credentials_from_source(source: &CredentialSource) -> Option { - match source { - CredentialSource::Windows(path) => { - let content = std::fs::read_to_string(path).ok()?; - parse_credentials(&content, source.clone()) - } - CredentialSource::Wsl { distro } => read_wsl_credentials(distro), - } -} - -fn codex_auth_path() -> Option { - if let Some(codex_home) = std::env::var_os("CODEX_HOME").map(PathBuf::from) { - return Some(codex_home.join("auth.json")); - } - - Some(dirs::home_dir()?.join(".codex").join("auth.json")) -} - -fn read_codex_credentials() -> Option { - let auth_path = codex_auth_path()?; - let content = match std::fs::read_to_string(&auth_path) { - Ok(content) => content, - Err(error) => { - diagnose::log_error( - &format!( - "unable to read Codex credentials at {}", - auth_path.display() - ), - error, - ); - return None; - } - }; - - let auth: CodexAuthFile = serde_json::from_str(&content).ok()?; - auth.tokens.filter(|tokens| !tokens.access_token.is_empty()) -} - -fn read_wsl_credentials(distro: &str) -> Option { - let output = run_with_timeout( - Command::new("wsl.exe") - .arg("-d") - .arg(distro) - .arg("--") - .arg("sh") - .arg("-lc") - .arg("cat ~/.claude/.credentials.json") - .creation_flags(CREATE_NO_WINDOW) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()), - Duration::from_secs(5), - )?; - - if !output.status.success() { - diagnose::log(format!( - "WSL credentials probe failed for distro {distro} with status {}", - output.status - )); - return None; - } - - let content = String::from_utf8(output.stdout).ok()?; - parse_credentials( - &content, - CredentialSource::Wsl { - distro: distro.to_string(), - }, - ) -} - -fn parse_credentials(content: &str, source: CredentialSource) -> Option { - let json: serde_json::Value = serde_json::from_str(content).ok()?; - - let oauth = json.get("claudeAiOauth")?; - let access_token = oauth - .get("accessToken") - .and_then(|v| v.as_str())? - .to_string(); - let expires_at = oauth.get("expiresAt").and_then(|v| v.as_i64()); - - Some(Credentials { - access_token, - expires_at, - source, - }) -} - -fn read_next_credentials_after(source: &CredentialSource) -> Option { - match source { - CredentialSource::Windows(_) => { - for distro in list_wsl_distros() { - if let Some(creds) = read_wsl_credentials(&distro) { - return Some(creds); - } - } - } - CredentialSource::Wsl { distro } => { - let mut past_current = false; - for candidate_distro in list_wsl_distros() { - if !past_current { - past_current = candidate_distro == *distro; - continue; - } - if let Some(creds) = read_wsl_credentials(&candidate_distro) { - return Some(creds); - } - } - } - } - - None -} - -fn list_wsl_distros() -> Vec { - let output = match run_with_timeout( - Command::new("wsl.exe") - .args(["-l", "-q"]) - .creation_flags(CREATE_NO_WINDOW) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()), - Duration::from_secs(5), - ) { - Some(output) if output.status.success() => output, - _ => { - diagnose::log("unable to enumerate WSL distros"); - return Vec::new(); - } - }; - - let stdout = decode_wsl_text(&output.stdout); - stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(ToOwned::to_owned) - .collect() -} - -fn decode_wsl_text(bytes: &[u8]) -> String { - if bytes.is_empty() { - return String::new(); - } - - if let Some(decoded) = decode_utf16le(bytes) { - return decoded; - } - - String::from_utf8_lossy(bytes).into_owned() -} - -fn decode_utf16le(bytes: &[u8]) -> Option { - if bytes.len() < 2 || bytes.len() % 2 != 0 { - return None; - } - - let body = if bytes.starts_with(&[0xFF, 0xFE]) { - &bytes[2..] - } else if looks_like_utf16le(bytes) { - bytes - } else { - return None; - }; - - let units: Vec = body - .chunks_exact(2) - .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) - .collect(); - - Some(String::from_utf16_lossy(&units)) -} - -fn looks_like_utf16le(bytes: &[u8]) -> bool { - let sample_len = bytes.len().min(128); - let units = sample_len / 2; - if units == 0 { - return false; - } - - let nul_high_bytes = bytes[..sample_len] - .chunks_exact(2) - .filter(|chunk| chunk[1] == 0) - .count(); - - nul_high_bytes * 2 >= units -} - -fn is_token_expired(expires_at: Option) -> bool { - let Some(exp) = expires_at else { return false }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - now >= exp -} - -/// Parse an ISO 8601 timestamp string into a SystemTime. -fn parse_iso8601(s: Option<&str>) -> Option { - let s = s?; - // Strip timezone offset to get "YYYY-MM-DDTHH:MM:SS" or with fractional seconds - // The API returns formats like "2026-03-05T08:00:00.321598+00:00" - let datetime_part = s.split('+').next().unwrap_or(s); - let datetime_part = datetime_part.split('Z').next().unwrap_or(datetime_part); - - // Try parsing with and without fractional seconds - let formats = ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S"]; - for fmt in &formats { - if let Ok(secs) = parse_datetime_to_unix(datetime_part, fmt) { - return Some(UNIX_EPOCH + Duration::from_secs(secs)); - } - } - None -} - -/// Minimal datetime parser — avoids pulling in chrono/time crates. -fn parse_datetime_to_unix(s: &str, _fmt: &str) -> Result { - // Extract date and time parts from "YYYY-MM-DDTHH:MM:SS[.frac]" - let (date_str, time_str) = s.split_once('T').ok_or(())?; - let date_parts: Vec<&str> = date_str.split('-').collect(); - if date_parts.len() != 3 { - return Err(()); - } - - let year: u64 = date_parts[0].parse().map_err(|_| ())?; - let month: u64 = date_parts[1].parse().map_err(|_| ())?; - let day: u64 = date_parts[2].parse().map_err(|_| ())?; - - // Strip fractional seconds - let time_base = time_str.split('.').next().unwrap_or(time_str); - let time_parts: Vec<&str> = time_base.split(':').collect(); - if time_parts.len() != 3 { - return Err(()); - } - - let hour: u64 = time_parts[0].parse().map_err(|_| ())?; - let min: u64 = time_parts[1].parse().map_err(|_| ())?; - let sec: u64 = time_parts[2].parse().map_err(|_| ())?; - - // Days from year (using a simplified calculation for dates after 1970) - let mut days: u64 = 0; - for y in 1970..year { - days += if is_leap(y) { 366 } else { 365 }; - } - - let month_days = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - for m in 1..month { - days += month_days[m as usize]; - if m == 2 && is_leap(year) { - days += 1; - } - } - days += day - 1; - - Ok(days * 86400 + hour * 3600 + min * 60 + sec) -} - -fn is_leap(y: u64) -> bool { - (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 -} - -/// Format a usage section as "X% · Yh" style text -pub fn format_line(section: &UsageSection, strings: Strings) -> String { - let pct = format!("{:.0}%", section.percentage); - let cd = format_countdown(section.resets_at, strings); - if cd.is_empty() { - pct - } else { - format!("{pct} \u{00b7} {cd}") - } -} - -fn format_countdown(resets_at: Option, strings: Strings) -> String { - let reset = match resets_at { - Some(t) => t, - None => return String::new(), - }; - - let remaining = match reset.duration_since(SystemTime::now()) { - Ok(d) => d, - Err(_) => return strings.now.to_string(), - }; - - format_countdown_from_secs(remaining.as_secs(), strings) -} - -/// Calculate how long until the display text would change -pub fn time_until_display_change(resets_at: Option) -> Option { - let reset = resets_at?; - let remaining = reset.duration_since(SystemTime::now()).ok()?; - Some(time_until_display_change_from_secs(remaining.as_secs())) -} - -fn format_countdown_from_secs(total_secs: u64, strings: Strings) -> String { - let total_mins = total_secs / 60; - let total_hours = total_secs / 3600; - let total_days = total_secs / 86400; - - if total_days >= 1 { - format!("{total_days}{}", strings.day_suffix) - } else if total_hours >= 1 { - format!("{total_hours}{}", strings.hour_suffix) - } else if total_mins >= 1 { - format!("{total_mins}{}", strings.minute_suffix) - } else { - format!("{total_secs}{}", strings.second_suffix) - } -} - -fn time_until_display_change_from_secs(total_secs: u64) -> Duration { - let total_mins = total_secs / 60; - let total_hours = total_secs / 3600; - let total_days = total_secs / 86400; - - let current_bucket_start = if total_days >= 1 { - total_days * 86400 - } else if total_hours >= 1 { - total_hours * 3600 - } else if total_mins >= 1 { - total_mins * 60 - } else { - total_secs - }; - - Duration::from_secs(total_secs.saturating_sub(current_bucket_start) + 1) -} - -/// Returns true if either section has reached "now" (reset time has passed). -pub fn is_past_reset(data: &UsageData) -> bool { - let now = SystemTime::now(); - let past = |s: &UsageSection| matches!(s.resets_at, Some(t) if now.duration_since(t).is_ok()); - past(&data.session) || past(&data.weekly) -} - -pub fn app_is_past_reset(data: &AppUsageData) -> bool { - data.claude_code.as_ref().is_some_and(is_past_reset) - || data.codex.as_ref().is_some_and(is_past_reset) -} diff --git a/src/settings.rs b/src/settings.rs index d61c10d..bbdc6f0 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -3,7 +3,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::bubble::DEFAULT_BUBBLE_SIZE; -use crate::tray_icon::TrayIconKind; +use crate::usage::ProviderId; +type TrayIconKind = ProviderId; const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble"; const SETTINGS_FILE: &str = "settings.json"; @@ -38,20 +39,20 @@ pub struct BubblePositions { impl BubblePositions { pub fn get(&self, model: TrayIconKind) -> Option<(i32, i32)> { match model { - TrayIconKind::Claude => self.claude, - TrayIconKind::Codex => self.codex, + ProviderId::Claude => self.claude, + ProviderId::ChatGpt => self.codex, } } pub fn set(&mut self, model: TrayIconKind, pos: (i32, i32)) { match model { - TrayIconKind::Claude => self.claude = Some(pos), - TrayIconKind::Codex => self.codex = Some(pos), + ProviderId::Claude => self.claude = Some(pos), + ProviderId::ChatGpt => self.codex = Some(pos), } } pub fn reset(&mut self, model: TrayIconKind) { match model { - TrayIconKind::Claude => self.claude = None, - TrayIconKind::Codex => self.codex = None, + ProviderId::Claude => self.claude = None, + ProviderId::ChatGpt => self.codex = None, } } pub fn reset_all(&mut self) { diff --git a/src/theme.rs b/src/theme.rs deleted file mode 100644 index 05ad0bb..0000000 --- a/src/theme.rs +++ /dev/null @@ -1,51 +0,0 @@ -use windows::core::PCWSTR; -use windows::Win32::System::Registry::*; - -use crate::native_interop::wide_str; - -const REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; -const REGISTRY_KEY: &str = "SystemUsesLightTheme"; - -/// Check if the system is in dark mode by reading the registry -pub fn is_dark_mode() -> bool { - !is_light_theme() -} - -fn is_light_theme() -> bool { - unsafe { - let path = wide_str(REGISTRY_PATH); - let key_name = wide_str(REGISTRY_KEY); - - let mut hkey = HKEY::default(); - let result = RegOpenKeyExW( - HKEY_CURRENT_USER, - PCWSTR::from_raw(path.as_ptr()), - 0, - KEY_READ, - &mut hkey, - ); - - if result.is_err() { - return false; // Default to dark mode - } - - let mut data: u32 = 0; - let mut data_size: u32 = std::mem::size_of::() as u32; - let result = RegQueryValueExW( - hkey, - PCWSTR::from_raw(key_name.as_ptr()), - None, - None, - Some(&mut data as *mut u32 as *mut u8), - Some(&mut data_size), - ); - - let _ = RegCloseKey(hkey); - - if result.is_err() { - return false; // Default to dark mode - } - - data == 1 - } -} diff --git a/src/tray/badge.rs b/src/tray/badge.rs new file mode 100644 index 0000000..499d5a0 --- /dev/null +++ b/src/tray/badge.rs @@ -0,0 +1,228 @@ +// Anti-aliased tray badge rendering. +// +// We draw a filled circle + percentage-sweep ring with tiny-skia, then +// hand the resulting BGRA pixmap to Win32 as an HICON via +// `CreateIconIndirect`. The badge is intentionally text-free — the +// floating bubble already shows the exact percentage; the tray badge +// is just a coarse colour-and-fill indicator. + +use std::ffi::c_void; + +use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform}; +use windows::core::PCWSTR; +use windows::Win32::Foundation::HWND; +use windows::Win32::Graphics::Gdi::{ + CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER, + DIB_RGB_COLORS, HBITMAP, +}; +use windows::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, HICON, ICONINFO}; + +use crate::usage::ProviderId; + +const BADGE_PX: u32 = 32; + +pub fn render_hicon(kind: ProviderId, percent: Option) -> HICON { + let pixmap = render_pixmap(kind, percent); + pixmap_to_hicon(&pixmap).unwrap_or_default() +} + +fn render_pixmap(kind: ProviderId, percent: Option) -> Pixmap { + let mut pixmap = Pixmap::new(BADGE_PX, BADGE_PX).expect("32×32 pixmap"); + pixmap.fill(tiny_skia::Color::TRANSPARENT); + + let cx = BADGE_PX as f32 / 2.0; + let cy = BADGE_PX as f32 / 2.0; + let outer = (BADGE_PX as f32 / 2.0) - 1.0; + let inner = outer * 0.62; + + // Filled inner disk in the model's base tint. + let base = base_color(kind); + { + let mut paint = Paint::default(); + paint.set_color_rgba8(base[0], base[1], base[2], 255); + paint.anti_alias = true; + let mut pb = PathBuilder::new(); + pb.push_circle(cx, cy, inner); + if let Some(path) = pb.finish() { + pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + } + } + + // Track ring (the unused portion of the quota). + { + let mut paint = Paint::default(); + paint.set_color_rgba8(0x3a, 0x3a, 0x3a, 255); + paint.anti_alias = true; + let mut stroke = Stroke::default(); + stroke.width = outer - inner - 1.0; + let mut pb = PathBuilder::new(); + pb.push_circle(cx, cy, (inner + outer) / 2.0); + if let Some(path) = pb.finish() { + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + + // Active sweep — percentage of ring filled in usage colour. + if let Some(p) = percent { + let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32; + if sweep > 0.0 { + let fill = usage_color(p); + let mut paint = Paint::default(); + paint.set_color_rgba8(fill[0], fill[1], fill[2], 255); + paint.anti_alias = true; + let mut stroke = Stroke::default(); + stroke.width = outer - inner - 1.0; + stroke.line_cap = tiny_skia::LineCap::Round; + + let pb_path = build_arc(cx, cy, (inner + outer) / 2.0, sweep); + if let Some(path) = pb_path { + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + } + + pixmap +} + +fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option { + // tiny-skia 0.11 lacks a direct arc primitive. Approximate by sampling + // points along the circumference from the 12 o'clock position clockwise. + let segments = (sweep_fraction * 64.0).ceil() as usize; + let segments = segments.max(1); + let mut pb = PathBuilder::new(); + let start_angle: f32 = -std::f32::consts::FRAC_PI_2; + let total = sweep_fraction * std::f32::consts::TAU; + for i in 0..=segments { + let t = i as f32 / segments as f32; + let a = start_angle + t * total; + let x = cx + a.cos() * radius; + let y = cy + a.sin() * radius; + if i == 0 { + pb.move_to(x, y); + } else { + pb.line_to(x, y); + } + } + pb.finish() +} + +fn base_color(kind: ProviderId) -> [u8; 3] { + match kind { + // Warm orange-ish tint reads as "Claude" without copying the + // upstream's exact #D97757; close enough to be familiar. + ProviderId::Claude => [0x2a, 0x1f, 0x1c], + // Cool dark slate for ChatGPT/Codex. + ProviderId::ChatGpt => [0x1a, 0x1f, 0x26], + } +} + +fn usage_color(percent: f64) -> [u8; 3] { + // Color gradient: soft orange (low usage) → red (near-cap). + let stops: [(f64, [u8; 3]); 5] = [ + (0.0, [0xD9, 0x77, 0x57]), + (50.0, [0xD9, 0x77, 0x57]), + (75.0, [0xCC, 0x8C, 0x20]), + (90.0, [0xC4, 0x50, 0x20]), + (100.0, [0xB8, 0x20, 0x20]), + ]; + for pair in stops.windows(2) { + let (a_p, a_c) = pair[0]; + let (b_p, b_c) = pair[1]; + if percent <= b_p { + let span = (b_p - a_p).max(f64::EPSILON); + let t = ((percent - a_p) / span).clamp(0.0, 1.0); + return [ + lerp(a_c[0], b_c[0], t), + lerp(a_c[1], b_c[1], t), + lerp(a_c[2], b_c[2], t), + ]; + } + } + stops[stops.len() - 1].1 +} + +fn lerp(a: u8, b: u8, t: f64) -> u8 { + (a as f64 + (b as f64 - a as f64) * t).round() as u8 +} + +// ---------- Pixmap → HICON ---------- + +fn pixmap_to_hicon(pixmap: &Pixmap) -> Option { + let width = pixmap.width() as i32; + let height = pixmap.height() as i32; + let pixels = pixmap.data(); // tiny-skia premultiplied RGBA bytes + + // Build a 32bpp top-down DIB section for the colour bitmap. + let bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: width, + biHeight: -height, + biPlanes: 1, + biBitCount: 32, + biCompression: 0, // BI_RGB + ..Default::default() + }, + ..Default::default() + }; + + unsafe { + let hdc = GetDC(HWND::default()); + let mut bits: *mut c_void = std::ptr::null_mut(); + let color_bmp = CreateDIBSection(hdc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0) + .ok() + .unwrap_or_default(); + ReleaseDC(HWND::default(), hdc); + if color_bmp.is_invalid() || bits.is_null() { + return None; + } + // tiny-skia produces RGBA (premultiplied); GDI's DIB is BGRA. Swap. + let pixel_count = (width * height) as usize; + let dst = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count); + for i in 0..pixel_count { + let r = pixels[i * 4]; + let g = pixels[i * 4 + 1]; + let b = pixels[i * 4 + 2]; + let a = pixels[i * 4 + 3]; + dst[i] = (a as u32) << 24 | (r as u32) << 16 | (g as u32) << 8 | (b as u32); + } + + // Monochrome AND mask — opaque pixels marked 0, transparent 1. + let mask_row_stride = ((width + 15) / 16) * 2; // 16-bit aligned per scanline + let mut mask = vec![0u8; (mask_row_stride * height) as usize]; + for y in 0..height { + for x in 0..width { + let idx = (y * width + x) as usize; + let alpha = pixels[idx * 4 + 3]; + if alpha == 0 { + let byte = (y * mask_row_stride + (x / 8)) as usize; + mask[byte] |= 0x80 >> (x % 8); + } + } + } + let mask_bmp: HBITMAP = CreateBitmap(width, height, 1, 1, Some(mask.as_ptr() as *const _)); + if mask_bmp.is_invalid() { + let _ = DeleteObject(color_bmp); + return None; + } + + let info = ICONINFO { + fIcon: BOOL(1), + xHotspot: 0, + yHotspot: 0, + hbmMask: mask_bmp, + hbmColor: color_bmp, + }; + let hicon = CreateIconIndirect(&info).ok(); + + let _ = DeleteObject(color_bmp); + let _ = DeleteObject(mask_bmp); + hicon + } +} + +// Silence import warnings if we end up not needing PCWSTR after later edits. +#[allow(dead_code)] +const _: PCWSTR = PCWSTR::null(); + +use windows::Win32::Foundation::BOOL; diff --git a/src/tray/callback.rs b/src/tray/callback.rs new file mode 100644 index 0000000..8849d5c --- /dev/null +++ b/src/tray/callback.rs @@ -0,0 +1,22 @@ +// Dispatch the `WM_APP_TRAY` notification message. +// +// Shell_NotifyIconW packs the event in the LOWORD of lparam (the +// underlying mouse-event code) and the icon ID in HIWORD. We translate +// to a `TrayAction` and let the app handle it. + +use windows::Win32::Foundation::LPARAM; + +use super::TrayAction; + +const WM_LBUTTONUP: u32 = 0x0202; +const WM_RBUTTONUP: u32 = 0x0205; + +pub fn handle(lparam: LPARAM) -> TrayAction { + let raw = lparam.0 as u32; + let event = raw & 0xFFFF; + match event { + WM_LBUTTONUP => TrayAction::ToggleWidget, + WM_RBUTTONUP => TrayAction::ShowContextMenu, + _ => TrayAction::None, + } +} diff --git a/src/tray/mod.rs b/src/tray/mod.rs new file mode 100644 index 0000000..57b4269 --- /dev/null +++ b/src/tray/mod.rs @@ -0,0 +1,131 @@ +// Tray-area icon management — stateless module-level API. +// +// Each enabled provider gets one notification-area icon. `sync` reconciles +// the set of registered icons with the supplied desired list, issuing +// `NIM_ADD`, `NIM_MODIFY`, or `NIM_DELETE` per icon. We track registration +// state in a private mutex so callers (the app orchestrator) don't have +// to thread a `Manager` through their snapshot/clone pipelines. + +use std::collections::HashSet; +use std::sync::{Mutex, OnceLock}; + +use windows::Win32::Foundation::HWND; +use windows::Win32::UI::Shell::{ + Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_WARNING, NIM_ADD, + NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, +}; +use windows::Win32::UI::WindowsAndMessaging::DestroyIcon; + +pub mod badge; +pub mod callback; + +pub use crate::usage::ProviderId as IconKind; + +/// Menu-command ID for the "Show widget" toggle that appears in the +/// right-click menu and is also fired by left-clicking a tray icon. +pub const IDM_TOGGLE_WIDGET: u16 = 50; + +/// Notification message routed back to the owner HWND when the user +/// interacts with a tray icon (left/right click). +pub const WM_APP_TRAY: u32 = 0x8003; + +#[derive(Clone, Debug)] +pub struct TrayIcon { + pub kind: IconKind, + pub percent: Option, + pub tooltip: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrayAction { + None, + ToggleWidget, + ShowContextMenu, +} + +fn registered() -> &'static Mutex> { + static R: OnceLock>> = OnceLock::new(); + R.get_or_init(|| Mutex::new(HashSet::new())) +} + +/// Reconcile registered icons with the desired list. +pub fn sync(owner: HWND, desired: &[TrayIcon]) { + let mut current = registered().lock().expect("tray registry mutex poisoned"); + let target: HashSet = desired.iter().map(|i| i.kind).collect(); + + let to_remove: Vec = current.difference(&target).copied().collect(); + for kind in to_remove { + unsafe { + let _ = Shell_NotifyIconW(NIM_DELETE, &build_data(owner, kind)); + } + current.remove(&kind); + } + + for icon in desired { + let hicon = badge::render_hicon(icon.kind, icon.percent); + let mut data = build_data(owner, icon.kind); + data.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; + data.hIcon = hicon; + write_utf16(&mut data.szTip, &icon.tooltip); + let msg = if current.contains(&icon.kind) { + NIM_MODIFY + } else { + NIM_ADD + }; + unsafe { + if Shell_NotifyIconW(msg, &data).as_bool() && msg == NIM_ADD { + current.insert(icon.kind); + } + let _ = DestroyIcon(hicon); + } + } +} + +/// Show a balloon notification on an already-registered icon. +pub fn notify(owner: HWND, kind: IconKind, title: &str, body: &str) { + let mut data = build_data(owner, kind); + data.uFlags = NIF_INFO; + write_utf16(&mut data.szInfoTitle, title); + write_utf16(&mut data.szInfo, body); + data.dwInfoFlags = NIIF_WARNING; + unsafe { + let _ = Shell_NotifyIconW(NIM_MODIFY, &data); + } +} + +/// Tear down every registered icon. Call from app shutdown if you want to. +#[allow(dead_code)] +pub fn remove_all(owner: HWND) { + let mut current = registered().lock().expect("tray registry mutex poisoned"); + for kind in current.drain().collect::>() { + unsafe { + let _ = Shell_NotifyIconW(NIM_DELETE, &build_data(owner, kind)); + } + } +} + +fn build_data(owner: HWND, kind: IconKind) -> NOTIFYICONDATAW { + NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: owner, + uID: icon_id(kind), + uCallbackMessage: WM_APP_TRAY, + ..Default::default() + } +} + +fn icon_id(kind: IconKind) -> u32 { + match kind { + IconKind::Claude => 1, + IconKind::ChatGpt => 2, + } +} + +fn write_utf16(dst: &mut [u16], src: &str) { + let units: Vec = src.encode_utf16().collect(); + let n = units.len().min(dst.len().saturating_sub(1)); + dst[..n].copy_from_slice(&units[..n]); + if n < dst.len() { + dst[n] = 0; + } +} diff --git a/src/tray_icon.rs b/src/tray_icon.rs deleted file mode 100644 index 27c3352..0000000 --- a/src/tray_icon.rs +++ /dev/null @@ -1,441 +0,0 @@ -use windows::core::PCWSTR; -use windows::Win32::Foundation::*; -use windows::Win32::Graphics::Gdi::*; -use windows::Win32::System::LibraryLoader::GetModuleFileNameW; -use windows::Win32::UI::Shell::{ - ExtractIconExW, Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_WARNING, - NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, -}; -use windows::Win32::UI::WindowsAndMessaging::*; - -use crate::native_interop::{self, Color, WM_APP_TRAY}; - -const CLAUDE_TRAY_ICON_ID: u32 = 1; -const CODEX_TRAY_ICON_ID: u32 = 2; - -/// Menu item ID for toggling widget visibility (used by window.rs context menu). -pub const IDM_TOGGLE_WIDGET: u16 = 50; - -/// Actions the tray message handler can request from the main window. -pub enum TrayAction { - None, - ToggleWidget, - ShowContextMenu, -} - -#[derive(Clone, Copy)] -pub enum TrayIconKind { - Claude, - Codex, -} - -pub struct TrayIconData { - pub kind: TrayIconKind, - pub percent: Option, - pub tooltip: String, -} - -impl TrayIconKind { - fn id(self) -> u32 { - match self { - Self::Claude => CLAUDE_TRAY_ICON_ID, - Self::Codex => CODEX_TRAY_ICON_ID, - } - } -} - -fn lerp_channel(start: u8, end: u8, t: f64) -> u8 { - (start as f64 + (end as f64 - start as f64) * t.clamp(0.0, 1.0)).round() as u8 -} - -fn lerp_color(start: Color, end: Color, t: f64) -> Color { - Color::new( - lerp_channel(start.r, end.r, t), - lerp_channel(start.g, end.g, t), - lerp_channel(start.b, end.b, t), - ) -} - -fn interpolated_fill(percent: f64) -> Color { - if percent <= 50.0 { - return Color::from_hex("#D97757"); - } - - let stops = [ - (50.0, Color::from_hex("#D97757")), - (70.0, Color::from_hex("#D08540")), - (85.0, Color::from_hex("#CC8C20")), - (95.0, Color::from_hex("#C45020")), - (100.0, Color::from_hex("#B82020")), - ]; - - for pair in stops.windows(2) { - let (start_pct, start_color) = pair[0]; - let (end_pct, end_color) = pair[1]; - if percent <= end_pct { - let span = (end_pct - start_pct).max(f64::EPSILON); - let t = (percent - start_pct) / span; - return lerp_color(start_color, end_color, t); - } - } - - stops[stops.len() - 1].1 -} - -fn codex_fill(percent: f64) -> Color { - if percent >= 90.0 { - Color::from_hex("#FFFFFF") - } else { - Color::from_hex("#111111") - } -} - -/// Create a rounded-rectangle tray icon badge showing the usage percentage. -/// For Claude, `percent` = None uses the embedded app icon as the loading state. -/// For Codex, `percent` = None uses a black/white Codex placeholder badge. -pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { - if matches!(kind, TrayIconKind::Claude) && percent.is_none() { - let app_icon = load_embedded_app_icon(); - if !app_icon.is_invalid() { - return app_icon; - } - } - - let size = 64_i32; - let margin = 0_i32; - let radius = 2_i32; - let outline = if matches!(kind, TrayIconKind::Codex) { - 3_i32 - } else { - 0_i32 - }; - - let fill = match kind { - TrayIconKind::Claude => interpolated_fill(percent.unwrap_or(0.0)), - TrayIconKind::Codex => codex_fill(percent.unwrap_or(0.0)), - }; - let text_col = match kind { - TrayIconKind::Claude => Color::from_hex("#FFFFFF"), - TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"), - TrayIconKind::Codex => Color::from_hex("#FFFFFF"), - }; - let outline_col = match kind { - TrayIconKind::Claude => fill, - TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"), - TrayIconKind::Codex => Color::from_hex("#FFFFFF"), - }; - - let display_text = match percent { - Some(p) => format!("{}", p.round().clamp(0.0, 999.0) as u32), - None => match kind { - TrayIconKind::Claude => String::new(), - TrayIconKind::Codex => "C".to_string(), - }, - }; - - let font_h = match display_text.len() { - 1 => -50, - 2 => -42, - _ => -30, - }; - - unsafe { - let screen_dc = GetDC(HWND::default()); - let mem_dc = CreateCompatibleDC(screen_dc); - - let bmi = BITMAPINFO { - bmiHeader: BITMAPINFOHEADER { - biSize: std::mem::size_of::() as u32, - biWidth: size, - biHeight: -size, - biPlanes: 1, - biBitCount: 32, - biCompression: 0, - ..Default::default() - }, - ..Default::default() - }; - - let mut bits: *mut std::ffi::c_void = std::ptr::null_mut(); - let dib = - CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default(); - - if dib.is_invalid() { - let _ = DeleteDC(mem_dc); - ReleaseDC(HWND::default(), screen_dc); - return HICON::default(); - } - - let old_bmp = SelectObject(mem_dc, dib); - - // Zero-fill (transparent background) - let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, (size * size) as usize); - for px in pixel_data.iter_mut() { - *px = 0; - } - - // Draw rounded rectangle badge - let null_pen = GetStockObject(NULL_PEN); - let old_pen = SelectObject(mem_dc, null_pen); - - if outline > 0 { - let br_outline = CreateSolidBrush(COLORREF(outline_col.to_colorref())); - let old_brush = SelectObject(mem_dc, br_outline); - let _ = RoundRect( - mem_dc, - margin, - margin, - size - margin + 1, - size - margin + 1, - (radius + 1) * 2, - (radius + 1) * 2, - ); - SelectObject(mem_dc, old_brush); - let _ = DeleteObject(br_outline); - } - - let br_fill = CreateSolidBrush(COLORREF(fill.to_colorref())); - let old_brush = SelectObject(mem_dc, br_fill); - let _ = RoundRect( - mem_dc, - margin + outline, - margin + outline, - size - margin - outline + 1, - size - margin - outline + 1, - (radius - 1) * 2, - (radius - 1) * 2, - ); - - SelectObject(mem_dc, old_brush); - SelectObject(mem_dc, old_pen); - let _ = DeleteObject(br_fill); - - // Draw centered percentage text - let font_name = native_interop::wide_str("Arial Bold"); - let font = CreateFontW( - font_h, - 0, - 0, - 0, - FW_BOLD.0 as i32, - 0, - 0, - 0, - DEFAULT_CHARSET.0 as u32, - OUT_TT_PRECIS.0 as u32, - CLIP_DEFAULT_PRECIS.0 as u32, - ANTIALIASED_QUALITY.0 as u32, - (DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32, - PCWSTR::from_raw(font_name.as_ptr()), - ); - let old_font = SelectObject(mem_dc, font); - let _ = SetBkMode(mem_dc, TRANSPARENT); - let _ = SetTextColor(mem_dc, COLORREF(text_col.to_colorref())); - - let mut text_rect = RECT { - left: margin, - top: margin, - right: size - margin, - bottom: size - margin, - }; - let mut text_wide: Vec = display_text.encode_utf16().collect(); - let _ = DrawTextW( - mem_dc, - &mut text_wide, - &mut text_rect, - DT_CENTER | DT_VCENTER | DT_SINGLELINE, - ); - - SelectObject(mem_dc, old_font); - let _ = DeleteObject(font); - - // Set alpha: non-zero BGR pixel -> fully opaque; background stays transparent - for px in pixel_data.iter_mut() { - if *px != 0 { - *px = (*px & 0x00FF_FFFF) | 0xFF00_0000; - } - } - - // Monochrome mask (per-pixel alpha from colour bitmap) - let mask_bytes = vec![0u8; ((size * size + 7) / 8) as usize]; - let mask_bmp = CreateBitmap( - size, - size, - 1, - 1, - Some(mask_bytes.as_ptr() as *const std::ffi::c_void), - ); - - let icon_info = ICONINFO { - fIcon: TRUE, - xHotspot: 0, - yHotspot: 0, - hbmMask: mask_bmp, - hbmColor: dib, - }; - let hicon = CreateIconIndirect(&icon_info).unwrap_or_default(); - - let _ = DeleteObject(mask_bmp); - SelectObject(mem_dc, old_bmp); - let _ = DeleteObject(dib); - let _ = DeleteDC(mem_dc); - ReleaseDC(HWND::default(), screen_dc); - - hicon - } -} - -fn load_embedded_app_icon() -> HICON { - unsafe { - let mut exe_buf = [0u16; 260]; - let len = GetModuleFileNameW(None, &mut exe_buf) as usize; - if len == 0 { - return HICON::default(); - } - - let mut small_icon = HICON::default(); - let mut large_icon = HICON::default(); - let extracted = ExtractIconExW( - PCWSTR::from_raw(exe_buf.as_ptr()), - 0, - Some(&mut large_icon), - Some(&mut small_icon), - 1, - ); - - if extracted == 0 { - HICON::default() - } else if !small_icon.is_invalid() { - small_icon - } else { - large_icon - } - } -} - -/// Show a Windows balloon notification from the tray icon. -/// Used to alert the user when re-authentication is required. -pub fn notify_balloon(hwnd: HWND, kind: TrayIconKind, title: &str, message: &str) { - unsafe { - let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); - nid.cbSize = std::mem::size_of::() as u32; - nid.hWnd = hwnd; - nid.uID = kind.id(); - nid.uFlags = NIF_INFO; - nid.dwInfoFlags = NIIF_WARNING; - copy_wide(title, &mut nid.szInfoTitle); - copy_wide_256(message, &mut nid.szInfo); - let _ = Shell_NotifyIconW(NIM_MODIFY, &nid); - } -} - -/// Copy a string into a fixed-size wide buffer (truncates to fit). -fn copy_wide(s: &str, buf: &mut [u16; N]) { - let wide: Vec = s.encode_utf16().collect(); - let len = wide.len().min(N - 1); - buf[..len].copy_from_slice(&wide[..len]); - buf[len] = 0; -} - -/// Copy a string into a 256-wide buffer. -fn copy_wide_256(s: &str, buf: &mut [u16; 256]) { - copy_wide(s, buf) -} - -/// Register the tray icon with the shell. -pub fn add(hwnd: HWND, kind: TrayIconKind, percent: Option, tooltip: &str) { - let hicon = create_icon(kind, percent); - unsafe { - let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); - nid.cbSize = std::mem::size_of::() as u32; - nid.hWnd = hwnd; - nid.uID = kind.id(); - nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; - nid.uCallbackMessage = WM_APP_TRAY; - nid.hIcon = hicon; - copy_to_tip(tooltip, &mut nid.szTip); - let _ = Shell_NotifyIconW(NIM_ADD, &nid); - if !hicon.is_invalid() { - let _ = DestroyIcon(hicon); - } - } -} - -/// Update the tray icon colour and tooltip to reflect current usage. -pub fn update(hwnd: HWND, kind: TrayIconKind, percent: Option, tooltip: &str) { - let hicon = create_icon(kind, percent); - unsafe { - let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); - nid.cbSize = std::mem::size_of::() as u32; - nid.hWnd = hwnd; - nid.uID = kind.id(); - nid.uFlags = NIF_ICON | NIF_TIP; - nid.hIcon = hicon; - copy_to_tip(tooltip, &mut nid.szTip); - let _ = Shell_NotifyIconW(NIM_MODIFY, &nid); - if !hicon.is_invalid() { - let _ = DestroyIcon(hicon); - } - } -} - -/// Remove the tray icon from the shell. -pub fn remove(hwnd: HWND, kind: TrayIconKind) { - unsafe { - let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); - nid.cbSize = std::mem::size_of::() as u32; - nid.hWnd = hwnd; - nid.uID = kind.id(); - let _ = Shell_NotifyIconW(NIM_DELETE, &nid); - } -} - -pub fn sync(hwnd: HWND, icons: &[TrayIconData]) { - let show_claude = icons - .iter() - .find(|icon| matches!(icon.kind, TrayIconKind::Claude)); - let show_codex = icons - .iter() - .find(|icon| matches!(icon.kind, TrayIconKind::Codex)); - - if let Some(icon) = show_claude { - add(hwnd, icon.kind, icon.percent, &icon.tooltip); - update(hwnd, icon.kind, icon.percent, &icon.tooltip); - } else { - remove(hwnd, TrayIconKind::Claude); - } - - if let Some(icon) = show_codex { - add(hwnd, icon.kind, icon.percent, &icon.tooltip); - update(hwnd, icon.kind, icon.percent, &icon.tooltip); - } else { - remove(hwnd, TrayIconKind::Codex); - } -} - -pub fn remove_all(hwnd: HWND) { - remove(hwnd, TrayIconKind::Claude); - remove(hwnd, TrayIconKind::Codex); -} - -/// Interpret a tray callback message and return the action to take. -pub fn handle_message(lparam: LPARAM) -> TrayAction { - let mouse_msg = lparam.0 as u32; - match mouse_msg { - WM_LBUTTONUP => TrayAction::ToggleWidget, - WM_RBUTTONUP => TrayAction::ShowContextMenu, - _ => TrayAction::None, - } -} - -/// Copy a string into the fixed-size szTip field (max 127 chars + null). -fn copy_to_tip(s: &str, tip: &mut [u16; 128]) { - let wide: Vec = s.encode_utf16().collect(); - let mut len = wide.len().min(127); - // Don't leave a lone high surrogate at the truncation point - if len > 0 && (0xD800..=0xDBFF).contains(&wide[len - 1]) { - len -= 1; - } - tip[..len].copy_from_slice(&wide[..len]); - tip[len] = 0; -} diff --git a/src/update/channel.rs b/src/update/channel.rs new file mode 100644 index 0000000..ee45f61 --- /dev/null +++ b/src/update/channel.rs @@ -0,0 +1,16 @@ +// Install-channel detection. +// +// Until this app has a winget package, every install is treated as +// "portable" — meaning self-update applies via the inline-cmd handoff. +// When a winget package exists, restore the path-probe code from git +// history (see commit before Phase 6). + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Channel { + Portable, + Winget, +} + +pub fn current() -> Channel { + Channel::Portable +} diff --git a/src/update/install.rs b/src/update/install.rs new file mode 100644 index 0000000..d248704 --- /dev/null +++ b/src/update/install.rs @@ -0,0 +1,89 @@ +// Download a release asset and hand off via inline `cmd /c`. +// +// We avoid the helper-exe pattern entirely: after writing the new .exe +// to a staging path, we spawn cmd.exe with a one-liner that waits 2 s, +// moves the new binary over the running one (Windows releases the file +// lock when our process exits), and relaunches it. + +use std::os::windows::process::CommandExt; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use crate::net::Client; + +const CREATE_NO_WINDOW: u32 = 0x0800_0000; +const DETACHED_PROCESS: u32 = 0x0000_0008; + +pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error> { + let current = std::env::current_exe()?; + ensure_writable(¤t)?; + let staging = stage_path()?; + if let Some(parent) = staging.parent() { + std::fs::create_dir_all(parent)?; + } + download(http, &release.asset_url, &staging)?; + spawn_handoff(&staging, ¤t)?; + Ok(()) +} + +/// CLI entry-point compatibility for `--apply-update `. +/// The inline-cmd handoff already does the swap-and-restart; if this binary +/// is invoked with the legacy flag (e.g. from an older release's helper) +/// just exit cleanly so the upgrade still completes. +pub fn run_cli(args: &[String]) -> Option { + if args.len() >= 2 && args[1] == "--apply-update" { + Some(0) + } else { + None + } +} + +fn download(http: &Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> { + let resp = http + .get(url) + .header("User-Agent", super::release::user_agent()) + .send()?; + if !(200..300).contains(&resp.status()) { + return Err(super::Error::Network(crate::net::Error::Status(resp.status()))); + } + std::fs::write(to, resp.body())?; + Ok(()) +} + +fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(), super::Error> { + let src_str = source.to_string_lossy().replace('"', ""); + let tgt_str = target.to_string_lossy().replace('"', ""); + // 2-second wait gives the current process time to exit and release the + // file lock before `move` overwrites it. + let cmd = format!( + r#"timeout /t 2 /nobreak >nul & move /y "{src_str}" "{tgt_str}" & start "" "{tgt_str}""# + ); + Command::new("cmd.exe") + .args(["/c", &cmd]) + .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + Ok(()) +} + +fn stage_path() -> Result { + let base = dirs::data_local_dir().ok_or_else(|| { + super::Error::NotWritable("no local data directory available".to_string()) + })?; + Ok(base + .join("ClaudeCodeUsageBubble") + .join("updates") + .join("update.exe")) +} + +fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> { + let parent = target.parent().ok_or_else(|| { + super::Error::NotWritable("could not resolve install directory".to_string()) + })?; + let probe = parent.join(".__bubble_update_probe"); + std::fs::write(&probe, b"").map_err(|e| super::Error::NotWritable(e.to_string()))?; + let _ = std::fs::remove_file(&probe); + Ok(()) +} diff --git a/src/update/mod.rs b/src/update/mod.rs new file mode 100644 index 0000000..752d2a3 --- /dev/null +++ b/src/update/mod.rs @@ -0,0 +1,34 @@ +// Self-update subsystem. +// +// Two stages: `release::fetch_latest` checks GitHub releases for a newer +// build; `install::begin` downloads the .exe and hands off to a detached +// `cmd /c` script that swaps the binary and restarts. + +pub mod channel; +pub mod install; +pub mod release; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("network: {0}")] + Network(#[from] crate::net::Error), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("no matching release asset")] + NoAsset, + #[error("install location not writable: {0}")] + NotWritable(String), + #[error("malformed version: {0}")] + BadVersion(String), +} + +pub use channel::{current as current_channel, Channel}; +pub use install::{begin, run_cli}; +pub use release::{fetch_latest, Release, Version}; + +/// Result of a release-check call. +#[derive(Debug)] +pub enum CheckOutcome { + UpToDate, + Available(Release), +} diff --git a/src/update/release.rs b/src/update/release.rs new file mode 100644 index 0000000..766e51c --- /dev/null +++ b/src/update/release.rs @@ -0,0 +1,91 @@ +// Query the GitHub Releases API and pick the relevant asset. + +use serde::Deserialize; + +use crate::net::Client; + +const ASSET_NAME: &str = "claude-code-usage-bubble.exe"; +const REPO_OWNER: &str = "tiennm99"; +const REPO_NAME: &str = "claude-code-usage-bubble"; + +#[derive(Clone, Debug)] +pub struct Release { + pub version: Version, + pub asset_url: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version { + pub major: u32, + pub minor: u32, + pub patch: u32, +} + +impl Version { + pub fn current() -> Self { + Self::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version { + major: 0, + minor: 0, + patch: 0, + }) + } + + pub fn parse(s: &str) -> Option { + let core = s.trim().trim_start_matches('v').split('-').next()?; + let mut parts = core.split('.').map(|p| p.parse::().ok()); + Some(Version { + major: parts.next().flatten().unwrap_or(0), + minor: parts.next().flatten().unwrap_or(0), + patch: parts.next().flatten().unwrap_or(0), + }) + } +} + +pub fn fetch_latest(http: &Client) -> Result { + let url = format!("https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"); + let resp = http + .get(&url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", user_agent()) + .send()?; + if !(200..300).contains(&resp.status()) { + return Err(super::Error::Network(crate::net::Error::Status(resp.status()))); + } + let body: GhRelease = resp.json()?; + let candidate = Version::parse(&body.tag_name) + .ok_or_else(|| super::Error::BadVersion(body.tag_name.clone()))?; + if candidate <= Version::current() { + return Ok(super::CheckOutcome::UpToDate); + } + let asset = body + .assets + .iter() + .find(|a| a.name.eq_ignore_ascii_case(ASSET_NAME)) + .or_else(|| { + body.assets + .iter() + .find(|a| a.name.to_ascii_lowercase().ends_with(".exe")) + }) + .ok_or(super::Error::NoAsset)?; + Ok(super::CheckOutcome::Available(Release { + version: candidate, + asset_url: asset.browser_download_url.clone(), + })) +} + +pub fn user_agent() -> &'static str { + concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) +} + +#[derive(Deserialize)] +struct GhRelease { + tag_name: String, + assets: Vec, +} + +#[derive(Deserialize)] +struct GhAsset { + name: String, + browser_download_url: String, +} diff --git a/src/updater.rs b/src/updater.rs deleted file mode 100644 index 7d80add..0000000 --- a/src/updater.rs +++ /dev/null @@ -1,512 +0,0 @@ -use std::fs::File; -use std::io::{self, Write}; -use std::os::windows::process::CommandExt; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::Duration; - -use serde::Deserialize; -use windows::core::PCWSTR; -use windows::Win32::Foundation::{HWND, WAIT_OBJECT_0, WAIT_TIMEOUT}; -use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE}; -use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK}; - -const GITHUB_API_ACCEPT: &str = "application/vnd.github+json"; -const GITHUB_API_VERSION: &str = "2022-11-28"; -const RELEASE_ASSET_NAME: &str = "claude-code-usage-bubble.exe"; -const HELPER_EXE_NAME: &str = "updater-helper.exe"; -const DOWNLOAD_EXE_NAME: &str = "update-download.exe"; -const CREATE_NO_WINDOW: u32 = 0x08000000; -const CREATE_NEW_CONSOLE: u32 = 0x00000010; -// Reserved for future winget submission. Until then `current_install_channel` -// always returns `Portable` and this constant is unused. -#[allow(dead_code)] -const WINGET_PACKAGE_ID: &str = "ClaudeCodeUsageBubble"; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum InstallChannel { - Portable, - Winget, -} - -#[derive(Clone, Debug)] -pub struct ReleaseDescriptor { - pub latest_version: String, - asset_url: String, -} - -#[derive(Debug)] -pub enum UpdateCheckResult { - UpToDate, - Available(ReleaseDescriptor), -} - -#[derive(Deserialize)] -struct GitHubRelease { - tag_name: String, - assets: Vec, -} - -#[derive(Deserialize)] -struct GitHubAsset { - name: String, - browser_download_url: String, -} - -pub fn handle_cli_mode(args: &[String]) -> Option { - if args.len() == 5 && args[1] == "--apply-update" { - let target = PathBuf::from(&args[2]); - let source = PathBuf::from(&args[3]); - let pid = args[4].parse::().unwrap_or(0); - - return Some(match apply_update(target, source, pid) { - Ok(()) => 0, - Err(error) => { - show_error_message("Update failed", &error); - 1 - } - }); - } - - None -} - -pub fn current_install_channel() -> InstallChannel { - InstallChannel::Portable -} - -pub fn check_for_updates() -> Result { - match fetch_latest_release()? { - Some(release) => Ok(UpdateCheckResult::Available(release)), - None => Ok(UpdateCheckResult::UpToDate), - } -} - -pub fn begin_winget_update() -> Result<(), String> { - let current_exe = - std::env::current_exe().map_err(|e| format!("Unable to locate current executable: {e}"))?; - let current_dir = current_exe - .parent() - .ok_or_else(|| "Unable to determine the app directory for restart.".to_string())?; - let command = winget_upgrade_command( - std::process::id(), - ¤t_exe.to_string_lossy(), - ¤t_dir.to_string_lossy(), - ); - - Command::new("powershell.exe") - .arg("-NoLogo") - .arg("-Command") - .arg(&command) - .creation_flags(CREATE_NEW_CONSOLE) - .spawn() - .map_err(|e| format!("Unable to launch WinGet update command: {e}"))?; - - Ok(()) -} - -pub fn begin_self_update(release: &ReleaseDescriptor) -> Result<(), String> { - let current_exe = - std::env::current_exe().map_err(|e| format!("Unable to locate current executable: {e}"))?; - ensure_target_location_writable(¤t_exe)?; - - let stage_dir = updates_dir()?; - std::fs::create_dir_all(&stage_dir) - .map_err(|e| format!("Unable to create updater working directory: {e}"))?; - - let helper_path = stage_dir.join(HELPER_EXE_NAME); - let download_path = stage_dir.join(DOWNLOAD_EXE_NAME); - let partial_download_path = stage_dir.join(format!("{DOWNLOAD_EXE_NAME}.part")); - - if helper_path.exists() { - let _ = std::fs::remove_file(&helper_path); - } - if download_path.exists() { - let _ = std::fs::remove_file(&download_path); - } - if partial_download_path.exists() { - let _ = std::fs::remove_file(&partial_download_path); - } - - download_release_asset(&release.asset_url, &partial_download_path, &download_path)?; - std::fs::copy(¤t_exe, &helper_path) - .map_err(|e| format!("Unable to prepare updater helper: {e}"))?; - - let pid = std::process::id().to_string(); - let target = current_exe.to_string_lossy().to_string(); - let source = download_path.to_string_lossy().to_string(); - - Command::new(&helper_path) - .arg("--apply-update") - .arg(target) - .arg(source) - .arg(pid) - .creation_flags(CREATE_NO_WINDOW) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .map_err(|e| format!("Unable to launch updater helper: {e}"))?; - - Ok(()) -} - -fn apply_update(target: PathBuf, source: PathBuf, pid: u32) -> Result<(), String> { - if !source.exists() { - return Err(format!( - "Downloaded update not found at {}", - source.display() - )); - } - - let _ = wait_for_process_exit(pid, Duration::from_secs(30)); - replace_target_binary(&target, &source)?; - relaunch_target(&target)?; - let _ = std::fs::remove_file(&source); - - Ok(()) -} - -fn fetch_latest_release() -> Result, String> { - let (owner, repo) = github_repo()?; - let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/latest"); - let agent = build_agent()?; - - let response = agent - .get(&url) - .set("Accept", GITHUB_API_ACCEPT) - .set("User-Agent", user_agent()) - .set("X-GitHub-Api-Version", GITHUB_API_VERSION) - .call() - .map_err(|e| format!("Unable to check GitHub releases: {e}"))?; - - let release: GitHubRelease = response - .into_json() - .map_err(|e| format!("Unable to parse GitHub release data: {e}"))?; - - let latest_version = release.tag_name.trim_start_matches('v').to_string(); - if !is_version_newer(&latest_version, env!("CARGO_PKG_VERSION")) { - return Ok(None); - } - - let asset = release - .assets - .iter() - .find(|asset| asset.name.eq_ignore_ascii_case(RELEASE_ASSET_NAME)) - .or_else(|| { - release - .assets - .iter() - .find(|asset| asset.name.to_ascii_lowercase().ends_with(".exe")) - }) - .ok_or_else(|| { - "No Windows executable asset was found in the latest release.".to_string() - })?; - - Ok(Some(ReleaseDescriptor { - latest_version, - asset_url: asset.browser_download_url.clone(), - })) -} - -fn build_agent() -> Result { - let tls = native_tls::TlsConnector::new() - .map_err(|e| format!("Unable to initialize TLS support for update checks: {e}"))?; - Ok(ureq::AgentBuilder::new() - .timeout(Duration::from_secs(30)) - .tls_connector(std::sync::Arc::new(tls)) - .build()) -} - -fn download_release_asset(url: &str, partial_path: &Path, final_path: &Path) -> Result<(), String> { - let agent = build_agent()?; - let response = agent - .get(url) - .set("User-Agent", user_agent()) - .call() - .map_err(|e| format!("Unable to download the latest release: {e}"))?; - - let mut reader = response.into_reader(); - let mut file = File::create(partial_path) - .map_err(|e| format!("Unable to create temporary download file: {e}"))?; - - io::copy(&mut reader, &mut file) - .map_err(|e| format!("Unable to write the downloaded update: {e}"))?; - file.flush() - .map_err(|e| format!("Unable to finalize the downloaded update: {e}"))?; - - std::fs::rename(partial_path, final_path) - .map_err(|e| format!("Unable to finalize the downloaded update file: {e}"))?; - - Ok(()) -} - -fn replace_target_binary(target: &Path, source: &Path) -> Result<(), String> { - let backup_path = backup_path_for(target); - let mut last_error = None; - - for _ in 0..60 { - let _ = std::fs::remove_file(&backup_path); - - let renamed_existing = match std::fs::rename(target, &backup_path) { - Ok(()) => true, - Err(error) if error.kind() == io::ErrorKind::NotFound => false, - Err(error) => { - last_error = Some(error); - std::thread::sleep(Duration::from_millis(500)); - continue; - } - }; - - match std::fs::copy(source, target) { - Ok(_) => { - let _ = std::fs::remove_file(&backup_path); - return Ok(()); - } - Err(error) => { - last_error = Some(error); - let _ = std::fs::remove_file(target); - if renamed_existing { - let _ = std::fs::rename(&backup_path, target); - } - } - } - - std::thread::sleep(Duration::from_millis(500)); - } - - Err(format!( - "Unable to replace {}. {}", - target.display(), - last_error - .map(|error| error.to_string()) - .unwrap_or_else(|| { - "The file may still be locked or the install directory may not be writable." - .to_string() - }) - )) -} - -fn relaunch_target(target: &Path) -> Result<(), String> { - let mut command = Command::new(target); - if let Some(parent) = target.parent() { - command.current_dir(parent); - } - - command - .creation_flags(CREATE_NO_WINDOW) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .map_err(|e| { - format!( - "The update was installed, but the app could not be restarted automatically: {e}" - ) - })?; - - Ok(()) -} - -fn wait_for_process_exit(pid: u32, timeout: Duration) -> Result<(), String> { - if pid == 0 { - return Ok(()); - } - - unsafe { - let handle = OpenProcess(PROCESS_SYNCHRONIZE, false, pid) - .map_err(|e| format!("Unable to monitor the running app process: {e}"))?; - - let result = WaitForSingleObject(handle, timeout.as_millis().min(u32::MAX as u128) as u32); - let _ = windows::Win32::Foundation::CloseHandle(handle); - - if result == WAIT_OBJECT_0 { - Ok(()) - } else if result == WAIT_TIMEOUT { - Err("Timed out waiting for the running app to exit.".to_string()) - } else { - Err("Unable to confirm that the running app has exited.".to_string()) - } - } -} - -fn updates_dir() -> Result { - dirs::data_local_dir() - .map(|dir| dir.join("ClaudeCodeUsageBubble").join("updates")) - .or_else(|| { - Some( - std::env::temp_dir() - .join("ClaudeCodeUsageBubble") - .join("updates"), - ) - }) - .ok_or_else(|| "Unable to resolve a writable local updates directory.".to_string()) -} - -fn winget_upgrade_command(pid: u32, target: &str, working_dir: &str) -> String { - let target = powershell_single_quoted(target); - let working_dir = powershell_single_quoted(working_dir); - let package_id = WINGET_PACKAGE_ID; - - format!( - concat!( - "$ErrorActionPreference = 'Stop'; ", - "$pidToWait = {pid}; ", - "$target = '{target}'; ", - "$workingDir = '{working_dir}'; ", - "try {{ Wait-Process -Id $pidToWait -Timeout 30 -ErrorAction Stop }} catch {{ }}; ", - "winget upgrade --id {package_id} --exact; ", - "$exitCode = $LASTEXITCODE; ", - "if ($exitCode -eq 0) {{ ", - "Start-Sleep -Seconds 2; ", - "Start-Process -FilePath $target -WorkingDirectory $workingDir; ", - "exit 0 ", - "}}; ", - "Write-Host ''; ", - "Write-Host 'WinGet update failed with exit code' $exitCode; ", - "Read-Host 'Press Enter to close'; ", - "exit $exitCode" - ), - pid = pid, - target = target, - working_dir = working_dir, - package_id = package_id, - ) -} - -fn powershell_single_quoted(value: &str) -> String { - value.replace('\'', "''") -} - -fn backup_path_for(target: &Path) -> PathBuf { - let file_name = target - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("app.exe"); - target.with_file_name(format!("{file_name}.old")) -} - -fn ensure_target_location_writable(target: &Path) -> Result<(), String> { - let parent = target.parent().ok_or_else(|| { - "Unable to determine the install directory for the current executable.".to_string() - })?; - - let probe_path = parent.join(".__ccum_update_probe"); - match File::create(&probe_path) { - Ok(_) => { - let _ = std::fs::remove_file(&probe_path); - Ok(()) - } - Err(error) => Err(format!( - "The current install location is not writable. Move the app to a user-writable folder or install it somewhere outside Program Files. {error}" - )), - } -} - -fn github_repo() -> Result<(&'static str, &'static str), String> { - let repository = env!("CARGO_PKG_REPOSITORY").trim_end_matches('/'); - let parts: Vec<&str> = repository.split('/').collect(); - if parts.len() < 2 { - return Err("Package repository URL is not configured for GitHub releases.".to_string()); - } - - let owner = parts[parts.len() - 2]; - let repo = parts[parts.len() - 1]; - if owner.is_empty() || repo.is_empty() { - return Err("Package repository URL is not configured for GitHub releases.".to_string()); - } - - Ok((owner, repo)) -} - -fn user_agent() -> &'static str { - concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) -} - -#[allow(dead_code)] -fn is_winget_install_path(path: &Path) -> bool { - let normalized_path = normalize_path(path); - winget_install_roots() - .into_iter() - .map(|root| normalize_path(&root)) - .any(|root| normalized_path.starts_with(&root)) -} - -#[allow(dead_code)] -fn winget_install_roots() -> Vec { - let mut roots = Vec::new(); - - if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { - roots.push( - PathBuf::from(local_app_data) - .join("Microsoft") - .join("WinGet") - .join("Packages"), - ); - } - - if let Ok(program_files) = std::env::var("ProgramFiles") { - roots.push(PathBuf::from(program_files).join("WinGet").join("Packages")); - } else { - roots.push(PathBuf::from(r"C:\Program Files\WinGet\Packages")); - } - - if let Ok(program_files_x86) = std::env::var("ProgramFiles(x86)") { - roots.push( - PathBuf::from(program_files_x86) - .join("WinGet") - .join("Packages"), - ); - } else { - roots.push(PathBuf::from(r"C:\Program Files (x86)\WinGet\Packages")); - } - - roots -} - -#[allow(dead_code)] -fn normalize_path(path: &Path) -> String { - let normalized = path - .to_string_lossy() - .replace('/', "\\") - .trim_end_matches('\\') - .to_ascii_lowercase(); - - normalized - .strip_prefix("\\\\?\\unc\\") - .map(|rest| format!("\\\\{rest}")) - .or_else(|| normalized.strip_prefix("\\\\?\\").map(str::to_owned)) - .unwrap_or(normalized) -} - -fn is_version_newer(candidate: &str, current: &str) -> bool { - parse_version(candidate) > parse_version(current) -} - -fn parse_version(version: &str) -> (u32, u32, u32) { - let core = version.split('-').next().unwrap_or(version); - let mut parts = core.split('.').map(|part| part.parse::().unwrap_or(0)); - - ( - parts.next().unwrap_or(0), - parts.next().unwrap_or(0), - parts.next().unwrap_or(0), - ) -} - -fn show_error_message(title: &str, message: &str) { - unsafe { - let title_wide = wide_str(title); - let message_wide = wide_str(message); - let _ = MessageBoxW( - HWND::default(), - PCWSTR::from_raw(message_wide.as_ptr()), - PCWSTR::from_raw(title_wide.as_ptr()), - MB_OK | MB_ICONERROR, - ); - } -} - -fn wide_str(value: &str) -> Vec { - value.encode_utf16().chain(std::iter::once(0)).collect() -} diff --git a/src/usage/anthropic.rs b/src/usage/anthropic.rs new file mode 100644 index 0000000..513077f --- /dev/null +++ b/src/usage/anthropic.rs @@ -0,0 +1,232 @@ +// Claude (Anthropic) usage provider. +// +// Two HTTP paths: the dedicated `/api/oauth/usage` endpoint (preferred, +// returns structured JSON with ISO 8601 reset times) and a fallback POST +// to `/v1/messages` that exposes rate-limit data via response headers. + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; + +use crate::creds::{CredentialSource, Locator}; +use crate::net::Client; +use crate::usage::{headers, Error, ProviderId, UsageProvider, UsageWindows, Window}; + +const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; +const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; +const BETA_HEADER: &str = "oauth-2025-04-20"; +const API_VERSION: &str = "2023-06-01"; + +const PROBE_MODELS: &[&str] = &[ + "claude-3-haiku-20240307", + "claude-haiku-4-5-20251001", +]; + +pub struct ClaudeProvider { + locator: Locator, +} + +impl ClaudeProvider { + pub fn new(locator: Locator) -> Self { + Self { locator } + } + + pub fn locator(&self) -> &Locator { + &self.locator + } +} + +impl UsageProvider for ClaudeProvider { + fn id(&self) -> ProviderId { + ProviderId::Claude + } + + fn poll(&mut self, http: &Client) -> Result { + let source = self.locator.first_available().ok_or(Error::NoCredentials)?; + let token = source.read()?; + if token_is_expired(token.expires_at_unix_ms) { + return Err(Error::AuthRequired); + } + fetch_with_fallback(http, &token.access_token) + } +} + +fn fetch_with_fallback(http: &Client, token: &str) -> Result { + // First try the dedicated endpoint. + match try_usage_endpoint(http, token)? { + Some(windows) if has_reset_times(&windows) => return Ok(windows), + Some(partial) => { + // Got percentages but no reset times — fill them in from messages. + if let Ok(fallback) = try_messages_endpoint(http, token) { + return Ok(merge_resets(partial, fallback)); + } + return Ok(partial); + } + None => {} + } + try_messages_endpoint(http, token) +} + +fn try_usage_endpoint(http: &Client, token: &str) -> Result, Error> { + let resp = match http + .get(USAGE_URL) + .header("Authorization", &format!("Bearer {token}")) + .header("anthropic-beta", BETA_HEADER) + .send() + { + Ok(r) => r, + Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => { + return Err(Error::AuthRequired); + } + Err(_) => return Ok(None), + }; + if resp.status() == 401 || resp.status() == 403 { + return Err(Error::AuthRequired); + } + if !(200..300).contains(&resp.status()) { + return Ok(None); + } + let body: OauthUsage = match resp.json() { + Ok(b) => b, + Err(_) => return Ok(None), + }; + let primary = body.five_hour.map(bucket_to_window).unwrap_or_default(); + let secondary = body.seven_day.map(bucket_to_window).unwrap_or_default(); + Ok(Some(UsageWindows { primary, secondary })) +} + +fn try_messages_endpoint(http: &Client, token: &str) -> Result { + for model in PROBE_MODELS { + let body = serde_json::json!({ + "model": model, + "max_tokens": 1, + "messages": [{"role": "user", "content": "."}], + }); + let resp = match http + .post(MESSAGES_URL) + .header("Authorization", &format!("Bearer {token}")) + .header("anthropic-version", API_VERSION) + .header("anthropic-beta", BETA_HEADER) + .json_body(&body) + .and_then(|rb| rb.send()) + { + Ok(r) => r, + Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => { + return Err(Error::AuthRequired); + } + Err(_) => continue, + }; + if resp.status() == 401 || resp.status() == 403 { + return Err(Error::AuthRequired); + } + // Even an error response from Messages can carry rate-limit headers. + if resp.header("anthropic-ratelimit-unified-5h-utilization").is_some() + || resp.header("anthropic-ratelimit-unified-7d-utilization").is_some() + { + return Ok(headers::parse_anthropic(&resp)); + } + } + Err(Error::BadResponse( + "no rate-limit headers in messages response".into(), + )) +} + +fn bucket_to_window(bucket: Bucket) -> Window { + Window { + utilization: bucket.utilization, + resets_at: bucket.resets_at.as_deref().and_then(parse_iso8601), + } +} + +fn has_reset_times(w: &UsageWindows) -> bool { + w.primary.resets_at.is_some() && w.secondary.resets_at.is_some() +} + +fn merge_resets(mut primary: UsageWindows, fallback: UsageWindows) -> UsageWindows { + if primary.primary.resets_at.is_none() { + primary.primary.resets_at = fallback.primary.resets_at; + } + if primary.secondary.resets_at.is_none() { + primary.secondary.resets_at = fallback.secondary.resets_at; + } + primary +} + +fn token_is_expired(expires_at_unix_ms: Option) -> bool { + let Some(exp_ms) = expires_at_unix_ms else { + return false; + }; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + now_ms >= exp_ms +} + +// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|+00:00]") --- + +fn parse_iso8601(s: &str) -> Option { + let trimmed = s.split('Z').next().unwrap_or(s); + let trimmed = trimmed.split('+').next().unwrap_or(trimmed); + let trimmed = trimmed.split('-').take(3).collect::>().join("-"); + // We want the original `s` for parsing time-part. Re-split on 'T'. + let (date, time) = s + .split_once('T') + .map(|(d, t)| (d, t)) + .or_else(|| Some(("", "")))?; + let _ = trimmed; // shadow; using the raw `date` + `time` below. + + let date_parts: Vec<&str> = date.split('-').collect(); + if date_parts.len() != 3 { + return None; + } + let y: u64 = date_parts[0].parse().ok()?; + let mo: u64 = date_parts[1].parse().ok()?; + let d: u64 = date_parts[2].parse().ok()?; + + let time_no_offset = time + .split(|c| c == 'Z' || c == '+' || (c == '-' && time.find(c) != Some(0))) + .next() + .unwrap_or(time); + let time_no_frac = time_no_offset.split('.').next().unwrap_or(time_no_offset); + let time_parts: Vec<&str> = time_no_frac.split(':').collect(); + if time_parts.len() != 3 { + return None; + } + let h: u64 = time_parts[0].parse().ok()?; + let mi: u64 = time_parts[1].parse().ok()?; + let se: u64 = time_parts[2].parse().ok()?; + + let mut days: u64 = 0; + for year in 1970..y { + days += if is_leap(year) { 366 } else { 365 }; + } + const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + for month in 1..mo { + days += DAYS_IN_MONTH[month as usize]; + if month == 2 && is_leap(y) { + days += 1; + } + } + days += d - 1; + let secs = days * 86_400 + h * 3_600 + mi * 60 + se; + Some(UNIX_EPOCH + Duration::from_secs(secs)) +} + +fn is_leap(year: u64) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + +// --- JSON shape --- + +#[derive(Deserialize)] +struct OauthUsage { + five_hour: Option, + seven_day: Option, +} + +#[derive(Deserialize)] +struct Bucket { + utilization: f64, + resets_at: Option, +} diff --git a/src/usage/chatgpt.rs b/src/usage/chatgpt.rs new file mode 100644 index 0000000..ceddfaa --- /dev/null +++ b/src/usage/chatgpt.rs @@ -0,0 +1,125 @@ +// Codex (ChatGPT) usage provider. +// +// Single endpoint: `/backend-api/wham/usage`. Response shape includes +// `rate_limit.{primary_window,secondary_window}.{used_percent,reset_at}`. + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; + +use crate::creds::{CredentialSource, Locator}; +use crate::net::Client; +use crate::usage::{Error, ProviderId, UsageProvider, UsageWindows, Window}; + +const USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage"; + +pub struct ChatGptProvider { + locator: Locator, +} + +impl ChatGptProvider { + pub fn new(locator: Locator) -> Self { + Self { locator } + } + + pub fn locator(&self) -> &Locator { + &self.locator + } +} + +impl UsageProvider for ChatGptProvider { + fn id(&self) -> ProviderId { + ProviderId::ChatGpt + } + + fn poll(&mut self, http: &Client) -> Result { + let source = self.locator.first_available().ok_or(Error::NoCredentials)?; + let token = source.read()?; + let mut req = http + .get(USAGE_URL) + .header("Authorization", &format!("Bearer {}", token.access_token)) + .header("User-Agent", "codex-cli"); + if let Some(account_id) = token.account_id.as_deref().filter(|s| !s.is_empty()) { + req = req.header("ChatGPT-Account-Id", account_id); + } + let resp = match req.send() { + Ok(r) => r, + Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => { + return Err(Error::AuthRequired); + } + Err(e) => return Err(Error::Network(e)), + }; + if resp.status() == 401 || resp.status() == 403 { + return Err(Error::AuthRequired); + } + if !(200..300).contains(&resp.status()) { + return Err(Error::BadResponse(format!( + "Codex usage endpoint returned {}", + resp.status() + ))); + } + let body: Envelope = resp + .json() + .map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?; + envelope_to_windows(body) + .ok_or_else(|| Error::BadResponse("missing rate_limit section".into())) + } +} + +fn envelope_to_windows(envelope: Envelope) -> Option { + let rl = envelope.rate_limit.flatten()?; + Some(UsageWindows { + primary: rl + .primary_window + .flatten() + .map(window_from) + .unwrap_or_default(), + secondary: rl + .secondary_window + .flatten() + .map(window_from) + .unwrap_or_default(), + }) +} + +fn window_from(w: ApiWindow) -> Window { + Window { + utilization: w.used_percent, + resets_at: unix_to_systemtime(Some(w.reset_at)), + } +} + +fn unix_to_systemtime(secs: Option) -> Option { + let s = secs?; + if s < 0 { + return None; + } + Some(UNIX_EPOCH + Duration::from_secs(s as u64)) +} + +#[derive(Deserialize)] +struct Envelope { + rate_limit: Option>>, +} + +#[derive(Deserialize)] +struct RateLimit { + primary_window: Option>>, + secondary_window: Option>>, +} + +#[derive(Deserialize)] +struct ApiWindow { + used_percent: f64, + reset_at: i64, +} + +// Helpers used to make `Option>>` flatten cleanly. +trait FlattenBoxed { + fn flatten(self) -> Option; +} +impl FlattenBoxed for Option>> { + fn flatten(self) -> Option { + self.and_then(|inner| inner.map(|b| *b)) + } +} diff --git a/src/usage/headers.rs b/src/usage/headers.rs new file mode 100644 index 0000000..c2d2031 --- /dev/null +++ b/src/usage/headers.rs @@ -0,0 +1,51 @@ +// Parse Anthropic rate-limit headers into `UsageWindows`. +// +// The Messages API returns the user's remaining quota in response headers +// when the dedicated usage endpoint isn't available. Header names: +// anthropic-ratelimit-unified-5h-utilization (0.0–1.0) +// anthropic-ratelimit-unified-5h-reset (Unix seconds) +// anthropic-ratelimit-unified-7d-utilization +// anthropic-ratelimit-unified-7d-reset + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::net::Response; +use crate::usage::{UsageWindows, Window}; + +pub fn parse_anthropic(response: &Response) -> UsageWindows { + UsageWindows { + primary: Window { + utilization: header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0, + resets_at: unix_to_systemtime(header_i64( + response, + "anthropic-ratelimit-unified-5h-reset", + )), + }, + secondary: Window { + utilization: header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0, + resets_at: unix_to_systemtime(header_i64( + response, + "anthropic-ratelimit-unified-7d-reset", + )), + }, + } +} + +fn header_f64(response: &Response, name: &str) -> f64 { + response + .header(name) + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0) +} + +fn header_i64(response: &Response, name: &str) -> Option { + response.header(name).and_then(|s| s.parse().ok()) +} + +fn unix_to_systemtime(secs: Option) -> Option { + let s = secs?; + if s < 0 { + return None; + } + Some(UNIX_EPOCH + Duration::from_secs(s as u64)) +} diff --git a/src/usage/mod.rs b/src/usage/mod.rs new file mode 100644 index 0000000..c65e47d --- /dev/null +++ b/src/usage/mod.rs @@ -0,0 +1,36 @@ +// Usage subsystem: trait + per-provider implementations + registry. +// +// Phase 2 stubs out only `types`. Phase 4 fills in `anthropic`, `chatgpt`, +// `refresh`, `registry`, and `headers`. The trait below is the contract +// every provider must satisfy. + +pub mod anthropic; +pub mod chatgpt; +pub mod headers; +pub mod refresh; +pub mod registry; +pub mod types; + +pub use registry::Registry; +pub use types::{ProviderId, ProviderSnapshot, UsageWindows, Window}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("authentication required")] + AuthRequired, + #[error("no credentials configured")] + NoCredentials, + #[error("token expired after refresh attempt")] + TokenExpired, + #[error("network: {0}")] + Network(#[from] crate::net::Error), + #[error("unexpected response shape: {0}")] + BadResponse(String), +} + +/// Every provider exposes a stable identity and a sync `poll` that performs +/// HTTP calls against the supplied client. +pub trait UsageProvider: Send { + fn id(&self) -> ProviderId; + fn poll(&mut self, http: &crate::net::Client) -> Result; +} diff --git a/src/usage/refresh.rs b/src/usage/refresh.rs new file mode 100644 index 0000000..d6a0d28 --- /dev/null +++ b/src/usage/refresh.rs @@ -0,0 +1,123 @@ +// Token refresh orchestrator. +// +// Each provider's credential source advertises a `RefreshHint` describing +// which CLI to spawn. We invoke that CLI and watch the credential file's +// signature; if it changes within the timeout we declare success. + +use std::os::windows::process::CommandExt; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use crate::creds::{CredentialSource, RefreshHint}; + +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + Refreshed, + StillExpired, + CliMissing, + Timeout, +} + +pub struct Orchestrator { + timeout: Duration, + poll_interval: Duration, +} + +impl Orchestrator { + pub fn new(timeout: Duration) -> Self { + Self { + timeout, + poll_interval: Duration::from_millis(500), + } + } + + pub fn refresh(&self, source: &dyn CredentialSource) -> Outcome { + let initial_sig = source.signature(); + let hint = source.refresh_hint(); + if !spawn_cli(&hint) { + return Outcome::CliMissing; + } + let start = Instant::now(); + while start.elapsed() < self.timeout { + std::thread::sleep(self.poll_interval); + if source.signature() != initial_sig { + return Outcome::Refreshed; + } + } + if source.signature() != initial_sig { + Outcome::Refreshed + } else { + Outcome::Timeout + } + } +} + +fn spawn_cli(hint: &RefreshHint) -> bool { + match hint { + RefreshHint::LocalClaudeCli => spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."]), + RefreshHint::WslClaudeCli { distro } => spawn_wsl(distro), + RefreshHint::LocalCodexCli => { + spawn_local(&["codex.cmd", "codex.ps1", "codex.exe", "codex"], &["exec", "."]) + } + } +} + +fn spawn_local(candidates: &[&str], args: &[&str]) -> bool { + for name in candidates { + let lower = name.to_ascii_lowercase(); + let mut cmd = if lower.ends_with(".ps1") { + let mut c = Command::new("powershell.exe"); + c.arg("-NoProfile").arg("-ExecutionPolicy").arg("Bypass").arg("-File").arg(name); + for a in args { + c.arg(a); + } + c + } else if lower.ends_with(".cmd") || lower.ends_with(".bat") { + let mut c = Command::new("cmd.exe"); + c.arg("/c").arg(name); + for a in args { + c.arg(a); + } + c + } else { + let mut c = Command::new(name); + for a in args { + c.arg(a); + } + c + }; + cmd.env_remove("CLAUDECODE") + .env_remove("CLAUDE_CODE_ENTRYPOINT") + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if cmd.spawn().is_ok() { + return true; + } + } + false +} + +fn spawn_wsl(distro: &str) -> bool { + let script = "if command -v claude >/dev/null 2>&1; then claude -p .; \ + elif [ -x \"$HOME/.local/bin/claude\" ]; then \"$HOME/.local/bin/claude\" -p .; \ + else exit 127; fi"; + Command::new("wsl.exe") + .arg("-d") + .arg(distro) + .arg("--") + .arg("bash") + .arg("-lic") + .arg(script) + .env_remove("CLAUDECODE") + .env_remove("CLAUDE_CODE_ENTRYPOINT") + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .is_ok() +} diff --git a/src/usage/registry.rs b/src/usage/registry.rs new file mode 100644 index 0000000..e9b145a --- /dev/null +++ b/src/usage/registry.rs @@ -0,0 +1,61 @@ +// Provider registry: holds every enabled provider and dispatches polls. +// +// The app owns one `Registry` and calls `poll_enabled(http, settings)` on +// every cycle. The result is a flat list of `(id, Result)` +// pairs the app can apply to its state. + +use crate::creds::Locator; +use crate::net::Client; +use crate::settings::Settings; +use crate::usage::{anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider, UsageWindows}; + +pub struct Registry { + claude: ClaudeProvider, + chatgpt: ChatGptProvider, +} + +impl Registry { + pub fn with_defaults() -> Self { + Self { + claude: ClaudeProvider::new(Locator::for_claude()), + chatgpt: ChatGptProvider::new(Locator::for_chatgpt()), + } + } + + pub fn poll_enabled( + &mut self, + http: &Client, + settings: &Settings, + ) -> Vec<(ProviderId, Result)> { + let mut out = Vec::new(); + if settings.show_claude_code { + out.push((ProviderId::Claude, self.claude.poll(http))); + } + if settings.show_codex { + out.push((ProviderId::ChatGpt, self.chatgpt.poll(http))); + } + out + } + + /// Attempt to refresh the active source for one provider. + pub fn try_refresh(&self, id: ProviderId, orchestrator: &refresh::Orchestrator) -> refresh::Outcome { + let locator = match id { + ProviderId::Claude => self.claude.locator(), + ProviderId::ChatGpt => self.chatgpt.locator(), + }; + match locator.first_available() { + Some(src) => orchestrator.refresh(src), + None => refresh::Outcome::CliMissing, + } + } + + /// Snapshot of credential-file fingerprints across both providers — + /// used to detect external re-authentication between poll cycles. + pub fn credential_signatures(&self) -> Vec { + let mut sigs = self.claude.locator().signatures(); + sigs.extend(self.chatgpt.locator().signatures()); + sigs.sort(); + sigs.dedup(); + sigs + } +} diff --git a/src/usage/types.rs b/src/usage/types.rs new file mode 100644 index 0000000..2aca532 --- /dev/null +++ b/src/usage/types.rs @@ -0,0 +1,44 @@ +// Usage data shapes shared across providers. +// +// Every provider reports its quota as two named "windows" (short + long). +// For Claude: 5-hour and 7-day. For ChatGPT: primary and secondary. We +// normalise to `primary` + `secondary` so the UI layer doesn't care which +// provider produced the snapshot. + +use std::time::SystemTime; + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum ProviderId { + Claude, + ChatGpt, +} + +impl ProviderId { + pub fn slug(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::ChatGpt => "chatgpt", + } + } +} + +/// One usage window: how much you've consumed (0–100), and when it resets. +#[derive(Clone, Copy, Debug, Default)] +pub struct Window { + pub utilization: f64, + pub resets_at: Option, +} + +/// The pair of windows a provider reports per poll. +#[derive(Clone, Copy, Debug, Default)] +pub struct UsageWindows { + pub primary: Window, + pub secondary: Window, +} + +/// One provider's most recent poll result, keyed by `id`. +#[derive(Clone, Debug)] +pub struct ProviderSnapshot { + pub id: ProviderId, + pub windows: UsageWindows, +}