chore: remove implemented plan docs

This commit is contained in:
2026-06-03 09:31:46 +07:00
parent 4022d3f2de
commit bfcf3b56cd
30 changed files with 0 additions and 4553 deletions
@@ -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?