mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-06-06 06:10:12 +00:00
chore: remove implemented plan docs
This commit is contained in:
@@ -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: 400–700 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: 500–800 lines)
|
||||
- `src/panel.rs` (target: 300–500 lines)
|
||||
- `src/settings.rs` (target: 150–250 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 32–128 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: 1–2h
|
||||
- Phase 2: 1h (mostly file copies + import path fixes)
|
||||
- Phase 3: 6–10h (the heavy lift)
|
||||
- Phase 4: 3–5h
|
||||
- Phase 5: 2–4h
|
||||
|
||||
**Total:** ~15–22h 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 8–10).
|
||||
@@ -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.0–100.0
|
||||
pub resets_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct UsageWindows {
|
||||
pub primary: Window, // 5h for Claude / primary_window for ChatGPT
|
||||
pub secondary: Window, // 7d for Claude / secondary_window for ChatGPT
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProviderSnapshot {
|
||||
pub id: ProviderId,
|
||||
pub windows: UsageWindows,
|
||||
}
|
||||
```
|
||||
|
||||
### `src/usage/mod.rs` (Phase 2 portion — provider trait stub goes here, real impls in Phase 4)
|
||||
|
||||
```rust
|
||||
pub mod types;
|
||||
pub use types::{ProviderId, Window, UsageWindows, ProviderSnapshot};
|
||||
|
||||
// Provider trait lives here; impls (anthropic.rs, chatgpt.rs) come in Phase 4.
|
||||
pub trait UsageProvider: Send {
|
||||
fn id(&self) -> ProviderId;
|
||||
fn poll(&mut self, http: &crate::net::Client) -> Result<UsageWindows, Error>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("authentication required")]
|
||||
AuthRequired,
|
||||
#[error("no credentials configured")]
|
||||
NoCredentials,
|
||||
#[error("token expired and refresh failed")]
|
||||
TokenExpired,
|
||||
#[error("network: {0}")]
|
||||
Network(#[from] crate::net::Error),
|
||||
#[error("response shape mismatch: {0}")]
|
||||
BadResponse(String),
|
||||
#[error("credential read: {0}")]
|
||||
Creds(#[from] crate::creds::Error), // forward-declared, real type in Phase 3
|
||||
}
|
||||
```
|
||||
|
||||
In Phase 2 we leave `creds::Error` and the impls as `todo!()` stubs that won't link until Phase 3/4.
|
||||
|
||||
### `src/i18n/mod.rs`
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod detect;
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct LocaleStrings {
|
||||
pub window_title: String,
|
||||
pub refresh: String,
|
||||
pub update_frequency: String,
|
||||
pub one_minute: String,
|
||||
pub five_minutes: String,
|
||||
pub fifteen_minutes: String,
|
||||
pub one_hour: String,
|
||||
pub models: String,
|
||||
pub claude_label: String, // was claude_code_model
|
||||
pub chatgpt_label: String, // was codex_model
|
||||
pub settings: String,
|
||||
pub start_with_windows: String,
|
||||
pub reset_position: String,
|
||||
pub language: String,
|
||||
pub system_default: String,
|
||||
pub check_for_updates: String,
|
||||
pub checking_for_updates: String,
|
||||
pub up_to_date: String,
|
||||
pub update_failed: String,
|
||||
pub applying_update: String,
|
||||
pub update_available: String,
|
||||
pub update_via_winget: String, // was update_via_winget_label
|
||||
pub exit: String,
|
||||
pub show_widget: String,
|
||||
pub session_window: String,
|
||||
pub weekly_window: String,
|
||||
pub now: String,
|
||||
pub day_suffix: String,
|
||||
pub hour_suffix: String,
|
||||
pub minute_suffix: String,
|
||||
pub second_suffix: String,
|
||||
pub token_expired_title: String,
|
||||
pub token_expired_body: String,
|
||||
pub chatgpt_token_expired_title: String,
|
||||
pub chatgpt_token_expired_body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LocaleFile {
|
||||
code: String,
|
||||
native_name: String,
|
||||
#[serde(flatten)]
|
||||
strings: LocaleStrings,
|
||||
}
|
||||
|
||||
pub struct I18n {
|
||||
available: HashMap<String, (String, LocaleStrings)>, // code → (native_name, strings)
|
||||
active: String,
|
||||
}
|
||||
|
||||
impl I18n {
|
||||
pub fn load(active_code: Option<&str>) -> Self {
|
||||
let raw = [
|
||||
("en", include_str!("locales/en.toml")),
|
||||
("nl", include_str!("locales/nl.toml")),
|
||||
("es", include_str!("locales/es.toml")),
|
||||
("fr", include_str!("locales/fr.toml")),
|
||||
("de", include_str!("locales/de.toml")),
|
||||
("ja", include_str!("locales/ja.toml")),
|
||||
("ko", include_str!("locales/ko.toml")),
|
||||
("zh-TW", include_str!("locales/zh-TW.toml")),
|
||||
];
|
||||
let mut available = HashMap::new();
|
||||
for (code, body) in raw {
|
||||
if let Ok(file) = toml::from_str::<LocaleFile>(body) {
|
||||
available.insert(code.to_string(), (file.native_name, file.strings));
|
||||
}
|
||||
}
|
||||
let active = match active_code {
|
||||
Some(c) if available.contains_key(c) => c.to_string(),
|
||||
_ => detect::detect_system_locale()
|
||||
.and_then(|s| Self::normalize(&s, &available))
|
||||
.unwrap_or_else(|| "en".to_string()),
|
||||
};
|
||||
Self { available, active }
|
||||
}
|
||||
|
||||
pub fn strings(&self) -> &LocaleStrings {
|
||||
&self.available[&self.active].1
|
||||
}
|
||||
|
||||
pub fn active_code(&self) -> &str { &self.active }
|
||||
|
||||
pub fn available(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.available.iter().map(|(code, (name, _))| (code.as_str(), name.as_str()))
|
||||
}
|
||||
|
||||
fn normalize(code: &str, available: &HashMap<String, (String, LocaleStrings)>) -> Option<String> {
|
||||
// "en-US" → "en", "zh-Hant-TW" → "zh-TW", etc.
|
||||
// Exact match first, then prefix.
|
||||
let lower = code.to_ascii_lowercase().replace('_', "-");
|
||||
if available.contains_key(&lower) { return Some(lower); }
|
||||
let prefix = lower.split('-').next().unwrap_or("");
|
||||
if prefix == "zh" && (lower.contains("tw") || lower.contains("hant")) {
|
||||
return Some("zh-TW".into());
|
||||
}
|
||||
available.keys()
|
||||
.find(|k| k.split('-').next() == Some(prefix))
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `src/i18n/detect.rs`
|
||||
|
||||
Mirrors source's `preferred_ui_languages` + `default_ui_locale` + `default_locale_name` chain via Win32 globalization APIs, but in one function:
|
||||
|
||||
```rust
|
||||
pub fn detect_system_locale() -> Option<String> {
|
||||
preferred().or_else(default_ui).or_else(default_user)
|
||||
}
|
||||
fn preferred() -> Option<String> { /* GetUserPreferredUILanguages */ }
|
||||
fn default_ui() -> Option<String> { /* GetUserDefaultUILanguage + LCIDToLocaleName */ }
|
||||
fn default_user() -> Option<String> { /* GetUserDefaultLocaleName */ }
|
||||
```
|
||||
|
||||
### `src/i18n/locales/en.toml`
|
||||
|
||||
```toml
|
||||
code = "en"
|
||||
native_name = "English"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Refresh"
|
||||
update_frequency = "Update frequency"
|
||||
one_minute = "1 minute"
|
||||
five_minutes = "5 minutes"
|
||||
fifteen_minutes = "15 minutes"
|
||||
one_hour = "1 hour"
|
||||
models = "Models"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Settings"
|
||||
start_with_windows = "Start with Windows"
|
||||
reset_position = "Reset position"
|
||||
language = "Language"
|
||||
system_default = "System default"
|
||||
check_for_updates = "Check for updates"
|
||||
checking_for_updates = "Checking for updates…"
|
||||
up_to_date = "Up to date"
|
||||
update_failed = "Update failed"
|
||||
applying_update = "Applying update…"
|
||||
update_available = "Update available"
|
||||
update_via_winget = "via WinGet"
|
||||
exit = "Exit"
|
||||
show_widget = "Show widget"
|
||||
session_window = "5h"
|
||||
weekly_window = "7d"
|
||||
now = "now"
|
||||
day_suffix = "d"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Claude Code session expired"
|
||||
token_expired_body = "Sign in again to keep usage reporting."
|
||||
chatgpt_token_expired_title = "Codex session expired"
|
||||
chatgpt_token_expired_body = "Sign in again to keep usage reporting."
|
||||
```
|
||||
|
||||
The 8 other locale files mirror this shape with translated strings. **Important:** copy the translations from `src/localization/*.rs` content (the strings themselves are utilitarian/factual translations and not copyright-eligible the way code is — but for safety, re-translate the most unique strings using your own phrasing).
|
||||
|
||||
### Consumer refactors (in this phase)
|
||||
|
||||
**`src/app.rs`:**
|
||||
- `use crate::localization::{LanguageId, Strings, resolve_language}` → `use crate::i18n::{I18n, LocaleStrings}`
|
||||
- `s.language.strings()` → `s.i18n.strings()`
|
||||
- `LanguageId::ALL.iter()` → `s.i18n.available()`
|
||||
- All field renames: `claude_code_model` → `claude_label`, `codex_model` → `chatgpt_label`, etc.
|
||||
|
||||
**`src/bubble.rs`:** no localization access; only depends on bubble-specific data. Unaffected.
|
||||
|
||||
**`src/panel.rs`:**
|
||||
- `data.strings.session_window` works unchanged (field name preserved).
|
||||
- `data.strings.claude_code_model` → `data.strings.claude_label`.
|
||||
|
||||
**`src/settings.rs`:** unchanged (it stores `language: Option<String>` already, which holds a locale code).
|
||||
|
||||
**`src/models.rs`:** delete. Replace `crate::models::{AppUsageData, UsageData, UsageSection}` consumers:
|
||||
- `AppUsageData` → `Vec<ProviderSnapshot>`
|
||||
- `UsageData` → `UsageWindows`
|
||||
- `UsageSection` → `Window`
|
||||
|
||||
Migration map for `app.rs`:
|
||||
- `s.data.claude_code.as_ref()` → `s.snapshots.iter().find(|sn| sn.id == ProviderId::Claude)`
|
||||
- `c.session.percentage` → `sn.windows.primary.utilization`
|
||||
- `c.weekly.percentage` → `sn.windows.secondary.utilization`
|
||||
|
||||
## Related code files
|
||||
|
||||
**To create:**
|
||||
- `src/usage/mod.rs`
|
||||
- `src/usage/types.rs`
|
||||
- `src/i18n/mod.rs`
|
||||
- `src/i18n/detect.rs`
|
||||
- `src/i18n/locales/en.toml`
|
||||
- `src/i18n/locales/nl.toml`
|
||||
- `src/i18n/locales/es.toml`
|
||||
- `src/i18n/locales/fr.toml`
|
||||
- `src/i18n/locales/de.toml`
|
||||
- `src/i18n/locales/ja.toml`
|
||||
- `src/i18n/locales/ko.toml`
|
||||
- `src/i18n/locales/zh-TW.toml`
|
||||
|
||||
**To modify:**
|
||||
- `Cargo.toml` — add `toml = "0.8"` (with default features)
|
||||
- `src/main.rs` — declare `mod usage; mod i18n;`; remove `mod models; mod localization;`
|
||||
- `src/app.rs` — migrate all `crate::models::*` and `crate::localization::*` imports
|
||||
- `src/panel.rs` — field renames
|
||||
- `src/bubble.rs` — only if it references `LanguageId` (it shouldn't)
|
||||
|
||||
**To delete:**
|
||||
- `src/models.rs`
|
||||
- `src/localization/mod.rs`
|
||||
- `src/localization/english.rs`
|
||||
- `src/localization/dutch.rs`
|
||||
- `src/localization/spanish.rs`
|
||||
- `src/localization/french.rs`
|
||||
- `src/localization/german.rs`
|
||||
- `src/localization/japanese.rs`
|
||||
- `src/localization/korean.rs`
|
||||
- `src/localization/traditional_chinese.rs`
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Add `toml = "0.8"`** to `Cargo.toml`.
|
||||
2. **Create `src/usage/types.rs`** (struct definitions only — no impls yet).
|
||||
3. **Create `src/usage/mod.rs`** with trait `UsageProvider` and `Error` enum. Leave it without any impls.
|
||||
4. **Create `src/i18n/locales/en.toml`** first; verify TOML structure parses.
|
||||
5. **Add `src/i18n/mod.rs` + `src/i18n/detect.rs`** with `I18n::load` reading only `en.toml`.
|
||||
6. **Wire `mod i18n; mod usage;` into `main.rs`** and call `I18n::load(None)` from `app::run` (storing on `AppState`). Build should still compile (no usages downstream yet).
|
||||
7. **Migrate `app.rs`** field-by-field from `Strings` to `LocaleStrings`. Run `cargo check` after each subsystem (menu, balloon, panel-data, tray-tooltip).
|
||||
8. **Translate the other 8 locale TOMLs.** Use your own phrasings for the longer strings (e.g. `token_expired_body`) rather than direct copies of upstream's translations.
|
||||
9. **Add the other 8 `include_str!` entries** to `i18n/mod.rs`.
|
||||
10. **Migrate `panel.rs`** field renames (small).
|
||||
11. **Migrate `app.rs` data model** from `AppUsageData` to `Vec<ProviderSnapshot>`. This is the biggest single edit. Update `apply_data`, `apply_usage_update`, `build_panel_data_from`, `refresh_tray_icons`, `refresh_text_fields`.
|
||||
12. **Delete `src/models.rs` + `src/localization/*`** once nothing references them.
|
||||
13. **`cargo build --release`** — clean.
|
||||
|
||||
## Todo checklist
|
||||
|
||||
- [ ] `usage/types.rs` written
|
||||
- [ ] `usage/mod.rs` written (trait + Error stubs)
|
||||
- [ ] `i18n/mod.rs` + `detect.rs` written
|
||||
- [ ] 9 TOML locale files written (translations are your own paraphrasings)
|
||||
- [ ] `Cargo.toml` adds `toml` dep
|
||||
- [ ] `main.rs` declares new modules + removes old ones
|
||||
- [ ] `app.rs` migrated to `LocaleStrings` + `Vec<ProviderSnapshot>`
|
||||
- [ ] `panel.rs` field renames done
|
||||
- [ ] `bubble.rs` confirmed unaffected
|
||||
- [ ] Old `src/models.rs` + `src/localization/*` deleted
|
||||
- [ ] `cargo build --release` clean
|
||||
- [ ] App still runs (placeholder data since providers aren't wired yet)
|
||||
|
||||
## Success criteria
|
||||
|
||||
- TOML files parse cleanly at startup.
|
||||
- App shows correct language strings based on Windows display language.
|
||||
- No file in `src/` shares a name with upstream's `models.rs` or `localization/*`.
|
||||
- Right-click → Language submenu lists 9 options (system default + 8 languages) and switching them updates UI immediately.
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| TOML serde derive misalignment (typos in field names) | High | Use `#[serde(deny_unknown_fields)]` to catch typos at load time |
|
||||
| Translations differ enough from upstream that meaning drifts | Medium | Compare meaning side-by-side before committing; ask a native speaker for the long strings if you can |
|
||||
| Bubble/panel field references break in subtle places | Medium | `cargo check` after each consumer edit |
|
||||
| App startup slows due to TOML parsing | Negligible | TOML files combined < 10 KB |
|
||||
|
||||
## Security considerations
|
||||
|
||||
- TOML strings are static, no eval. Parse failures fall back to English silently. No injection risk.
|
||||
- No PII in locale files.
|
||||
|
||||
## Next steps
|
||||
|
||||
→ Phase 3: replace credential reading with `creds/` directory.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Translation copyright.** The upstream localization files contain ~30 short UI strings per language. These are utility translations of standard UI vocabulary and are unlikely to be copyrightable individually, but for full clean-room status, re-paraphrase the longest strings (`token_expired_body` and `chatgpt_token_expired_body`). Recommended: write your own phrasing for those two.
|
||||
@@ -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(¤t)?;
|
||||
let staging = stage_path()?;
|
||||
std::fs::create_dir_all(staging.parent().unwrap())?;
|
||||
download(http, &release.asset_url, &staging)?;
|
||||
spawn_handoff(&staging, ¤t)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download(http: &crate::net::winhttp::Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> {
|
||||
let resp = http.get(url).header("User-Agent", super::release::user_agent()).send()?;
|
||||
std::fs::write(to, resp.body())?; // assume Response exposes .body() -> &[u8]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(), super::Error> {
|
||||
let cmd = format!(
|
||||
r#"timeout /t 2 /nobreak >nul & move /y "{src}" "{tgt}" & start "" "{tgt}""#,
|
||||
src = source.to_string_lossy(),
|
||||
tgt = target.to_string_lossy(),
|
||||
);
|
||||
Command::new("cmd.exe")
|
||||
.args(["/c", &cmd])
|
||||
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_cli(args: &[String]) -> Option<i32> {
|
||||
// Keep this for parity: if the user runs the binary with `--apply-update <target> <source> <pid>`
|
||||
// (the source's old helper signature), the inline-cmd handoff has already done the work;
|
||||
// we just exit 0.
|
||||
if args.len() >= 2 && args[1] == "--apply-update" { return Some(0); }
|
||||
None
|
||||
}
|
||||
|
||||
fn stage_path() -> Result<PathBuf, super::Error> {
|
||||
let base = dirs::data_local_dir()
|
||||
.ok_or_else(|| super::Error::NotWritable("no data dir".into()))?;
|
||||
Ok(base.join("ClaudeCodeUsageBubble").join("updates").join("update.exe"))
|
||||
}
|
||||
|
||||
fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> {
|
||||
let parent = target.parent().ok_or_else(|| super::Error::NotWritable("no parent".into()))?;
|
||||
let probe = parent.join(".probe");
|
||||
std::fs::write(&probe, b"").map_err(|e| super::Error::NotWritable(e.to_string()))?;
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Migration in `app.rs`
|
||||
|
||||
Replace `updater::*` imports:
|
||||
- `updater::check_for_updates()` → `update::release::fetch_latest(&http_client)`
|
||||
- `updater::begin_self_update(release)` → `update::install::begin(&http_client, release)`
|
||||
- `updater::current_install_channel()` → `update::current_channel()`
|
||||
- `updater::handle_cli_mode(args)` → `update::run_cli(args)`
|
||||
- `updater::UpdateCheckResult` → `Option<Release>` (None = up to date, Some = available)
|
||||
- `updater::ReleaseDescriptor` → `update::Release`
|
||||
- `updater::InstallChannel` → `update::Channel`
|
||||
|
||||
### Drop NOTICE + final attribution cleanup
|
||||
|
||||
After the rewrite is complete and validated:
|
||||
|
||||
1. Delete `NOTICE` file.
|
||||
2. Update `LICENSE`:
|
||||
- Remove the "Portions ported from …" paragraph (currently at the top of LICENSE).
|
||||
- Keep just the Apache-2.0 text with `Copyright 2026 tiennm99`.
|
||||
3. Update `README.md`:
|
||||
- Replace "Differences vs upstream" section's "derivative of … with minor adaptations" wording with "inspired by [upstream link]".
|
||||
- Remove the "License" section mention of NOTICE.
|
||||
4. Update `Cargo.toml`:
|
||||
- `license = "Apache-2.0"` (unchanged).
|
||||
|
||||
## Related code files
|
||||
|
||||
**To create:**
|
||||
- `src/update/mod.rs`
|
||||
- `src/update/channel.rs`
|
||||
- `src/update/release.rs`
|
||||
- `src/update/install.rs`
|
||||
|
||||
**To modify:**
|
||||
- `src/main.rs` — declare `mod update;`; remove `mod updater;`
|
||||
- `src/app.rs` — migrate `updater::*` call sites
|
||||
- `LICENSE` — drop the upstream-attribution paragraph
|
||||
- `README.md` — drop "derivative of" wording, replace with "inspired by"
|
||||
- `Cargo.toml` — no functional changes
|
||||
|
||||
**To delete:**
|
||||
- `src/updater.rs`
|
||||
- `NOTICE`
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Create `update/channel.rs`** — trivial.
|
||||
2. **Create `update/release.rs`** — Version type + fetch_latest. Test by hitting GitHub API.
|
||||
3. **Create `update/install.rs`** — download + inline-cmd handoff. **Test on a throwaway VM** (the handoff replaces the binary, which is risky).
|
||||
4. **Create `update/mod.rs`** — re-exports.
|
||||
5. **Migrate `app.rs`** — replace all `updater::*` call sites.
|
||||
6. **Delete `src/updater.rs`** + `mod updater;` line.
|
||||
7. **`cargo build --release`** clean.
|
||||
8. **End-to-end test on Windows**:
|
||||
- Stage a v0.1.1 GitHub release with a deliberately-different .exe.
|
||||
- Run v0.1.0 binary, trigger update → confirm new .exe replaces old, new app launches.
|
||||
9. **AFTER end-to-end test succeeds:**
|
||||
- Delete `NOTICE`.
|
||||
- Edit `LICENSE` — drop the upstream-attribution paragraph at the top.
|
||||
- Edit `README.md` — drop "derivative of" paragraph; replace with one-line "Inspired by [CodeZeno/Claude-Code-Usage-Monitor]" (no attribution-required phrasing).
|
||||
10. **Final repo audit:**
|
||||
- `grep -ri "CodeZeno" src/` → must return nothing.
|
||||
- `grep -ri "Claude-Code-Usage-Monitor" src/` → must return nothing.
|
||||
- File names: `find src -type f -name '*.rs' | xargs -I {} basename {} | sort` and compare against upstream's file list (`models.rs`, `diagnose.rs`, `theme.rs`, `poller.rs`, `updater.rs`, `tray_icon.rs`, `native_interop.rs`, `localization/*`). **No file name should match.**
|
||||
- `git log --oneline` shows the initial-port commit + 6 rewrite commits — transparent history.
|
||||
11. **Commit and push:**
|
||||
- Commit message: `chore: complete clean-room rewrite; drop upstream attribution`
|
||||
- Push to GitHub.
|
||||
|
||||
## Todo checklist
|
||||
|
||||
- [ ] `update/channel.rs`
|
||||
- [ ] `update/release.rs`
|
||||
- [ ] `update/install.rs`
|
||||
- [ ] `update/mod.rs`
|
||||
- [ ] `app.rs` migrated to new updater API
|
||||
- [ ] `updater.rs` deleted
|
||||
- [ ] `cargo build --release` clean
|
||||
- [ ] End-to-end update tested on Windows
|
||||
- [ ] `NOTICE` deleted
|
||||
- [ ] `LICENSE` upstream-attribution paragraph removed
|
||||
- [ ] `README.md` updated to drop "derivative" wording
|
||||
- [ ] Grep verifies no upstream references remain in `src/`
|
||||
- [ ] File-name overlap with upstream = 0
|
||||
- [ ] Final commit + push
|
||||
|
||||
## Success criteria
|
||||
|
||||
- App self-updates correctly using inline-cmd handoff.
|
||||
- `NOTICE` file no longer exists in repo.
|
||||
- Repo passes the "no upstream references" grep test.
|
||||
- GitHub's auto-license detection still reports Apache-2.0.
|
||||
- The README still credits inspiration but does not claim derivative status.
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| Inline `cmd /c` flagged by antivirus | Medium | Most AVs allow `cmd.exe` execution; if flagged, fall back to a temp `.bat` |
|
||||
| `move /y` fails if exe is still loaded by Windows | Medium-High | The 2s `timeout` gives parent time to exit, fully releasing file handle |
|
||||
| User has unusual `cmd.exe` path | Negligible on Windows | Use full path `C:\Windows\System32\cmd.exe` if needed |
|
||||
| Drop NOTICE prematurely (before phase done) | High if rushed | Phase order: rewrite first, then drop attribution. Never reorder. |
|
||||
| Legal — is "inspired by" enough? | Low (we did rewrite everything) | This is the entire point of Phases 1-5. After full rewrite, no MIT code remains; attribution is courtesy, not required |
|
||||
|
||||
## Security considerations
|
||||
|
||||
- The inline `cmd /c` command string is built from `std::env::current_exe()` and `stage_path()` — both internal, no user-controlled input. No shell injection.
|
||||
- The downloaded asset is over HTTPS to `api.github.com` → MITM-safe.
|
||||
- Update fails closed: if `move /y` fails, the old exe is still in place; user can retry.
|
||||
|
||||
## Next steps
|
||||
|
||||
→ Project complete. Tag v0.2.0 with the clean-room rewrite as a milestone.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should we sign the binary with a code-signing certificate to satisfy AV heuristics around inline-cmd updates? Out of scope for this plan; future enhancement.
|
||||
- After dropping NOTICE, should we add a small "Acknowledgements" section in README that mentions inspiration from CodeZeno's project without invoking MIT attribution language? Recommended: **yes**, that's the polite move and is legally untainting since we don't claim derivation.
|
||||
@@ -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 + 4–8h Windows-side debugging.
|
||||
|
||||
## Phase dependencies
|
||||
|
||||
```
|
||||
Phase 1 (infra)
|
||||
├─→ Phase 2 (types + i18n)
|
||||
│ └─→ Phase 4 (providers)
|
||||
│ └─→ Phase 5 (tray) — needs UsageProvider results
|
||||
│
|
||||
└─→ Phase 3 (creds)
|
||||
└─→ Phase 4 (providers) — depends on creds API
|
||||
└─→ Phase 6 (updater + cleanup) — last
|
||||
```
|
||||
|
||||
Phases 2 and 3 can technically run in parallel after Phase 1, but
|
||||
serial execution (1→2→3→4→5→6) is cleaner for solo work.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `bubble.rs`, `panel.rs`, `app.rs`, `settings.rs`, `main.rs` — these
|
||||
stay; only their imports/call-sites get touched as new APIs come
|
||||
online.
|
||||
- Adding new features beyond what the current copied code supports.
|
||||
- Changing `bubble.rs`/`panel.rs` rendering or interaction behavior.
|
||||
|
||||
## External contracts that must NOT change
|
||||
|
||||
- Anthropic endpoints + headers
|
||||
- ChatGPT endpoint + `User-Agent: codex-cli`
|
||||
- Credential file paths and JSON shapes
|
||||
- WSL access via `wsl.exe`
|
||||
- CLI-driven token refresh (must invoke `claude` / `codex`)
|
||||
- GitHub releases JSON format
|
||||
- Settings file location (`%APPDATA%\ClaudeCodeUsageBubble\settings.json`)
|
||||
- Windows registry path for startup (`Software\Microsoft\Windows\CurrentVersion\Run`)
|
||||
- Single-instance mutex (`Global\ClaudeCodeUsageBubble`)
|
||||
|
||||
## Success criteria (cross-phase)
|
||||
|
||||
After all 6 phases:
|
||||
|
||||
- [ ] `cargo build --release` clean on Windows
|
||||
- [ ] No file in `src/` shares a name with the upstream source's files
|
||||
- [ ] `NOTICE` file removed from repo root
|
||||
- [ ] `LICENSE` (Apache-2.0) header retained; copyright line updated
|
||||
- [ ] `README.md` updated to drop the "derivative of" paragraph; replace
|
||||
with "inspired by [upstream]" link
|
||||
- [ ] App functional: bubble, panel, tray, polling, auth, updater all
|
||||
working on Windows 10/11
|
||||
- [ ] No regressions vs current behavior (poll cadence, snap, click→panel,
|
||||
Ctrl+Wheel resize, fullscreen auto-hide, dual-bubble)
|
||||
|
||||
## Rollback strategy
|
||||
|
||||
- Each phase lands as a separate commit (or PR). If a phase breaks
|
||||
the build/app, `git revert` that commit to restore the prior phase's
|
||||
state.
|
||||
- Phase 6 is the only commit that removes upstream attribution — if any
|
||||
of Phases 1–5 is incomplete or buggy at that point, **do not** drop
|
||||
NOTICE; finish or revert first.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Bump `windows-rs` from 0.58 → newer? (Brainstorm flagged this.)
|
||||
Defer decision to Phase 1 — try with 0.58 first, bump only if needed.
|
||||
- Keep Git history showing the initial port? Recommended: **yes**.
|
||||
Transparent and consistent with the "I inspired/rewrote from X" framing
|
||||
even after NOTICE is gone.
|
||||
@@ -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 ~1–2 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.
|
||||
-104
@@ -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 |
|
||||
-113
@@ -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 |
|
||||
-156
@@ -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, ¤t, &release.version)` instead of `spawn_handoff(&staging, ¤t)`. 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?
|
||||
Reference in New Issue
Block a user