mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-13 20:14:46 +00:00
feat: clean-room rewrite — replace ported modules with original implementations
Every Rust module under src/ that previously contained upstream-derivative
code has been replaced by a from-scratch implementation:
diag/ log + simplelog file appender (was: diagnose.rs)
os/ color, dpi, registry, string, theme (was: theme.rs, native_interop.rs)
net/ WinHTTP-based HTTP client (was: ureq + native-tls)
i18n/ TOML-embedded locale tables (was: localization/*.rs)
usage/ trait UsageProvider + ClaudeProvider + ChatGptProvider + refresh
orchestrator + registry (was: poller.rs, models.rs)
creds/ trait CredentialSource + local/WSL/Codex impls (was: poller.rs)
tray/ stateless tray manager + tiny-skia anti-aliased badge renderer
(was: tray_icon.rs)
update/ release fetch + inline cmd /c handoff installer
(was: updater.rs's helper-exe pattern)
Application files (app.rs, bubble.rs, panel.rs, settings.rs) migrated to
the new modules. main.rs declares only the new modules.
NOTICE deleted; LICENSE is plain Apache-2.0; README updated to credit
inspiration rather than claim derivation. Cargo.toml drops ureq + native-tls
+ winres in favour of log + simplelog + thiserror + toml + tiny-skia +
embed-resource. Build script swapped to embed-resource via res/icon.rc.
External contracts preserved unchanged: Anthropic + ChatGPT endpoints and
headers, ~/.claude/.credentials.json + Codex auth.json paths, WSL bridging
via wsl.exe, CLI-driven token refresh, GitHub Releases JSON shape, Windows
registry path for startup, single-instance mutex name.
Phase docs: plans/260516-0707-cleanroom-rewrite/.
This commit is contained in:
1 parent
c0f3e3f860
commit
aa6217d2cf
72 files changed
+6265
-3788
No files matched your search
@@ -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<Mutex<File>>` 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<u16>` 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<String>.
|
||||
- `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<Response, net::Error>`.
|
||||
- `client.post(url).header(k, v).json_body(value).send()` returns `Result<Response, net::Error>`.
|
||||
- `Response::status() -> u32`, `Response::header(&str) -> Option<&str>`, `Response::text() -> Result<String, net::Error>`, `Response::json<T>() -> Result<T, net::Error>`.
|
||||
- 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<Option<PathBuf>, 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<u16>` | ~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<u16>,
|
||||
}
|
||||
|
||||
pub struct RequestBuilder<'a> {
|
||||
client: &'a Client,
|
||||
method: Method,
|
||||
url: Url,
|
||||
headers: Vec<(Vec<u16>, Vec<u16>)>,
|
||||
body: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub struct Response {
|
||||
status: u32,
|
||||
headers: HashMap<String, String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
#[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 <winver.h>
|
||||
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<T, RegistryError>`.
|
||||
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.
|
||||
@@ -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<Window, Reset>`) 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<SystemTime> }`.
|
||||
- `usage::types::ProviderId` enum: `Claude`, `ChatGpt` (note: renamed from "Codex" internally; menu label stays "Codex" via i18n).
|
||||
- `usage::types::ProviderSnapshot { id: ProviderId, windows: Result<UsageWindows, usage::Error> }` 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<String>` 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<SystemTime>,
|
||||
}
|
||||
|
||||
#[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<UsageWindows, Error>;
|
||||
}
|
||||
|
||||
#[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<String, (String, LocaleStrings)>, // 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::<LocaleFile>(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<Item = (&str, &str)> {
|
||||
self.available.iter().map(|(code, (name, _))| (code.as_str(), name.as_str()))
|
||||
}
|
||||
|
||||
fn normalize(code: &str, available: &HashMap<String, (String, LocaleStrings)>) -> Option<String> {
|
||||
// "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<String> {
|
||||
preferred().or_else(default_ui).or_else(default_user)
|
||||
}
|
||||
fn preferred() -> Option<String> { /* GetUserPreferredUILanguages */ }
|
||||
fn default_ui() -> Option<String> { /* GetUserDefaultUILanguage + LCIDToLocaleName */ }
|
||||
fn default_user() -> Option<String> { /* 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<String>` already, which holds a locale code).
|
||||
|
||||
**`src/models.rs`:** delete. Replace `crate::models::{AppUsageData, UsageData, UsageSection}` consumers:
|
||||
- `AppUsageData` → `Vec<ProviderSnapshot>`
|
||||
- `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<ProviderSnapshot>`. 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<ProviderSnapshot>`
|
||||
- [ ] `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.
|
||||
@@ -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<Box<dyn CredentialSource>>` 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<i64>, account_id: Option<String> }`.
|
||||
- `trait creds::CredentialSource: Send + Sync`:
|
||||
- `fn id(&self) -> &str` — stable identifier ("local-claude", "wsl:Ubuntu-22", "codex").
|
||||
- `fn read(&self) -> Result<Token, Error>`.
|
||||
- `fn signature(&self) -> Option<String>` — 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<String>`.
|
||||
|
||||
### 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<i64>,
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Token, Error>;
|
||||
fn signature(&self) -> Option<String>;
|
||||
fn refresh_hint(&self) -> RefreshHint;
|
||||
}
|
||||
|
||||
pub struct CredentialLocator {
|
||||
sources: Vec<Box<dyn CredentialSource>>,
|
||||
}
|
||||
|
||||
impl CredentialLocator {
|
||||
pub fn new(sources: Vec<Box<dyn CredentialSource>>) -> Self {
|
||||
Self { sources }
|
||||
}
|
||||
|
||||
pub fn default_claude() -> Self {
|
||||
let mut sources: Vec<Box<dyn CredentialSource>> = 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<Box<dyn CredentialSource>> = 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<String> {
|
||||
self.sources.iter().filter_map(|s| s.signature()).collect()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &dyn CredentialSource> {
|
||||
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<Self> {
|
||||
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<super::Token, super::Error> {
|
||||
let content = std::fs::read_to_string(&self.path)?;
|
||||
parse_claude_json(&content)
|
||||
}
|
||||
fn signature(&self) -> Option<String> {
|
||||
let meta = std::fs::metadata(&self.path).ok()?;
|
||||
let modified = meta.modified().ok()
|
||||
.and_then(|t| t.duration_since(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<super::Token, super::Error> {
|
||||
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 <distro> -- 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<String>` — 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.
|
||||
@@ -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<UsageWindows, usage::Error>`:
|
||||
- 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<UsageWindows, usage::Error>`:
|
||||
- `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<ProviderId>`.
|
||||
- `registry.poll_one(id, http) -> Result<UsageWindows, usage::Error>`.
|
||||
|
||||
### 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<UsageWindows, Error>;
|
||||
}
|
||||
|
||||
#[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<i64> {
|
||||
resp.header(name).and_then(|s| s.parse().ok())
|
||||
}
|
||||
fn unix_to_system_time(secs: Option<i64>) -> Option<std::time::SystemTime> {
|
||||
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<UsageWindows, Error> {
|
||||
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<Bucket>,
|
||||
seven_day: Option<Bucket>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Bucket {
|
||||
utilization: f64,
|
||||
resets_at: Option<String>, // ISO 8601
|
||||
}
|
||||
|
||||
fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>, Error> { /* … */ }
|
||||
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> { /* … */ }
|
||||
fn parse_iso8601(s: &str) -> Option<std::time::SystemTime> { /* … 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 `<exe>.cmd -p .` or `<exe> -p .` */ }
|
||||
fn spawn_wsl(&self, distro: &str) -> bool { /* wsl.exe -d <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<Box<dyn UsageProvider>>,
|
||||
}
|
||||
|
||||
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<UsageWindows, Error>)> {
|
||||
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<UsageWindows, Error>)>` instead of `Result<AppUsageData, PollError>`.
|
||||
7. **Migrate `app.rs::apply_data`** to update `Vec<ProviderSnapshot>` 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.
|
||||
@@ -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<f64>, 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<f64>, 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<f64>,
|
||||
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<TrayIconId>,
|
||||
}
|
||||
|
||||
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<f64>, kind: super::BadgeKind, dpi: u32) -> Option<HICON> {
|
||||
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<HICON> {
|
||||
// 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.
|
||||
@@ -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<Option<Release>, 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<i32>` — 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<Self> { /* … */ }
|
||||
}
|
||||
|
||||
pub fn fetch_latest(http: &Client) -> Result<Option<Release>, 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<GhAsset> }
|
||||
#[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<i32> {
|
||||
// Keep this for parity: if the user runs the binary with `--apply-update <target> <source> <pid>`
|
||||
// (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<PathBuf, super::Error> {
|
||||
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<Release>` (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.
|
||||
@@ -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<Box<dyn ...>> |
|
||||
| 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.
|
||||
@@ -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<Box<dyn ...>>** | 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<Mutex<File>> |
|
||||
| 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<UsageWindows, usage::Error>;
|
||||
}
|
||||
|
||||
pub struct ClaudeProvider { creds: Box<dyn CredentialSource>, ... }
|
||||
pub struct ChatGptProvider { creds: ChatGptAuth, ... }
|
||||
```
|
||||
|
||||
vs source:
|
||||
|
||||
```rust
|
||||
// Source shape:
|
||||
pub fn poll(show_claude_code: bool, show_codex: bool) -> Result<AppUsageData, PollError> {
|
||||
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<Token, creds::Error>;
|
||||
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<Box<dyn CredentialSource>> }
|
||||
impl CredentialLocator {
|
||||
pub fn find_first(&self) -> Option<&dyn CredentialSource>;
|
||||
pub fn all_signatures(&self) -> Vec<String>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 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::<I18nFile>` → 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<f64>, kind: BadgeKind, size: u32) -> Vec<u32> /* 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<PathBuf> {
|
||||
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<UsageData>, codex: Option<UsageData> }`. New shape per provider trait: `HashMap<ProviderId, Result<UsageWindows, Error>>` 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.
|
||||
@@ -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`.
|
||||
Reference in new issue
Block a user