Compare commits

...
12 Commits
63 changed files with 1286 additions and 5594 deletions
Generated
+312 -69
View File
@@ -8,6 +8,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -26,6 +32,12 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -59,18 +71,18 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-bubble"
version = "0.3.9"
version = "0.5.0"
dependencies = [
"dirs",
"embed-resource",
"log",
"self-replace",
"serde",
"serde_json",
"sha2",
"simplelog",
"thiserror",
"tiny-skia",
"toml 0.8.23",
"windows",
]
@@ -151,7 +163,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 1.1.2+spec-1.1.0",
"toml",
"vswhom",
"winreg",
]
@@ -162,6 +174,22 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -187,6 +215,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -208,12 +242,46 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -221,7 +289,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
@@ -230,6 +300,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.186"
@@ -245,6 +321,12 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.29"
@@ -282,6 +364,12 @@ dependencies = [
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -294,7 +382,7 @@ version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags",
"bitflags 1.3.2",
"crc32fast",
"fdeflate",
"flate2",
@@ -307,6 +395,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -325,13 +423,19 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom",
"getrandom 0.2.17",
"libredox",
"thiserror",
]
@@ -345,6 +449,30 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "self-replace"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7"
dependencies = [
"fastrand",
"tempfile",
"windows-sys 0.52.0",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -394,15 +522,6 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
@@ -463,6 +582,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -551,18 +683,6 @@ dependencies = [
"strict-num",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit",
]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
@@ -571,20 +691,11 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 1.0.3",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
"winnow",
]
[[package]]
@@ -596,35 +707,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
@@ -643,6 +734,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -675,6 +772,58 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -754,6 +903,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -836,15 +994,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.3"
@@ -861,6 +1010,100 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.21"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-bubble"
version = "0.3.9"
version = "0.5.0"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows"
@@ -14,9 +14,9 @@ dirs = "6"
log = "0.4"
simplelog = "0.12"
thiserror = "2"
toml = "0.8"
tiny-skia = "0.11"
sha2 = "0.10"
self-replace = "1.5"
[dependencies.windows]
version = "0.58"
+16 -19
View File
@@ -18,7 +18,7 @@ which solves the same "how close am I to the Claude Code limit?" problem
with a horizontal taskbar widget. This project takes the UX in a different
direction — a floating, draggable circular bubble that the user can place
anywhere on screen — and is a clean-room implementation: the HTTP client,
provider polling, credential discovery, localisation, tray rendering, and
provider polling, credential discovery, tray rendering, and
self-updater are all written from scratch against the same public APIs
(Anthropic, ChatGPT, GitHub Releases).
@@ -33,21 +33,17 @@ self-updater are all written from scratch against the same public APIs
(140360 logical pixels)
- Left-click the bubble for an expanded panel with both **5h** and **7d**
bars plus reset countdowns
- Right-click for refresh, displayed models, update frequency, language,
startup, updates, exit
- Optional system tray icons (one per enabled model)
- Right-click for refresh, displayed providers, update frequency, startup,
updates, exit
- Optional system tray icons (one per enabled provider)
- Auto-hide when a fullscreen app is in the foreground (games, video,
presentations) — reappears when you leave fullscreen
## Who this is for
Windows 10/11 users who already have **Claude Code (CLI or App) installed
and signed in**. Codex support is optional — install and sign in to the
Codex CLI, then enable Codex from the right-click **Models** menu.
If you use Claude Code through WSL, that is supported too. The monitor
can read your Claude Code credentials from Windows or from your WSL
environment.
and signed in on Windows**. Codex support is optional — install and sign in
to the Codex CLI, then enable Codex from the right-click **Providers** menu.
## Requirements
@@ -85,23 +81,26 @@ corner of your primary monitor on first launch. Drag it where you want it,
release to snap to the nearest edge if you let go close to one.
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
- **Right-click** for refresh, models, refresh frequency, language, "Start
with Windows", controls, auto-update check (Disabled / Hourly / Daily /
Weekly), manual "Check for updates", exit
- **Right-click** for refresh, providers, refresh frequency, "Start with
Windows", controls, auto-update check (Disabled / Hourly / Daily / Weekly),
manual "Check for updates", exit
- **Drag** anywhere — it floats on top of all other windows
- **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click
menu, to resize it
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
right-click opens the same menu
### Models
### Providers
Use the right-click **Models** menu to choose what is shown:
Use the right-click **Providers** menu to choose what is shown:
- **Claude Code** is enabled by default
- **Codex** can be enabled alongside Claude Code or shown by itself
- **OpenCode Go** is listed for future support; usage bars remain disabled
until OpenCode exposes a stable quota source or console integration is
explicitly enabled
When both models are shown, each gets its own bubble that you can position
When multiple providers are shown, each gets its own bubble that you can position
independently.
## Diagnostics
@@ -127,7 +126,6 @@ Settings are saved to:
What the app reads:
- Your local Claude Code OAuth credentials from `~/.claude/.credentials.json`
- If needed, the same credentials file inside an installed WSL distro
- If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json`
or `~/.codex/auth.json`
@@ -142,9 +140,8 @@ What the app stores locally:
- Bubble position(s) per model
- Bubble size
- Polling frequency
- Language preference
- Last update check time
- Displayed model preferences
- Displayed provider preferences
What it does **not** do: send credentials to any third-party server, run a
backend service, collect analytics, upload your project files, or write to
@@ -1,121 +0,0 @@
# Phase 1: Bootstrap Repo
## Context Links
- Source `Cargo.toml`: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/Cargo.toml`
- Source `build.rs`: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/build.rs`
- Source icons: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/icons/`
## Overview
- **Priority:** Must-first (every other phase depends on this)
- **Status:** pending
- **Description:** Create the new repo's foundation: `Cargo.toml` with the right windows-rs features, `build.rs` for icon embedding, LICENSE (MIT, dual attribution), README with attribution to source, fresh icon assets, project layout.
## Key Insights
- Source uses `windows = 0.58` with 12 feature flags. New repo can **drop** `Win32_UI_Accessibility` (no more `SetWinEventHook` on TrayNotifyWnd) and **drop** `Win32_UI_Input_KeyboardAndMouse` if drag is handled via `WM_NCHITTEST` + `HTCAPTION` instead of manual `SetCapture`.
- Source uses `ureq` + `native-tls` + `serde` + `dirs` — all keep verbatim.
- Source release profile: `opt-level="z"`, `lto=true`, `strip=true`, `codegen-units=1`, `panic="abort"` — keep all (produces ~2MB binary).
- Package name change: `claude-code-usage-bubble` (binary name `claude-code-usage-bubble.exe`).
## Requirements
### Functional
- `cargo build --release` produces a single-file `.exe`
- `winres` embeds icon resource so the .exe has a proper Windows icon
- README clearly attributes original repo + MIT license
- Project compiles with no implementation yet (empty `main.rs` returning `()`)
### Non-functional
- Binary size target: < 3 MB stripped
- No Linux/Mac build (Windows-only `#![windows_subsystem]` in main)
## Architecture
```
claude-code-usage-bubble/
├── Cargo.toml
├── build.rs
├── LICENSE # MIT, with attribution clause
├── README.md # Attribution + usage
├── src/
│ ├── main.rs # entry, mod declarations only
│ └── icons/
│ ├── icon.ico
│ ├── 16x16.png, 32x32.png, 48x48.png, 256x256.png
│ └── *.svg sources
└── plans/ # this directory
```
## Related Code Files
**To create:**
- `Cargo.toml`
- `build.rs`
- `LICENSE`
- `README.md`
- `src/main.rs` (stub)
- `src/icons/*` (placeholder copies from source; can be replaced with bubble-specific art later)
- `.gitignore`
**To modify:** none (greenfield repo)
## Implementation Steps
1. **Create `Cargo.toml`** with package metadata, the same `windows-rs` features minus accessibility + keyboard/mouse:
```toml
[package]
name = "claude-code-usage-bubble"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Floating bubble showing Claude Code / Codex usage on Windows"
repository = "<set this>"
```
Features to include: `Win32_Foundation`, `Win32_Globalization`, `Win32_Graphics_Gdi`, `Win32_System_LibraryLoader`, `Win32_UI_Shell`, `Win32_UI_WindowsAndMessaging`, `Win32_System_Registry`, `Win32_System_Threading`, `Win32_Security`, `Win32_UI_HiDpi`.
2. **Create `build.rs`** mirroring source `build.rs`; embed `src/icons/icon.ico` via `winres`.
3. **Copy `src/icons/*`** from source verbatim (placeholder; designer can replace).
4. **Write `LICENSE`** — MIT text with a header line crediting CodeZeno/Claude-Code-Usage-Monitor.
5. **Write `README.md`** — short, includes:
- One-paragraph what-it-is
- Attribution: "This project ports usage-polling, updater, and tray-icon code from [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) (MIT)."
- Install/run section (placeholder)
6. **Stub `src/main.rs`**:
```rust
#![windows_subsystem = "windows"]
fn main() {}
```
7. **Run `cargo build`** — must compile clean.
8. **Run `cargo build --release`** — confirm binary produced, check size.
## Todo List
- [ ] Cargo.toml with correct features
- [ ] build.rs with winres
- [ ] Icons copied from source
- [ ] LICENSE with attribution
- [ ] README.md with attribution
- [ ] src/main.rs stub
- [ ] .gitignore (target/, Cargo.lock for libs only — keep Cargo.lock for binaries)
- [ ] `cargo build` succeeds
- [ ] `cargo build --release` succeeds, binary < 3 MB
## Success Criteria
- `cargo build --release` on Windows produces a runnable .exe with embedded icon
- README links source repo
- LICENSE includes attribution clause
## Risk Assessment
- **Low.** Pure configuration; no logic.
- Cross-compile note: if developer is on Linux/Mac, will need MinGW or Windows machine for `winres` step. Document this in README.
## Security Considerations
- N/A in this phase.
## Next Steps
→ Phase 2: port portable modules into `src/`
@@ -1,124 +0,0 @@
# Phase 2: Port Portable Modules
## Context Links
- Source: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/`
- Modules to port verbatim (or near-verbatim): `models.rs`, `diagnose.rs`, `theme.rs`, `poller.rs`, `updater.rs`, `tray_icon.rs`, `localization/*`
- Module to trim: `native_interop.rs`
## Overview
- **Priority:** High (must precede phases 3-4)
- **Status:** pending
- **Description:** Bring over the portable subsystems from the source repo with minimal changes. These represent ~2,700 lines of working, tested code — the goal is to keep them intact and only edit what's required for the new project name and the simplified Win32 surface.
## Key Insights
- `poller.rs` (1099 lines) is fully self-contained — depends only on `serde`, `ureq`, `native-tls`, `dirs`, plus `diagnose` and `models` from the same crate.
- `updater.rs` (510 lines) embeds `env!("CARGO_PKG_REPOSITORY")` to resolve GitHub owner/repo automatically — no hardcoded references.
- `tray_icon.rs` uses `WM_APP_TRAY` (`WM_APP + 3`) and `IDM_TOGGLE_WIDGET = 50`. Keep the constants; `app.rs` (phase 4) will own dispatch.
- `native_interop.rs` is the trimming target: drop `find_taskbar`, `find_child_window`, `get_taskbar_rect`, `embed_in_taskbar`, `set_tray_event_hook`, `get_window_thread_id`, `unhook_win_event`. Keep `wide_str`, `colorref`, `Color`, timer-ID constants, custom-message constants.
## Requirements
### Functional
- `mod models; mod diagnose; mod theme; mod poller; mod updater; mod tray_icon; mod localization; mod native_interop;` all compile against current `main.rs`
- `cargo check` passes with zero warnings beyond unused-symbol warnings (which will resolve in phases 3-4)
- All `pub` symbols documented above remain reachable
### Non-functional
- No behavioral changes vs source — diffs limited to module boundaries
## Architecture
```
src/
├── main.rs (stub from phase 1)
├── models.rs COPIED
├── diagnose.rs COPIED
├── theme.rs COPIED
├── poller.rs COPIED
├── updater.rs COPIED + 1-line stub for current_install_channel
├── tray_icon.rs COPIED
├── native_interop.rs TRIMMED (~80 lines vs source 179)
└── localization/
├── mod.rs COPIED
├── english.rs COPIED
├── dutch.rs COPIED
├── french.rs COPIED
├── german.rs COPIED
├── japanese.rs COPIED
├── korean.rs COPIED
├── spanish.rs COPIED
└── traditional_chinese.rs COPIED
```
## Related Code Files
**To create (copy from source):**
- All files listed in Architecture section above.
**To modify after copying:**
- `src/updater.rs` — replace `current_install_channel()` body with `InstallChannel::Portable` while keeping the rest of the function intact (preserves code for future winget enablement).
- `src/native_interop.rs` — delete taskbar/WinEvent functions and their imports.
- `src/main.rs` — declare modules; do NOT yet call `window::run` (window module doesn't exist yet).
**To delete:** none.
## Implementation Steps
1. **Copy modules verbatim:**
```
cp -r ../Claude-Code-Usage-Monitor/src/{models,diagnose,theme,poller,updater,tray_icon}.rs src/
cp -r ../Claude-Code-Usage-Monitor/src/localization src/
```
2. **Copy & trim `native_interop.rs`:**
- Keep: `wide_str`, `colorref`, `Color`, `TIMER_*` constants, `WM_APP_*` constants, `get_window_rect_safe`, `move_window`
- Delete: `WS_POPUP_STYLE`, `WS_CHILD_STYLE`, `WS_CLIPSIBLINGS_STYLE` (bubble uses standard windows-rs constants), `EVENT_OBJECT_LOCATIONCHANGE`, `WINEVENT_OUTOFCONTEXT`, `find_taskbar`, `find_child_window`, `get_taskbar_rect`, `embed_in_taskbar`, `set_tray_event_hook`, `get_window_thread_id`, `unhook_win_event`
- Drop imports for `Accessibility`, `Shell::SHAppBarMessage`/`APPBARDATA`, `Foundation::RECT` (if no longer used after trim)
3. **Stub winget detection in `updater.rs`:**
```rust
pub fn current_install_channel() -> InstallChannel {
// Bubble repo is not yet published to winget; once it is, restore the
// is_winget_install_path probe by reading the source repo's logic.
InstallChannel::Portable
}
```
Keep `is_winget_install_path` + `winget_install_roots` + `normalize_path` as `#[allow(dead_code)]` to preserve the code path.
4. **Update `src/main.rs`** to declare the modules:
```rust
#![windows_subsystem = "windows"]
mod diagnose; mod localization; mod models; mod native_interop;
mod poller; mod theme; mod tray_icon; mod updater;
fn main() { /* phase-04 will wire this */ }
```
5. **Run `cargo check`** — expect warnings about unused public items; should be zero errors.
## Todo List
- [ ] Copy 7 source modules + localization directory
- [ ] Trim `native_interop.rs` to ~80 lines (drop taskbar/WinEvent helpers)
- [ ] Stub `updater::current_install_channel`
- [ ] Wire `mod` declarations in `main.rs`
- [ ] `cargo check` clean (only dead-code warnings)
- [ ] `cargo build --release` still produces a binary
## Success Criteria
- All ported modules compile in the new crate without modification beyond what is listed above
- No warnings about missing imports
- Source code license headers (if any) are preserved
## Risk Assessment
- **Low.** Copy-with-rename operation; the trimming of `native_interop.rs` is the only judgment call.
- Edge case: `tray_icon.rs` imports `crate::native_interop::WM_APP_TRAY` — verify constant survives the trim.
## Security Considerations
- `poller.rs` reads OAuth credentials from `~/.claude/.credentials.json` and (optionally) WSL distros. No new attack surface vs source.
- `updater.rs` downloads .exe from GitHub. Same trust model as source. Until the new repo has releases published, this code is dormant.
## Next Steps
→ Phase 3: build the bubble window (replaces 2847 lines of `window.rs`)
@@ -1,166 +0,0 @@
# Phase 3: Build Floating Bubble Window
## Context Links
- Source `window.rs` painting + drag logic: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/window.rs` (lines around `UpdateLayeredWindow`, `WM_LBUTTONDOWN`, `SetCapture`)
- Reference UX: 360 Security floating ball, IObit Advanced SystemCare RAM-boost ball — both are circular, top-most, draggable-anywhere with edge snap.
## Overview
- **Priority:** Critical — this is the heart of the new UX
- **Status:** pending
- **Description:** Build a circular floating bubble window that floats on top of everything, can be dragged anywhere, snaps to monitor edges, and shows usage percentage in the center over a colored progress ring. Replaces the 2847-line `window.rs` taskbar embedding code.
## Key Insights
- Use `WS_POPUP | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW``TOOLWINDOW` keeps it out of Alt+Tab; `NOACTIVATE` prevents focus theft.
- Drag-anywhere = handle `WM_NCHITTEST`, return `HTCAPTION` for the entire bubble area. The OS handles drag automatically, including with proper cursor and Win+drag behavior. No need for `SetCapture`.
- Circular alpha mask: render to a DIB section with per-pixel alpha. Pixels outside the circle = `0x00000000` (fully transparent). Then `UpdateLayeredWindow` with `ULW_ALPHA`. Click-through outside the circle happens automatically because alpha=0 doesn't hit-test (default behavior of layered windows with `WS_EX_LAYERED` + per-pixel alpha; verify via test).
- Snap to edge: in `WM_EXITSIZEMOVE`, query current position via `GetWindowRect`, find nearest monitor via `MonitorFromPoint`, get its work area via `GetMonitorInfo`. If center is within 12px of any work-area edge, snap that edge.
- HiDPI: use `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` early in `main`. Query `GetDpiForWindow` on `WM_DPICHANGED` and rescale bubble size + font.
- GDI ring drawing: parametric — sweep angle proportional to percentage. Use `Polygon` filled with brush, or `AngleArc` with thick pen. For clean anti-aliased look on layered window, draw into DIB section manually with alpha-weighted line algorithm. Simpler path: use GDI+ via `gdiplus` crate, or accept GDI-aliased look for v1.
- Bubble size default: **56×56 px** at 100% DPI (matches reference apps). Allow user to tweak in `Cargo.toml` constant for v1.
## Requirements
### Functional
- Window appears as a circular bubble, can be dragged anywhere on any monitor
- Bubble shows percentage text (e.g. "73%") in center
- Colored progress ring around the percentage; color matches the source app's color stops (orange → red gradient from 50% to 100%)
- Top-most: stays visible over other windows
- No taskbar entry, no Alt-Tab entry
- Left-click → posts `WM_APP_PANEL_TOGGLE` to self (panel implementation in phase 4)
- Right-click → context menu (phase 4 owns menu items; bubble owns the right-click detection)
- Drag releases → snap to nearest monitor edge if within 12 px of it
- Repaints when percentage / theme / DPI changes
### Non-functional
- 60 FPS not required; redraws on data update only (every 60s poll cycle plus countdown ticks)
- Per-monitor DPI aware
- Visible on dark and light Windows themes (use `theme::is_dark_mode` for ring background tint)
## Architecture
```
src/
├── bubble.rs NEW — Window class, message loop owner, GDI painting,
│ drag + snap, DPI, hit-testing
└── (other modules unchanged from phase 2)
```
Bubble owns:
- HWND lifecycle (`RegisterClassExW` + `CreateWindowExW`)
- DIB section + `UpdateLayeredWindow` call
- Percentage state (`Option<f64>` for each enabled model)
- Drag state (managed by OS via `HTCAPTION`)
- Snap math
- DPI scale factor cache
Bubble delegates:
- Polling → `poller::poll` (phase 4 wires the background thread)
- Panel toggle → `app::on_panel_toggle` (phase 4)
- Right-click menu → `app::on_show_context_menu` (phase 4)
- Settings → `settings` module (phase 4)
## Related Code Files
**To create:**
- `src/bubble.rs` (target: 400700 lines)
**To modify:**
- `src/main.rs` — eventually call `bubble::run()` (wired in phase 4)
- `src/native_interop.rs` — may add helpers if hit-testing geometry math gets gnarly
**To delete:** none.
## Implementation Steps
1. **Window class registration:**
- Class name: `ClaudeCodeUsageBubble`
- Style: `CS_DBLCLKS` (allow `WM_LBUTTONDBLCLK` if we want double-click later)
- WndProc: `bubble_wnd_proc`
2. **Window creation:**
- `WS_POPUP`, ext `WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW`
- Initial position: load from settings (phase 4); fall back to "near bottom-right corner of primary monitor"
- Size: 56×56 logical px scaled by current DPI
3. **DPI awareness:**
- In `bubble::run`, call `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)`.
- On `WM_CREATE`, cache DPI via `GetDpiForWindow`.
- On `WM_DPICHANGED`, update scale and resize.
4. **Painting (the hard part):**
- On every state change (percentage, DPI, theme), call `redraw()`.
- `redraw()`:
- Create DIB section sized to bubble pixel dimensions (`CreateDIBSection` with `BI_RGB` and 32bpp).
- Clear to fully transparent (`0x00000000`).
- For each pixel inside the circle radius, write background fill (theme-adjusted: dark theme → semi-opaque dark with high alpha; light theme → semi-opaque white).
- Stroke progress ring: for the sweep angle proportional to current percentage, draw a thick arc using either GDI `AngleArc` with rounded `Pen`, or manual pixel writes (4 px ring thickness at 100% DPI).
- Draw percentage text in center via `DrawTextW` with `DT_CENTER | DT_VCENTER | DT_SINGLELINE`. Font: bold 14 pt at 100% DPI, scaled by DPI factor.
- Call `UpdateLayeredWindow` with `ULW_ALPHA` and the DIB.
5. **Drag-anywhere via `WM_NCHITTEST`:**
```rust
WM_NCHITTEST => {
// Convert lparam (screen coords) to client coords
let p = screen_to_client(hwnd, lparam);
if inside_circle(p, radius) { LRESULT(HTCAPTION as isize) }
else { LRESULT(HTTRANSPARENT as isize) }
}
```
OS handles drag + cursor. `HTTRANSPARENT` outside the circle ensures clicks pass through.
6. **Snap on drag release:**
- `WM_EXITSIZEMOVE` → snap logic.
- `MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)` → `GetMonitorInfo` → work area rect.
- Compare bubble's center to each edge of work area. If distance < 12 logical px (scaled by DPI), adjust window position to snap.
- Persist new position via `app::on_bubble_moved(model, x, y)` (phase 4).
7. **Click handling:**
- `WM_LBUTTONUP` → if no drag occurred (compare with `WM_LBUTTONDOWN` position), `PostMessageW(WM_APP_PANEL_TOGGLE)`.
- `WM_RBUTTONUP` → call into `app::show_context_menu(hwnd, screen_pos)` (phase 4 implements).
8. **Public API:**
```rust
pub fn run(initial: BubbleConfig) -> ! { /* never returns; spins message loop */ }
pub struct BubbleConfig {
pub model: TrayIconKind, // Claude or Codex
pub initial_position: Option<(i32, i32)>,
pub initial_percentage: Option<f64>,
}
pub fn update_percentage(hwnd: HWND, percentage: Option<f64>); // called from poll thread via PostMessage
```
For dual-bubble mode, phase 4 spawns one `bubble::run` per enabled model on separate threads (each with its own message loop) — simpler than juggling two HWNDs in one thread.
## Todo List
- [ ] Window class + creation with correct styles
- [ ] Per-monitor DPI awareness on entry
- [ ] DIB section + layered window painting pipeline
- [ ] Circle fill with theme-aware background
- [ ] Progress ring painted at correct sweep angle, correct color stop
- [ ] Percentage text drawn centered
- [ ] `WM_NCHITTEST` returns `HTCAPTION` inside circle, `HTTRANSPARENT` outside
- [ ] Drag works smoothly across monitors
- [ ] Snap on release within 12px of work-area edge
- [ ] Left-click (no drag) posts panel-toggle message
- [ ] Right-click posts context-menu request
- [ ] Public API `run`, `update_percentage`
- [ ] Manual test: bubble visible, draggable, snaps, percentage updates
## Success Criteria
- Bubble appears on Windows 10/11 with a Visual Studio-clean cargo build
- Drag works smoothly with no flicker
- Edge snap engages reliably from 12 px
- Bubble survives display reconnection (laptop → external monitor → unplug)
- Percentage text remains crisp on 100%, 125%, 150%, 175% DPI
## Risk Assessment
- **High** — this is novel code with no exact analog in source.
- Risk: ClearType sub-pixel text rendering on a per-pixel-alpha layered window looks bad. Source repo's `window.rs` solved this with a black background-pixel hack (`alpha = 0x01` so it's nearly transparent but still gets ClearType). Apply the same trick for the circle's interior fill region.
- Risk: GDI `AngleArc` doesn't anti-alias. Mitigation: either accept aliased v1, or render to a 2x supersampled DIB and downsample.
- Risk: Snap math wrong on rotated taskbar or unusual DPI configurations. Mitigation: clamp to monitor work area only, ignore taskbar position.
## Security Considerations
- N/A in this phase; bubble does not handle user input beyond mouse position and clicks.
## Next Steps
→ Phase 4: expanded panel, settings persistence, polling thread, orchestration
@@ -1,190 +0,0 @@
# Phase 4: Expanded Panel + Settings Persistence + Orchestration
## Context Links
- Source `window.rs` — borrow message-loop dispatch, polling-thread orchestration, settings persistence pattern
- Source `poller.rs``poll`, `credential_watch_snapshot`, `format_line`, `time_until_display_change`
- Source `tray_icon.rs``add/update/remove/sync`, `handle_message`
## Overview
- **Priority:** High
- **Status:** pending
- **Description:** Build (a) the expanded panel that appears on bubble click and shows both 5h and 7d bars with countdowns, (b) settings persistence to `%APPDATA%\ClaudeCodeUsageBubble\settings.json`, (c) the orchestrating `app.rs` module that owns polling, message routing, context menus, and dual-bubble lifecycle.
## Key Insights
- Panel is a separate window: `WS_POPUP | WS_EX_LAYERED | WS_EX_TOPMOST`, opaque background, shown adjacent to bubble. Source's draw code for the horizontal bars can be ported almost directly (it already draws progress bars + countdown text via GDI).
- Settings file location matches source pattern: `%APPDATA%\ClaudeCodeUsageBubble\settings.json` (renamed dir). Use `dirs::config_dir()`.
- Polling: background `std::thread` spawned in `app::run`. Posts `WM_APP_USAGE_UPDATED` to each bubble window when data refreshes. Source's poll-loop logic is copy-friendly.
- Context menu: built via `CreatePopupMenu` + `AppendMenuW` + `TrackPopupMenu`. Source has the full menu structure — port it but remove "Reset position" → rename to "Reset bubble position" (per model).
- Dual-bubble: each enabled model gets its own HWND + tray icon + bubble window. Settings stores `bubble_positions: { claude: {x, y}, codex: {x, y} }`.
## Requirements
### Functional
- Settings persist across restarts: window positions, polling frequency, enabled models, language, "Start with Windows" state, last update check
- Expanded panel: shows session bar + weekly bar + countdowns + reset times for the model whose bubble was clicked
- Panel auto-closes on focus loss or after a brief timeout (optional)
- Right-click menu mirrors source's menu structure: Refresh, Models, Update frequency, Language, Start with Windows, Reset position, Updates, Exit
- Single-instance enforced via named mutex `Global\ClaudeCodeUsageBubble`
- Polling runs in background, posts updates via `PostMessageW(WM_APP_USAGE_UPDATED, ...)`
- Countdown timer adapts to display granularity (`time_until_display_change`)
### Non-functional
- Settings file is atomically written (write to `.tmp`, rename)
- Polling thread cannot block UI thread
- Mutex released on clean shutdown
## Architecture
```
src/
├── app.rs NEW — orchestrator: spawns bubbles, polls, routes messages,
│ owns tray icons, owns context menu builder
├── panel.rs NEW — expanded panel window (one per model on demand)
├── settings.rs NEW — load/save settings.json, schema
└── main.rs modified — calls app::run
```
Message flow:
```
poll thread ────PostMessage(WM_APP_USAGE_UPDATED)──▶ bubble HWND
└─▶ updates percentage, redraws
bubble click ──PostMessage(WM_APP_PANEL_TOGGLE)─▶ app handler (in bubble wndproc)
└─▶ panel::show_for(model)
right-click ──app::show_context_menu(hwnd)──▶ TrackPopupMenu ─▶ WM_COMMAND
└─▶ menu action dispatch
tray icon ────WM_APP_TRAY───────────▶ tray_icon::handle_message ─▶ TrayAction
└─▶ toggle/ shutdown / refresh
```
## Related Code Files
**To create:**
- `src/app.rs` (target: 500800 lines)
- `src/panel.rs` (target: 300500 lines)
- `src/settings.rs` (target: 150250 lines)
**To modify:**
- `src/main.rs`:
```rust
#![windows_subsystem = "windows"]
mod app; mod bubble; mod diagnose; mod localization; mod models;
mod native_interop; mod panel; mod poller; mod settings; mod theme;
mod tray_icon; mod updater;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--diagnose") {
if let Ok(path) = diagnose::init() {
diagnose::log(format!("startup args={args:?} log_path={}", path.display()));
}
}
if let Some(exit_code) = updater::handle_cli_mode(&args) {
std::process::exit(exit_code);
}
app::run();
}
```
**To delete:** none.
## Implementation Steps
1. **`settings.rs`** — define schema:
```rust
#[derive(Serialize, Deserialize, Default)]
pub struct Settings {
pub show_claude_code: bool, // default true
pub show_codex: bool, // default false
pub bubble_positions: BubblePositions,
pub poll_minutes: u32, // default 5
pub language: Option<String>, // None = system
pub start_with_windows: bool,
pub last_update_check_unix: Option<i64>,
}
#[derive(Serialize, Deserialize, Default)]
pub struct BubblePositions {
pub claude: Option<(i32, i32)>,
pub codex: Option<(i32, i32)>,
}
pub fn load() -> Settings { /* dirs::config_dir + read + serde + atomic */ }
pub fn save(s: &Settings) { /* write .tmp + rename */ }
```
2. **`panel.rs`** — port the existing horizontal-bar painting code from source `window.rs`:
- Window class `ClaudeCodeUsageBubblePanel`
- On `WM_LBUTTONDOWN` outside → close
- On `WM_KILLFOCUS` → close (with debounce so it doesn't close instantly when bubble is clicked again to toggle off)
- Paints two rows (5h, 7d) for the model that was clicked; uses `poller::format_line` for the countdown text
- Position: anchor next to bubble; flip side if would go off-screen
3. **`app.rs`** — orchestrator:
- `pub fn run() -> !`:
1. Acquire single-instance mutex; if already running, exit.
2. `SetProcessDpiAwarenessContext`.
3. Load settings.
4. Resolve language (`localization::resolve_language`).
5. Start polling thread.
6. For each enabled model, spawn a bubble window thread.
7. Run main message loop on UI thread (the bubble windows can be on the same thread — easier than multi-thread message pumps).
- Polling thread:
```rust
loop {
match poller::poll(show_claude, show_codex) {
Ok(data) => post_update_to_bubbles(data),
Err(e) => post_error_to_app(e),
}
sleep(poll_interval);
}
```
- Context menu builder: replicate source's menu structure verbatim; localized strings via `Strings`. Map `WM_COMMAND` IDs to handlers (refresh, toggle model, change interval, etc.).
- "Start with Windows": registry write to `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run\ClaudeCodeUsageBubble`.
- Token-expired flow: if `PollError::TokenExpired`, show tray balloon via `tray_icon::notify_balloon` with localized title/body.
4. **Wire `bubble.rs` events back to `app.rs`:**
- `WM_APP_PANEL_TOGGLE` handler in bubble wndproc → call `panel::show_for_model(model)` (registered via callback or via `app::handle_panel_toggle`).
- `WM_RBUTTONUP` → call `app::show_context_menu_at(point, model)`.
- `WM_EXITSIZEMOVE` → call `app::on_bubble_moved(model, x, y)` which updates settings.
5. **Reuse `tray_icon.rs`** as the source-app's secondary indicator:
- In `app::run`, after creating bubbles, call `tray_icon::sync(hwnd, &[TrayIconData{kind: Claude, percent: …, tooltip: …}, …])`.
- Tray icon clicked → `tray_icon::handle_message` → if `ToggleWidget`, toggle bubble visibility (set `WS_VISIBLE` style); if `ShowContextMenu`, dispatch to `app::show_context_menu_at`.
## Todo List
- [ ] `settings.rs` with atomic save
- [ ] `panel.rs` with bar painting + auto-close
- [ ] `app.rs` orchestrator
- [ ] Single-instance mutex acquisition + release
- [ ] Polling thread spawning + `PostMessage` updates
- [ ] Context menu localized + dispatchable
- [ ] Start-with-Windows registry roundtrip
- [ ] Tray icons synced from polling updates
- [ ] Bubble-to-app event callbacks wired
- [ ] Dual-bubble mode tested (both Claude + Codex enabled)
- [ ] `cargo build --release` → working binary
## Success Criteria
- Launching the app twice: second launch exits silently
- Bubble + panel + tray icon all show correct usage after first poll
- Right-click menu functional for all items
- Toggle Claude/Codex via menu: bubbles appear/disappear; settings persist
- Restart app: bubble reappears at last saved position
- Token-expired triggers tray balloon
- Poll frequency change takes effect within one poll cycle
## Risk Assessment
- **Medium.** Most logic is structural — message routing and state management. Source repo has all the patterns.
- Risk: dual-bubble on same UI thread with two HWNDs — should work, but verify message routing keys off `hwnd` parameter.
- Risk: panel auto-close races with bubble re-click. Mitigation: 200 ms debounce on `WM_KILLFOCUS` before destroying panel; if bubble clicked within that window, cancel close.
## Security Considerations
- Settings file written to `%APPDATA%`, user-scoped, no privileged ops.
- Single-instance mutex name `Global\ClaudeCodeUsageBubble` — distinct from source app to allow coexistence.
## Next Steps
→ Phase 5: polish (HiDPI testing, multi-monitor, README, attribution, etc.)
@@ -1,120 +0,0 @@
# Phase 5: Polish & Finishing
## Context Links
- All prior phases
- Source README for attribution / language list
## Overview
- **Priority:** Required before any release
- **Status:** pending
- **Description:** Multi-monitor and HiDPI verification, accessibility checks, README, license attribution, version 0.1.0 tag, optional CI workflow.
## Requirements
### Functional
- Bubble renders crisply at 100% / 125% / 150% / 175% / 200% DPI
- Bubble survives display add/remove (laptop dock/undock)
- Bubble respects monitor work area when snapping (does not overlap taskbar)
- README has install + run + uninstall sections
- LICENSE has CodeZeno attribution paragraph
- `--diagnose` flag works (writes `%TEMP%\claude-code-usage-bubble.log`)
### Non-functional
- Cargo build is fully reproducible
- No clippy warnings on `cargo clippy -- -D warnings` (or document the ones you keep)
## Architecture
No new modules.
## Related Code Files
**To modify:**
- `README.md` — fill out final content
- `LICENSE` — final attribution paragraph
- Maybe `.github/workflows/build.yml` for CI Windows build
**To delete:** none.
## Implementation Steps
1. **HiDPI manual test matrix:**
- Windows 10 at 100% DPI: bubble visible, text readable, ring smooth
- Windows 11 at 150%: same
- 4K monitor at 200%: same
- Mixed-DPI dual monitor: drag bubble between monitors → verify rescale on `WM_DPICHANGED`
2. **Multi-monitor edge tests:**
- Snap bubble to right edge of secondary monitor → settings saved with correct coords
- Disconnect monitor → bubble should reposition to primary monitor's work area on next start
- Test with taskbar on top / left / right (not just default bottom)
3. **README content checklist:**
- One-paragraph what-it-is + screenshot/gif placeholder
- **Attribution section** (required by source MIT license):
> This project is a derivative of [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) (MIT, © 2026 Code Zeno Pty Ltd). The usage-polling, updater, tray-icon, and localization modules are ported from that codebase with minor adaptations; the floating-bubble UI is original to this project.
- Install: cargo build instructions; future winget block
- Use: bubble + panel + tray icon described
- Models: same content as source
- Diagnostics: `--diagnose` flag, log path
- Privacy: same content as source (credentials read locally, GitHub for updates)
- License: MIT
4. **LICENSE file** — include both:
```
MIT License
Copyright (c) 2026 <your name>
Portions of this software are derived from Claude Code Usage Monitor,
Copyright (c) 2026 Code Zeno Pty Ltd, licensed under the MIT License.
<rest of MIT license text>
```
5. **Optional CI** (`.github/workflows/build.yml`):
- Runs `cargo fmt --check`, `cargo clippy`, `cargo build --release` on `windows-latest`
- Uploads artifact on tag
6. **Smoke test before tagging:**
- Run `claude-code-usage-bubble.exe`
- Verify bubble appears with placeholder data (or real data if Claude CLI signed in)
- Drag, snap, expand, menu, exit — all work
- Re-launch → second instance exits silently
- `claude-code-usage-bubble.exe --diagnose` → log file populated
7. **Tag v0.1.0** (only after the above passes):
- `git tag v0.1.0`
- Push to GitHub
- Create release with the .exe artifact attached (so `updater.rs` works for future versions)
## Todo List
- [ ] HiDPI matrix tested
- [ ] Multi-monitor edge tests done
- [ ] README.md final
- [ ] LICENSE attribution finalized
- [ ] Diagnostic log verified
- [ ] Clippy clean
- [ ] CI workflow (optional)
- [ ] Smoke test green
- [ ] v0.1.0 tagged
## Success Criteria
- App can be downloaded fresh, built once, and used end-to-end
- Source repo attribution is unambiguous
- No regressions vs phases 1-4
## Risk Assessment
- **Low.** Polish phase.
- Possible regression: HiDPI bug discovered late — fix in `bubble.rs` painting code.
## Security Considerations
- Verify `updater.rs` `current_install_channel()` still returns `Portable`. Re-enabling winget detection is a future task — not part of v0.1.0.
## Next Steps
→ v0.1.0 release; future tasks (out of scope for this port):
- Winget package submission (when ready)
- Custom bubble art per model (Claude orange, Codex green) replacing inherited icons
- Optional bubble-size setting (S/M/L) in right-click menu
- Auto-hide when fullscreen apps active
@@ -1,83 +0,0 @@
# Plan: claude-code-usage-bubble — port from CodeZeno/Claude-Code-Usage-Monitor
**Mode:** `/ck:xia --port`
**Source repo:** `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor` (Rust ~5.8k LOC, MIT)
**Target repo:** `/config/workspace/CodeZeno/claude-code-usage-bubble` (new)
**Date:** 2026-05-15
## Source Manifest
- Path: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor`
- Branch: `main` @ `b5f038d` (v1.4.1)
- License: MIT (attribution required in new repo README)
- Scope: portable subsystems only — see `phase-02-port-portable-modules.md`
## Decision Matrix (Approved)
| Decision | Choice |
|---|---|
| Platform | Windows-only (Win32 GDI + layered window) |
| WSL credential reading | Keep |
| Snap-to-edge | On, 12px zone, monitor work area |
| Click behavior | Left-click = toggle panel, right-click = menu |
| Dual-model layout | Two independent bubbles, positions persisted per model |
| Winget channel | Code kept, `current_install_channel` stubbed to Portable |
| Single-instance mutex | `Global\ClaudeCodeUsageBubble` |
| Auto-hide when fullscreen | **Yes** (added to phase 3) — detect via `SHQueryUserNotificationState` or `MonitorFromWindow + window-rect == monitor-rect` against foreground HWND |
| Bubble size customization | **Yes**, free range 32128 px persisted in settings.json; resize via Ctrl+MouseWheel on bubble (no S/M/L menu) |
| Per-model bubble art | **No** — both models share the same bubble look; differentiation only via usage-percentage ring color |
## Dependency Matrix (Source → New)
| Source file | LOC | Action | Target file |
|---|---|---|---|
| `src/models.rs` | 19 | COPY | `src/models.rs` |
| `src/diagnose.rs` | 52 | COPY | `src/diagnose.rs` |
| `src/theme.rs` | 52 | COPY | `src/theme.rs` |
| `src/poller.rs` | 1099 | COPY | `src/poller.rs` |
| `src/updater.rs` | 510 | COPY + stub channel | `src/updater.rs` |
| `src/tray_icon.rs` | 441 | COPY | `src/tray_icon.rs` |
| `src/localization/*` | ~620 | COPY | `src/localization/*` |
| `src/native_interop.rs` | 179 | ADAPT (drop taskbar/WinEvent helpers) | `src/native_interop.rs` |
| `src/main.rs` | 40 | ADAPT (call `bubble::run` instead of `window::run`, rename single-instance mutex) | `src/main.rs` |
| `src/window.rs` | 2847 | **REWRITE** as `bubble.rs` + `panel.rs` + `settings.rs` + `app.rs` | NEW |
| `build.rs`, `Cargo.toml`, `src/icons/*` | — | ADAPT | NEW |
## Phases
| # | Phase | Status | File |
|---|---|---|---|
| 1 | Bootstrap repo (Cargo.toml, build.rs, LICENSE, README, icons) | pending | `phase-01-bootstrap-repo.md` |
| 2 | Port portable modules verbatim | pending | `phase-02-port-portable-modules.md` |
| 3 | Build floating bubble window (layered alpha, GDI ring, drag-anywhere, snap) | pending | `phase-03-build-bubble-window.md` |
| 4 | Build expanded panel + settings persistence + orchestration | pending | `phase-04-panel-and-orchestration.md` |
| 5 | Polish: HiDPI, multi-monitor, startup registry, mutex, tray icon wiring, README | pending | `phase-05-polish-and-finishing.md` |
## Risk Score
**Medium.** Highest-risk surface is **phase 3** — circular layered window with HiDPI-aware GDI ring drawing. Source codebase has no precedent for that exact pattern; needs fresh implementation. All other phases are straightforward ports or thin orchestration.
| Risk | Severity | Mitigation |
|---|---|---|
| GDI ring + ClearType text on layered alpha window | High | Reference `window.rs` UpdateLayeredWindow + DIB section pattern (lines around layered painting); keep ring math simple (parametric arc) |
| Drag + snap interaction on multi-monitor | Medium | Use `MonitorFromPoint` per move; clamp to nearest monitor work area |
| Two-bubble position state | Low | Independent `BubbleState` structs in settings.json |
| WSL credential read regressions | Low | Verbatim port; no behavioral changes |
## Estimated Effort
- Phase 1: 12h
- Phase 2: 1h (mostly file copies + import path fixes)
- Phase 3: 610h (the heavy lift)
- Phase 4: 35h
- Phase 5: 24h
**Total:** ~1522h of focused implementation.
## Rollback Strategy
The new repo is greenfield — rollback means `rm -rf /config/workspace/CodeZeno/claude-code-usage-bubble`. No source-repo changes; this plan does not modify the source app.
## Open Questions
- None. All three formerly-deferred items resolved by user on 2026-05-15 (see Decision Matrix rows 810).
@@ -1,285 +0,0 @@
---
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.
@@ -1,403 +0,0 @@
---
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.0100.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.
@@ -1,253 +0,0 @@
---
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.
@@ -1,346 +0,0 @@
---
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.
@@ -1,264 +0,0 @@
---
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.
@@ -1,312 +0,0 @@
---
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(&current)?;
let staging = stage_path()?;
std::fs::create_dir_all(staging.parent().unwrap())?;
download(http, &release.asset_url, &staging)?;
spawn_handoff(&staging, &current)?;
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.
-134
View File
@@ -1,134 +0,0 @@
---
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 + 48h 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 15 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.
@@ -1,176 +0,0 @@
---
phase: 1
title: "Release CI workflow"
status: in_progress
priority: P1
effort: "2h"
dependencies: []
---
# Phase 1: Release CI workflow
## Overview
Ship `.github/workflows/release.yml`. On a pushed tag matching `v*`,
the workflow verifies the tag matches `Cargo.toml`'s `version` field
(fail-fast if not), builds `cargo build --release` on `windows-latest`,
renames/copies the binary to the exact asset name the updater expects
(`claude-code-usage-bubble.exe`), and creates a GitHub Release with
that asset attached and auto-generated notes. Also supports
`workflow_dispatch` for dry-run testing without cutting a real tag.
<!-- Updated: Validation Session 1 — version-tag match enforcement added per plan.md Validation Log decision 1 -->
<!-- Updated: Validation Session 1 — *.exe fallback at src/update/release.rs:67-69 is out of scope per plan.md Validation Log decision 3 -->
## Requirements
### Functional
- Trigger on tag push matching `v*.*.*` (and `workflow_dispatch` for testing).
- **Verify the tag matches `Cargo.toml` `version`** before building. Workflow aborts on mismatch.
- Build on `windows-latest` with stable Rust toolchain, `x86_64-pc-windows-msvc` target.
- Cache cargo registry + target dir to keep wall time under ~5 min.
- Upload `target/release/claude-code-usage-bubble.exe` as a Release asset.
- Use `gh release create` with `--generate-notes` for the body.
- Use `--draft` on `workflow_dispatch` runs so test runs don't become public.
- On real tag runs (`vX.Y.Z`), publish immediately (not draft).
### Non-functional
- Workflow file under ~100 lines.
- No third-party Marketplace actions other than `actions/checkout` and `Swatinem/rust-cache` (or just `actions/cache`). Avoid `softprops/action-gh-release`-style wrappers — `gh` CLI is preinstalled on the runner and is one less supply-chain risk.
- Default `GITHUB_TOKEN` permissions, with explicit `contents: write` only on the release job.
## Architecture
### Flow
```
push tag v0.1.1
└─→ release.yml (job: build, runs-on: windows-latest)
├─ checkout (at the tag)
├─ Swatinem/rust-cache (uses runner-default stable Rust)
├─ resolve tag (from refs/tags or workflow_dispatch input)
├─ verify Cargo.toml version == tag (strip leading 'v') → abort on mismatch
├─ cargo build --release --locked
├─ gh release create v0.1.1 target/release/claude-code-usage-bubble.exe \
│ --title "v0.1.1" --generate-notes [--draft on workflow_dispatch]
└─ done
```
### Asset name verification
The updater's primary matcher is `eq_ignore_ascii_case("claude-code-usage-bubble.exe")`
(`src/update/release.rs:64`). Cargo's `name = "claude-code-usage-bubble"`
already produces that exe name in `target/release/`, so no rename is
needed — just upload the file as-is.
## Related Code Files
- Create: `.github/workflows/release.yml`
- Reference (do not modify in this phase): `src/update/release.rs`, `Cargo.toml`
## Implementation Steps
1. Create `.github/workflows/release.yml` with this shape:
```yaml
name: Release
on:
push:
tags: ['v*.*.*']
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (must already exist, e.g. v0.1.1)'
required: true
permissions:
contents: write
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- uses: Swatinem/rust-cache@v2
- name: Resolve tag
id: tag
shell: pwsh
run: |
$tag = if ($env:GITHUB_REF -like 'refs/tags/*') {
$env:GITHUB_REF -replace '^refs/tags/',''
} else {
'${{ github.event.inputs.tag }}'
}
"tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Verify Cargo.toml version matches tag
shell: pwsh
run: |
$tag = '${{ steps.tag.outputs.tag }}'
$expected = $tag -replace '^v',''
$cargoVersion = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1).Matches.Groups[1].Value
if ($cargoVersion -ne $expected) {
Write-Error "Tag ($tag → $expected) does not match Cargo.toml version ($cargoVersion). Bump Cargo.toml before tagging."
exit 1
}
Write-Host "Cargo version $cargoVersion matches tag $tag"
- name: Build release
run: cargo build --release --locked
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$tag = '${{ steps.tag.outputs.tag }}'
$asset = 'target/release/claude-code-usage-bubble.exe'
$draft = if ('${{ github.event_name }}' -eq 'workflow_dispatch') { '--draft' } else { '' }
gh release create $tag $asset --title $tag --generate-notes $draft
```
2. Sanity-check: `gh workflow list` after pushing the file shows the new "Release" workflow.
3. Verify the workflow YAML lints clean by viewing it in the GitHub UI (or with `actionlint` locally if installed).
## Todo List
- [x] `.github/workflows/release.yml` written
- [x] `permissions: contents: write` set
- [x] Tag-push trigger and `workflow_dispatch` both wired
- [x] Cargo.toml-version-vs-tag check step added and fails on mismatch
- [x] Asset path is `target/release/claude-code-usage-bubble.exe` exactly
- [x] `--generate-notes` enabled
- [ ] Committed and pushed to `main`
## Success Criteria
- [ ] Workflow appears under Actions tab on GitHub.
- [ ] Manually dispatching with a throwaway tag (`v0.0.0-test`) produces a **draft** release with the `.exe` attached.
- [ ] Pushing a tag whose value disagrees with `Cargo.toml` fails the workflow before `cargo build` runs (verify by intentionally mismatching once on a throwaway dispatch).
- [ ] No third-party actions beyond `actions/checkout@v4` and `Swatinem/rust-cache@v2`.
- [ ] Workflow file is under ~100 lines including blank lines.
- [ ] Out of scope (do not touch): `*.exe` fallback at `src/update/release.rs:67-69`. Tracked as future cleanup once multi-arch ships.
## Risk Assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| `Cargo.lock` drift causes `--locked` to fail | Low | Lockfile is committed; bump it locally before tagging if deps changed |
| Build time >10 min and cache cold | Low | `rust-cache` covers cargo registry + `target/`; first run is slow, subsequent fast |
| Tag pushed without prior `Cargo.toml` version bump | Was Medium → now Mitigated | CI now fails fast in the "Verify Cargo.toml version matches tag" step; maintainer cannot accidentally ship a version-mismatched binary |
| `gh release create` fails because tag does not exist for workflow_dispatch | Medium | Workflow_dispatch input takes a tag string and `actions/checkout` is pinned to it — if the tag does not exist, checkout fails fast with a clear error |
| Pre-release tag like `v0.1.0-rc1` triggers workflow but does not match Cargo's stable version | Low | Tag-match check uses string equality after `^v` strip; `0.1.0-rc1 != 0.1.0` fails fast. If pre-releases are wanted later, change Cargo `version` and the check still works |
## Security Considerations
- `permissions: contents: write` is the minimum scope needed to create a release; no `id-token` or package perms requested.
- `GH_TOKEN` is the default `GITHUB_TOKEN`, scoped to this repo only.
- No secrets are echoed; `gh` reads `GH_TOKEN` from env.
- The published `.exe` is unsigned. SmartScreen will show "Unknown publisher" the first time a user runs it. Document this in Phase 3; code signing is out of scope.
@@ -1,120 +0,0 @@
---
phase: 2
title: "End-to-end update test"
status: pending
priority: P1
effort: "2h"
dependencies: [1]
---
# Phase 2: End-to-end update test
## Overview
Cut a real test release pair and prove the in-app updater finds it,
downloads it, swaps the running .exe, and relaunches into the new
version. The updater code already exists; this phase is about
flushing out integration bugs between CI output and the updater's
expectations (asset name casing, redirect behavior, file lock release
timing, version-comparison edge cases).
## Requirements
### Functional
- Cut `v0.1.0` from current `main` → CI uploads asset.
- Bump `Cargo.toml` to `0.1.1`, commit, push, tag `v0.1.1` → CI uploads asset.
- On a Windows test machine, run the v0.1.0 binary downloaded from the v0.1.0 release.
- From the right-click menu, "Check for updates" must transition `Idle → Checking → Available`.
- Clicking "Update available" must transition `Available → Applying` and exit the process.
- ~2 seconds later, v0.1.1 must be running (verify via right-click menu showing "Up to date" after re-check, or via file properties on the .exe).
- The `version_action` apply branch (`src/app.rs:1037-1066`) must succeed: `update::install::begin` returns `Ok(())` and the process posts `WM_QUIT` (`PostQuitMessage(0)` at `src/app.rs:1056`).
### Non-functional
- Test machine has no admin privileges → confirms `ensure_writable` (`src/update/install.rs:81-89`) works for `%LOCALAPPDATA%` install.
- Run from an install path that contains a space (e.g. `C:\Users\test user\bin\`) to validate `spawn_handoff` quoting (`src/update/install.rs:53-69`).
## Architecture
### Test matrix
| Scenario | Where exe lives | Expected |
|---|---|---|
| Vanilla user-local install | `%LOCALAPPDATA%\ClaudeCodeUsageBubble\` | Succeeds |
| Path with spaces | `C:\Users\test user\bin\` | Succeeds (cmd /c quoting) |
| Read-only install dir (e.g. `C:\Program Files\…`) | `C:\Program Files\Bubble\` | `Failed` status surfaces; no crash |
| Offline | n/a | `Failed` status, no crash, retries on next 24h timer |
| Already on latest | n/a | `UpToDate` status, no download |
### Observability
- Run with `claude-code-usage-bubble.exe --diagnose` to capture the
log at `%TEMP%\claude-code-usage-bubble.log`. Look for `update apply
failed:` lines (`src/app.rs:1058`).
- After update, the new process is started by `cmd.exe` (detached);
Task Manager parent column will show no parent — that's expected.
## Related Code Files
- Reference only (no edits expected): `src/update/release.rs`, `src/update/install.rs`, `src/app.rs` (lines 1025-1135), `Cargo.toml`
## Implementation Steps
1. **Cut v0.1.0:**
```bash
git -C D:/tiennm99/claude-code-usage-bubble tag -a v0.1.0 -m "v0.1.0"
git -C D:/tiennm99/claude-code-usage-bubble push origin v0.1.0
```
Wait for the Phase-1 workflow to produce `v0.1.0` release with `claude-code-usage-bubble.exe`. Download the asset locally — this is the "old" binary.
2. **Smoke-test the v0.1.0 download** on a Windows machine: run it, confirm the bubble appears, right-click → "Check for updates" returns "Up to date" (no v0.1.1 yet).
3. **Cut v0.1.1:**
- Bump `Cargo.toml` `version = "0.1.0"` → `"0.1.1"`.
- `cargo build --release` locally to refresh `Cargo.lock`.
- Commit: `chore: bump version to 0.1.1`.
- Tag: `git tag -a v0.1.1 -m "v0.1.1"`.
- Push both: `git push origin main && git push origin v0.1.1`.
4. **Run the v0.1.0 binary** (still installed from step 1) and right-click → "Check for updates". Status should transition to "Update available". Click it. The process exits, ~2 s pass, the new v0.1.1 binary should launch automatically.
5. **Verify v0.1.1 is running:** right-click → "Check for updates" should now return "Up to date". Cross-check `claude-code-usage-bubble.exe --diagnose` log for the version line, or check file properties in Explorer.
6. **Cleanup if it goes wrong:**
- Stuck "Applying" status with no swap → kill the detached `cmd.exe` in Task Manager, manually copy `%LOCALAPPDATA%\ClaudeCodeUsageBubble\updates\update.exe` over the running exe location.
- `cmd /c` quoting broke → fix in `src/update/install.rs:58-60` and retag as `v0.1.2`.
7. **Run negative scenarios** (table above): path-with-spaces, read-only install dir, offline. Each must fail-soft without crashing.
## Todo List
- [ ] v0.1.0 release cut and asset downloaded
- [ ] v0.1.0 binary verified runnable on Windows
- [ ] Cargo.toml bumped to 0.1.1, committed, tagged, pushed
- [ ] v0.1.1 release produced by CI
- [ ] v0.1.0 binary self-updates to v0.1.1 successfully
- [ ] Post-update, "Check for updates" returns "Up to date"
- [ ] Negative scenario: install in path with space succeeds
- [ ] Negative scenario: read-only install dir surfaces "Failed" status, no crash
- [ ] Negative scenario: offline → "Failed", retry timer rearmed
## Success Criteria
- [ ] A v0.1.0 download → click update → v0.1.1 running with no manual file copying.
- [ ] No SmartScreen kill (it will warn on first run; that's expected and documented).
- [ ] `%TEMP%\claude-code-usage-bubble.log` contains no `update apply failed` lines after the successful run.
- [ ] All negative scenarios fail without crashing the bubble.
## Risk Assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| File lock not released in 2 s window | Low-Medium | The 2 s `timeout` in `spawn_handoff` is conservative; if it ever races, bump to 3 s |
| GitHub CDN redirect not followed by WinHTTP | Very Low | WinHTTP follows redirects by default (no `WINHTTP_OPTION_DISABLE_FEATURE` set); will surface in step 4 if broken |
| Antivirus quarantines the freshly-written staging exe | Medium | Document the workaround (allowlist the install dir); future signing fixes this |
| Test pollutes real release feed | Low | If you must test with throwaway tags, use `workflow_dispatch` (creates draft) instead of pushing the tag |
## Security Considerations
- The downloaded asset is fetched over HTTPS from a `*.githubusercontent.com` CDN; WinHTTP validates certs against the system root store.
- No checksum verification yet — accepted risk (HTTPS + cert pinning is the floor). Future enhancement: ship `SHA256SUMS.txt` and verify in `install::download`.
- The `cmd /c` command string is composed only from `current_exe()` and `stage_path()`; neither is user-controlled. No shell injection vector.
@@ -1,133 +0,0 @@
---
phase: 3
title: "Docs and release process"
status: in_progress
priority: P2
effort: "1h"
dependencies: [2]
---
# Phase 3: Docs and release process
## Overview
Now that v0.1.x ships out of CI, update the user-facing docs to point
people at the GitHub Release instead of "build from source", and
write a short maintainer checklist that future-me can follow to cut
a release without re-deriving it from this plan.
## Requirements
### Functional
- `README.md` "Install" section points to the Releases page and the SmartScreen warning.
- A new `docs/release-process.md` (one page) lists the cut-a-release steps: bump `Cargo.toml`, commit, tag, push.
- Keep the "build from source" path as a secondary option for developers.
### Non-functional
- `docs/release-process.md` under 60 lines.
- No `CHANGELOG.md` — the GitHub auto-generated release notes are the changelog.
## Architecture
The README has one "Install" section (`README.md:56-66`). Replace it
with a two-track structure:
```
Install
├── Download binary (recommended, one paragraph + SmartScreen note)
└── Build from source (existing block, kept verbatim)
```
## Related Code Files
- Modify: `README.md` (Install section)
- Create: `docs/release-process.md`
## Implementation Steps
1. **README.md** — replace the "Install" section. Sketch:
```markdown
## Install
### Download the latest release
Grab `claude-code-usage-bubble.exe` from the
[Releases page](https://github.com/tiennm99/claude-code-usage-bubble/releases/latest).
Put it anywhere on disk (e.g. `%LOCALAPPDATA%\ClaudeCodeUsageBubble\`)
and run it. The app self-updates from the same Releases feed.
First-run note: the binary is unsigned, so SmartScreen will show
"Windows protected your PC". Click "More info" → "Run anyway".
Code signing is on the roadmap.
### Build from source
<existing block: git clone + cargo build --release>
```
2. **docs/release-process.md** — new file. Sketch:
```markdown
# Cutting a release
The `release.yml` workflow builds and publishes on every pushed
tag matching `v*.*.*`. The workflow asserts that the pushed tag
matches `Cargo.toml` `version` and **fails fast on mismatch**, so
the order below matters: bump Cargo *before* you tag.
Steps for a new version:
1. Bump `Cargo.toml` `version` (`X.Y.Z`).
2. `cargo build --release` locally to refresh `Cargo.lock`.
3. Commit: `chore: bump version to X.Y.Z`.
4. Tag and push:
```bash
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin main
git push origin vX.Y.Z
```
5. Watch the "Release" workflow run; it creates the GitHub Release
with `claude-code-usage-bubble.exe` attached and auto-generated
notes.
## Testing without a real tag
Use the workflow's `workflow_dispatch` input with a throwaway tag
like `v0.0.0-test`. The release is created as a **draft**, so it
does not show up on the public Releases feed or trigger
self-updates for users.
## Versioning
Semver-ish: bump patch for fixes, minor for features, major for
breaking changes (e.g. settings.json schema change). The in-app
updater compares `Version { major, minor, patch }` lexicographically.
```
3. Verify the Releases page link in the README resolves (it will once Phase 2 has cut at least v0.1.0).
## Todo List
- [x] `README.md` Install section rewritten with two tracks
- [x] SmartScreen note added
- [x] `docs/release-process.md` created
- [ ] Links verified by clicking through (requires Phase 2 v0.1.0 release to exist)
## Success Criteria
- [ ] A new contributor reading only `README.md` knows how to install without building.
- [ ] A maintainer reading only `docs/release-process.md` can cut a release without re-reading this plan.
- [ ] No mention of "Until packaged binaries are published" remains anywhere.
## Risk Assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| README link to `/releases/latest` 404s before first release exists | Certain pre-Phase-2 | Land this phase **after** Phase 2 has cut v0.1.0 |
| Users skip the SmartScreen note and panic | Medium | Bold the "Click More info → Run anyway" line; mention it in the Releases body too if needed |
## Security Considerations
- The SmartScreen warning is the user's signal that the binary is unsigned. Be honest about it; do not obscure it.
- Recommending `%LOCALAPPDATA%` as the install location keeps the user inside their writable tree (no UAC needed for self-update).
@@ -1,103 +0,0 @@
---
title: "GitHub release CI + auto-update wiring"
description: "Publish Windows binaries to GitHub Releases via Actions so the existing in-app updater can self-update."
status: pending
priority: P2
created: 2026-05-16
---
# GitHub release CI + auto-update wiring
## Overview
The self-update subsystem already exists end-to-end in `src/update/`:
`release::fetch_latest` polls `releases/latest` on GitHub, parses the
`tag_name` into a `Version`, picks the asset whose name matches
`claude-code-usage-bubble.exe` (or the first `.exe` as fallback), and
`install::begin` downloads it + spawns an inline `cmd /c` handoff
that swaps the running exe and relaunches. `app.rs` wires this to a
24-hour timer (`UPDATE_CHECK_INTERVAL_SECS`) and the right-click menu
("Check for updates" / "Update available" / "Applying update…").
What is missing is the **producer side**: no `.github/workflows/`
directory exists, the repo has no tags, and the README explicitly
says "Until packaged binaries are published, build from source". The
updater therefore has nothing to pull from. Closing that loop is the
whole job.
This plan ships three things: (1) a tag-triggered GitHub Actions
workflow that builds release on `windows-latest` and attaches the
exe to a GitHub Release, (2) an end-to-end test that proves a running
v0.1.0 actually self-updates to v0.1.1 in the wild, and (3) the docs
and release-cutting checklist so future versions ship by pushing a
tag.
Out of scope: code signing, winget channel (`Channel::Winget` stays
stubbed), SHA256 sidecar verification (HTTPS + cert pinning by WinHTTP
is the security floor; checksum is a nice-to-have for later), and any
new updater code paths beyond what the existing code already supports.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Release CI workflow](./phase-01-release-ci-workflow.md) | Files written, awaiting commit + push |
| 2 | [End-to-end update test](./phase-02-end-to-end-update-test.md) | Pending (user-driven: requires tag pushes + Windows runs) |
| 3 | [Docs and release process](./phase-03-docs-and-release-process.md) | Files written, awaiting commit; link verification gated on Phase 2 |
## Key contracts (must not break)
The updater is already shipped logic — these constants are the
contract the CI workflow has to satisfy:
| Contract | Source | Value |
|---|---|---|
| Asset filename (primary match) | `src/update/release.rs:7` | `claude-code-usage-bubble.exe` |
| Asset filename (fallback) | `src/update/release.rs:67-69` | any `*.exe` |
| Endpoint | `src/update/release.rs:45` | `https://api.github.com/repos/tiennm99/claude-code-usage-bubble/releases/latest` |
| Tag → version parse | `src/update/release.rs:33-41` | strips leading `v`, splits on `-`, takes `major.minor.patch` |
| Current version source | `Cargo.toml` `version` | bumped per release |
A tag like `v0.1.1` → parses as `Version { 0, 1, 1 }`. The workflow
MUST upload an asset named exactly `claude-code-usage-bubble.exe`.
## Dependencies
No cross-plan dependencies. The prior plan
[`260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md`](../260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md)
delivered the consumer side and is complete in code (whether its own
phase row is checked is independent of this plan).
## Validation Log
### Session 1 — 2026-05-16
#### Verification Results
- **Tier:** Standard (3 phases → Fact Checker + Contract Verifier)
- **Claims checked:** 9
- **Verified:** 9 | **Failed:** 0 | **Unverified:** 0
- Claims verified: `ASSET_NAME` constant at `src/update/release.rs:7`, matcher at `src/update/release.rs:64`, fallback at `src/update/release.rs:67-69`, URL endpoint at `src/update/release.rs:45`, version parse at `src/update/release.rs:33-41`, `version_action` apply branch at `src/app.rs:1037-1066`, 24-hour interval at `src/app.rs:48`, Cargo `name = "claude-code-usage-bubble"` and `version = "0.1.0"` at `Cargo.toml:2-3`, README "Until packaged binaries are published" at `README.md:58`.
#### Decisions
1. **Version-tag match enforcement: YES, fail-fast in CI.**
The release workflow must parse `Cargo.toml` and abort if the tag (e.g. `v0.1.1`) does not equal the Cargo version. Prevents silent mismatch where a binary self-reports a different version than the release tag — which would in turn break the updater's `Version::current() vs Version::parse(tag_name)` comparison and either skip a real update or loop on the same one.
→ Propagated to `phase-01-release-ci-workflow.md` as a new step + extra success criterion.
2. **Initial release strategy: tag v0.1.0 first, then bump to v0.1.1 for the E2E test.**
Phase 2 stays as written. Cut v0.1.0 from current `main` (no Cargo bump needed since Cargo.toml is already `0.1.0`), download the asset, bump Cargo to `0.1.1`, tag `v0.1.1`, watch the v0.1.0 binary self-update to v0.1.1. Two real releases, clean test.
→ No changes needed in Phase 2 — already aligned.
3. **Asset matcher `*.exe` fallback: defer.**
Today only one asset ships so the fallback at `src/update/release.rs:67-69` is dead code. Once multi-arch lands (`x86_64`/`arm64`), the fallback could pick the wrong binary. Tracked as a future cleanup, NOT in scope for this plan.
→ Documented in Phase 1 success criteria as a non-action.
#### Whole-Plan Consistency Sweep
- Files reread: `plan.md`, `phase-01-release-ci-workflow.md`, `phase-02-end-to-end-update-test.md`, `phase-03-docs-and-release-process.md`
- Decision deltas checked: 3 (version-match enforcement, initial release version, asset fallback)
- Reconciled stale references: 4
- Phase 1 architecture flow + todo list + success criteria updated to include version-tag check
- Phase 1 non-functional line-count budget bumped 80 → ~100 lines to match the actual YAML after adding the check step
- Phase 1 flow diagram cleaned up (removed phantom `rustup default stable` step; added cache + dispatch-draft notation)
- Phase 3 `release-process.md` sketch now calls out that CI enforces tag-vs-Cargo match, explaining why step ordering matters
- Unresolved contradictions: 0
@@ -1,122 +0,0 @@
# Phase 01 — Implement Restart Action
## Context Links
- Reused pattern: `src/update/install.rs:1-120` (cmd-handoff swap-and-restart). Documented in `docs/release-process.md` if it exists.
- Menu wiring reference: `src/app.rs:870-1045` (`show_context_menu`) and `src/app.rs:363-392` (`on_menu_command`).
- Mutex acquisition: `src/app.rs:152-168` (`Global\ClaudeCodeUsageBubble`).
- i18n schema: `src/i18n/mod.rs` (`LocaleStrings` struct around line 22-80).
## Overview
- **Priority:** Low (UX enhancement).
- **Status:** Done. Code-reviewer DONE_WITH_CONCERNS — M1 (match-arm ordering) + L3 (lock-during-save) addressed in follow-up edits.
- **Size:** ~50 LOC across 3 files (+ 8 locale TOMLs, one line each).
## Key Insights
- The existing mutex check rejects a second instance immediately. A naive "spawn-then-exit" races. The `cmd.exe /c timeout` handoff (1 s sleep, then `start ""`) is the simplest decoupling — same trick `update::install::begin` already uses.
- `cmd.exe` expands `%var%` in argument strings. Current `current_exe()` path containing `%` is an injection vector; reject it (existing precedent: `update::install` rejects too).
- `std::process::Command` with `creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)` ensures the helper outlives the parent silently.
## Requirements
### Functional
- Right-click context menu shows a "Restart" item directly above "Exit".
- Clicking "Restart" closes the current process and a new instance starts within ~12 seconds, restoring tray icons and bubbles.
- No confirmation prompt.
- Item label is i18n-aware: all 8 locales get a translation.
### Non-functional
- No regression in mutex single-instance behavior — second instance must still be blocked if user accidentally launches manually mid-restart.
- No console window flashes during handoff.
## Architecture
```
User clicks "Restart"
→ WM_COMMAND with IDM_RESTART
→ app::on_menu_command → app::restart_app()
→ settings::save current snapshot (defensive flush)
→ spawn detached cmd.exe with delayed `start ""` for current_exe
→ PostQuitMessage(0)
→ message loop exits → mutex released
→ cmd.exe wakes up → new instance launches → acquires mutex → run()
```
## Related Code Files
**Modify:**
- `src/app.rs` — add `IDM_RESTART: u16 = 33` const (next free in the 30-39 band), match arm in `on_menu_command`, menu append in `show_context_menu` between `IDM_TOGGLE_WIDGET` row and the separator before `IDM_EXIT`, new `fn restart_app()`.
- `src/i18n/mod.rs` — add `pub restart: String,` field to `LocaleStrings` (place near `exit`).
- `src/i18n/locales/en.toml`, `de.toml`, `es.toml`, `fr.toml`, `ja.toml`, `ko.toml`, `nl.toml`, `zh-TW.toml` — add `restart = "<translation>"`.
**Create:** none.
**Delete:** none.
## Implementation Steps
1. **Add menu ID and i18n field.**
- `app.rs`: `const IDM_RESTART: u16 = 33;` (after `IDM_VERSION_ACTION`).
- `i18n/mod.rs`: add `pub restart: String,` to `LocaleStrings`. Place adjacent to `exit`.
- Add `restart = "Restart"` to `en.toml`. Translate for the other 7 locales (Vietnamese-quality is acceptable; native fluency not required for a single-word menu item).
2. **Wire the menu entry.**
- `app.rs::show_context_menu` — between the `show_widget` append and the `MF_SEPARATOR` before `IDM_EXIT`, add `append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));`.
- Add `IDM_RESTART => restart_app(),` arm in `on_menu_command` before the `_ => {}` catch-all.
3. **Implement `restart_app()`.**
- Persist a final settings snapshot (defensive flush). Read current state, call `settings::save(&snap)`.
- Resolve `std::env::current_exe()`. If `Err`, log error and `PostQuitMessage(0)` (degrade to plain Exit).
- Convert path to string. If it contains `%`, log error and return (refuse — matches `update::install` precedent).
- Build the cmd line: `timeout /t 1 >nul & start "" "<exe>"`.
- Spawn via `std::process::Command::new("cmd.exe")` with `.creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)` and a `raw_arg` payload `/c "<cmd>"` (mirrors `install.rs:104-114`).
- On spawn success: `PostQuitMessage(0)`. On failure: log error and return (app stays running).
4. **Verify.**
- `cargo check` — no warnings.
- Manual smoke test: build release, right-click tray, Restart, observe close + relaunch within ~2 s, mutex acquired by new instance, bubbles + tray icons rendered.
## Todo List
- [x] Add `IDM_RESTART` const in `app.rs`.
- [x] Add `restart: String` to `LocaleStrings` in `i18n/mod.rs`.
- [x] Update all 8 locale `.toml` files.
- [x] Append menu item in `show_context_menu`.
- [x] Add match arm in `on_menu_command` (placed before `IDM_LANG_BASE` guard per reviewer M1).
- [x] Implement `restart_app()` in `app.rs` (clone-then-save per reviewer L3).
- [x] `cargo check` clean.
- [ ] Manual smoke test on Windows (deferred to user; needs release build).
## Success Criteria
- Right-click tray → menu shows "Restart" between "Show widget" group and "Exit".
- Clicking it closes the process and a new one starts within 2 s with identical state (settings honored, bubble positions persisted, tray icons restored).
- No console window flashes.
- `cargo check` passes with no new warnings.
- All 8 locales include the new key (no fallback to English).
## Risk Assessment
| Risk | Severity | Mitigation |
|------|----------|------------|
| Mutex race — new instance starts before old releases | Medium | 1 s `timeout` in cmd handoff; matches update module precedent. |
| `current_exe()` path contains `%` (injection) | Low | Reject, log, abort (same as `install.rs:90-94`). |
| `cmd.exe` not on PATH (broken Windows install) | Very Low | Log error, app stays running. User can Exit manually. |
| Settings not flushed before quit | Low | Explicit `settings::save()` before `PostQuitMessage`. Bubble positions already persist on drag, so worst case is a no-op. |
| User restart-spams the menu | Low | Each click queues a new cmd handoff; the timeout dedupes via mutex. Worst case: one extra instance attempt that exits immediately on `ERROR_ALREADY_EXISTS`. |
## Security Considerations
- `%`-in-path rejection prevents `cmd.exe` variable expansion injection.
- No user-supplied input enters the cmd line — only `std::env::current_exe()` output.
- Detached process flags prevent inherited stdio from leaking.
## Next Steps
- After merge: bump version (semver patch — UX addition with no API change).
- Consider an analogous restart action for the bubble's context menu (currently the bubble also fires `on_menu_command` via `WM_COMMAND`, so the same menu id works there for free).
@@ -1,41 +0,0 @@
# Plan: Menu Restart Button
**Slug:** menu-restart-button
**Created:** 2026-05-18 09:45
**Branch:** main
**Status:** Implemented (awaiting commit)
## Goal
Add a "Restart" entry to the tray right-click context menu, positioned directly above "Exit". Clicking it relaunches the running binary in-place without prompting for confirmation.
## Why
User-requested. Current flow to apply a config/locale tweak that doesn't hot-reload (or to recover after a hang) is Exit → relaunch from Start menu. A one-click restart is symmetric with Exit and avoids hunting for the binary again.
## Phases
| # | Title | Status |
|---|-------|--------|
| 01 | Implement Restart action | Done — [phase-01-implement-restart-action.md](phase-01-implement-restart-action.md) |
## Key Decisions
- **Placement:** main menu, between `Show widget` separator and `Exit`. NOT inside Settings submenu — keeps top-level discoverability.
- **No confirmation dialog.** Settings auto-save on every change (settings.rs:138-152); restart is non-destructive.
- **Mechanism:** detached `cmd.exe /c timeout /t 1 >nul & start "" "<exe>"` handoff, then `PostQuitMessage(0)`. Same pattern as `update::install::begin` minus the swap step. The 1-second wait lets the current process release `Global\ClaudeCodeUsageBubble` mutex before the new instance's `CreateMutexW` runs.
- **Reject paths containing `%`** — cmd.exe expands `%var%`, same defense the update module already uses (install.rs:90-94).
## Dependencies
- None. Pure Rust + existing `windows` crate features.
## Out of Scope
- Restart after settings change auto-trigger (would be a separate feature).
- Restart-with-args (e.g., toggle `--diagnose`).
- Cross-platform — Windows-only by design.
## Unresolved Questions
None.
@@ -1,104 +0,0 @@
---
phase: 1
title: "Foundation: CLI flags + native spawn helper + mutex retry"
status: complete
priority: P1
effort: "3h"
dependencies: []
---
# Phase 1: Foundation: CLI flags + native spawn helper + mutex retry
## Overview
Build the primitives that phases 2-4 reuse: a CLI argument parser for `--wait-pid <pid>` and `--updated-to <version>`, a `spawn_detached_self` helper that calls `CreateProcessW` directly (no cmd.exe), and mutex-acquisition retry logic that activates only when `--wait-pid` was passed.
## Requirements
**Functional**
- Parse `--wait-pid <u32>` and `--updated-to <version-string>` from `std::env::args` without breaking existing flags (`--diagnose`, `--apply-update`).
- Expose `spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()>` that uses `CreateProcessW` with `CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`.
- Expose `wait_for_parent_exit(pid: u32, timeout_ms: u32)` that uses `OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject`. Returns silently on timeout (best-effort).
- Mutex acquisition in `app::run` retries `CreateMutexW` for ~3 seconds (200ms backoff) ONLY when `--wait-pid` was present; preserves today's immediate-fail behavior for normal startup.
**Non-functional**
- No new external dependencies. All Win32 calls via the existing `windows = "0.58"` crate features.
- Helper module ≤ ~120 lines total.
## Architecture
```
src/
├── update/
│ └── handoff.rs ← NEW: spawn_detached, wait_for_parent_exit, cleanup_stale_old_exes
├── main.rs ← parse --wait-pid early, call wait_for_parent_exit BEFORE app::run
└── app.rs ← run() reads a static "wait_pid_was_passed" flag, retries mutex if set
```
Rationale for `src/update/handoff.rs`: keeps low-level Win32 process/file ops alongside the update module that uses them most. `app.rs` and `main.rs` import it for restart + post-update bootstrap.
## Related Code Files
- **Create**: `src/update/handoff.rs`
- **Modify**: `src/main.rs` (early arg parse + wait + flag handoff to app)
- **Modify**: `src/update/mod.rs` (declare `pub mod handoff`)
- **Modify**: `src/app.rs` (mutex retry loop, gated on flag from main)
- **Modify**: `Cargo.toml` if a new `windows` feature is needed (likely `Win32_System_Threading` already covers `OpenProcess`/`WaitForSingleObject`/`CreateProcessW`)
## Implementation Steps
1. **Audit `windows` crate features.** Confirm `Win32_System_Threading` is in `Cargo.toml` (it is — line 31). Verify `CreateProcessW`, `STARTUPINFOW`, `PROCESS_INFORMATION` are accessible. Add `Win32_Storage_FileSystem` if not already present (needed for phase 3's `MoveFileExW`).
2. **Create `src/update/handoff.rs`** with three pub fns:
- `pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()>`
- Build a wide-char command line: quoted exe path + space-joined args, NUL-terminated.
- `STARTUPINFOW` zero-initialized, `cb` set.
- `CreateProcessW(NULL, cmdline_wide.as_mut_ptr(), NULL, NULL, FALSE, CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, NULL, NULL, &si, &pi)`.
- Close `pi.hProcess` and `pi.hThread` immediately (fire-and-forget).
- `pub fn wait_for_parent_exit(pid: u32, timeout_ms: u32)`
- `OpenProcess(SYNCHRONIZE, FALSE, pid)`. If it fails (parent already gone), return immediately.
- `WaitForSingleObject(h, timeout_ms)`. Ignore return value.
- `CloseHandle(h)`.
- `pub fn cleanup_stale_old_exes(current_exe: &Path)` (used by phase 4; stub here, fill in phase 4)
- Stub: returns `Ok(())`.
3. **Modify `src/update/mod.rs`** to add `pub mod handoff;`.
4. **Modify `src/main.rs`** — insert BEFORE `app::run()`:
```rust
let wait_pid = args.iter()
.position(|a| a == "--wait-pid")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse::<u32>().ok());
if let Some(pid) = wait_pid {
update::handoff::wait_for_parent_exit(pid, 5_000);
}
```
Note: keep this AFTER `update::run_cli` so the legacy `--apply-update` path still short-circuits cleanly.
5. **Modify `src/app.rs::run`** — gate mutex retry on whether `--wait-pid` appeared. Cheapest implementation: re-parse `std::env::args` once at the top of `run()`. Then around line 156:
```rust
let retry_mutex = std::env::args().any(|a| a == "--wait-pid");
let _mutex = acquire_singleton_mutex(retry_mutex)?; // new helper
```
New helper `acquire_singleton_mutex(retry: bool)`:
- If `!retry`: today's behavior (fail immediately on `ERROR_ALREADY_EXISTS`).
- If `retry`: loop CreateMutexW → on `ALREADY_EXISTS`, `Sleep(200)` and retry. Budget 15 iterations = ~3 seconds. Log every retry. After budget exhausted, return error and exit cleanly.
6. **Compile check.** `cargo build --release`. Fix any feature gaps. No behavior should change yet — `--wait-pid` arg is parsed but no caller passes it yet.
## Success Criteria
- [ ] `cargo build --release` succeeds with zero warnings beyond the existing `dead_code` allow.
- [ ] Running the binary normally (no flags) behaves identically to today (mutex check is immediate, no retry).
- [ ] Running the binary with `--wait-pid <pid-of-running-instance>` against a live instance: the new process waits ≤5s for old one to exit, then acquires the mutex within ~200ms of its release. Verifiable by killing the original after 2s and watching the new one continue.
- [ ] `update::handoff::spawn_detached` smoke test: from a small one-off snippet in `main` (gated behind a never-used flag) verify CreateProcessW returns success and pid increments. Remove before commit.
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Wide-char cmdline construction has off-by-one / missing NUL | Hand-test with a path containing spaces; assert `OsStringExt::encode_wide` produces expected bytes |
| `CREATE_NO_WINDOW | DETACHED_PROCESS` combination misbehaves on GUI subsystem binaries | Documented Windows behavior: for a GUI subsystem child, both flags are effectively no-ops (no console requested), but combining them is harmless. Tested by phase 5 |
| Mutex retry loop hangs forever if budget logic wrong | Hard cap = 15 iterations × 200ms = 3.0s. After that, exit. Add log line per retry so a stuck loop is visible in `--diagnose` |
| `OpenProcess(SYNCHRONIZE, ...)` returns access-denied for cross-session | Fallback: `WaitForSingleObject` simply isn't called; mutex retry compensates |
@@ -1,113 +0,0 @@
---
phase: 2
title: "Restart path: replace restart_app with native CreateProcessW"
status: complete
priority: P1
effort: "1h"
dependencies: [1]
---
# Phase 2: Restart path: replace restart_app with native CreateProcessW
## Overview
Replace `src/app.rs::restart_app`'s `cmd.exe /c "timeout & start ..."` handoff with a direct `spawn_detached(current_exe, ["--wait-pid", our_pid])` call. The new instance handles the parent-exit wait itself using phase 1's helper; no timer needed.
## Requirements
**Functional**
- Restart triggered from the tray menu produces zero console flash.
- New instance acquires `Global\ClaudeCodeUsageBubble` mutex successfully every time.
- Settings still flushed to disk before exit (preserve current `snap + save` defensive write).
- Path-with-`%` defense becomes unnecessary (no cmd.exe); the check is removed.
**Non-functional**
- `restart_app` function shrinks from ~40 lines to ~25 lines.
## Architecture
```
restart_app():
1. settings::save(snap) (unchanged)
2. exe = current_exe() (unchanged)
3. our_pid = GetCurrentProcessId()
4. handoff::spawn_detached(exe, [--wait-pid, our_pid])
5. on success: PostQuitMessage(0)
6. on failure: log error, do NOT quit
```
Mutex release is implicit on process exit — no explicit `ReleaseMutex` needed because the `_mutex` handle in `app::run` is dropped when `run()` returns after `PostQuitMessage`. `Drop` closes the handle, which releases the mutex.
## Related Code Files
- **Modify**: `src/app.rs::restart_app` (lines ~1378-1432)
- **Remove**: the `RESTART_CREATE_NO_WINDOW` / `RESTART_DETACHED_PROCESS` constants (now in handoff.rs)
- **Remove**: the `%`-rejection defense (no cmd.exe to exploit)
- **Remove**: the `replace('"', "")` quote-stripping (handoff.rs handles quoting)
## Implementation Steps
1. **Read current `restart_app`** at `src/app.rs:1378-1432` to confirm exact bounds.
2. **Rewrite `restart_app`** to:
```rust
fn restart_app() {
// Defensive settings flush (unchanged)
let snap = lock_state().as_ref().map(|s| s.settings.clone());
if let Some(s) = snap {
settings::save(&s);
}
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
log::error!("restart: current_exe failed: {e}");
return;
}
};
let pid = unsafe { GetCurrentProcessId() };
let args = vec![
OsString::from("--wait-pid"),
OsString::from(pid.to_string()),
];
match update::handoff::spawn_detached(&exe, &args) {
Ok(()) => {
log::info!("restart: spawned detached child, posting quit");
unsafe { PostQuitMessage(0) };
}
Err(e) => log::error!("restart: spawn failed: {e}"),
}
}
```
3. **Drop the two `RESTART_*` const declarations** above the function — they were specific to the old cmd.exe path. Phase 1's helper has its own.
4. **Drop the `%`-rejection block** in the new `restart_app`. The brainstorm doc keeps the equivalent check in `install.rs` for defense-in-depth; here it's pure dead weight without cmd.exe.
5. **Imports**: add `use std::ffi::OsString;` and `use windows::Win32::System::Threading::GetCurrentProcessId;` if not already in scope.
6. **Compile check**: `cargo build --release`.
7. **Manual smoke test**: run the binary, click Restart in the tray menu, verify:
- No console window flashes
- New instance appears within ~1s
- Old instance log shows "spawned detached child, posting quit"
- New instance log shows mutex acquired (via Phase 1's retry path)
## Success Criteria
- [ ] `cargo build --release` clean.
- [ ] Manual restart from menu produces ZERO visible console window across 20 consecutive triggers.
- [ ] New instance window appears within 1500ms of menu click.
- [ ] Settings file (`settings.json`) shows updated mtime after restart, confirming defensive save still runs.
- [ ] `restart_app` function is ≤25 lines.
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Mutex handle not yet dropped when child tries to acquire | Phase 1's `--wait-pid` + 5s `WaitForSingleObject` + 3s mutex retry covers race comfortably (8s total budget vs <1s actual parent exit) |
| `PostQuitMessage` doesn't immediately exit; window-message pump may process more events | Phase 1 mutex retry tolerates up to 3s of overlap |
| `current_exe()` returns a path that the child can't load (rare: deleted exe, fileshare disconnect) | Existing behavior preserved: log error, do not quit. User can manually retry |
@@ -1,156 +0,0 @@
---
phase: 3
title: "Update install: rename + move + native spawn"
status: complete
priority: P1
effort: "2h"
dependencies: [1]
---
# Phase 3: Update install: rename + move + native spawn
## Overview
Replace `src/update/install.rs::begin`'s `cmd.exe /c "timeout & move & start ..."` handoff with native steps: `MoveFileExW` to rename the running exe sideways, `MoveFileExW` to move the staged exe into place, then `spawn_detached` of the new binary. Removes the only remaining cmd.exe invocation in the update flow.
## Requirements
**Functional**
- Update install produces zero console flash.
- New binary version starts after auto-update without user interaction.
- SHA-256 verification continues to gate the swap (no swap on checksum mismatch).
- On any failure step, the original exe must remain runnable (no half-state).
**Non-functional**
- `install.rs` net change ≈ -30 lines (cmd-quoting code is gone, replaced by short Win32 calls).
- New code paths use the windows crate; no new dependencies.
## Architecture
```
install::begin(http, release):
1. current = current_exe()
2. ensure_writable(current.parent()) (unchanged)
3. staging = stage_path()
4. reject_unsafe_path(current) (kept for defense-in-depth; harmless now)
5. reject_unsafe_path(staging)
6. create_dir_all(staging.parent())
7. download(http, asset_url, staging, sha256) (unchanged)
8. backup = current.with_file_name(format!("{}.old.{}", filename, pid))
9. MoveFileExW(current, backup, 0) ← NEW: rename running exe sideways
10. MoveFileExW(staging, current, MOVEFILE_REPLACE_EXISTING) ← NEW
11. our_pid = GetCurrentProcessId()
12. handoff::spawn_detached(current,
["--wait-pid", our_pid, "--updated-to", version_str]) ← NEW
13. return Ok(())
```
Caller (`app.rs` Apply action) is responsible for `PostQuitMessage` after `begin` returns Ok — same as today.
### Rollback semantics
| Step that failed | State | Recovery |
|---|---|---|
| 7 (download) | Original exe untouched | Existing behavior: error surfaced, user retries |
| 9 (rename current → backup) | Original exe untouched | Surface `Error::NotWritable`; do not proceed |
| 10 (move staging → current) | Original exe is at backup path, current path empty | Best-effort revert: rename backup back to current; surface error |
| 10 + revert (both fail) | Original at backup path; current path empty; user has no runnable binary at the install location | **Per Validation Session 1 decision:** show a Windows `MessageBoxW` (MB_OK \| MB_ICONERROR) telling the user where the backup is, then exit. Message: "Update failed. Your original binary is saved as `{backup_path}`. Please rename it back to `{exe_name}` manually." |
| 12 (spawn child) | New exe at correct path, but app didn't restart | Log + tray balloon "Update applied; restart manually". Rare — `CreateProcessW` on a fresh fully-written exe almost never fails |
<!-- Updated: Validation Session 1 - Rollback escalation MessageBox added -->
### Rollback escalation helper
Add a private `surface_rollback_failure(backup_path: &Path, target_name: &str)` helper that calls `MessageBoxW` with `MB_OK | MB_ICONERROR` and the localized message. Adds a new `LocaleStrings` field `update_rollback_failed_body` parameterized with `{backup_path}` and `{exe_name}` (Rust `format!` substitution at call site). The plain MessageBox uses the Win32 dialog, so no console can flash.
## Related Code Files
- **Modify**: `src/update/install.rs::begin`
- **Modify**: `src/update/install.rs::spawn_handoff` → REMOVED entirely
- **Modify**: `src/update/install.rs` imports (drop `os::windows::process::CommandExt`, `process::{Command, Stdio}`; add `MoveFileExW`, `MOVEFILE_REPLACE_EXISTING`, `GetCurrentProcessId`, `MessageBoxW`, `MB_OK`, `MB_ICONERROR`)
- **Modify**: `src/update/mod.rs::Error` — add `Error::SwapFailed(String)` variant if MoveFileExW failures don't fit existing variants cleanly
- **Modify**: `src/i18n/mod.rs::LocaleStrings` — add `update_rollback_failed_body: String` (also belongs to Phase 4 i18n group, but Phase 3 is the consumer)
## Implementation Steps
1. **Read current `install.rs::begin` + `spawn_handoff`** to confirm exact bounds (lines 19-36 + 99-121).
2. **Decide path-safety policy**: keep `reject_unsafe_path` (the `%`-check) as defense-in-depth even though no cmd.exe runs. Update the function-level comment to reflect new reality (kept for paranoia, not strict need).
3. **Add `swap_and_spawn` private helper** (replaces `spawn_handoff`):
```rust
fn swap_and_spawn(
source: &Path,
target: &Path,
version: &super::release::Version,
) -> Result<(), super::Error> {
let backup = backup_path(target);
move_file(target, &backup, 0)?;
if let Err(e) = move_file(source, target, MOVEFILE_REPLACE_EXISTING) {
// Best-effort revert
let _ = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING);
return Err(e);
}
let pid = unsafe { GetCurrentProcessId() };
let args = vec![
OsString::from("--wait-pid"),
OsString::from(pid.to_string()),
OsString::from("--updated-to"),
OsString::from(format!("{}.{}.{}", version.major, version.minor, version.patch)),
];
super::handoff::spawn_detached(target, &args)
.map_err(super::Error::Io)
}
fn move_file(src: &Path, dst: &Path, flags: MOVE_FILE_FLAGS) -> Result<(), super::Error> {
let src_w = to_utf16_nul(src);
let dst_w = to_utf16_nul(dst);
let r = unsafe {
MoveFileExW(
PCWSTR::from_raw(src_w.as_ptr()),
PCWSTR::from_raw(dst_w.as_ptr()),
flags,
)
};
r.ok().map_err(|e| super::Error::SwapFailed(e.to_string()))
}
fn backup_path(target: &Path) -> PathBuf {
let pid = unsafe { GetCurrentProcessId() };
let mut p = target.to_owned();
let fname = target.file_name().map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "exe".to_string());
p.set_file_name(format!("{fname}.old.{pid}"));
p
}
```
Use the existing `os::to_utf16_nul` helper for wide-char conversion (already used in `app.rs::run`).
4. **Rewrite `begin`** to call `swap_and_spawn(&staging, &current, &release.version)` instead of `spawn_handoff(&staging, &current)`. Pass the version from the `Release` struct already in hand.
5. **Add `Error::SwapFailed(String)` variant** to `src/update/mod.rs` if no existing variant fits the move-failure semantics. The `#[error(...)]` message should be `"file swap failed: {0}"`.
6. **Remove `spawn_handoff` function** entirely. Remove now-unused imports (`std::os::windows::process::CommandExt`, `Command`, `Stdio`, `CREATE_NO_WINDOW`, `DETACHED_PROCESS` constants).
7. **Compile check**: `cargo build --release`. Address any feature-flag gaps (likely need `Win32_Storage_FileSystem` added to Cargo `windows` features for `MoveFileExW`).
8. **Test rollback path manually**: write a temp .exe to staging that is read-only or has wrong permissions to force step 10 to fail; verify backup is restored and original is still runnable.
## Success Criteria
- [ ] `cargo build --release` clean.
- [ ] Manual auto-update from a test-tagged v0.1.99 produces ZERO visible console window across 5 consecutive runs.
- [ ] SHA-256 mismatch still rejects the swap (verify by tampering with a downloaded asset before swap).
- [ ] On forced step-10 failure (simulated): backup is restored, original binary still launches.
- [ ] `spawn_handoff` function no longer exists in the codebase (`grep -r spawn_handoff src/` returns empty).
- [ ] No `cmd.exe` string remains in `src/update/install.rs`.
## Risk Assessment
| Risk | Mitigation |
|---|---|
| `MoveFileExW` fails because AV holds a handle on the running exe | Surface `Error::SwapFailed`; user retries. Rare on Defender (which scans on read, not perpetually) |
| Two updates triggered in quick succession leave two `.old.<pid>` files | Phase 4 cleanup at startup handles this — glob removes ALL `.old.*` siblings |
| User's install dir is on a network share where renaming-while-open is forbidden | Existing `ensure_writable` probe catches read-only / no-write cases. Network FS oddities → surface as `NotWritable` |
| `MoveFileExW` with `REPLACE_EXISTING` on a non-NTFS volume | Works on FAT32 (per MS docs); the renaming-while-running concern is NTFS-specific but step 9 always renames an empty (just-emptied) path in step 10 |
| Release-build inlines + LTO breaks symbol-level rollback assumption | Functional rollback path is exercised by phase 5's manual test under release profile |
@@ -1,165 +0,0 @@
---
phase: 4
title: "Cleanup + tray notification"
status: complete
priority: P2
effort: "1.5h"
dependencies: [1, 3]
---
# Phase 4: Cleanup + tray notification
## Overview
Two additions that make the silent update visible to the user without being intrusive: (a) startup cleanup of stale `bubble.exe.old.<pid>` files from previous updates, and (b) a tray balloon "Updated to vX.Y.Z" on first launch after auto-update (driven by the `--updated-to` flag passed by phase 3).
<!-- Updated: Validation Session 1 - i18n approach corrected to match TOML struct-field architecture -->
## Requirements
**Functional**
- On every startup, scan `current_exe().parent()` for files matching `<exe-stem>.exe.old.*` and remove them silently. Errors logged at debug level, never surfaced to user.
- When `--updated-to vX.Y.Z` is passed AND the tray subsystem is initialized, show a balloon notification with localized title + body.
- Add **3 new fields** to `LocaleStrings` (`src/i18n/mod.rs:23-71`): `update_applied_title`, `update_applied_body`, `update_rollback_failed_body` (the last one is consumed by Phase 3 but the i18n change belongs to this phase's pattern). Body strings use Rust `format!` at call site — TOML strings hold raw text (e.g. body = `"Updated to v"`, then call site does `format!("{}{}", strings.update_applied_body, version)`). Choice of suffix vs prefix vs `{}` placeholder substitution is dialect-sensitive; use literal positional substitution via `format!` because TOML doesn't support template placeholders the i18n loader recognizes.
- Translate the 3 new strings in all **8 existing locale files**: `src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml`.
**Non-functional**
- Cleanup runs in the foreground startup path (it's a few file operations — no need for a thread).
- Balloon uses `NIIF_INFO` (blue info icon) not `NIIF_WARNING` (yellow triangle). The existing `tray::notify` (`src/tray/mod.rs:85`) hardcodes `NIIF_WARNING` and has **2 callers** (`src/app.rs:831` usage threshold, `src/app.rs:859` token expired) — both correctly semantically "warning". Rename existing `notify``notify_warning`; add new sibling `notify_info`.
## Architecture
### Cleanup
Triggered from `app::run` after tray icons register but before main message loop. Implementation lives in `update::handoff::cleanup_stale_old_exes` (stub was added in phase 1).
```rust
pub fn cleanup_stale_old_exes(current_exe: &Path) {
let Some(dir) = current_exe.parent() else { return };
let Some(stem) = current_exe.file_name() else { return };
let prefix = format!("{}.old.", stem.to_string_lossy());
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with(&prefix) {
if let Err(e) = std::fs::remove_file(entry.path()) {
log::debug!("cleanup_stale_old_exes: remove {:?} failed: {e}", entry.path());
}
}
}
}
```
### Balloon notification
Parse `--updated-to` argument early (alongside `--wait-pid` in main.rs), stash it on `AppState`. After the tray icons are registered for the first time, if the version string is present, call `tray::notify_info(hwnd, kind, title, body)`.
```rust
// main.rs (after --wait-pid parse):
let updated_to = args.iter()
.position(|a| a == "--updated-to")
.and_then(|i| args.get(i + 1))
.cloned();
// pass into app::run via existing arg-threading or a static OnceLock
```
```rust
// tray/mod.rs: split notify into two variants
pub fn notify_info(owner: HWND, kind: IconKind, title: &str, body: &str) {
notify_inner(owner, kind, title, body, NIIF_INFO);
}
pub fn notify_warning(owner: HWND, kind: IconKind, title: &str, body: &str) {
notify_inner(owner, kind, title, body, NIIF_WARNING);
}
fn notify_inner(owner: HWND, kind: IconKind, title: &str, body: &str, flags: NOTIFY_ICON_INFOTIP_FLAGS) {
// existing body, but use `flags` instead of hardcoded NIIF_WARNING
}
```
If `tray::notify` has only one caller today (which the brainstorm scout suggested), just rename it to `notify_warning` and add `notify_info`.
## Related Code Files
- **Modify**: `src/update/handoff.rs::cleanup_stale_old_exes` (fill in phase-1 stub)
- **Modify**: `src/app.rs::run` (call cleanup; call tray::notify_info after tray registration if updated_to is set)
- **Modify**: `src/app.rs:831,859` (rename `tray::notify``tray::notify_warning` at both existing call sites)
- **Modify**: `src/main.rs` (parse `--updated-to`, stash for app)
- **Modify**: `src/tray/mod.rs` — rename `notify``notify_warning`; add `notify_info`; extract shared `notify_inner(... flags: NOTIFY_ICON_INFOTIP_FLAGS)`
- **Modify**: `src/i18n/mod.rs::LocaleStrings` — add 3 new `String` fields: `update_applied_title`, `update_applied_body`, `update_rollback_failed_body`
- **Modify**: `src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml` — 8 files, add the 3 new keys to each. Use machine translation for non-English where idiomatic translation unavailable; flag with comment for native-speaker review later
## Implementation Steps
1. **i18n: add 3 new fields to `LocaleStrings`** in `src/i18n/mod.rs:23-71`. After the existing `threshold_95_body` field, add:
```rust
pub update_applied_title: String,
pub update_applied_body: String,
pub update_rollback_failed_body: String,
```
2. **Translate in 8 locale files** (`src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml`). Suggested English values (mirror style of existing `token_expired_title`/`token_expired_body`):
```toml
update_applied_title = "Update applied"
update_applied_body = "Updated to v" # call site appends version string
update_rollback_failed_body = "Update failed. Your original binary is saved as " # call site appends backup path + suffix
```
For non-English, machine-translate body+title, leave the trailing space/prefix structure intact. Comment in PR description: "Translations machine-generated; flagged for native-speaker review".
3. **Split `tray::notify`** (`src/tray/mod.rs:85-94`) into:
- rename existing fn → `notify_warning` (preserves current `NIIF_WARNING` semantics)
- extract shared `fn notify_inner(owner, kind, title, body, flags: NOTIFY_ICON_INFOTIP_FLAGS)`
- add new `pub fn notify_info(owner, kind, title, body)` that calls `notify_inner(..., NIIF_INFO)`
Update both existing call sites (`src/app.rs:831,859`) to call `notify_warning` instead of `notify`.
4. **Fill in `cleanup_stale_old_exes`** per architecture section.
5. **Parse `--updated-to` in main.rs**, thread it into `app::run`. Two options:
- Pass as a new arg to `pub fn run(updated_to: Option<String>)`.
- Store in a `OnceLock<Option<String>>` inside `update::handoff`, set in main, read in app.
Pick the simpler one — direct function arg is preferred unless `run`'s signature is already heavily used elsewhere.
6. **In `app::run`** after tray icons register and the main window message loop is about to enter:
```rust
if let Some(v) = updated_to.as_ref() {
let strings = i18n.strings();
let title = strings.update_applied_title.clone();
let body = format!("{}{}", strings.update_applied_body, v);
tray::notify_info(msg_hwnd, IconKind::ClaudeCode, &title, &body);
}
update::handoff::cleanup_stale_old_exes(&exe);
```
Use `ClaudeCode` IconKind because it's always present when Claude is enabled (default). If user disabled Claude and enabled only Codex, fall back to Codex kind. Cheapest: try ClaudeCode first; if `tray::notify_info` fails silently, no harm.
7. **Compile check**: `cargo build --release`.
8. **Manual test cleanup**:
- Create a file `claude-code-usage-bubble.exe.old.1234` next to the running binary.
- Launch the app.
- Verify the file is gone after launch.
9. **Manual test notification**:
- Launch with `--updated-to 9.9.9` flag.
- Verify Windows notification appears with the title + version body.
- Verify it uses the blue info icon, not yellow warning.
## Success Criteria
- [ ] `cargo build --release` clean.
- [ ] Stale `.old.<pid>` files in install dir are removed on every startup (idempotent).
- [ ] Launching with `--updated-to vX.Y.Z` shows a tray balloon with localized title and version body.
- [ ] Balloon icon is blue info (NIIF_INFO), not yellow warning.
- [ ] Launching WITHOUT `--updated-to` shows no balloon (existing behavior preserved).
- [ ] All 8 locale TOML files (`en, nl, es, fr, de, ja, ko, zh-TW`) contain the 3 new keys (`update_applied_title`, `update_applied_body`, `update_rollback_failed_body`); `cargo build --release` would fail to deserialize otherwise.
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Cleanup removes a `.old.<pid>` file that another running instance still depends on | Impossible by construction: only the spawning instance writes `.old.<pid>` and it has already exited by the time the new instance runs cleanup |
| Glob false-positive (e.g. a user-created file matching the pattern) | Pattern requires `.old.` literal AND a numeric pid-like suffix is implied; we don't pattern-match the suffix strictly. Risk is theoretical; user-created files matching `claude-code-usage-bubble.exe.old.*` is extremely unlikely |
| Tray balloon doesn't show because NIM_MODIFY runs before NIM_ADD completes | Defer the `notify_info` call by one tick (PostMessage to message loop) if testing shows races. Likely unnecessary because tray icons register synchronously |
| i18n loader is struct-field based, no template substitution at the loader level | Confirmed by Validation Session 1: append/prepend the version via Rust `format!` at the call site. TOML strings are static text fragments only |
| Translations diverge from idiomatic expression in non-English locales | Machine-translate for first cut, mark "FIXME: review by native speaker" in commit message. Subsequent crowd-sourced fixes are out of this plan's scope |
@@ -1,104 +0,0 @@
---
phase: 5
title: "Manual end-to-end verification"
status: pending
priority: P2
effort: "1h"
dependencies: [1, 2, 3, 4]
---
# Phase 5: Manual end-to-end verification
## Overview
Functional sign-off across all changed code paths. No unit tests added (the changed surface is Win32-heavy and effectively integration territory); instead, a documented manual checklist that the maintainer runs once before committing.
## Requirements
**Functional**
- All success criteria from phases 1-4 verified on a real Windows machine (Win11 preferred, Win10 as secondary if available).
- Build artifact runs without errors when launched plainly (no flags).
- No regression in existing update / restart / refresh paths.
**Non-functional**
- Verification log saved as a section in `phase-05` after run, with date + outcome per item.
## Test Matrix
### Group A: Restart path (phase 2)
| # | Step | Expected |
|---|---|---|
| A1 | Launch binary, open right-click menu, click "Restart" | No console flash; new instance appears within 1.5s |
| A2 | Repeat A1 twenty times back-to-back | Zero flashes observed; settings.json mtime updates each time |
| A3 | Launch with `--diagnose`, click Restart, inspect `%TEMP%\claude-code-usage-bubble.log` | Shows "restart: spawned detached child, posting quit" and new instance shows mutex acquired (within 200ms of the wait completing) |
### Group B: Update install path (phase 3)
Setup: tag a test release `v0.1.99` via the existing GitHub Actions workflow (per `plans/260516-1730-github-release-auto-update`). Bump local `Cargo.toml` back to `v0.1.0` before running. Build the local v0.1.0 with this plan's changes.
| # | Step | Expected |
|---|---|---|
| B1 | Launch v0.1.0 build, set update channel to "Hourly", manually trigger "Check for updates" | "Update available" shown; click "Apply" |
| B2 | During B1 apply | No console flash; new v0.1.99 instance appears |
| B3 | After B1/B2 | Tray balloon "Update applied — Updated to v0.1.99" appears (blue info icon) |
| B4 | Inspect install dir after B1/B2 | NO `.old.*` files remain (cleanup removed them) |
| B5 | Repeat B1 four more times (after re-tagging v0.1.100 etc.) | Zero flashes across 5 update cycles |
| B6 | Tamper test: edit downloaded asset on disk before swap (would require pausing between download and swap — gate via `--diagnose` log timestamps) | SHA-256 mismatch raises `Error::ChecksumMismatch`; swap is NOT performed; original binary still runs |
| B7 | Rollback test: make staging path read-only or simulate step-10 failure | Backup restored; original binary still runs after a manual restart |
### Group C: Cleanup + notification (phase 4)
| # | Step | Expected |
|---|---|---|
| C1 | Manually drop `claude-code-usage-bubble.exe.old.9999` next to the running binary; launch app | Stale file is removed within 1s of launch |
| C2 | Launch app with `--updated-to 9.9.9` arg from a terminal | Tray balloon shows title + "Updated to v9.9.9" in current UI language |
| C3 | Repeat C2 with each supported locale | Each locale shows correct translation |
| C4 | Launch normally (no `--updated-to`) | No balloon appears |
### Group D: Regression smoke
| # | Step | Expected |
|---|---|---|
| D1 | Launch binary fresh (cold start, no flags) | Bubble appears in <2s; usage refresh fires once; tray icon registers |
| D2 | Open settings (right-click → Language → switch), confirm restart happens | Restart works without flash (this is the same `restart_app` path) |
| D3 | Disable auto-update ("Disabled"), wait 30s, re-enable Hourly | No state corruption; check timer resets |
| D4 | Run with `--diagnose --apply-update <some-path> <pid>` (legacy compat) | Returns exit code 0 cleanly (per `update::install::run_cli`) — unchanged |
## Implementation Steps
1. Build `cargo build --release`.
2. Copy `target/release/claude-code-usage-bubble.exe` to `%LOCALAPPDATA%\ClaudeCodeUsageBubble\` (fresh test directory).
3. Run through Test Matrix A → B → C → D in order.
4. For each test row, record outcome (PASS/FAIL + notes) in the Verification Log section below.
5. If any FAIL: open an issue describing the failure, do NOT mark phase complete.
6. If all PASS: mark phase complete, commit changes following project commit conventions (no `chore:` or `docs:` per CLAUDE.md).
## Success Criteria
- [ ] All Group A tests PASS.
- [ ] All Group B tests PASS (B6 + B7 may be skipped if test scaffolding too costly; document as such).
- [ ] All Group C tests PASS.
- [ ] All Group D tests PASS.
- [ ] Verification Log filled in with date + outcomes.
- [ ] No console flash observed across the entire test session.
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Manual testing skips Group B because tagging real releases is annoying | Alternative: build two local copies (v0.1.0 + v0.1.99), set up a local HTTP server with a fake GitHub-Releases-shaped JSON, point the app at it via a debug flag. Out of scope for this plan but noted |
| "Flash" is subjective at 60Hz | Record screen with OBS at 60fps for one test run, scrub frame-by-frame to confirm zero console window appearance |
| Win10-only flash regression (only test on Win11) | Document Win10 testing as "best effort"; primary target is Win11. Note Win10 result in Verification Log |
## Verification Log
<!-- Fill in after running the test matrix -->
### Session 1 — TBD
- Group A: TBD
- Group B: TBD
- Group C: TBD
- Group D: TBD
- Flash observed: TBD
- Notes: TBD
@@ -1,96 +0,0 @@
---
title: "Silent in-app update + restart (no cmd.exe)"
description: "Replace cmd.exe handoff in update install + app restart paths with native Win32 (MoveFileExW + CreateProcessW) so no terminal window can ever flash. Add tray-balloon notification after auto-updates."
status: in_progress
priority: P2
branch: "main"
tags: ["update", "restart", "win32", "ux"]
blockedBy: []
blocks: []
created: "2026-05-21T07:43:16.332Z"
createdBy: "ck:plan"
source: skill
---
# Silent in-app update + restart (no cmd.exe)
## Overview
Two paths today spawn `cmd.exe /c "timeout ... & start ..."` for update install (`src/update/install.rs::begin`) and app restart (`src/app.rs::restart_app`). Combination of `CREATE_NO_WINDOW | DETACHED_PROCESS` + inner `start ""` can still flash a console window on some Windows configs. This plan replaces both with native `MoveFileExW` + `CreateProcessW` (the main exe is `windows_subsystem = "windows"`, so direct spawn never allocates a console). Also wires a tray balloon "Updated to vX.Y.Z" on first launch after an auto-update.
Brainstorm context: [`plans/reports/brainstormer-260521-1530-silent-update-no-cmd.md`](../reports/brainstormer-260521-1530-silent-update-no-cmd.md).
Supersedes the cmd.exe mechanism decision in [`260518-0945-menu-restart-button`](../260518-0945-menu-restart-button/plan.md) (that plan picked cmd.exe deliberately, modeled on `update::install`; this plan replaces both call sites with the native path).
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Foundation: CLI flags + native spawn helper + mutex retry](./phase-01-foundation-cli-flags-native-spawn-helper-mutex-retry.md) | Complete |
| 2 | [Restart path: replace restart_app with native CreateProcessW](./phase-02-restart-path-replace-restart-app-with-native-createprocessw.md) | Complete |
| 3 | [Update install: rename + move + native spawn](./phase-03-update-install-rename-move-native-spawn.md) | Complete |
| 4 | [Cleanup + tray notification](./phase-04-cleanup-tray-notification.md) | Complete |
| 5 | [Manual end-to-end verification](./phase-05-manual-end-to-end-verification.md) | Pending (user-driven) |
## Key contracts (must hold across plan)
| Contract | Source today | Invariant |
|---|---|---|
| Singleton mutex name | `app.rs` `APP_MUTEX_NAME` = `Global\ClaudeCodeUsageBubble` | New instance must wait for parent to release before acquiring |
| Main binary subsystem | `main.rs:1` `#![windows_subsystem = "windows"]` | Direct spawn allocates no console |
| Asset filename | `release.rs:7` `claude-code-usage-bubble.exe` | Unchanged |
| SHA-256 verification | `install.rs:64-73` | Unchanged — still verified before swap |
| Settings save on shutdown | `app.rs::restart_app` snap+save | Preserved in new restart helper |
## Dependencies
No cross-plan dependencies. Related (superseded mechanism): `260518-0945-menu-restart-button`.
## Out of Scope
- `src/usage/refresh.rs` CLI spawns (`claude.cmd`, `codex.cmd`, `powershell.exe`, `wsl.exe`) — already use `CREATE_NO_WINDOW`; tracked for follow-up.
- `src/creds/wsl_bridge.rs` `wsl.exe` calls — same.
- Code signing / SmartScreen suppression — separate roadmap item.
- Cross-platform restart/update — Windows-only by design.
## Unresolved Questions
None.
## Validation Log
### Session 1 — 2026-05-21
#### Verification Results
- **Tier**: Full (5 phases)
- **Claims checked**: 8
- **Verified**: 6 | **Failed**: 2 | **Unverified**: 0
- Verified: `app.rs:155` mutex creation; `app.rs:1378-1432` restart_app bounds; `install.rs:42-48` `--apply-update` exit-clean handler; `install.rs:99-121` `spawn_handoff`; `os::to_utf16_nul` exists and is in use; `windows = 0.58` features in Cargo.toml include `Win32_System_Threading` but NOT `Win32_Storage_FileSystem` (phase 3 must add it).
- Failed: (V1) phase 4 said `tray::notify` has "one current caller" — actually 2 (`app.rs:831`, `app.rs:859`); (V2) phase 4 said i18n uses key-based template lookup with `{v}` placeholder — actually uses struct-field-based `LocaleStrings` with TOML, no template engine.
#### Decisions
1. **i18n approach: add 3 fields to `LocaleStrings` + translate 8 locale TOML files; use Rust `format!` at call site for version substitution.**
Reason: existing pattern is struct-field-based via `include_str!` of `src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml`. No template substitution at the loader level.
→ Propagated to `phase-04-cleanup-tray-notification.md`: Requirements, Related Code Files, Implementation Steps, Success Criteria, Risk Assessment all updated.
2. **Rollback failure escalation: Windows `MessageBoxW` (MB_OK | MB_ICONERROR) when MoveFileExW step 10 AND best-effort revert both fail.**
Reason: silent failure here would orphan the user — they'd have no exe at install path. A clear modal tells them where the backup is (`bubble.exe.old.<pid>`).
→ Propagated to `phase-03-update-install-rename-move-native-spawn.md`: rollback table extended; imports list updated; new helper `surface_rollback_failure` added; new `LocaleStrings` field `update_rollback_failed_body` added to phase 4's i18n work.
3. **Stuck-parent fallback: accept 8s total budget (5s `WaitForSingleObject` + 3s mutex retry), then exit cleanly. No `TerminateProcess`.**
Reason: forcing process termination would defeat the `settings::save` defensive flush guarantee. The 8s ceiling is generous for normal Windows scheduling; if a real hang persists, user can manually kill old process via Task Manager — acceptable failure mode.
→ No phase file change needed. Phase 1 budget numbers (5s + 3s) already match.
4. **Auto-update timing: apply when check fires, no idle-window deferral.**
Reason: matches user's brainstorm-phase choice ("Fully silent auto-update"). Idle detection adds complexity (state tracking, defer-budget, defer-never-applies edge case) for marginal UX gain — bubble flicker during ~1s restart is acceptable.
→ No phase file change needed.
#### Whole-Plan Consistency Sweep
- Files reread: `plan.md`, `phase-01-…md`, `phase-02-…md`, `phase-03-…md`, `phase-04-…md`, `phase-05-…md`
- Decision deltas checked: 4
- Reconciled stale references: 2
- Phase 4 i18n section rewritten from key-based template to struct-field + Rust `format!`
- Phase 4 `tray::notify` caller count corrected from "1" to "2" with explicit file:line citations
- Cross-phase touchpoints verified: Phase 3 adds `update_rollback_failed_body` to `LocaleStrings`, Phase 4 owns the full i18n change (3 fields + 8 TOMLs) — both phases reference the same struct, consistent
- Unresolved contradictions: 0
@@ -1,57 +0,0 @@
---
phase: 1
title: "Lock geometry"
status: pending
priority: P1
effort: "45m"
dependencies: []
---
# Phase 1: Lock geometry
## Overview
Define the tail layout contract in `compute_bubble_layout` so both rows consume the same horizontal geometry and differ only in vertical placement and bar height.
## Requirements
- Functional: percent text and countdown text appear after the bar, not inside it.
- Functional: `tail_usage_bar_rect.left/right == tail_time_bar_rect.left/right`.
- Functional: `tail_usage_pct_rect.left/right == tail_time_text_rect.left/right`.
- Non-functional: preserve the 140-360 logical size behavior and the minimum bar-width guard.
- Non-functional: no new renderer inputs from `src/app.rs`.
## Architecture
- Data flow: `src/app.rs:639-655` -> `bubble::update_data` -> `PaintInputs` -> `compute_bubble_layout` -> `paint_bubble_pixmap` / `paint_bubble_text`.
- Geometry source of truth: shared `text_w`, `text_left`, `bar_left`, and `bar_right` in `src/bubble.rs:1114-1126`.
- Current rect assignment already fans those shared values into both tail rows at `src/bubble.rs:1141-1163`.
## Related Code Files
- Modify: `src/bubble.rs`
- Read-only check: `src/app.rs`
## Implementation Steps
1. Re-verify the live mismatch before changing code; the current source already shares bar and text columns.
2. Make the target contract explicit in `compute_bubble_layout`: one shared bar lane, one shared text lane, row-specific `top/bottom` only.
3. Preserve `bar_min` fallback so long countdowns shrink text first, not bar width below usability.
4. Keep bar-height asymmetry unless the user confirms that equal thickness is also required.
## Todo List
- [ ] Confirm whether the bug is still reproducible on `main`.
- [ ] Document the target geometry near `compute_bubble_layout`.
- [ ] Ensure no later row-specific width override remains.
## Success Criteria
- [ ] Both tail bars have identical `left/right` bounds.
- [ ] Both tail texts start at the same `left` and end at the same `right`.
- [ ] No upstream data-contract change is required.
## Risk Assessment
- High: the source may already satisfy the request; unnecessary edits would add churn. Mitigation: prove the runtime mismatch first.
- Medium: long localized countdown strings can starve bar width at minimum size. Mitigation: keep `bar_min` and shared fallback math.
- Rollback: revert only the `compute_bubble_layout` diff.
## Security Considerations
- None beyond normal memory-safety review; change is layout-only.
## Next Steps
- Hand off the shared-geometry contract to Phase 2 for text painting and stale-comment cleanup.
@@ -1,57 +0,0 @@
---
phase: 2
title: "Apply renderer change"
status: pending
priority: P2
effort: "45m"
dependencies: [1]
---
# Phase 2: Apply renderer change
## Overview
Apply the tail text-placement change in the renderer so the weekly percent lane and weekly remaining-time lane follow the same `bar -> text` behavior, without changing provider or state plumbing.
## Requirements
- Functional: weekly percent text renders from `tail_usage_pct_rect`; weekly countdown renders from `tail_time_text_rect`.
- Functional: both texts stay right-aligned after the bar using `DT_RIGHT`.
- Functional: no tail text is drawn inside the bar fill.
- Non-functional: preserve the existing pulse behavior for `weekly_pct >= 95`.
- Non-functional: avoid touching `PaintInputs`, polling, or panel code unless a stale comment must be corrected.
## Architecture
- Bar drawing is tiny-skia-only in `src/bubble.rs:1298-1331`.
- Tail text drawing is a later GDI overlay in `src/bubble.rs:1643-1661`.
- `src/app.rs:636-638` currently describes the bubble percent as inline in the bar fill; if that wording is now false, correct it in the same phase.
## Related Code Files
- Modify: `src/bubble.rs`
- Optional modify: `src/app.rs`
## Implementation Steps
1. Align `paint_bubble_text` with the Phase 1 geometry contract and keep the percent/countdown text outside the bars.
2. Remove any remaining inline-percent assumption in comments or naming if it conflicts with the final behavior.
3. Keep the weekly percent highlight behavior and empty-countdown handling intact.
4. Stop scope creep: no changes to provider snapshots, update timers, or panel layout.
## Todo List
- [ ] Confirm `paint_bubble_text` is the only text-placement site for the tail rows.
- [ ] Update or remove stale inline-bar comments if they become misleading.
- [ ] Re-check placeholder and `None` states after the layout change.
## Success Criteria
- [ ] Weekly percent text appears after the top tail bar.
- [ ] Weekly countdown text appears after the bottom tail bar.
- [ ] Tail bar widths are driven only by shared geometry from `compute_bubble_layout`.
## Risk Assessment
- Medium: if the reported mismatch is only visual perception from unequal bar heights, text-placement edits alone will not fix it. Mitigation: compare runtime screenshots before and after Phase 1.
- Low: optional comment cleanup in `src/app.rs` can drift from renderer reality. Mitigation: change comments only after the final behavior is locked.
- Rollback: revert only the text-placement and comment diffs.
## Security Considerations
- None; no auth, network, or filesystem behavior changes.
## Next Steps
- Hand off to Phase 3 for compile checks and Windows visual verification.
@@ -1,64 +0,0 @@
---
phase: 3
title: "Validate on Windows"
status: pending
priority: P2
effort: "30m"
dependencies: [2]
---
# Phase 3: Validate on Windows
## Overview
Verify that the scoped renderer change compiles and that the native layered-window bubble actually presents equal-width tail bars with text after each bar across common runtime conditions.
## Requirements
- Functional: both tail rows visually render as `bar -> text`.
- Functional: both tail bars have the same visible width.
- Non-functional: confirm no regression to head ring, head text, or tray/panel refresh behavior.
- Non-functional: validation stays command-light and uses the existing Windows runtime.
## Architecture
- Compile-time validation covers the Rust renderer path end to end.
- Runtime validation must observe the real layered window because there are no snapshot/golden tests for `tiny-skia + GDI` composition in this repo.
## Related Code Files
- Verify: `src/bubble.rs`
- Verify if touched: `src/app.rs`
## Implementation Steps
1. Run the compile/test commands below.
2. Launch the app and verify the bubble at 140, default, and max logical sizes.
3. Check light and dark theme, Claude and Codex bubbles, and a long countdown string if available.
4. Capture before/after notes so a no-op or perception-only result is explicit.
## Validation Commands
```powershell
cargo check
cargo test
cargo run
```
## Todo List
- [ ] `cargo check` passes.
- [ ] `cargo test` passes, or any pre-existing failures are called out separately.
- [ ] Manual runtime check confirms equal bar widths and text-after-bar alignment.
- [ ] No regression is seen in the head ring/text or bubble refresh path.
## Success Criteria
- [ ] Compile succeeds on the current branch.
- [ ] The top and bottom tail bars share the same left/right edges at runtime.
- [ ] The percent and countdown texts both sit to the right of their bars at runtime.
- [ ] Any remaining mismatch is explained with evidence, not assumption.
## Risk Assessment
- High: native renderer issues are hard to prove without manual observation. Mitigation: test at minimum/default/maximum sizes and common DPI settings.
- Medium: reproducing the original complaint may require a specific locale, DPI, or stale binary. Mitigation: record the runtime conditions used during verification.
- Rollback: revert the renderer change if compile or visual regression appears.
## Security Considerations
- None.
## Next Steps
- If validation passes, implementation can be approved as a scoped `src/bubble.rs` change. If not, reopen Phase 1 with the observed runtime evidence.
@@ -1,50 +0,0 @@
---
title: "Bubble tail bar layout alignment"
description: "Scoped renderer-only plan to align weekly percent and remaining-time tail bar geometry."
status: pending
priority: P2
effort: 2h
branch: "main"
tags: [rust, renderer, bubble, layout]
blockedBy: []
blocks: []
created: 2026-05-23
createdBy: "ck:plan"
source: skill
---
# Bubble tail bar layout alignment
## Scope
- User-facing goal: weekly percent lane and weekly remaining-time lane both render as `bar -> text`, and both bars share identical left/right bounds.
- Expected code scope: `src/bubble.rs`; touch `src/app.rs` only if comment cleanup is needed.
- Backwards compatibility: no settings, storage, IPC, or provider-data changes.
## Verified Codebase Facts
- Bubble data already provides `weekly_pct`, `weekly_text`, and `weekly_resets_at` through `bubble::update_data`; no new inputs are needed (`src/app.rs:639-655`).
- `compute_bubble_layout` already derives one shared text column and one shared bar lane for the two tail rows (`src/bubble.rs:1114-1163`).
- `paint_bubble_text` already renders weekly percent and weekly countdown as separate right-aligned texts (`src/bubble.rs:1644-1661`).
- `paint_bubble_pixmap` paints both tail bars from rects only; text is a GDI overlay, so geometry must stay the single source of truth (`src/bubble.rs:1298-1331`).
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Lock geometry](./phase-01-lock-geometry.md) | Pending |
| 2 | [Apply renderer change](./phase-02-apply-renderer-change.md) | Pending |
| 3 | [Validate on Windows](./phase-03-validate-on-windows.md) | Pending |
## Dependencies
- Sequence: Phase 1 -> Phase 2 -> Phase 3.
- File ownership: `src/bubble.rs` stays single-owner across phases; optional `src/app.rs` comment cleanup happens only in Phase 2.
- Related existing plan: `plans/260523-ui-ux-improvement-plan/plan.md` Phase 3 overlaps in theme but does not block this scoped renderer-only change.
## Rollback
- Revert layout math and text-placement changes in `src/bubble.rs`.
- Revert optional comment cleanup in `src/app.rs`.
- No data migration or persisted-state rollback is needed.
## Unresolved Questions
- Does "same length" mean equal width only, or should the two tail bars also share the same height? Current code intentionally uses different heights (`src/bubble.rs:1105-1110`).
- The current source already looks close to the requested behavior. If runtime still differs, is the issue in this branch, a stale binary, or perception caused by different bar heights?
@@ -1,91 +0,0 @@
# UI/UX Improvement Plan
## Context
- Product: native Windows floating usage bubble for Claude Code/Codex.
- Current UI stack: Win32 popup/layered windows, tiny-skia drawing, GDI text, Shell tray icons.
- Current baseline: `cargo check` passes on 2026-05-23.
- Primary files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, `src/tray/*`, `src/usage_color.rs`, `src/i18n/locales/*.toml`.
## Phase 1 - Accessibility And Status Clarity
- Status: Partially Complete
- Priority: High
- Files: `src/usage_color.rs`, `src/bubble.rs`, `src/panel.rs`, `src/tray/mod.rs`, locale TOMLs.
- Improve non-color status cues for normal/warning/critical/auth/error states.
- Add richer tray tooltips: model, 5h percent/countdown, 7d percent/countdown, current state.
- Add localized strings for warning/critical labels and unavailable/auth states.
- Keep usage colors centralized in `usage_color.rs`; avoid per-surface color drift.
- Validation: contrast check for light/dark colors, manual tray tooltip check, `cargo check`.
- Completed 2026-05-23: richer tray tooltip now includes model, 5h, 7d, and left-click hint.
- Completed 2026-05-23: tray tooltip uses shorter localized tray hint text to reduce truncation risk.
## Phase 2 - Discoverability And Native Controls
- Status: Partially Complete
- Priority: High
- Files: `src/app.rs`, `src/bubble.rs`, locale TOMLs.
- Add menu items for common hidden actions: resize smaller/larger, reset size, show details.
- Add a short localized "Help" or "Controls" submenu listing drag, click, right-click, Ctrl+wheel.
- Make resize available through menu commands, not only Ctrl+MouseWheel.
- Review context-menu grouping so status/update/model/settings actions scan as separate groups.
- Validation: keyboard-access menu traversal, menu command behavior, persisted settings.
- Completed 2026-05-23: added localized Controls submenu with resize actions and disabled help rows.
- Completed 2026-05-23: disabled resize commands when they would no-op and unified menu/wheel resize through shared bubble size.
## Phase 3 - Bubble Legibility And Interaction Robustness
- Status: Planned
- Priority: Medium
- Files: `src/bubble.rs`, optional extracted bubble modules.
- Improve layout for smallest sizes: reserve stable text bounds, handle `100%`, placeholder, `!`, and long countdowns.
- Consider minimum size/shape copy update because code uses 140-360 logical width while README mentions 32-128 pixels.
- Add drag threshold and click behavior review around `WM_EXITSIZEMOVE` to reduce accidental panel opens.
- Add optional pulse reduction path if Windows animation/reduced-motion preference is available.
- Validation: manual checks at min/default/max size, 100/125/150/200% DPI, both models enabled.
## Phase 4 - Expanded Panel Redesign
- Status: Planned
- Priority: Medium
- Files: `src/panel.rs`, locale TOMLs, maybe `src/app.rs`.
- Replace fixed 280x120 assumptions with measured or wider adaptive layout.
- Make rows self-explanatory: model header, 5h and 7d labels, percent plus reset countdown.
- Add explicit error/auth/loading state rendering instead of only symbols/placeholders.
- Improve panel placement near screen edges and multi-monitor boundaries.
- Consider extracting panel layout/painting into smaller modules before behavior changes.
- Validation: all locales, long countdown text, light/dark theme, focus-loss close behavior.
## Phase 5 - Tray And Notification Polish
- Status: Planned
- Priority: Medium
- Files: `src/tray/mod.rs`, `src/tray/badge.rs`, `src/app.rs`.
- Make tray icon state readable without exact color distinction: tooltip carries exact data, icon bands remain coarse.
- Review notification throttling and text for threshold crossings.
- Ensure tray left-click/right-click behavior matches Windows notification-area conventions.
- Add manual test matrix for one-provider and two-provider modes.
- Validation: tray add/modify/delete, balloon messages, no stale icons after exit/restart.
## Phase 6 - Structure And Verification
- Status: Planned
- Priority: Medium
- Files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, docs if behavior changes.
- Split only where it reduces real risk: bubble layout/rendering/interaction and panel layout/rendering first.
- Keep public behavior stable while extracting.
- Add unit tests for pure functions where practical: color bands, size clamps, layout math, countdown formatting.
- Run `cargo check`; run `cargo test` if tests are added.
- Update README/docs after behavior changes, especially controls and size range.
- Completed 2026-05-23: added locale schema tests covering embedded locale parsing and Controls/tray strings.
## Success Criteria
- Bubble and panel remain readable at min/default/max sizes and common DPI scales.
- Warning/critical/auth/error states are understandable without relying only on color.
- Hidden interactions have menu alternatives or discoverable help text.
- Panel handles all existing locales without clipping core data.
- Tray tooltip and notifications communicate exact state.
- Source still compiles; new pure behavior has focused tests where feasible.
## Risks
- Native Win32 UI changes require manual Windows runtime verification; screenshots/tests are limited.
- `src/bubble.rs` and `src/app.rs` are large and coupled; extract before broad changes when touching multiple concerns.
- Adaptive text/layout can regress small-size readability if not verified at 140 logical width.
## Unresolved Questions
- Should the bubble stay stadium-shaped, or should compact circular mode return as an option?
- Should menu help be always present, or only shown on first run/first right-click?
- Should reduced-motion preference disable only pulse, or all nonessential animation?
@@ -0,0 +1,56 @@
---
phase: 1
title: Research OpenCode Go contracts
status: blocked
priority: P1
effort: 2h
dependencies: []
---
# Phase 1: Research OpenCode Go Contracts
## Overview
Prove the OpenCode Go credential, provider ID, and usage-data contracts before implementation. This is the gate that prevents guessed percentages.
Current result: blocked. Provider/model IDs and console quota math are verified, but there is no verified stable CLI/public API for this app to fetch current weekly/monthly quota usage.
## Requirements
- Functional: Identify exact local credential path(s), JSON shape, auth key name, refresh/login command, weekly/monthly usage percent, and weekly/monthly reset time.
- Non-functional: Use primary sources first: official OpenCode docs, OpenCode source, locally installed `opencode` behavior if available.
## Architecture
OpenCode Go will only become a supported provider if it can return normalized weekly/monthly data from a stable source. Official docs confirm Go limits are 5h/weekly/monthly and dollar-value based, but not a public usage endpoint. For this app, OpenCode Go must render a custom four-bar layout: weekly usage percent + weekly remaining time in the upper section, monthly usage percent + monthly remaining time in the lower section.
## Related Code Files
- Read: `src/creds/mod.rs`
- Read: `src/creds/codex_auth.rs`
- Read: `src/usage/chatgpt.rs`
- Read: `src/usage/types.rs`
- Read: `src/usage/registry.rs`
- Created: `plans/260602-1115-opencode-go-provider-support/reports/research-opencode-go-contracts.md`
## Implementation Steps
1. Check official OpenCode Go docs for provider ID, limits, endpoints, and console usage semantics.
2. Check official OpenCode CLI/source for `auth.json` storage path and schema.
3. If OpenCode is installed locally, run safe read-only commands: `opencode stats --help`, `opencode providers --help`, `opencode models --help`.
4. Inspect local `~/.local/share/opencode/auth.json` only with user approval if privacy hook blocks or if file may contain API keys.
5. Search for an official usage endpoint or CLI command that returns current weekly/monthly usage percent or used/limit values plus reset/period end times.
6. Record exact request/response shape or command output needed by Phase 3, including how to compute remaining time for weekly and monthly sections.
7. If only console-authenticated usage exists, stop and ask user before planning implementation beyond auth detection.
## Success Criteria
- [x] Exact credential docs path documented.
- [x] Exact provider key documented: `opencode-go`.
- [x] Weekly/monthly usage percent and reset-time shape verified in console source.
- [x] Stable app-callable quota source confirmed unavailable from current CLI/docs.
- [ ] User decision recorded: console integration, limited detector, or defer.
## Risk Assessment
Main risk: OpenCode docs mention tracking usage in the console but do not document a public usage API. Mitigation: make Phase 3 blocked until a stable source exists; do not infer usage from price tables.
@@ -0,0 +1,61 @@
---
phase: 2
title: Refactor provider metadata and settings
status: completed
priority: P1
effort: 4h
dependencies:
- 1
---
# Phase 2: Refactor Provider Metadata And Settings
## Overview
Remove hard-coded two-provider assumptions before adding OpenCode Go. Keep behavior identical for Claude Code and Codex.
## Requirements
- Functional: Represent enabled providers and positions by `ProviderId`, not one field per provider.
- Non-functional: Preserve old `settings.json` compatibility for `show_claude_code`, `show_codex`, and `bubble_positions.{claude,codex}`.
## Architecture
Introduce a small provider metadata layer near `usage::types` or `usage::registry`: stable ID, slug, display label key, tray icon ID, default enabled, and display mode. Existing providers use the current two-window mode. OpenCode Go uses a custom weekly/monthly four-bar mode.
Keep `ProviderId::ChatGpt` internally unless touching the code anyway. The user-facing label remains Codex. Avoid a mechanical rename that adds risk without feature value.
## Related Code Files
- Modify: `src/usage/types.rs`
- Modify: `src/usage/registry.rs`
- Modify: `src/settings.rs`
- Modify: `src/app.rs`
- Modify: `src/tray/mod.rs`
- Modify: `src/tray/badge.rs`
- Modify: `src/usage_color.rs`
- Modify: `src/bubble.rs`
- Modify: `src/panel.rs`
## Implementation Steps
1. Add `ProviderId::OpenCodeGo` and a stable `slug()` value `opencode-go`.
2. Add `ProviderId::all()` or equivalent ordered provider list: Claude, Codex, OpenCode Go.
3. Replace `Settings { show_claude_code, show_codex }` runtime logic with a provider-enabled map or compact struct that can hold `opencode_go`.
4. Preserve serde compatibility: deserialize old fields and write new fields only after migration, or keep old fields plus add `show_opencode_go` if map migration is too broad.
5. Extend `BubblePositions` to store OpenCode Go position while keeping old `claude` and `codex` JSON keys.
6. Update tray icon IDs and badge colors for third provider.
7. Add provider-specific display metadata: Claude/Codex mode=`two-window`; OpenCode Go mode=`weekly-monthly-four-bar`.
8. Update app loops to iterate providers instead of hand-writing Claude/Codex branches where this reduces match duplication.
## Success Criteria
- [ ] Existing `settings.json` with only Claude/Codex still loads.
- [ ] If all providers disabled, Claude Code is re-enabled as today.
- [ ] Three providers can have separate positions and tray icon IDs.
- [ ] OpenCode Go can select a custom four-bar renderer without changing Claude/Codex labels.
- [ ] No behavior change for existing Claude Code/Codex users.
## Risk Assessment
Settings migration is the highest regression risk. Mitigation: add serde/default tests with old two-provider JSON and new three-provider JSON.
@@ -0,0 +1,56 @@
---
phase: 3
title: "Implement OpenCode Go provider"
status: pending
priority: P1
effort: "5h"
dependencies: [1, 2]
---
# Phase 3: Implement OpenCode Go Provider
## Overview
Add OpenCode Go credential discovery and polling once Phase 1 proves the source of truth.
## Requirements
- Functional: When enabled and authenticated, OpenCode Go returns weekly/monthly usage percent and reset time, enough for four bars: usage percent + remaining time for each section.
- Non-functional: Do not store or transmit API keys outside OpenCode/OpenCode Go endpoints. Do not mutate OpenCode auth files.
## Architecture
Create an OpenCode Go credential source that reads OpenCode's auth store and a provider implementation that maps verified weekly/monthly usage percent + reset time into a provider-specific snapshot. Do not force OpenCode Go into the existing `UsageWindows.primary/secondary` shape if that would lose the grouped weekly/monthly rendering semantics.
## Related Code Files
- Create: `src/creds/opencode_auth.rs`
- Create: `src/usage/opencode_go.rs`
- Modify: `src/creds/mod.rs`
- Modify: `src/usage/mod.rs`
- Modify: `src/usage/registry.rs`
- Modify: `src/usage/refresh.rs`
- Modify: `src/app.rs`
## Implementation Steps
1. Add `LocalOpenCodeGoCreds` with path detection from Phase 1. Expected primary path: `%LOCALAPPDATA%`/XDG-equivalent for `opencode/auth.json`; verify exact Windows path before coding.
2. Parse only the OpenCode Go auth entry. Support API-key style auth if Phase 1 confirms schema.
3. Add `RefreshHint::LocalOpenCodeCli` and spawn `opencode auth login` or equivalent only if Phase 1 confirms the command.
4. Add `OpenCodeGoProvider::poll`, using the verified usage endpoint/command.
5. Add a provider-specific snapshot type if needed, for example `ProviderUsage::TwoWindow(UsageWindows)` and `ProviderUsage::WeeklyMonthly { weekly: Window, monthly: Window }`.
6. Map returned weekly/monthly source data to usage percent and reset-time windows. Existing renderer can then draw usage and remaining-time bars for each window.
7. Return `AuthRequired` on 401/403 or missing provider auth.
8. Add unit tests for credential parsing and response-to-snapshot mapping.
## Success Criteria
- [ ] Provider compiles behind the existing registry.
- [ ] Missing OpenCode auth produces `NoCredentials`, not panic.
- [ ] Expired/invalid auth produces `AuthRequired`.
- [ ] Weekly/monthly usage percent clamps to 0-100 and remaining-time bars derive from verified reset times.
- [ ] If Phase 1 cannot verify usage, this phase is not implemented.
## Risk Assessment
OpenCode Go limits are dollar-value based. If the usage source returns dollars instead of percentages, compute percent only as `used / limit * 100` from server-provided weekly/monthly values. Never estimate usage from model prices. If reset times are unavailable, stop and ask before shipping remaining-time bars with guessed periods.
@@ -0,0 +1,60 @@
---
phase: 4
title: Rename Models UI to Providers
status: in-progress
priority: P2
effort: 2h
dependencies:
- 2
---
# Phase 4: Rename Models UI To Providers
## Overview
Rename the menu and documentation wording from "Models" to "Providers" where the app is selecting quota sources.
Current result: partial. Provider wording and OpenCode Go listing are implemented. The OpenCode Go four-bar renderer is still blocked by Phase 1 because no stable quota source is available.
## Requirements
- Functional: Context menu uses "Providers" and lists Claude Code, Codex, OpenCode Go.
- Functional: OpenCode Go panel/bubble layout renders two grouped parts: Weekly on top and Monthly below, each with usage percent and remaining-time bars. Claude/Codex keep their current 5h/7d labels.
- Non-functional: Keep real model wording where it means actual LLM models, not provider selection.
## Architecture
This is mostly i18n and README copy. Keep internal `model` variable renames scoped to files touched by provider iteration. Do not churn every `model` local in renderer code just for style.
## Related Code Files
- Modify: `src/i18n/mod.rs`
- Modify: `src/i18n/locales/en.toml`
- Modify: `src/i18n/locales/ja.toml`
- Modify: `src/i18n/locales/ko.toml`
- Modify: `src/i18n/locales/vi.toml`
- Modify: `src/i18n/locales/zh-TW.toml`
- Modify: `src/app.rs`
- Modify: `README.md`
## Implementation Steps
1. Rename `LocaleStrings.models` to `providers`, or keep field name and change values if minimizing code churn is preferred.
2. Update English label to `Providers`; update other locales with best available translation.
3. Add `opencode_go_label = "OpenCode Go"` to all locale files.
4. Add localized generic labels for Weekly and Monthly group headers. Reuse existing usage-percent and remaining-time visual conventions.
5. Add or branch rendering for OpenCode Go's four-bar layout in `bubble.rs` and `panel.rs`; do not distort Claude/Codex two-bar layout.
6. Update README sections: "displayed models" -> "displayed providers"; "### Models" -> "### Providers".
7. Add locale schema tests for the new label.
## Success Criteria
- [x] Right-click menu says Providers.
- [x] README consistently describes selectable services as providers.
- [ ] OpenCode Go UI has Weekly and Monthly grouped sections, each with usage percent and remaining-time bars.
- [x] No accidental rename of OpenCode's documented `/models` command.
- [x] All embedded locale tests pass.
## Risk Assessment
Translations may be imperfect. Mitigation: keep provider product names untranslated and only translate the generic "Providers" label.
@@ -0,0 +1,56 @@
---
phase: 5
title: "Verify and document"
status: pending
priority: P1
effort: "3h"
dependencies: [3, 4]
---
# Phase 5: Verify And Document
## Overview
Prove the third-provider workflow works and document setup/privacy accurately.
## Requirements
- Functional: Claude Code, Codex, and OpenCode Go can be toggled independently; polling and tray refresh do not regress.
- Non-functional: Privacy docs must list every local file read and network endpoint called.
## Architecture
Verification must cover pure tests, build, and Windows manual smoke tests. OpenCode Go cannot be fully claimed without a real authenticated account or a captured fixture from Phase 1.
## Related Code Files
- Modify: `README.md`
- Optional Modify: `docs/release-process.md` only if release steps change
- Read: `src/app.rs`
- Read: `src/settings.rs`
- Read: `src/usage/*`
- Read: `src/creds/*`
## Implementation Steps
1. Run `cargo test`.
2. Run `cargo build --release`.
3. Run app with old settings file and confirm migration/defaults.
4. Run app with all three providers enabled; verify separate bubbles, positions, tray icons, panel labels, and menu toggles.
5. Verify missing OpenCode Go auth shows auth/no-credentials state without breaking other providers.
6. With real OpenCode Go auth, verify weekly/monthly percent and remaining-time bars match the official console/source found in Phase 1.
7. Update README privacy section with OpenCode auth file and endpoints.
## Success Criteria
- [ ] Tests pass.
- [ ] Release build passes.
- [ ] Existing two-provider settings migrate.
- [ ] Three-provider UI works on Windows.
- [ ] OpenCode Go renders four bars: weekly usage percent, weekly remaining time, monthly usage percent, monthly remaining time.
- [ ] README setup/privacy docs are accurate.
- [ ] No unresolved OpenCode Go usage-source uncertainty remains.
## Risk Assessment
Manual verification needs real OpenCode Go auth. If unavailable, ship code only behind disabled-by-default toggle and explicitly mark e2e verification deferred.
@@ -0,0 +1,72 @@
---
title: OpenCode Go provider support and providers terminology
description: >-
Add OpenCode Go as a usage provider and rename user-facing Models wording to
Providers.
status: in-progress
priority: P2
branch: main
tags:
- providers
- opencode-go
- usage
- ui-copy
blockedBy: []
blocks: []
created: '2026-06-02T04:20:10.983Z'
createdBy: 'ck:plan'
source: skill
---
# OpenCode Go Provider Support And Providers Terminology
## Overview
Add OpenCode Go as a third selectable usage provider beside Claude Code and Codex. Rename the app's user-facing "Models" menu/copy to "Providers" because the app chooses quota sources/services, not individual model IDs.
I agree with the rename. OpenCode itself still has a `/models` command, but this app's menu toggles Claude Code, Codex, and OpenCode Go providers. Internals already use `ProviderId`, so the naming is conceptually aligned.
## Codebase Findings
- Rust Win32 app; usage abstraction lives under `src/usage/*`.
- Current extension points: `UsageProvider`, `ProviderId`, `Registry`, `CredentialSource`, `RefreshHint`.
- Two-provider assumptions remain in `src/settings.rs`, `src/app.rs`, `src/tray/mod.rs`, `src/tray/badge.rs`, `src/panel.rs`, `src/usage_color.rs`, i18n TOMLs, and README.
- OpenCode official docs say Go is configured as an OpenCode provider, uses `/connect`, stores credentials in `~/.local/share/opencode/auth.json`, has 5h/weekly/monthly dollar-value limits, and current usage is visible in the console.
- User decision: OpenCode Go should display four bars in two parts: upper part is weekly usage, lower part is monthly usage. Each part has the same two concepts as current Claude/Codex: usage percent and remaining time. Keep Claude/Codex on their existing two-bar presentation unless a separate UI redesign decides otherwise.
- OpenCode docs expose model endpoints and `https://opencode.ai/zen/go/v1/models`, but do not document a stable usage endpoint. Do not fake usage percentages.
- Phase 1 found the OpenCode console computes `weeklyUsage` and `monthlyUsage` with `usagePercent` and `resetInSec`, but only through an authenticated console server query. `opencode stats` is local cost/token history and cannot drive the requested Go bars.
## Related Plans
- `plans/260516-0707-cleanroom-rewrite/plan.md`: introduced current provider abstraction; related, no blocking dependency.
- `plans/260523-ui-ux-improvement-plan/plan.md`: overlaps labels/tooltips; related, no blocking dependency.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Research OpenCode Go contracts](./phase-01-research-opencode-go-contracts.md) | Blocked |
| 2 | [Refactor provider metadata and settings](./phase-02-refactor-provider-metadata-and-settings.md) | Completed |
| 3 | [Implement OpenCode Go provider](./phase-03-implement-opencode-go-provider.md) | Pending |
| 4 | [Rename Models UI to Providers](./phase-04-rename-models-ui-to-providers.md) | In Progress |
| 5 | [Verify and document](./phase-05-verify-and-document.md) | Pending |
## Dependencies
Phase 1 gates Phase 3. If no stable OpenCode Go usage source is found, pause before Phase 3 and ask user whether to defer usage support or ship a limited connectivity/auth detector.
Phase 2 is implemented. Phase 4 provider wording is implemented; OpenCode Go remains listed but disabled until Phase 1's quota-source decision is resolved.
## Success Criteria
- OpenCode Go can be enabled/disabled independently without breaking Claude Code or Codex.
- Existing settings migrate safely; at least one provider remains enabled.
- UI and README say "Providers" where the app means selectable services.
- No guessed OpenCode Go usage. Weekly/monthly percent and reset-time data comes from verified source, or feature pauses.
- `cargo test` and `cargo build --release` pass.
## Sources
- OpenCode Go docs: https://dev.opencode.ai/docs/go/
- OpenCode CLI docs: https://opencode.ai/docs/cli/
- Current repo README: `README.md`
@@ -0,0 +1,42 @@
# Research: OpenCode Go Contracts
Date: 2026-06-02
Status: blocked
## Summary
OpenCode Go provider/model identity is verified, and the official console computes the exact quota fields we want. Implementation should not proceed yet because the verified quota source is an authenticated console server query, not a documented CLI command or public endpoint this desktop app can call safely.
## Verified Facts
- Provider/model ID format: OpenCode Go models use `opencode-go/<model-id>`, for example `opencode-go/kimi-k2.6`.
- Official limits: Go has a rolling 5-hour window, a weekly window, and a monthly window. The requested UI only needs weekly and monthly sections.
- Limits are cost based, not request-count based: weekly `$30`, monthly `$60`, and rolling 5-hour `$12`.
- CLI auth docs say credentials are stored at `~/.local/share/opencode/auth.json`; locally, `opencode providers` is an alias for auth management.
- Local `opencode stats` only aggregates local sessions from the OpenCode database and reports cost/token totals. It does not report Go quota percentages or reset times.
- Console source has the desired shape:
- `weeklyUsage: { status, resetInSec, usagePercent }`
- `monthlyUsage: { status, resetInSec, usagePercent }`
- Console source derives those from `LiteTable.weeklyUsage`, `LiteTable.monthlyUsage`, `LiteTable.timeWeeklyUpdated`, `LiteTable.timeMonthlyUpdated`, `LiteTable.timeCreated`, and `LiteData.getLimits()`.
## Sources
- OpenCode Go docs: https://dev.opencode.ai/docs/go/
- OpenCode CLI docs: https://opencode.ai/docs/cli/
- OpenCode source: `packages/opencode/src/cli/cmd/stats.ts`
- OpenCode source: `packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx`
- OpenCode source: `packages/console/core/src/subscription.ts`
- Local command: `opencode stats --help`
## Decision
Do not implement OpenCode Go four-bar usage yet. The app cannot honestly render weekly/monthly usage percent and remaining time until one of these is chosen:
1. Use an official/detected console endpoint with user-approved authentication.
2. Ship only provider/auth detection now and hide/disable Go usage bars until quota data is available.
3. Ask OpenCode upstream for a documented quota API or CLI output.
## Unresolved Questions
- Should this app integrate with the authenticated OpenCode console, or should Go support be limited to detection until OpenCode exposes a stable quota API?
- Do we still want to continue Phase 2/4 now for the provider terminology rename, while deferring Phase 3?
+56 -114
View File
@@ -66,6 +66,7 @@ const IDM_FREQ_15MIN: u16 = 12;
const IDM_FREQ_1HOUR: u16 = 13;
const IDM_MODEL_CLAUDE: u16 = 20;
const IDM_MODEL_CHATGPT: u16 = 21;
const IDM_MODEL_OPENCODE_GO: u16 = 22;
const IDM_START_WITH_WINDOWS: u16 = 30;
const IDM_RESET_POSITION: u16 = 31;
const IDM_VERSION_ACTION: u16 = 32;
@@ -73,17 +74,12 @@ const IDM_RESTART: u16 = 33;
const IDM_SIZE_SMALLER: u16 = 34;
const IDM_SIZE_LARGER: u16 = 35;
const IDM_RESET_SIZE: u16 = 36;
const IDM_LANG_SYSTEM: u16 = 40;
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
// clear of it (and any future tray ids in the 5x band).
const IDM_UPDATE_AUTO_OFF: u16 = 60;
const IDM_UPDATE_AUTO_HOURLY: u16 = 61;
const IDM_UPDATE_AUTO_DAILY: u16 = 62;
const IDM_UPDATE_AUTO_WEEKLY: u16 = 63;
// IMPORTANT: language ids are dynamic and start at IDM_LANG_BASE.
// Keep IDM_LANG_BASE the highest static id so the catch-all match arm
// stays unambiguous.
const IDM_LANG_BASE: u16 = 100;
// ---------- State ----------
@@ -212,7 +208,7 @@ pub fn run(args: crate::AppArgs) {
};
let settings = settings::load();
let i18n = I18n::load(settings.language.as_deref());
let i18n = I18n::load();
let is_dark = os::theme::is_dark();
let install_channel = update::current_channel();
let http = match net::Client::new(HTTP_USER_AGENT) {
@@ -267,6 +263,7 @@ pub fn run(args: crate::AppArgs) {
if let Ok(exe_path) = std::env::current_exe() {
update::handoff::cleanup_stale_old_exes(&exe_path);
}
update::install::cleanup_staged_update_files();
let poll_interval = lock_state()
.as_ref()
@@ -324,11 +321,10 @@ fn create_initial_bubbles() {
Some(s) => (s.settings.clone(), s.is_dark),
None => return,
};
if settings.show_claude_code {
spawn_bubble(ProviderId::Claude, &settings, is_dark);
}
if settings.show_codex {
spawn_bubble(ProviderId::ChatGpt, &settings, is_dark);
for provider in ProviderId::LIVE_USAGE {
if settings.is_provider_enabled(provider) {
spawn_bubble(provider, &settings, is_dark);
}
}
}
@@ -420,6 +416,7 @@ fn on_menu_command(id: u32, _owner_hwnd: HWND) {
IDM_FREQ_1HOUR => set_poll_interval(POLL_1_HOUR),
IDM_MODEL_CLAUDE => toggle_model(ProviderId::Claude),
IDM_MODEL_CHATGPT => toggle_model(ProviderId::ChatGpt),
IDM_MODEL_OPENCODE_GO => {}
IDM_START_WITH_WINDOWS => toggle_startup(),
IDM_RESET_POSITION => reset_positions(),
IDM_VERSION_ACTION => version_action(),
@@ -434,13 +431,8 @@ fn on_menu_command(id: u32, _owner_hwnd: HWND) {
IDM_UPDATE_AUTO_WEEKLY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
}
IDM_LANG_SYSTEM => set_language(None),
// Static ids in the 30-99 band must match BEFORE the dynamic
// language guard, otherwise `x >= IDM_LANG_BASE` would swallow any
// future id that creeps into the >=100 range.
tray::IDM_TOGGLE_WIDGET => toggle_widget_visibility(),
IDM_RESTART => restart_app(),
x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize),
_ => {}
}
}
@@ -708,7 +700,7 @@ fn build_panel_data_from(snap: &UiSnapshot, model: ProviderId, p: &ProviderUiSta
}
fn placeholder_panel(model: ProviderId) -> PanelData {
let strings = i18n::I18n::load(None).strings().clone();
let strings = i18n::I18n::load().strings().clone();
PanelData {
model,
session_pct: 0.0,
@@ -760,28 +752,23 @@ fn refresh_tray_icons() {
fn refresh_tray_icons_with(snap: &UiSnapshot) {
let mut icons = Vec::new();
if snap.settings.show_claude_code {
let entry = snap.snapshots.get(&ProviderId::Claude);
for provider in ProviderId::LIVE_USAGE {
if !snap.settings.is_provider_enabled(provider) {
continue;
}
let entry = snap.snapshots.get(&provider);
icons.push(TrayIconData {
kind: ProviderId::Claude,
kind: provider,
percent: if snap.last_poll_ok {
entry.map(|e| e.windows.primary.utilization)
} else {
None
},
tooltip: tray_tooltip(&snap.i18n_strings.claude_label, entry, &snap.i18n_strings),
});
}
if snap.settings.show_codex {
let entry = snap.snapshots.get(&ProviderId::ChatGpt);
icons.push(TrayIconData {
kind: ProviderId::ChatGpt,
percent: if snap.last_poll_ok {
entry.map(|e| e.windows.primary.utilization)
} else {
None
},
tooltip: tray_tooltip(&snap.i18n_strings.chatgpt_label, entry, &snap.i18n_strings),
tooltip: tray_tooltip(
&provider_label(provider, &snap.i18n_strings),
entry,
&snap.i18n_strings,
),
});
}
tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
@@ -802,6 +789,14 @@ fn tray_tooltip(label: &str, entry: Option<&ProviderUiState>, strings: &LocaleSt
)
}
fn provider_label(provider: ProviderId, strings: &LocaleStrings) -> String {
match provider {
ProviderId::Claude => strings.claude_label.clone(),
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
ProviderId::OpenCodeGo => strings.opencode_go_label.clone(),
}
}
fn handle_tray_action(action: TrayAction) {
match action {
TrayAction::None => {}
@@ -858,10 +853,7 @@ fn show_threshold_balloon(provider: ProviderId, threshold: u8) {
}
s.last_balloon_at = Some(Instant::now());
let strings = s.i18n.strings();
let provider_label = match provider {
ProviderId::Claude => strings.claude_label.clone(),
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
};
let provider_label = provider_label(provider, strings);
let title = format!("{provider_label} · {threshold}%");
let body = if threshold >= 95 {
strings.threshold_95_body.clone()
@@ -895,6 +887,10 @@ fn show_token_expired_balloon(failed: ProviderId) {
strings.chatgpt_token_expired_title.clone(),
strings.chatgpt_token_expired_body.clone(),
),
ProviderId::OpenCodeGo => (
strings.opencode_go_label.clone(),
strings.update_failed.clone(),
),
};
(s.msg_hwnd, failed, title, body)
};
@@ -928,12 +924,11 @@ fn announce_update_applied(_msg_hwnd: HWND, version: &str) {
struct ContextMenuSnapshot {
strings: LocaleStrings,
available: Vec<(String, String)>,
language_override: Option<String>,
current_interval: u32,
update_check_interval_secs: Option<u64>,
show_claude: bool,
show_chatgpt: bool,
show_opencode_go: bool,
widget_visible: bool,
install_channel: InstallChannel,
update_status: UpdateStatus,
@@ -944,16 +939,11 @@ fn show_context_menu(owner_hwnd: HWND) {
let snap = match lock_state().as_ref() {
Some(s) => ContextMenuSnapshot {
strings: s.i18n.strings().clone(),
available: s
.i18n
.available()
.map(|(c, n)| (c.to_string(), n.to_string()))
.collect(),
language_override: s.settings.language.clone(),
current_interval: s.settings.poll_interval_ms,
update_check_interval_secs: s.settings.update_check_interval_secs,
show_claude: s.settings.show_claude_code,
show_chatgpt: s.settings.show_codex,
show_opencode_go: s.settings.show_opencode_go,
widget_visible: s.settings.widget_visible,
install_channel: s.install_channel,
update_status: s.update_status,
@@ -990,13 +980,13 @@ fn show_context_menu(owner_hwnd: HWND) {
}
append_submenu(menu, freq, &snap.strings.update_frequency);
let Ok(models) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(models) failed");
let Ok(providers) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(providers) failed");
let _ = DestroyMenu(menu);
return;
};
append_item(
models,
providers,
IDM_MODEL_CLAUDE,
&snap.strings.claude_label,
if snap.show_claude {
@@ -1006,7 +996,7 @@ fn show_context_menu(owner_hwnd: HWND) {
},
);
append_item(
models,
providers,
IDM_MODEL_CHATGPT,
&snap.strings.chatgpt_label,
if snap.show_chatgpt {
@@ -1015,7 +1005,17 @@ fn show_context_menu(owner_hwnd: HWND) {
MENU_ITEM_FLAGS(0)
},
);
append_submenu(menu, models, &snap.strings.models);
append_item(
providers,
IDM_MODEL_OPENCODE_GO,
&snap.strings.opencode_go_label,
if snap.show_opencode_go {
MF_CHECKED | MF_GRAYED
} else {
MF_GRAYED
},
);
append_submenu(menu, providers, &snap.strings.providers);
let Ok(settings_menu) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(settings_menu) failed");
@@ -1039,39 +1039,6 @@ fn show_context_menu(owner_hwnd: HWND) {
MENU_ITEM_FLAGS(0),
);
let Ok(lang) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(lang) failed");
let _ = DestroyMenu(settings_menu);
let _ = DestroyMenu(menu);
return;
};
append_item(
lang,
IDM_LANG_SYSTEM,
&snap.strings.system_default,
if snap.language_override.is_none() {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
for (i, (code, name)) in snap.available.iter().enumerate() {
let id = IDM_LANG_BASE + i as u16;
let flags = if snap
.language_override
.as_deref()
.map(|c| c == code)
.unwrap_or(false)
{
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
};
append_item(lang, id, name, flags);
}
append_submenu(settings_menu, lang, &snap.strings.language);
let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null());
let version_label = version_action_label(&snap);
let version_flags = if matches!(
snap.update_status,
@@ -1249,24 +1216,18 @@ fn toggle_model(model: ProviderId) {
let Some(s) = s.as_mut() else {
return;
};
match model {
ProviderId::Claude => s.settings.show_claude_code = !s.settings.show_claude_code,
ProviderId::ChatGpt => s.settings.show_codex = !s.settings.show_codex,
if !model.metadata().live_usage {
return;
}
if !s.settings.show_claude_code && !s.settings.show_codex {
match model {
ProviderId::Claude => s.settings.show_claude_code = true,
ProviderId::ChatGpt => s.settings.show_codex = true,
}
s.settings.toggle_provider(model);
if !s.settings.has_enabled_live_provider() {
s.settings.set_provider_enabled(model, true);
}
(s.settings.clone(), s.is_dark)
};
settings::save(&settings);
let want = match model {
ProviderId::Claude => settings.show_claude_code,
ProviderId::ChatGpt => settings.show_codex,
};
let want = settings.is_provider_enabled(model);
let existing = lock_state()
.as_ref()
.and_then(|s| s.bubbles.get(&model).copied());
@@ -1360,25 +1321,6 @@ fn set_bubble_size(size_logical: i32) {
}
}
fn set_language(_dummy: Option<()>) {
update_settings(|s| {
s.i18n.set_active(None);
s.settings.language = None;
});
propagate_to_ui();
}
fn set_language_by_index(idx: usize) {
update_settings(|s| {
let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string());
if let Some(c) = code.as_deref() {
s.i18n.set_active(Some(c));
}
s.settings.language = code;
});
propagate_to_ui();
}
fn version_action() {
enum Act {
Apply(update::Release, InstallChannel),
+22 -11
View File
@@ -107,6 +107,8 @@ fn window_duration_secs(model: ProviderId, window: UsageWindowKind) -> u64 {
(ProviderId::Claude, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
(ProviderId::ChatGpt, UsageWindowKind::Primary) => FIVE_HOURS_SECS,
(ProviderId::ChatGpt, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
(ProviderId::OpenCodeGo, UsageWindowKind::Primary) => SEVEN_DAYS_SECS,
(ProviderId::OpenCodeGo, UsageWindowKind::Secondary) => 30 * 24 * 60 * 60,
}
}
@@ -1210,7 +1212,9 @@ mod fullscreen_tests {
fn style_bits_allow_popup_or_borderless_windows_only() {
assert!(window_style_bits_allow_fullscreen(WS_POPUP.0));
assert!(window_style_bits_allow_fullscreen(0));
assert!(!window_style_bits_allow_fullscreen(WS_CAPTION.0 | WS_THICKFRAME.0));
assert!(!window_style_bits_allow_fullscreen(
WS_CAPTION.0 | WS_THICKFRAME.0
));
assert!(!window_style_bits_allow_fullscreen(WS_CHILD.0 | WS_POPUP.0));
}
@@ -1242,12 +1246,8 @@ mod fullscreen_tests {
// ---------- Painting ----------
// Sized for the widest countdown across all shipped locales. Korean
// "999시간" (3 digits + 2 CJK chars for the hour suffix) is the current
// worst case; ASCII-only "999d" was too narrow and let CJK text spill
// out of the column. Update this when adding a locale with a longer
// suffix.
const COUNTDOWN_TEMPLATE: &str = "999시간";
// Sized for the widest English countdown text the bubble renders.
const COUNTDOWN_TEMPLATE: &str = "999d";
/// Geometry for the bubble's "circle head + pill tail" shape, in DPI-scaled pixels.
///
@@ -1881,8 +1881,8 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
// Head: 5h countdown text if available, otherwise the static "5h" tag.
// The ring already signals "this is the 5h window", so the countdown
// is the more useful glanceable info when we have it. Fall back to
// "5h" when the localized countdown would overflow the rect (e.g.,
// wide CJK strings like "999시간" at the 140-logical minimum width)
// "5h" when the countdown would overflow the rect at the
// 140-logical minimum width —
// DT_NOCLIP would otherwise leak the glyphs onto the ring stroke.
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
let head_label_rect_w = layout.head_label_rect.right - layout.head_label_rect.left;
@@ -1922,7 +1922,12 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
}
SetTextColor(hdc, COLORREF(color.into_colorref()));
let weekly_pct_text = format!("{:.0}%", pct);
draw_tail_text_in_rect(hdc, &layout.tail_usage_pct_rect, &weekly_pct_text, DT_RIGHT);
draw_tail_text_in_rect(
hdc,
&layout.tail_usage_pct_rect,
&weekly_pct_text,
DT_RIGHT,
);
}
}
@@ -1932,7 +1937,12 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
SelectObject(hdc, main_font);
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
if !inputs.weekly_text.is_empty() {
draw_tail_text_in_rect(hdc, &layout.tail_time_text_rect, &inputs.weekly_text, DT_RIGHT);
draw_tail_text_in_rect(
hdc,
&layout.tail_time_text_rect,
&inputs.weekly_text,
DT_RIGHT,
);
}
SelectObject(hdc, prev_font);
@@ -2022,6 +2032,7 @@ fn default_position(width_px: i32, height_px: i32, model: ProviderId) -> (i32, i
let stagger = match model {
ProviderId::Claude => 0,
ProviderId::ChatGpt => height_px + gap,
ProviderId::OpenCodeGo => 2 * (height_px + gap),
};
let x = wa.right - width_px - gap;
let y = wa.bottom - height_px - gap - stagger;
+1 -2
View File
@@ -51,8 +51,7 @@ impl super::CredentialSource for LocalClaudeCreds {
}
}
/// Shared between local-fs and wsl-bridge sources — both parse the same
/// JSON shape, the only difference is how they get to the file content.
/// Parse the Claude credential JSON shape written by the official CLI.
pub(crate) fn parse_claude_json(content: &str) -> Result<super::Token, super::Error> {
let value: serde_json::Value = serde_json::from_str(content)?;
let oauth = value
+3 -14
View File
@@ -1,13 +1,12 @@
// Pluggable credential discovery.
//
// Each `CredentialSource` knows how to read a single OAuth token from
// somewhere (a local JSON file, a WSL filesystem, …). The `Locator`
// somewhere, such as a local JSON file. The `Locator`
// holds a priority-ordered list and serves the first source that
// actually has a token. New sources drop in without touching the locator.
pub mod codex_auth;
pub mod local_fs;
pub mod wsl_bridge;
#[derive(Clone, Debug)]
pub struct Token {
@@ -23,8 +22,6 @@ pub struct Token {
pub enum RefreshHint {
/// `claude.cmd` / `claude.exe` on PATH.
LocalClaudeCli,
/// Run `claude -p .` inside a specific WSL distro.
WslClaudeCli { distro: String },
/// `codex` / `codex.cmd` / `codex.ps1` on PATH.
LocalCodexCli,
}
@@ -37,10 +34,6 @@ pub enum Error {
Json(#[from] serde_json::Error),
#[error("required field missing from credential JSON: {0}")]
MissingField(&'static str),
#[error("WSL command in {distro:?} failed: {detail}")]
WslCommand { distro: String, detail: String },
#[error("timeout while talking to WSL")]
WslTimeout,
#[error("credential source unavailable")]
Unavailable,
}
@@ -50,7 +43,7 @@ pub trait CredentialSource: Send + Sync {
/// signatures (e.g. `"local:C:\\Users\\me\\.claude\\.credentials.json"`).
fn id(&self) -> &str;
/// Read the current token. May spawn subprocesses (for WSL).
/// Read the current token.
fn read(&self) -> Result<Token, Error>;
/// Cheap change-detection fingerprint. `None` means "source is missing".
@@ -70,16 +63,12 @@ impl Locator {
Self { sources }
}
/// Build a Claude locator with the standard search order: Windows
/// home directory first, then every installed WSL distro.
/// Build a Claude locator with the standard Windows credential path.
pub fn for_claude() -> Self {
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
if let Some(s) = local_fs::LocalClaudeCreds::detect() {
sources.push(Box::new(s));
}
for distro in wsl_bridge::list_distros() {
sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro)));
}
Self { sources }
}
-155
View File
@@ -1,155 +0,0 @@
// Reach into installed WSL distros to read their Claude credentials.
//
// We never mount the WSL filesystem ourselves — instead we shell out to
// `wsl.exe -d <distro> -- sh -lc '...'` and read stdout. Every call has
// a hard timeout so a hung WSL doesn't freeze the poll thread.
use std::os::windows::process::CommandExt;
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
use super::local_fs::parse_claude_json;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
/// Enumerate installed WSL distributions. Returns an empty vec if WSL is
/// not installed or the probe fails.
pub fn list_distros() -> Vec<String> {
let Some(output) = run_with_timeout(
Command::new("wsl.exe").args(["-l", "-q"]),
COMMAND_TIMEOUT,
) else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
decode_wsl_text(&output.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(ToOwned::to_owned)
.collect()
}
pub struct WslClaudeCreds {
distro: String,
id: String,
}
impl WslClaudeCreds {
pub fn new(distro: String) -> Self {
let id = format!("wsl:{distro}");
Self { distro, id }
}
pub fn distro(&self) -> &str {
&self.distro
}
}
impl super::CredentialSource for WslClaudeCreds {
fn id(&self) -> &str {
&self.id
}
fn read(&self) -> Result<super::Token, super::Error> {
let output = wsl_run(&self.distro, "cat ~/.claude/.credentials.json")?;
if !output.status.success() {
return Err(super::Error::WslCommand {
distro: self.distro.clone(),
detail: format!("cat exited {}", output.status),
});
}
let content = String::from_utf8(output.stdout).map_err(|_| super::Error::WslCommand {
distro: self.distro.clone(),
detail: "non-UTF-8 stdout".into(),
})?;
parse_claude_json(&content)
}
fn signature(&self) -> Option<String> {
let output = wsl_run(
&self.distro,
"if [ -f ~/.claude/.credentials.json ]; then \
stat -c '%s|%Y' ~/.claude/.credentials.json; \
else echo MISSING; fi",
)
.ok()?;
if !output.status.success() {
return None;
}
let body = decode_wsl_text(&output.stdout).trim().to_string();
if body == "MISSING" {
return None;
}
Some(format!("{}|{}", self.id, body))
}
fn refresh_hint(&self) -> super::RefreshHint {
super::RefreshHint::WslClaudeCli {
distro: self.distro.clone(),
}
}
}
fn wsl_run(distro: &str, script: &str) -> Result<Output, super::Error> {
run_with_timeout(
Command::new("wsl.exe")
.arg("-d")
.arg(distro)
.arg("--")
.arg("sh")
.arg("-lc")
.arg(script),
COMMAND_TIMEOUT,
)
.ok_or(super::Error::WslTimeout)
}
fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option<Output> {
let mut child = cmd
.creation_flags(CREATE_NO_WINDOW)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return child.wait_with_output().ok(),
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(80));
}
Err(_) => return None,
}
}
}
/// `wsl.exe -l -q` historically emits UTF-16LE on stdout; other commands
/// emit UTF-8. Detect by sampling high bytes and decode appropriately.
pub(crate) fn decode_wsl_text(bytes: &[u8]) -> String {
if bytes.len() >= 2 && bytes.len() % 2 == 0 {
let sample_end = bytes.len().min(256);
let mut high_nul = 0usize;
for chunk in bytes[..sample_end].chunks_exact(2) {
if chunk[1] == 0 {
high_nul += 1;
}
}
if high_nul * 2 >= sample_end / 2 {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
return String::from_utf16_lossy(&units);
}
}
String::from_utf8_lossy(bytes).into_owned()
}
-73
View File
@@ -1,73 +0,0 @@
// Discover the user's preferred Windows UI language.
//
// We try three sources in priority order and return the first non-empty
// result. Callers normalise the returned BCP-47-ish code against the
// list of locales we actually ship.
use windows::core::PWSTR;
use windows::Win32::Globalization::{
GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages,
LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME,
};
/// First non-empty locale code from the user's preferences. May be
/// `Some("en-US")` style; callers do prefix normalisation.
pub fn detect_system_locale() -> Option<String> {
preferred_ui()
.into_iter()
.next()
.or_else(default_ui_language)
.or_else(default_locale_name)
}
fn preferred_ui() -> Vec<String> {
unsafe {
let mut count: u32 = 0;
let mut buf_len: u32 = 0;
if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &mut count, PWSTR::null(), &mut buf_len)
.is_err()
|| buf_len == 0
{
return Vec::new();
}
let mut buffer = vec![0u16; buf_len as usize];
if GetUserPreferredUILanguages(
MUI_LANGUAGE_NAME,
&mut count,
PWSTR(buffer.as_mut_ptr()),
&mut buf_len,
)
.is_err()
{
return Vec::new();
}
buffer
.split(|u| *u == 0)
.filter(|s| !s.is_empty())
.map(String::from_utf16_lossy)
.collect()
}
}
fn default_ui_language() -> Option<String> {
unsafe {
let lcid = GetUserDefaultUILanguage();
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
let len = LCIDToLocaleName(lcid as u32, Some(&mut buf), LOCALE_ALLOW_NEUTRAL_NAMES);
if len <= 1 {
return None;
}
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
}
}
fn default_locale_name() -> Option<String> {
unsafe {
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
let len = GetUserDefaultLocaleName(&mut buf);
if len <= 1 {
return None;
}
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
}
}
-59
View File
@@ -1,59 +0,0 @@
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"
size_smaller = "Make smaller"
size_larger = "Make larger"
reset_size = "Reset size"
controls = "Controls"
control_left_click = "Left-click: details"
control_right_click = "Right-click: menu"
control_drag = "Drag: move/snap"
control_ctrl_wheel = "Ctrl+Wheel: resize"
control_tray_click = "Tray click: show/hide"
tray_left_click = "Left-click: show/hide"
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"
auto_update_check = "Auto-update check"
auto_check_disabled = "Disabled"
auto_check_hourly = "Hourly"
auto_check_daily = "Daily"
auto_check_weekly = "Weekly"
exit = "Exit"
restart = "Restart"
show_widget = "Show widget"
session_window = "5h"
weekly_window = "7d"
now = "now"
day_suffix = "d"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Claude Code session expired"
token_expired_body = "Sign in again to keep tracking your usage."
chatgpt_token_expired_title = "Codex session expired"
chatgpt_token_expired_body = "Sign in again to keep tracking your usage."
threshold_80_body = "Approaching the 5-hour limit."
threshold_95_body = "Limit is close — consider easing up."
update_applied_title = "Update applied"
update_applied_body = "Updated to v"
update_rollback_failed_body = "Update failed. Your original binary is saved at: "
-59
View File
@@ -1,59 +0,0 @@
code = "ja"
native_name = "日本語"
window_title = "Claude Code Usage Bubble"
refresh = "更新"
update_frequency = "更新間隔"
one_minute = "1分"
five_minutes = "5分"
fifteen_minutes = "15分"
one_hour = "1時間"
models = "モデル"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "設定"
start_with_windows = "Windows起動時に開始"
reset_position = "位置をリセット"
size_smaller = "小さくする"
size_larger = "大きくする"
reset_size = "サイズをリセット"
controls = "操作"
control_left_click = "左クリック: 詳細"
control_right_click = "右クリック: メニュー"
control_drag = "ドラッグ: 移動/吸着"
control_ctrl_wheel = "Ctrl+ホイール: サイズ変更"
control_tray_click = "トレイ左クリック: 表示/非表示"
tray_left_click = "左クリック: 表示/非表示"
language = "言語"
system_default = "システム既定"
check_for_updates = "更新を確認"
checking_for_updates = "確認中…"
up_to_date = "最新です"
update_failed = "更新に失敗しました"
applying_update = "更新を適用中…"
update_available = "更新あり"
update_via_winget = "WinGet経由"
auto_update_check = "更新の自動確認"
auto_check_disabled = "無効"
auto_check_hourly = "1時間ごと"
auto_check_daily = "毎日"
auto_check_weekly = "毎週"
exit = "終了"
restart = "再起動"
show_widget = "ウィジェットを表示"
session_window = "5時間"
weekly_window = "7日"
now = "今"
day_suffix = "日"
hour_suffix = "時"
minute_suffix = "分"
second_suffix = "秒"
token_expired_title = "Claude Codeのセッションが切れました"
token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
chatgpt_token_expired_title = "Codexのセッションが切れました"
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
threshold_80_body = "5時間の上限に近づいています。"
threshold_95_body = "上限に近づきました — ペースを落としましょう。"
update_applied_title = "更新を適用しました"
update_applied_body = "バージョン v"
update_rollback_failed_body = "更新に失敗しました。元のバイナリは次の場所に保存されています: "
-59
View File
@@ -1,59 +0,0 @@
code = "ko"
native_name = "한국어"
window_title = "Claude Code Usage Bubble"
refresh = "새로 고침"
update_frequency = "업데이트 주기"
one_minute = "1분"
five_minutes = "5분"
fifteen_minutes = "15분"
one_hour = "1시간"
models = "모델"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "설정"
start_with_windows = "Windows 시작 시 실행"
reset_position = "위치 초기화"
size_smaller = "작게"
size_larger = "크게"
reset_size = "크기 초기화"
controls = "조작"
control_left_click = "왼쪽 클릭: 상세"
control_right_click = "오른쪽 클릭: 메뉴"
control_drag = "드래그: 이동/스냅"
control_ctrl_wheel = "Ctrl+휠: 크기 조절"
control_tray_click = "트레이 클릭: 표시/숨김"
tray_left_click = "왼쪽 클릭: 표시/숨김"
language = "언어"
system_default = "시스템 기본값"
check_for_updates = "업데이트 확인"
checking_for_updates = "확인 중…"
up_to_date = "최신 상태"
update_failed = "업데이트 실패"
applying_update = "업데이트 적용 중…"
update_available = "업데이트 있음"
update_via_winget = "WinGet 사용"
auto_update_check = "업데이트 자동 확인"
auto_check_disabled = "사용 안 함"
auto_check_hourly = "매시간"
auto_check_daily = "매일"
auto_check_weekly = "매주"
exit = "종료"
restart = "다시 시작"
show_widget = "위젯 표시"
session_window = "5시간"
weekly_window = "7일"
now = "지금"
day_suffix = "일"
hour_suffix = "시간"
minute_suffix = "분"
second_suffix = "초"
token_expired_title = "Claude Code 세션 만료"
token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
chatgpt_token_expired_title = "Codex 세션 만료"
chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
threshold_80_body = "5시간 한도에 가까워지고 있어요."
threshold_95_body = "한도 임박 — 잠시 쉬어가세요."
update_applied_title = "업데이트가 적용되었습니다"
update_applied_body = "버전 v"
update_rollback_failed_body = "업데이트 실패. 원본 바이너리는 다음 위치에 저장되었습니다: "
-59
View File
@@ -1,59 +0,0 @@
code = "vi"
native_name = "Tiếng Việt"
window_title = "Claude Code Usage Bubble"
refresh = "Làm mới"
update_frequency = "Tần suất cập nhật"
one_minute = "1 phút"
five_minutes = "5 phút"
fifteen_minutes = "15 phút"
one_hour = "1 giờ"
models = "Mô hình"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Cài đặt"
start_with_windows = "Khởi động cùng Windows"
reset_position = "Đặt lại vị trí"
size_smaller = "Thu nhỏ"
size_larger = "Phóng to"
reset_size = "Đặt lại kích thước"
controls = "Điều khiển"
control_left_click = "Nhấp trái: chi tiết"
control_right_click = "Nhấp phải: menu"
control_drag = "Kéo: di chuyển/bám"
control_ctrl_wheel = "Ctrl+cuộn: đổi cỡ"
control_tray_click = "Khay: hiện/ẩn"
tray_left_click = "Nhấp trái: hiện/ẩn"
language = "Ngôn ngữ"
system_default = "Mặc định hệ thống"
check_for_updates = "Kiểm tra cập nhật"
checking_for_updates = "Đang kiểm tra cập nhật…"
up_to_date = "Đã là phiên bản mới nhất"
update_failed = "Cập nhật thất bại"
applying_update = "Đang áp dụng cập nhật…"
update_available = "Có bản cập nhật mới"
update_via_winget = "qua WinGet"
auto_update_check = "Tự động kiểm tra cập nhật"
auto_check_disabled = "Tắt"
auto_check_hourly = "Mỗi giờ"
auto_check_daily = "Hằng ngày"
auto_check_weekly = "Hằng tuần"
exit = "Thoát"
restart = "Khởi động lại"
show_widget = "Hiện widget"
session_window = "5g"
weekly_window = "7n"
now = "ngay"
day_suffix = "n"
hour_suffix = "g"
minute_suffix = "p"
second_suffix = "s"
token_expired_title = "Phiên Claude Code đã hết hạn"
token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
chatgpt_token_expired_title = "Phiên Codex đã hết hạn"
chatgpt_token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
threshold_80_body = "Sắp chạm giới hạn 5 giờ."
threshold_95_body = "Sắp tới giới hạn — hãy cân nhắc giảm tốc."
update_applied_title = "Đã áp dụng cập nhật"
update_applied_body = "Đã cập nhật lên v"
update_rollback_failed_body = "Cập nhật thất bại. Tệp gốc của bạn được lưu tại: "
-59
View File
@@ -1,59 +0,0 @@
code = "zh-TW"
native_name = "繁體中文"
window_title = "Claude Code Usage Bubble"
refresh = "重新整理"
update_frequency = "更新頻率"
one_minute = "1 分鐘"
five_minutes = "5 分鐘"
fifteen_minutes = "15 分鐘"
one_hour = "1 小時"
models = "模型"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "設定"
start_with_windows = "隨 Windows 啟動"
reset_position = "重設位置"
size_smaller = "縮小"
size_larger = "放大"
reset_size = "重設大小"
controls = "操作"
control_left_click = "左鍵: 詳細"
control_right_click = "右鍵: 選單"
control_drag = "拖曳: 移動/吸附"
control_ctrl_wheel = "Ctrl+滾輪: 調整大小"
control_tray_click = "系統匣: 顯示/隱藏"
tray_left_click = "左鍵: 顯示/隱藏"
language = "語言"
system_default = "系統預設"
check_for_updates = "檢查更新"
checking_for_updates = "檢查中…"
up_to_date = "已是最新"
update_failed = "更新失敗"
applying_update = "正在套用更新…"
update_available = "有可用更新"
update_via_winget = "透過 WinGet"
auto_update_check = "自動檢查更新"
auto_check_disabled = "停用"
auto_check_hourly = "每小時"
auto_check_daily = "每天"
auto_check_weekly = "每週"
exit = "結束"
restart = "重新啟動"
show_widget = "顯示小工具"
session_window = "5 小時"
weekly_window = "7 日"
now = "現在"
day_suffix = "日"
hour_suffix = "時"
minute_suffix = "分"
second_suffix = "秒"
token_expired_title = "Claude Code 工作階段已過期"
token_expired_body = "請重新登入以繼續追蹤使用量。"
chatgpt_token_expired_title = "Codex 工作階段已過期"
chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。"
threshold_80_body = "接近 5 小時上限。"
threshold_95_body = "上限將至 — 建議稍作休息。"
update_applied_title = "已套用更新"
update_applied_body = "已更新至 v"
update_rollback_failed_body = "更新失敗。您的原始執行檔已保存於: "
+87 -191
View File
@@ -1,25 +1,9 @@
// Embedded TOML-based localisation.
//
// Each supported language lives in `locales/<code>.toml`. At startup we
// `include_str!` every file, parse them with `toml`, and stash them in a
// HashMap keyed by language code. The active language defaults to whatever
// Windows reports for the user's preferred UI language; the menu lets the
// user override that.
//
// Adding a translation: copy `en.toml` to `<code>.toml`, translate the
// strings, then add one `include_str!` entry to `RAW_LOCALES` below.
// English UI strings and formatting helpers.
use std::collections::BTreeMap;
use std::time::{Duration, SystemTime};
use serde::Deserialize;
pub mod detect;
const FALLBACK_CODE: &str = "en";
/// The strings every UI module needs. Field names map 1:1 to TOML keys.
#[derive(Clone, Debug, Deserialize)]
/// The strings every UI module needs.
#[derive(Clone, Debug)]
pub struct LocaleStrings {
pub window_title: String,
pub refresh: String,
@@ -28,9 +12,10 @@ pub struct LocaleStrings {
pub five_minutes: String,
pub fifteen_minutes: String,
pub one_hour: String,
pub models: String,
pub providers: String,
pub claude_label: String,
pub chatgpt_label: String,
pub opencode_go_label: String,
pub settings: String,
pub start_with_windows: String,
pub reset_position: String,
@@ -44,8 +29,6 @@ pub struct LocaleStrings {
pub control_ctrl_wheel: String,
pub control_tray_click: String,
pub tray_left_click: String,
pub language: String,
pub system_default: String,
pub check_for_updates: String,
pub checking_for_updates: String,
pub up_to_date: String,
@@ -73,147 +56,93 @@ pub struct LocaleStrings {
pub chatgpt_token_expired_title: String,
pub chatgpt_token_expired_body: String,
/// Body text for "your usage just crossed 80% of the 5h limit". The
/// title is composed from the provider label + percent so it does not
/// need to be translated separately.
/// title is composed from the provider label + percent.
pub threshold_80_body: String,
/// Body text for the 95% threshold balloon.
pub threshold_95_body: String,
/// Title for the tray balloon shown on first launch after an auto-update.
pub update_applied_title: String,
/// Prefix for the tray balloon body. Call site appends the version (e.g. "0.1.10").
/// Prefix for the tray balloon body. Call site appends the version.
pub update_applied_body: String,
/// Prefix for the rollback-failed MessageBox body. Call site appends
/// the backup path and a separator with the expected target filename.
pub update_rollback_failed_body: String,
}
#[derive(Deserialize)]
struct LocaleFile {
code: String,
native_name: String,
#[serde(flatten)]
pub struct I18n {
strings: LocaleStrings,
}
const RAW_LOCALES: &[(&str, &str)] = &[
("en", include_str!("locales/en.toml")),
("ja", include_str!("locales/ja.toml")),
("ko", include_str!("locales/ko.toml")),
("vi", include_str!("locales/vi.toml")),
("zh-TW", include_str!("locales/zh-TW.toml")),
];
pub struct I18n {
/// Sorted by code so menus list languages deterministically.
available: BTreeMap<String, (String, LocaleStrings)>,
active: String,
}
impl I18n {
/// Load all embedded TOML files and pick an active language.
///
/// `requested` overrides system detection. `None` means "ask Windows".
pub fn load(requested: Option<&str>) -> Self {
let mut available = BTreeMap::new();
for (code, body) in RAW_LOCALES {
match toml::from_str::<LocaleFile>(body) {
Ok(file) => {
available.insert(file.code.clone(), (file.native_name, file.strings));
}
Err(e) => {
log::error!("failed to parse locale {code}: {e}");
}
}
pub fn load() -> Self {
Self {
strings: english_strings(),
}
if !available.contains_key(FALLBACK_CODE) {
// Embedded TOMLs are validated by tests; this should never
// happen in practice. Fall through with whatever we have.
log::error!("fallback locale '{FALLBACK_CODE}' missing");
}
let active = requested
.and_then(|c| normalise(c, &available))
.or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &available)))
.unwrap_or_else(|| FALLBACK_CODE.to_string());
Self { available, active }
}
pub fn strings(&self) -> &LocaleStrings {
self.available
.get(&self.active)
.map(|(_, s)| s)
.unwrap_or_else(|| {
// Defensive: if `active` was set to something unavailable
// (shouldn't happen given `load` validates) — fall back.
&self
.available
.get(FALLBACK_CODE)
.expect("fallback locale must exist")
.1
})
}
pub fn active_code(&self) -> &str {
&self.active
}
/// Iterate `(code, native_name)` pairs in stable order.
pub fn available(&self) -> impl Iterator<Item = (&str, &str)> {
self.available
.iter()
.map(|(code, (name, _))| (code.as_str(), name.as_str()))
}
pub fn set_active(&mut self, requested: Option<&str>) {
let new_active = requested
.and_then(|c| normalise(c, &self.available))
.or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &self.available)))
.unwrap_or_else(|| FALLBACK_CODE.to_string());
self.active = new_active;
&self.strings
}
}
/// Resolve a user-supplied or system-supplied locale code to one we have.
///
/// Handles `en_US`, `en-US`, `EN`, `zh-Hant-TW`, etc. by progressive
/// fallback: exact → ASCII-lower exact → prefix match.
fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>) -> Option<String> {
let cleaned = input.trim().replace('_', "-");
if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("system") {
return None;
fn english_strings() -> LocaleStrings {
LocaleStrings {
window_title: "Claude Code Usage Bubble".into(),
refresh: "Refresh".into(),
update_frequency: "Update frequency".into(),
one_minute: "1 minute".into(),
five_minutes: "5 minutes".into(),
fifteen_minutes: "15 minutes".into(),
one_hour: "1 hour".into(),
providers: "Providers".into(),
claude_label: "Claude Code".into(),
chatgpt_label: "Codex".into(),
opencode_go_label: "OpenCode Go".into(),
settings: "Settings".into(),
start_with_windows: "Start with Windows".into(),
reset_position: "Reset position".into(),
size_smaller: "Make smaller".into(),
size_larger: "Make larger".into(),
reset_size: "Reset size".into(),
controls: "Controls".into(),
control_left_click: "Left-click: details".into(),
control_right_click: "Right-click: menu".into(),
control_drag: "Drag: move/snap".into(),
control_ctrl_wheel: "Ctrl+Wheel: resize".into(),
control_tray_click: "Tray click: show/hide".into(),
tray_left_click: "Left-click: show/hide".into(),
check_for_updates: "Check for updates".into(),
checking_for_updates: "Checking for updates...".into(),
up_to_date: "Up to date".into(),
update_failed: "Update failed".into(),
applying_update: "Applying update...".into(),
update_available: "Update available".into(),
update_via_winget: "via WinGet".into(),
auto_update_check: "Auto-update check".into(),
auto_check_disabled: "Disabled".into(),
auto_check_hourly: "Hourly".into(),
auto_check_daily: "Daily".into(),
auto_check_weekly: "Weekly".into(),
exit: "Exit".into(),
restart: "Restart".into(),
show_widget: "Show widget".into(),
session_window: "5h".into(),
weekly_window: "7d".into(),
now: "now".into(),
day_suffix: "d".into(),
hour_suffix: "h".into(),
minute_suffix: "m".into(),
second_suffix: "s".into(),
token_expired_title: "Claude Code session expired".into(),
token_expired_body: "Sign in again to keep tracking your usage.".into(),
chatgpt_token_expired_title: "Codex session expired".into(),
chatgpt_token_expired_body: "Sign in again to keep tracking your usage.".into(),
threshold_80_body: "Approaching the 5-hour limit.".into(),
threshold_95_body: "Limit is close - consider easing up.".into(),
update_applied_title: "Update applied".into(),
update_applied_body: "Updated to v".into(),
update_rollback_failed_body: "Update failed. Your original binary is saved at: ".into(),
}
// Exact (case-insensitive)
for key in available.keys() {
if key.eq_ignore_ascii_case(&cleaned) {
return Some(key.clone());
}
}
// Special-case: Traditional Chinese variants → zh-TW
let lower = cleaned.to_ascii_lowercase();
if lower.starts_with("zh")
&& (lower.contains("tw") || lower.contains("hk") || lower.contains("hant"))
{
if available.contains_key("zh-TW") {
return Some("zh-TW".to_string());
}
}
// Prefix fallback (e.g. "en-US" → "en")
let prefix = lower.split('-').next().unwrap_or("");
if !prefix.is_empty() {
for key in available.keys() {
if key
.split('-')
.next()
.map(str::to_ascii_lowercase)
.as_deref()
== Some(prefix)
{
return Some(key.clone());
}
}
}
None
}
// ---------- Free-function helpers ----------
@@ -281,57 +210,24 @@ mod tests {
use super::*;
#[test]
fn embedded_locales_parse_and_include_required_control_strings() {
let mut has_fallback = false;
for (expected_code, body) in RAW_LOCALES {
let file = toml::from_str::<LocaleFile>(body)
.unwrap_or_else(|e| panic!("locale {expected_code} failed to parse: {e}"));
assert_eq!(file.code, *expected_code);
has_fallback |= file.code == FALLBACK_CODE;
let strings = file.strings;
for (name, value) in [
("size_smaller", strings.size_smaller.as_str()),
("size_larger", strings.size_larger.as_str()),
("reset_size", strings.reset_size.as_str()),
("controls", strings.controls.as_str()),
("control_left_click", strings.control_left_click.as_str()),
("control_right_click", strings.control_right_click.as_str()),
("control_drag", strings.control_drag.as_str()),
("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()),
("control_tray_click", strings.control_tray_click.as_str()),
("tray_left_click", strings.tray_left_click.as_str()),
] {
assert!(
!value.trim().is_empty(),
"locale {expected_code} has empty {name}"
);
}
fn english_strings_include_required_menu_labels() {
let strings = english_strings();
for (name, value) in [
("refresh", strings.refresh.as_str()),
("providers", strings.providers.as_str()),
("settings", strings.settings.as_str()),
("size_smaller", strings.size_smaller.as_str()),
("size_larger", strings.size_larger.as_str()),
("reset_size", strings.reset_size.as_str()),
("controls", strings.controls.as_str()),
("control_left_click", strings.control_left_click.as_str()),
("control_right_click", strings.control_right_click.as_str()),
("control_drag", strings.control_drag.as_str()),
("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()),
("control_tray_click", strings.control_tray_click.as_str()),
("tray_left_click", strings.tray_left_click.as_str()),
] {
assert!(!value.trim().is_empty(), "empty string: {name}");
}
assert!(has_fallback, "fallback locale {FALLBACK_CODE} missing");
}
#[test]
fn locale_schema_rejects_missing_or_malformed_control_strings() {
let (_, fallback_body) = RAW_LOCALES
.iter()
.find(|(code, _)| *code == FALLBACK_CODE)
.expect("fallback locale fixture must exist");
let missing_control =
fallback_body.replace("tray_left_click = \"Left-click: show/hide\"\n", "");
assert!(
toml::from_str::<LocaleFile>(&missing_control).is_err(),
"missing tray_left_click should fail locale deserialization"
);
let malformed_control = fallback_body.replace(
"control_tray_click = \"Tray click: show/hide\"",
"control_tray_click = [\"Tray click: show/hide\"]",
);
assert!(
toml::from_str::<LocaleFile>(&malformed_control).is_err(),
"malformed control_tray_click should fail locale deserialization"
);
}
}
+3 -5
View File
@@ -317,6 +317,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
let header = match data.model {
ProviderId::Claude => data.strings.claude_label.clone(),
ProviderId::ChatGpt => data.strings.chatgpt_label.clone(),
ProviderId::OpenCodeGo => data.strings.opencode_go_label.clone(),
};
draw_text(
hdc,
@@ -331,11 +332,8 @@ fn paint(hwnd: HWND, hdc: HDC) {
);
let bar_x = scaled(PADDING_LOGICAL) + scaled(LABEL_W_LOGICAL) + scaled(4);
let bar_w = rc.right
- bar_x
- scaled(PADDING_LOGICAL)
- scaled(RIGHT_TEXT_W_LOGICAL)
- scaled(4);
let bar_w =
rc.right - bar_x - scaled(PADDING_LOGICAL) - scaled(RIGHT_TEXT_W_LOGICAL) - scaled(4);
let row1_y = scaled(PADDING_LOGICAL) + scaled(24);
let row2_y = row1_y + scaled(BAR_HEIGHT_LOGICAL) + scaled(ROW_GAP_LOGICAL) + scaled(8);
+120 -16
View File
@@ -31,6 +31,9 @@ fn default_show_claude() -> bool {
fn default_show_codex() -> bool {
false
}
fn default_show_opencode_go() -> bool {
false
}
fn default_widget_visible() -> bool {
true
}
@@ -48,30 +51,35 @@ fn default_update_check_interval_secs() -> Option<u64> {
pub struct BubblePositions {
pub claude: Option<(i32, i32)>,
pub codex: Option<(i32, i32)>,
pub opencode_go: Option<(i32, i32)>,
}
impl BubblePositions {
pub fn get(&self, model: ProviderId) -> Option<(i32, i32)> {
match model {
pub fn get(&self, provider: ProviderId) -> Option<(i32, i32)> {
match provider {
ProviderId::Claude => self.claude,
ProviderId::ChatGpt => self.codex,
ProviderId::OpenCodeGo => self.opencode_go,
}
}
pub fn set(&mut self, model: ProviderId, pos: (i32, i32)) {
match model {
pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) {
match provider {
ProviderId::Claude => self.claude = Some(pos),
ProviderId::ChatGpt => self.codex = Some(pos),
ProviderId::OpenCodeGo => self.opencode_go = Some(pos),
}
}
pub fn reset(&mut self, model: ProviderId) {
match model {
pub fn reset(&mut self, provider: ProviderId) {
match provider {
ProviderId::Claude => self.claude = None,
ProviderId::ChatGpt => self.codex = None,
ProviderId::OpenCodeGo => self.opencode_go = None,
}
}
pub fn reset_all(&mut self) {
self.claude = None;
self.codex = None;
self.opencode_go = None;
}
/// Drop any saved position whose top-left no longer falls on a connected
@@ -80,16 +88,26 @@ impl BubblePositions {
pub fn validate(&mut self) {
if let Some((x, y)) = self.claude {
if !position_on_any_monitor(x, y) {
log::warn!("bubble position claude ({x},{y}) outside all monitors; resetting to default");
log::warn!(
"bubble position claude ({x},{y}) outside all monitors; resetting to default"
);
self.claude = None;
}
}
if let Some((x, y)) = self.codex {
if !position_on_any_monitor(x, y) {
log::warn!("bubble position codex ({x},{y}) outside all monitors; resetting to default");
log::warn!(
"bubble position codex ({x},{y}) outside all monitors; resetting to default"
);
self.codex = None;
}
}
if let Some((x, y)) = self.opencode_go {
if !position_on_any_monitor(x, y) {
log::warn!("bubble position opencode-go ({x},{y}) outside all monitors; resetting to default");
self.opencode_go = None;
}
}
}
}
@@ -112,6 +130,8 @@ pub struct Settings {
pub show_claude_code: bool,
#[serde(default = "default_show_codex")]
pub show_codex: bool,
#[serde(default = "default_show_opencode_go")]
pub show_opencode_go: bool,
#[serde(default)]
pub bubble_positions: BubblePositions,
#[serde(default = "default_bubble_size")]
@@ -119,8 +139,6 @@ pub struct Settings {
#[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u32,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub last_update_check_unix: Option<u64>,
#[serde(default = "default_update_check_interval_secs")]
pub update_check_interval_secs: Option<u64>,
@@ -133,10 +151,10 @@ impl Default for Settings {
Self {
show_claude_code: default_show_claude(),
show_codex: default_show_codex(),
show_opencode_go: default_show_opencode_go(),
bubble_positions: BubblePositions::default(),
bubble_size_logical: default_bubble_size(),
poll_interval_ms: default_poll_interval_ms(),
language: None,
last_update_check_unix: None,
update_check_interval_secs: default_update_check_interval_secs(),
widget_visible: default_widget_visible(),
@@ -144,6 +162,35 @@ impl Default for Settings {
}
}
impl Settings {
pub fn is_provider_enabled(&self, provider: ProviderId) -> bool {
match provider {
ProviderId::Claude => self.show_claude_code,
ProviderId::ChatGpt => self.show_codex,
ProviderId::OpenCodeGo => self.show_opencode_go,
}
}
pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) {
match provider {
ProviderId::Claude => self.show_claude_code = enabled,
ProviderId::ChatGpt => self.show_codex = enabled,
ProviderId::OpenCodeGo => self.show_opencode_go = enabled,
}
}
pub fn toggle_provider(&mut self, provider: ProviderId) {
let enabled = !self.is_provider_enabled(provider);
self.set_provider_enabled(provider, enabled);
}
pub fn has_enabled_live_provider(&self) -> bool {
ProviderId::LIVE_USAGE
.iter()
.any(|provider| self.is_provider_enabled(*provider))
}
}
pub fn settings_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join(APP_DIR_NAME))
}
@@ -161,19 +208,76 @@ pub fn load() -> Settings {
Err(_) => return Settings::default(),
};
let mut settings: Settings = serde_json::from_str(&content).unwrap_or_default();
// At least one model must be visible. Otherwise the app has nothing to show.
if !settings.show_claude_code && !settings.show_codex {
// At least one live provider must be visible. Otherwise the app has nothing to show.
if !settings.has_enabled_live_provider() {
settings.show_claude_code = true;
}
// Clamp bubble size to safe range in case settings.json was hand-edited.
settings.bubble_size_logical = settings
.bubble_size_logical
.clamp(crate::bubble::MIN_BUBBLE_SIZE, crate::bubble::MAX_BUBBLE_SIZE);
settings.bubble_size_logical = settings.bubble_size_logical.clamp(
crate::bubble::MIN_BUBBLE_SIZE,
crate::bubble::MAX_BUBBLE_SIZE,
);
// Drop positions on monitors that have since been disconnected.
settings.bubble_positions.validate();
settings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn old_two_provider_settings_json_still_loads() {
let json = r#"{
"show_claude_code": false,
"show_codex": true,
"bubble_positions": {
"claude": [10, 20],
"codex": [30, 40]
}
}"#;
let settings: Settings =
serde_json::from_str(json).expect("old settings json should parse");
assert!(!settings.show_claude_code);
assert!(settings.show_codex);
assert!(!settings.show_opencode_go);
assert_eq!(
settings.bubble_positions.get(ProviderId::Claude),
Some((10, 20))
);
assert_eq!(
settings.bubble_positions.get(ProviderId::ChatGpt),
Some((30, 40))
);
assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None);
}
#[test]
fn three_provider_settings_json_loads() {
let json = r#"{
"show_claude_code": true,
"show_codex": false,
"show_opencode_go": true,
"bubble_positions": {
"opencode_go": [50, 60]
}
}"#;
let settings: Settings =
serde_json::from_str(json).expect("new settings json should parse");
assert!(settings.show_claude_code);
assert!(!settings.show_codex);
assert!(settings.show_opencode_go);
assert_eq!(
settings.bubble_positions.get(ProviderId::OpenCodeGo),
Some((50, 60))
);
}
}
pub fn save(settings: &Settings) {
let path = settings_path();
if let Some(parent) = path.parent() {
+9 -1
View File
@@ -43,7 +43,13 @@ fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
let mut pb = PathBuilder::new();
pb.push_circle(cx, cy, inner);
if let Some(path) = pb.finish() {
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
}
@@ -112,6 +118,8 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
// Cool dark slate for ChatGPT/Codex.
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
// Deep green for OpenCode Go.
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
}
}
+3 -6
View File
@@ -11,8 +11,8 @@ use std::sync::{Mutex, OnceLock};
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::Shell::{
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_INFO, NIIF_WARNING,
NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS,
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_INFO, NIIF_WARNING, NIM_ADD,
NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS,
};
use windows::Win32::UI::WindowsAndMessaging::DestroyIcon;
@@ -130,10 +130,7 @@ fn build_data(owner: HWND, kind: IconKind) -> NOTIFYICONDATAW {
}
fn icon_id(kind: IconKind) -> u32 {
match kind {
IconKind::Claude => 1,
IconKind::ChatGpt => 2,
}
kind.metadata().tray_icon_id
}
fn write_utf16(dst: &mut [u16], src: &str) {
+2 -1
View File
@@ -22,6 +22,7 @@ use windows::Win32::System::Threading::{
/// so no zombie wait is required.
pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
let mut cmdline = build_command_line(exe, args);
let exe_w: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
let si = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
@@ -32,7 +33,7 @@ pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
let ok = unsafe {
CreateProcessW(
PCWSTR::null(),
PCWSTR::from_raw(exe_w.as_ptr()),
windows::core::PWSTR(cmdline.as_mut_ptr()),
None,
None,
+160 -26
View File
@@ -1,9 +1,10 @@
// Download a release asset and swap it in via a detached helper process.
// Download a release asset and swap it in through the proven `self_replace`
// handoff path.
//
// The running process cannot reliably replace its own mapped image on
// Windows. Instead, it writes the new .exe to a staging path, starts that
// staged binary with `--apply-update`, and then exits. The helper waits for
// the parent process to exit before replacing the install target.
// Windows. `self_replace` handles the platform-specific rename/copy sequence
// for that case. After the replacement succeeds, we start the installed path
// with `--wait-pid` and let the current process exit.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -12,17 +13,25 @@ use sha2::{Digest, Sha256};
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
};
use windows::Win32::System::Threading::GetCurrentProcessId;
use windows::Win32::UI::WindowsAndMessaging::{
MessageBoxW, MB_ICONERROR, MB_OK,
};
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
use crate::net::Client;
use crate::os::to_utf16_nul;
pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error> {
match begin_inner(http, release) {
Ok(()) => Ok(()),
Err(e) => {
write_update_error(&e);
Err(e)
}
}
}
fn begin_inner(http: &Client, release: &super::Release) -> Result<(), super::Error> {
let current = std::env::current_exe()?;
ensure_writable(&current)?;
let staging = stage_path()?;
@@ -34,8 +43,15 @@ pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error
if let Some(parent) = staging.parent() {
std::fs::create_dir_all(parent)?;
}
download(http, &release.asset_url, &staging, release.asset_sha256.as_ref())?;
spawn_update_helper(&staging, &current, &release.version)?;
download(
http,
&release.asset_url,
&staging,
release.asset_sha256.as_ref(),
)?;
replace_current_exe(&staging)?;
spawn_replaced_exe(&current, &release.version)?;
let _ = std::fs::remove_file(&staging);
Ok(())
}
@@ -80,7 +96,9 @@ fn download(
.header("User-Agent", super::release::user_agent())
.send()?;
if !(200..300).contains(&resp.status()) {
return Err(super::Error::Network(crate::net::Error::Status(resp.status())));
return Err(super::Error::Network(crate::net::Error::Status(
resp.status(),
)));
}
let body = resp.body();
if let Some(expected) = expected_sha256 {
@@ -111,14 +129,13 @@ fn hex_encode(bytes: &[u8]) -> String {
fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> {
let s = p.to_string_lossy();
if s.contains('%') {
return Err(super::Error::UnsafePath(format!(
"path contains '%': {s}"
)));
return Err(super::Error::UnsafePath(format!("path contains '%': {s}")));
}
Ok(())
}
fn spawn_update_helper(
helper: &Path,
staging: &Path,
target: &Path,
version: &super::release::Version,
@@ -132,7 +149,27 @@ fn spawn_update_helper(
OsString::from(pid.to_string()),
OsString::from(version_str),
];
super::handoff::spawn_detached(staging, &args).map_err(super::Error::Io)
super::handoff::spawn_detached(helper, &args).map_err(super::Error::Io)
}
fn replace_current_exe(staging: &Path) -> Result<(), super::Error> {
self_replace::self_replace(staging)
.map_err(|e| super::Error::SwapFailed(format!("self_replace({}): {e}", staging.display())))
}
fn spawn_replaced_exe(
target: &Path,
version: &super::release::Version,
) -> Result<(), super::Error> {
let pid = unsafe { GetCurrentProcessId() };
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
let args = vec![
OsString::from("--wait-pid"),
OsString::from(pid.to_string()),
OsString::from("--updated-to"),
OsString::from(version_str),
];
super::handoff::spawn_detached(target, &args).map_err(super::Error::Io)
}
fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<(), super::Error> {
@@ -140,16 +177,26 @@ fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<()
// Parent has exited, so the install target is no longer mapped.
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
if let Err(copy_err) = std::fs::copy(source, target) {
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("rollback also failed: {revert_err}; surfacing modal");
let target_name = target
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
surface_rollback_failure(&backup, &target_name);
let swap_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
if let Err(swap_err) = move_file(source, target, swap_flags) {
// Compatibility for users updating from a release that invoked
// the downloaded binary itself as the helper. A mapped source exe
// may not be movable, but it can usually still be copied.
let copy_result = std::fs::copy(source, target);
if copy_result.is_err() {
log::error!("source move failed before copy fallback: {swap_err}");
}
if let Err(copy_err) = copy_result {
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("rollback also failed: {revert_err}; surfacing modal");
let target_name = target
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
surface_rollback_failure(&backup, &target_name);
}
return Err(super::Error::Io(copy_err));
}
return Err(super::Error::Io(copy_err));
}
let args = vec![OsString::from("--updated-to"), OsString::from(version)];
@@ -162,6 +209,8 @@ fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<()
return Err(super::Error::Io(spawn_err));
}
let _ = std::fs::remove_file(source);
Ok(())
}
@@ -196,9 +245,9 @@ fn backup_path(target: &Path) -> PathBuf {
}
fn surface_rollback_failure(backup: &Path, target_name: &str) {
// Pull the localized body from i18n; the caller passes the
// Pull the UI body text from i18n; the caller passes the
// user-meaningful filename so we can format it in-place.
let strings = crate::i18n::I18n::load(None).strings().clone();
let strings = crate::i18n::I18n::load().strings().clone();
let body = format!(
"{}{}\n\n{}",
strings.update_rollback_failed_body,
@@ -227,6 +276,72 @@ fn stage_path() -> Result<PathBuf, super::Error> {
.join("update.exe"))
}
fn update_error_log_path() -> Result<PathBuf, super::Error> {
let base = dirs::data_local_dir().ok_or_else(|| {
super::Error::NotWritable("no local data directory available".to_string())
})?;
Ok(base
.join("ClaudeCodeUsageBubble")
.join("updates")
.join("update-error.log"))
}
fn write_update_error(error: &super::Error) {
let Ok(path) = update_error_log_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
let body = format!("unix_time={ts}\nerror={error}\n");
let _ = std::fs::write(path, body);
}
fn helper_path() -> Result<PathBuf, super::Error> {
let base = dirs::data_local_dir().ok_or_else(|| {
super::Error::NotWritable("no local data directory available".to_string())
})?;
let pid = unsafe { GetCurrentProcessId() };
Ok(base
.join("ClaudeCodeUsageBubble")
.join("updates")
.join(format!("updater-helper-{pid}.exe")))
}
fn prepare_update_helper(current: &Path, helper: &Path) -> Result<(), super::Error> {
if let Some(parent) = helper.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(current, helper)?;
Ok(())
}
pub fn cleanup_staged_update_files() {
let Ok(stage) = stage_path() else {
return;
};
let Some(dir) = stage.parent() else {
return;
};
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name == "update.exe" || (name.starts_with("updater-helper-") && name.ends_with(".exe")) {
if let Err(e) = std::fs::remove_file(&path) {
log::debug!("cleanup_staged_update_files: remove {:?} failed: {e}", path);
}
}
}
}
fn ensure_writable(target: &Path) -> Result<(), super::Error> {
let parent = target.parent().ok_or_else(|| {
super::Error::NotWritable("could not resolve install directory".to_string())
@@ -236,3 +351,22 @@ fn ensure_writable(target: &Path) -> Result<(), super::Error> {
let _ = std::fs::remove_file(&probe);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stage_and_helper_paths_are_distinct_exes() {
let stage = stage_path().expect("stage path");
let helper = helper_path().expect("helper path");
assert_eq!(stage.file_name().unwrap(), "update.exe");
assert!(helper
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("updater-helper-"));
assert_ne!(stage, helper);
}
}
+3 -3
View File
@@ -1,9 +1,9 @@
// Self-update subsystem.
//
// Two stages: `release::fetch_latest` checks GitHub releases for a newer
// build; `install::begin` downloads the .exe, swaps it in via native
// `MoveFileExW`, then spawns the new binary detached via
// `CreateProcessW`. No shell handoff — nothing can flash a console.
// build; `install::begin` downloads the .exe, swaps it in via `self_replace`,
// then spawns the new binary detached via `CreateProcessW`. No shell handoff —
// nothing can flash a console.
pub mod channel;
pub mod handoff;
+11 -26
View File
@@ -56,11 +56,13 @@ impl Orchestrator {
fn spawn_cli(hint: &RefreshHint) -> bool {
match hint {
RefreshHint::LocalClaudeCli => spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."]),
RefreshHint::WslClaudeCli { distro } => spawn_wsl(distro),
RefreshHint::LocalCodexCli => {
spawn_local(&["codex.cmd", "codex.ps1", "codex.exe", "codex"], &["exec", "."])
RefreshHint::LocalClaudeCli => {
spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."])
}
RefreshHint::LocalCodexCli => spawn_local(
&["codex.cmd", "codex.ps1", "codex.exe", "codex"],
&["exec", "."],
),
}
}
@@ -69,7 +71,11 @@ fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
let lower = name.to_ascii_lowercase();
let mut cmd = if lower.ends_with(".ps1") {
let mut c = Command::new("powershell.exe");
c.arg("-NoProfile").arg("-ExecutionPolicy").arg("Bypass").arg("-File").arg(name);
c.arg("-NoProfile")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-File")
.arg(name);
for a in args {
c.arg(a);
}
@@ -100,24 +106,3 @@ fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
}
false
}
fn spawn_wsl(distro: &str) -> bool {
let script = "if command -v claude >/dev/null 2>&1; then claude -p .; \
elif [ -x \"$HOME/.local/bin/claude\" ]; then \"$HOME/.local/bin/claude\" -p .; \
else exit 127; fi";
Command::new("wsl.exe")
.arg("-d")
.arg(distro)
.arg("--")
.arg("bash")
.arg("-lic")
.arg(script)
.env_remove("CLAUDECODE")
.env_remove("CLAUDE_CODE_ENTRYPOINT")
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.is_ok()
}
+22 -7
View File
@@ -7,7 +7,10 @@
use crate::creds::Locator;
use crate::net::Client;
use crate::settings::Settings;
use crate::usage::{anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider, UsageWindows};
use crate::usage::{
anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider,
UsageWindows,
};
pub struct Registry {
claude: ClaudeProvider,
@@ -28,20 +31,32 @@ impl Registry {
settings: &Settings,
) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
let mut out = Vec::new();
if settings.show_claude_code {
out.push((ProviderId::Claude, self.claude.poll(http)));
}
if settings.show_codex {
out.push((ProviderId::ChatGpt, self.chatgpt.poll(http)));
for provider in ProviderId::LIVE_USAGE {
if !settings.is_provider_enabled(provider) {
continue;
}
let result = match provider {
ProviderId::Claude => self.claude.poll(http),
ProviderId::ChatGpt => self.chatgpt.poll(http),
ProviderId::OpenCodeGo => {
unreachable!("OpenCode Go has no live usage provider yet")
}
};
out.push((provider, result));
}
out
}
/// Attempt to refresh the active source for one provider.
pub fn try_refresh(&self, id: ProviderId, orchestrator: &refresh::Orchestrator) -> refresh::Outcome {
pub fn try_refresh(
&self,
id: ProviderId,
orchestrator: &refresh::Orchestrator,
) -> refresh::Outcome {
let locator = match id {
ProviderId::Claude => self.claude.locator(),
ProviderId::ChatGpt => self.chatgpt.locator(),
ProviderId::OpenCodeGo => return refresh::Outcome::CliMissing,
};
match locator.first_available() {
Some(src) => orchestrator.refresh(src),
+50 -5
View File
@@ -1,9 +1,4 @@
// Usage data shapes shared across providers.
//
// Every provider reports its quota as two named "windows" (short + long).
// For Claude: 5-hour and 7-day. For ChatGPT: primary and secondary. We
// normalise to `primary` + `secondary` so the UI layer doesn't care which
// provider produced the snapshot.
use std::time::SystemTime;
@@ -11,15 +6,65 @@ use std::time::SystemTime;
pub enum ProviderId {
Claude,
ChatGpt,
OpenCodeGo,
}
impl ProviderId {
pub const ALL: [Self; 3] = [Self::Claude, Self::ChatGpt, Self::OpenCodeGo];
pub const LIVE_USAGE: [Self; 2] = [Self::Claude, Self::ChatGpt];
pub fn slug(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::ChatGpt => "chatgpt",
Self::OpenCodeGo => "opencode-go",
}
}
pub fn metadata(self) -> ProviderMetadata {
match self {
Self::Claude => ProviderMetadata {
id: self,
slug: "claude",
default_enabled: true,
tray_icon_id: 1,
display_mode: ProviderDisplayMode::TwoWindow,
live_usage: true,
},
Self::ChatGpt => ProviderMetadata {
id: self,
slug: "chatgpt",
default_enabled: false,
tray_icon_id: 2,
display_mode: ProviderDisplayMode::TwoWindow,
live_usage: true,
},
Self::OpenCodeGo => ProviderMetadata {
id: self,
slug: "opencode-go",
default_enabled: false,
tray_icon_id: 3,
display_mode: ProviderDisplayMode::WeeklyMonthlyFourBar,
live_usage: false,
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderDisplayMode {
TwoWindow,
WeeklyMonthlyFourBar,
}
#[derive(Clone, Copy, Debug)]
pub struct ProviderMetadata {
pub id: ProviderId,
pub slug: &'static str,
pub default_enabled: bool,
pub tray_icon_id: u32,
pub display_mode: ProviderDisplayMode,
pub live_usage: bool,
}
/// One usage window: how much you've consumed (0100), and when it resets.
+1
View File
@@ -19,6 +19,7 @@ pub fn accent_color_for(model: ProviderId, is_dark: bool) -> Color {
Color::from_hex("#1A1A1A")
}
}
ProviderId::OpenCodeGo => Color::from_hex("#3BAE75"),
}
}