From bfcf3b56cd1faa269ffbbf81d0d873fb971c15bc Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 3 Jun 2026 09:31:46 +0700 Subject: [PATCH] chore: remove implemented plan docs --- .../phase-01-bootstrap-repo.md | 121 ------ .../phase-02-port-portable-modules.md | 124 ------ .../phase-03-build-bubble-window.md | 166 -------- .../phase-04-panel-and-orchestration.md | 190 --------- .../phase-05-polish-and-finishing.md | 120 ------ .../plan.md | 83 ---- .../phase-01-infrastructure.md | 285 ------------- .../phase-02-types-and-i18n.md | 403 ------------------ .../phase-03-creds-module.md | 253 ----------- .../phase-04-providers-and-refresh.md | 346 --------------- .../phase-05-tray-badges.md | 264 ------------ .../phase-06-updater-and-remove-notice.md | 312 -------------- plans/260516-0707-cleanroom-rewrite/plan.md | 134 ------ .../phase-01-release-ci-workflow.md | 176 -------- .../phase-02-end-to-end-update-test.md | 120 ------ .../phase-03-docs-and-release-process.md | 133 ------ .../plan.md | 103 ----- .../phase-01-implement-restart-action.md | 122 ------ plans/260518-0945-menu-restart-button/plan.md | 41 -- ...i-flags-native-spawn-helper-mutex-retry.md | 104 ----- ...-restart-app-with-native-createprocessw.md | 113 ----- ...update-install-rename-move-native-spawn.md | 156 ------- .../phase-04-cleanup-tray-notification.md | 165 ------- ...phase-05-manual-end-to-end-verification.md | 104 ----- .../260521-1530-silent-update-no-cmd/plan.md | 96 ----- .../phase-01-lock-geometry.md | 57 --- .../phase-02-apply-renderer-change.md | 57 --- .../phase-03-validate-on-windows.md | 64 --- .../plan.md | 50 --- plans/260523-ui-ux-improvement-plan/plan.md | 91 ---- 30 files changed, 4553 deletions(-) delete mode 100644 plans/20260515-claude-code-usage-bubble-port/phase-01-bootstrap-repo.md delete mode 100644 plans/20260515-claude-code-usage-bubble-port/phase-02-port-portable-modules.md delete mode 100644 plans/20260515-claude-code-usage-bubble-port/phase-03-build-bubble-window.md delete mode 100644 plans/20260515-claude-code-usage-bubble-port/phase-04-panel-and-orchestration.md delete mode 100644 plans/20260515-claude-code-usage-bubble-port/phase-05-polish-and-finishing.md delete mode 100644 plans/20260515-claude-code-usage-bubble-port/plan.md delete mode 100644 plans/260516-0707-cleanroom-rewrite/phase-01-infrastructure.md delete mode 100644 plans/260516-0707-cleanroom-rewrite/phase-02-types-and-i18n.md delete mode 100644 plans/260516-0707-cleanroom-rewrite/phase-03-creds-module.md delete mode 100644 plans/260516-0707-cleanroom-rewrite/phase-04-providers-and-refresh.md delete mode 100644 plans/260516-0707-cleanroom-rewrite/phase-05-tray-badges.md delete mode 100644 plans/260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md delete mode 100644 plans/260516-0707-cleanroom-rewrite/plan.md delete mode 100644 plans/260516-1730-github-release-auto-update/phase-01-release-ci-workflow.md delete mode 100644 plans/260516-1730-github-release-auto-update/phase-02-end-to-end-update-test.md delete mode 100644 plans/260516-1730-github-release-auto-update/phase-03-docs-and-release-process.md delete mode 100644 plans/260516-1730-github-release-auto-update/plan.md delete mode 100644 plans/260518-0945-menu-restart-button/phase-01-implement-restart-action.md delete mode 100644 plans/260518-0945-menu-restart-button/plan.md delete mode 100644 plans/260521-1530-silent-update-no-cmd/phase-01-foundation-cli-flags-native-spawn-helper-mutex-retry.md delete mode 100644 plans/260521-1530-silent-update-no-cmd/phase-02-restart-path-replace-restart-app-with-native-createprocessw.md delete mode 100644 plans/260521-1530-silent-update-no-cmd/phase-03-update-install-rename-move-native-spawn.md delete mode 100644 plans/260521-1530-silent-update-no-cmd/phase-04-cleanup-tray-notification.md delete mode 100644 plans/260521-1530-silent-update-no-cmd/phase-05-manual-end-to-end-verification.md delete mode 100644 plans/260521-1530-silent-update-no-cmd/plan.md delete mode 100644 plans/260523-2242-bubble-tail-bar-layout/phase-01-lock-geometry.md delete mode 100644 plans/260523-2242-bubble-tail-bar-layout/phase-02-apply-renderer-change.md delete mode 100644 plans/260523-2242-bubble-tail-bar-layout/phase-03-validate-on-windows.md delete mode 100644 plans/260523-2242-bubble-tail-bar-layout/plan.md delete mode 100644 plans/260523-ui-ux-improvement-plan/plan.md diff --git a/plans/20260515-claude-code-usage-bubble-port/phase-01-bootstrap-repo.md b/plans/20260515-claude-code-usage-bubble-port/phase-01-bootstrap-repo.md deleted file mode 100644 index aba585f..0000000 --- a/plans/20260515-claude-code-usage-bubble-port/phase-01-bootstrap-repo.md +++ /dev/null @@ -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 = "" - ``` - 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/` diff --git a/plans/20260515-claude-code-usage-bubble-port/phase-02-port-portable-modules.md b/plans/20260515-claude-code-usage-bubble-port/phase-02-port-portable-modules.md deleted file mode 100644 index 470effd..0000000 --- a/plans/20260515-claude-code-usage-bubble-port/phase-02-port-portable-modules.md +++ /dev/null @@ -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`) diff --git a/plans/20260515-claude-code-usage-bubble-port/phase-03-build-bubble-window.md b/plans/20260515-claude-code-usage-bubble-port/phase-03-build-bubble-window.md deleted file mode 100644 index e8ec33b..0000000 --- a/plans/20260515-claude-code-usage-bubble-port/phase-03-build-bubble-window.md +++ /dev/null @@ -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` 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, - } - pub fn update_percentage(hwnd: HWND, percentage: Option); // 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 diff --git a/plans/20260515-claude-code-usage-bubble-port/phase-04-panel-and-orchestration.md b/plans/20260515-claude-code-usage-bubble-port/phase-04-panel-and-orchestration.md deleted file mode 100644 index 0bc6d86..0000000 --- a/plans/20260515-claude-code-usage-bubble-port/phase-04-panel-and-orchestration.md +++ /dev/null @@ -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 = 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, // None = system - pub start_with_windows: bool, - pub last_update_check_unix: Option, - } - #[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.) diff --git a/plans/20260515-claude-code-usage-bubble-port/phase-05-polish-and-finishing.md b/plans/20260515-claude-code-usage-bubble-port/phase-05-polish-and-finishing.md deleted file mode 100644 index 6d45f64..0000000 --- a/plans/20260515-claude-code-usage-bubble-port/phase-05-polish-and-finishing.md +++ /dev/null @@ -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 - - Portions of this software are derived from Claude Code Usage Monitor, - Copyright (c) 2026 Code Zeno Pty Ltd, licensed under the MIT License. - - - ``` -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 diff --git a/plans/20260515-claude-code-usage-bubble-port/plan.md b/plans/20260515-claude-code-usage-bubble-port/plan.md deleted file mode 100644 index 110edb9..0000000 --- a/plans/20260515-claude-code-usage-bubble-port/plan.md +++ /dev/null @@ -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). diff --git a/plans/260516-0707-cleanroom-rewrite/phase-01-infrastructure.md b/plans/260516-0707-cleanroom-rewrite/phase-01-infrastructure.md deleted file mode 100644 index 9657564..0000000 --- a/plans/260516-0707-cleanroom-rewrite/phase-01-infrastructure.md +++ /dev/null @@ -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>` logger. Standard ecosystem, same one-line ergonomics. -- `embed-resource` replaces `winres`. Same purpose, different file shape (`res/icon.rc` instead of builder API). -- `os/` directory consolidates color, wide-string, DPI, registry, theme helpers (currently scattered across `theme.rs` and `native_interop.rs`). - -## Requirements - -### Functional - -- `diag::init(enabled: bool)` writes a log file at `%TEMP%\claude-code-usage-bubble.log` when called with `true`; no-op otherwise. -- `log::info!` / `log::warn!` / `log::error!` macros work and route to the file. -- `os::wide_str(&str) -> Vec` returns a NUL-terminated UTF-16 vector. -- `os::Color` provides hex parsing + COLORREF conversion. -- `os::dpi::for_window(hwnd)` returns u32 DPI ≥ 96. -- `os::registry::read_string(hkey, path, name)` returns Option. -- `os::registry::write_string(hkey, path, name, value)` returns Result. -- `os::registry::delete_value(hkey, path, name)` returns Result. -- `os::theme::is_dark()` returns bool from registry. -- `net::winhttp::Client::new()` constructs a client with a user-agent string. -- `client.get(url).header(k, v).send()` returns `Result`. -- `client.post(url).header(k, v).json_body(value).send()` returns `Result`. -- `Response::status() -> u32`, `Response::header(&str) -> Option<&str>`, `Response::text() -> Result`, `Response::json() -> Result`. -- Build script (`build.rs`) embeds `res/icon.ico` and version info via `embed-resource`. - -### Non-functional - -- Binary size after this phase: ≤ current (we add `tiny-skia`/`embed-resource`/`simplelog`/`thiserror` deps in later phases; this phase should not balloon). -- No new behavior changes vs current build. -- All new modules pass `cargo clippy -- -W clippy::all` with zero new warnings. - -## Architecture - -### `diag/mod.rs` (~30 LOC) - -```rust -use std::path::PathBuf; -use simplelog::{Config, LevelFilter, WriteLogger}; -use std::fs::File; - -pub fn init(enabled: bool) -> Result, std::io::Error> { - if !enabled { return Ok(None); } - let path = std::env::temp_dir().join("claude-code-usage-bubble.log"); - WriteLogger::init(LevelFilter::Debug, Config::default(), File::create(&path)?) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; - log::info!("diagnostic logging enabled"); - Ok(Some(path)) -} -``` - -Callers use `log::info!` etc directly — no `diagnose::log(...)` indirection. - -### `os/` directory - -| File | Responsibility | LOC | -|---|---|---| -| `mod.rs` | `pub use` re-exports + module declarations | ~10 | -| `color.rs` | `Color { r, g, b }`, `from_hex`, `to_colorref()` | ~30 | -| `string.rs` | `wide_str(&str) -> Vec` | ~5 | -| `dpi.rs` | `for_window(hwnd)`, `for_system()`, `scale(logical, dpi)` | ~20 | -| `registry.rs` | typed wrapper over `RegOpenKeyExW`/`RegQueryValueExW`/`RegSetValueExW`/`RegDeleteValueW` | ~80 | -| `theme.rs` | `is_dark()` → reads `SystemUsesLightTheme` via `os::registry` | ~15 | - -### `net/winhttp.rs` (~150 LOC) - -```rust -pub struct Client { - session: HINTERNET, - user_agent: Vec, -} - -pub struct RequestBuilder<'a> { - client: &'a Client, - method: Method, - url: Url, - headers: Vec<(Vec, Vec)>, - body: Option>, -} - -pub struct Response { - status: u32, - headers: HashMap, - body: Vec, -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("WinHTTP error {code}: {context}")] - Win(u32, String), - #[error("HTTP {status}")] - Status(u32), - #[error("JSON parse: {0}")] - Json(#[from] serde_json::Error), - #[error("invalid URL: {0}")] - Url(String), - #[error("UTF-8 conversion failed")] - Utf8, -} -``` - -Internally chains: `WinHttpOpen` → `WinHttpCrackUrl` → `WinHttpConnect` → `WinHttpOpenRequest` → `WinHttpSendRequest` → `WinHttpReceiveResponse` → `WinHttpQueryHeaders` → `WinHttpReadData`. - -### `Cargo.toml` updates - -```toml -[dependencies] -# REMOVED: ureq, native-tls -serde = { version = "1", features = ["derive"] } -serde_json = "1" -dirs = "6" -log = "0.4" -simplelog = "0.12" -thiserror = "2" - -[dependencies.windows] -version = "0.58" -features = [ - # existing features + - "Win32_Networking_WinHttp", -] - -[build-dependencies] -# REMOVED: winres -embed-resource = "3" -``` - -### `build.rs` - -```rust -fn main() { - embed_resource::compile("res/icon.rc", embed_resource::NONE); -} -``` - -### `res/icon.rc` (new file) - -``` -#include -1 ICON "..\\src\\icons\\icon.ico" -1 VERSIONINFO - FILEVERSION 0,1,0,0 - PRODUCTVERSION 0,1,0,0 - FILEOS 0x40004L - FILETYPE 0x1L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904E4" - BEGIN - VALUE "ProductName", "Claude Code Usage Bubble\0" - VALUE "FileDescription", "Claude Code Usage Bubble\0" - VALUE "OriginalFilename", "claude-code-usage-bubble.exe\0" - VALUE "InternalName", "ClaudeCodeUsageBubble\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END -``` - -## Related code files - -**To create:** -- `src/diag/mod.rs` -- `src/os/mod.rs` -- `src/os/color.rs` -- `src/os/string.rs` -- `src/os/dpi.rs` -- `src/os/registry.rs` -- `src/os/theme.rs` -- `src/net/mod.rs` -- `src/net/winhttp.rs` -- `res/icon.rc` - -**To modify:** -- `Cargo.toml` (add new deps, remove `ureq`+`native-tls`+`winres`, add `Win32_Networking_WinHttp`) -- `build.rs` (replace winres with embed-resource) -- `src/main.rs` (add `mod diag; mod os; mod net;` declarations — do NOT remove old `mod diagnose; mod theme; mod native_interop;` yet) - -**To delete:** nothing in this phase (Phase 2 removes `theme.rs`, etc.) - -## Implementation steps - -1. **Add new dependencies** to `Cargo.toml`: `log`, `simplelog`, `thiserror`, `embed-resource`. Add `Win32_Networking_WinHttp` to `windows` features. Leave `ureq`, `native-tls`, `winres` in place for now. -2. **Create `res/icon.rc`** referencing `src/icons/icon.ico`. -3. **Replace `build.rs`** with `embed-resource::compile`. -4. **`cargo build --release`** — verify icon embedding works (PE has icon resource). -5. **Create `src/os/string.rs`** with `wide_str()`. Trivial. -6. **Create `src/os/color.rs`** with `Color` struct + `from_hex` + `to_colorref`. -7. **Create `src/os/registry.rs`** with `read_string`, `read_u32`, `write_string`, `delete_value` — `unsafe` wrappers over Win32 registry APIs returning `Result`. -8. **Create `src/os/theme.rs`** calling `registry::read_u32(HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", "SystemUsesLightTheme")` and inverting. -9. **Create `src/os/dpi.rs`** with `for_window`, `for_system`, `scale`. -10. **Create `src/os/mod.rs`** with `pub mod color; pub mod string; pub mod dpi; pub mod registry; pub mod theme;` + re-exports of common items (`Color`, `wide_str`). -11. **Create `src/diag/mod.rs`** with `init(bool)` that initialises simplelog. Add `log::set_max_level` if not handled by simplelog. -12. **Create `src/net/winhttp.rs`** in 4 commits: - - 12a. `Client::new`, `Drop` for `WinHttpCloseHandle`. - - 12b. `RequestBuilder` + GET path. - - 12c. POST + JSON body. - - 12d. `Response::header` + `Response::text` + `Response::json`. -13. **Create `src/net/mod.rs`** with `pub mod winhttp;` + `pub use winhttp::{Client, Error, Response};`. -14. **Wire modules into `src/main.rs`**: - ```rust - mod diag; - mod net; - mod os; - // OLD ones still present for now: - mod diagnose; - mod theme; - mod native_interop; - ``` -15. **`cargo build --release`** — must compile clean. Binary should run identically to current build (we haven't replaced anything yet). -16. **`cargo clippy --release`** — fix any clippy warnings on new modules. -17. **Manual smoke test on Windows** (or note as deferred to Phase 4 testing): - - Run `--diagnose`, confirm log file exists at `%TEMP%\claude-code-usage-bubble.log` (but it's empty for now since nothing calls `log::info!` yet — that's OK). - - Run a tiny dev-only test binary or `cargo test` that exercises `net::winhttp::Client.get("https://api.github.com").send()` to validate the HTTP wrapper end-to-end. - -## Todo checklist - -- [ ] Cargo.toml deps updated -- [ ] `res/icon.rc` created -- [ ] `build.rs` swapped to embed-resource -- [ ] Build produces .exe with embedded icon -- [ ] `src/os/string.rs` -- [ ] `src/os/color.rs` -- [ ] `src/os/registry.rs` -- [ ] `src/os/theme.rs` -- [ ] `src/os/dpi.rs` -- [ ] `src/os/mod.rs` -- [ ] `src/diag/mod.rs` -- [ ] `src/net/winhttp.rs` (incremental, 4 sub-commits) -- [ ] `src/net/mod.rs` -- [ ] `src/main.rs` declares new modules -- [ ] `cargo build --release` clean -- [ ] `cargo clippy` clean -- [ ] WinHTTP smoke test passes against `api.github.com` - -## Success criteria - -- Phase ends with a runnable binary that behaves exactly like the previous version (no business logic changes). -- `net::winhttp::Client` can successfully GET `https://api.github.com/repos/tiennm99/claude-code-usage-bubble/releases/latest` and parse JSON. -- `log::info!("test")` from anywhere writes to `%TEMP%\claude-code-usage-bubble.log` when `--diagnose` is passed. -- Binary size: not larger than current (we've removed `ureq`+`native-tls`, added smaller crates). - -## Risks + mitigations - -| Risk | Likelihood | Mitigation | -|---|---|---| -| WinHTTP TLS handshake fails on older Windows | Low | Test on Win10/Win11; WinHTTP supports TLS 1.2+ since Win10 1607 | -| `WinHttpCrackUrl` is awkward; URL parsing has edge cases | Medium | Use `url` crate (~50 KB) for parsing, then pass components to WinHTTP | -| `embed-resource` doesn't match `winres`'s VERSIONINFO output exactly | Low | Verify with `mt /inspect output.exe` | -| `log` + `simplelog` collide with another logger init | None | App owns the only init | -| Chunked transfer / compression auto-decode disabled | Medium | Set `WINHTTP_OPTION_DECOMPRESSION` flag | - -## Security considerations - -- WinHTTP enforces certificate validation by default — don't disable. -- `registry` module writes only to `HKEY_CURRENT_USER` (user-scoped). No admin escalation. -- Log file path is `%TEMP%` — user-scoped. No secrets logged (verify in Phase 4 when adding token-handling logs). - -## Next steps - -→ Phase 2: replace `models.rs` + `localization/*` with `usage/types.rs` + `i18n/` directory. diff --git a/plans/260516-0707-cleanroom-rewrite/phase-02-types-and-i18n.md b/plans/260516-0707-cleanroom-rewrite/phase-02-types-and-i18n.md deleted file mode 100644 index fe7ebd9..0000000 --- a/plans/260516-0707-cleanroom-rewrite/phase-02-types-and-i18n.md +++ /dev/null @@ -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`) is structurally different and works for both Anthropic (5h/7d) and Codex (which already uses "primary/secondary" terminology in its API response). -- Source dispatches localization via `enum LanguageId` + matching const tables. Embedded TOML via `include_str!` + parsed-at-startup HashMap is structurally different and easier for translators. -- `LocaleStrings` becomes a `serde::Deserialize` struct keyed by TOML section. - -## Requirements - -### Functional - -- `usage::types::UsageWindows` carries `primary: Window`, `secondary: Window`, both `pub`. -- `usage::types::Window { utilization: f64, resets_at: Option }`. -- `usage::types::ProviderId` enum: `Claude`, `ChatGpt` (note: renamed from "Codex" internally; menu label stays "Codex" via i18n). -- `usage::types::ProviderSnapshot { id: ProviderId, windows: Result }` for app-level results. -- `i18n::I18n::load(active_code: Option<&str>) -> Self` parses all embedded TOMLs at startup. -- `i18n::I18n::strings() -> &LocaleStrings` returns the active language's strings. -- `i18n::LocaleStrings` is a single struct with all UI strings as named fields (matches what bubble/panel/app need). -- `i18n::detect::detect_system_locale() -> Option` mirrors source's `GetUserPreferredUILanguages` chain. - -### Non-functional - -- Adding a new language = drop a new TOML file in `src/i18n/locales/` + add one line in `i18n/mod.rs` `include_str!` map. -- TOML parsing happens once at startup; ~9 small files combined < 10 KB; parse time < 5 ms. - -## Architecture - -### `src/usage/types.rs` - -```rust -use std::time::SystemTime; - -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] -pub enum ProviderId { - Claude, - ChatGpt, -} - -impl ProviderId { - pub fn as_str(self) -> &'static str { - match self { Self::Claude => "claude", Self::ChatGpt => "chatgpt" } - } -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct Window { - pub utilization: f64, // 0.0–100.0 - pub resets_at: Option, -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct UsageWindows { - pub primary: Window, // 5h for Claude / primary_window for ChatGPT - pub secondary: Window, // 7d for Claude / secondary_window for ChatGPT -} - -#[derive(Clone, Debug)] -pub struct ProviderSnapshot { - pub id: ProviderId, - pub windows: UsageWindows, -} -``` - -### `src/usage/mod.rs` (Phase 2 portion — provider trait stub goes here, real impls in Phase 4) - -```rust -pub mod types; -pub use types::{ProviderId, Window, UsageWindows, ProviderSnapshot}; - -// Provider trait lives here; impls (anthropic.rs, chatgpt.rs) come in Phase 4. -pub trait UsageProvider: Send { - fn id(&self) -> ProviderId; - fn poll(&mut self, http: &crate::net::Client) -> Result; -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("authentication required")] - AuthRequired, - #[error("no credentials configured")] - NoCredentials, - #[error("token expired and refresh failed")] - TokenExpired, - #[error("network: {0}")] - Network(#[from] crate::net::Error), - #[error("response shape mismatch: {0}")] - BadResponse(String), - #[error("credential read: {0}")] - Creds(#[from] crate::creds::Error), // forward-declared, real type in Phase 3 -} -``` - -In Phase 2 we leave `creds::Error` and the impls as `todo!()` stubs that won't link until Phase 3/4. - -### `src/i18n/mod.rs` - -```rust -use std::collections::HashMap; -use serde::Deserialize; - -pub mod detect; - -#[derive(Clone, Deserialize)] -pub struct LocaleStrings { - pub window_title: String, - pub refresh: String, - pub update_frequency: String, - pub one_minute: String, - pub five_minutes: String, - pub fifteen_minutes: String, - pub one_hour: String, - pub models: String, - pub claude_label: String, // was claude_code_model - pub chatgpt_label: String, // was codex_model - pub settings: String, - pub start_with_windows: String, - pub reset_position: String, - pub language: String, - pub system_default: String, - pub check_for_updates: String, - pub checking_for_updates: String, - pub up_to_date: String, - pub update_failed: String, - pub applying_update: String, - pub update_available: String, - pub update_via_winget: String, // was update_via_winget_label - pub exit: String, - pub show_widget: String, - pub session_window: String, - pub weekly_window: String, - pub now: String, - pub day_suffix: String, - pub hour_suffix: String, - pub minute_suffix: String, - pub second_suffix: String, - pub token_expired_title: String, - pub token_expired_body: String, - pub chatgpt_token_expired_title: String, - pub chatgpt_token_expired_body: String, -} - -#[derive(Deserialize)] -struct LocaleFile { - code: String, - native_name: String, - #[serde(flatten)] - strings: LocaleStrings, -} - -pub struct I18n { - available: HashMap, // code → (native_name, strings) - active: String, -} - -impl I18n { - pub fn load(active_code: Option<&str>) -> Self { - let raw = [ - ("en", include_str!("locales/en.toml")), - ("nl", include_str!("locales/nl.toml")), - ("es", include_str!("locales/es.toml")), - ("fr", include_str!("locales/fr.toml")), - ("de", include_str!("locales/de.toml")), - ("ja", include_str!("locales/ja.toml")), - ("ko", include_str!("locales/ko.toml")), - ("zh-TW", include_str!("locales/zh-TW.toml")), - ]; - let mut available = HashMap::new(); - for (code, body) in raw { - if let Ok(file) = toml::from_str::(body) { - available.insert(code.to_string(), (file.native_name, file.strings)); - } - } - let active = match active_code { - Some(c) if available.contains_key(c) => c.to_string(), - _ => detect::detect_system_locale() - .and_then(|s| Self::normalize(&s, &available)) - .unwrap_or_else(|| "en".to_string()), - }; - Self { available, active } - } - - pub fn strings(&self) -> &LocaleStrings { - &self.available[&self.active].1 - } - - pub fn active_code(&self) -> &str { &self.active } - - pub fn available(&self) -> impl Iterator { - self.available.iter().map(|(code, (name, _))| (code.as_str(), name.as_str())) - } - - fn normalize(code: &str, available: &HashMap) -> Option { - // "en-US" → "en", "zh-Hant-TW" → "zh-TW", etc. - // Exact match first, then prefix. - let lower = code.to_ascii_lowercase().replace('_', "-"); - if available.contains_key(&lower) { return Some(lower); } - let prefix = lower.split('-').next().unwrap_or(""); - if prefix == "zh" && (lower.contains("tw") || lower.contains("hant")) { - return Some("zh-TW".into()); - } - available.keys() - .find(|k| k.split('-').next() == Some(prefix)) - .cloned() - } -} -``` - -### `src/i18n/detect.rs` - -Mirrors source's `preferred_ui_languages` + `default_ui_locale` + `default_locale_name` chain via Win32 globalization APIs, but in one function: - -```rust -pub fn detect_system_locale() -> Option { - preferred().or_else(default_ui).or_else(default_user) -} -fn preferred() -> Option { /* GetUserPreferredUILanguages */ } -fn default_ui() -> Option { /* GetUserDefaultUILanguage + LCIDToLocaleName */ } -fn default_user() -> Option { /* GetUserDefaultLocaleName */ } -``` - -### `src/i18n/locales/en.toml` - -```toml -code = "en" -native_name = "English" - -window_title = "Claude Code Usage Bubble" -refresh = "Refresh" -update_frequency = "Update frequency" -one_minute = "1 minute" -five_minutes = "5 minutes" -fifteen_minutes = "15 minutes" -one_hour = "1 hour" -models = "Models" -claude_label = "Claude Code" -chatgpt_label = "Codex" -settings = "Settings" -start_with_windows = "Start with Windows" -reset_position = "Reset position" -language = "Language" -system_default = "System default" -check_for_updates = "Check for updates" -checking_for_updates = "Checking for updates…" -up_to_date = "Up to date" -update_failed = "Update failed" -applying_update = "Applying update…" -update_available = "Update available" -update_via_winget = "via WinGet" -exit = "Exit" -show_widget = "Show widget" -session_window = "5h" -weekly_window = "7d" -now = "now" -day_suffix = "d" -hour_suffix = "h" -minute_suffix = "m" -second_suffix = "s" -token_expired_title = "Claude Code session expired" -token_expired_body = "Sign in again to keep usage reporting." -chatgpt_token_expired_title = "Codex session expired" -chatgpt_token_expired_body = "Sign in again to keep usage reporting." -``` - -The 8 other locale files mirror this shape with translated strings. **Important:** copy the translations from `src/localization/*.rs` content (the strings themselves are utilitarian/factual translations and not copyright-eligible the way code is — but for safety, re-translate the most unique strings using your own phrasing). - -### Consumer refactors (in this phase) - -**`src/app.rs`:** -- `use crate::localization::{LanguageId, Strings, resolve_language}` → `use crate::i18n::{I18n, LocaleStrings}` -- `s.language.strings()` → `s.i18n.strings()` -- `LanguageId::ALL.iter()` → `s.i18n.available()` -- All field renames: `claude_code_model` → `claude_label`, `codex_model` → `chatgpt_label`, etc. - -**`src/bubble.rs`:** no localization access; only depends on bubble-specific data. Unaffected. - -**`src/panel.rs`:** -- `data.strings.session_window` works unchanged (field name preserved). -- `data.strings.claude_code_model` → `data.strings.claude_label`. - -**`src/settings.rs`:** unchanged (it stores `language: Option` already, which holds a locale code). - -**`src/models.rs`:** delete. Replace `crate::models::{AppUsageData, UsageData, UsageSection}` consumers: -- `AppUsageData` → `Vec` -- `UsageData` → `UsageWindows` -- `UsageSection` → `Window` - -Migration map for `app.rs`: -- `s.data.claude_code.as_ref()` → `s.snapshots.iter().find(|sn| sn.id == ProviderId::Claude)` -- `c.session.percentage` → `sn.windows.primary.utilization` -- `c.weekly.percentage` → `sn.windows.secondary.utilization` - -## Related code files - -**To create:** -- `src/usage/mod.rs` -- `src/usage/types.rs` -- `src/i18n/mod.rs` -- `src/i18n/detect.rs` -- `src/i18n/locales/en.toml` -- `src/i18n/locales/nl.toml` -- `src/i18n/locales/es.toml` -- `src/i18n/locales/fr.toml` -- `src/i18n/locales/de.toml` -- `src/i18n/locales/ja.toml` -- `src/i18n/locales/ko.toml` -- `src/i18n/locales/zh-TW.toml` - -**To modify:** -- `Cargo.toml` — add `toml = "0.8"` (with default features) -- `src/main.rs` — declare `mod usage; mod i18n;`; remove `mod models; mod localization;` -- `src/app.rs` — migrate all `crate::models::*` and `crate::localization::*` imports -- `src/panel.rs` — field renames -- `src/bubble.rs` — only if it references `LanguageId` (it shouldn't) - -**To delete:** -- `src/models.rs` -- `src/localization/mod.rs` -- `src/localization/english.rs` -- `src/localization/dutch.rs` -- `src/localization/spanish.rs` -- `src/localization/french.rs` -- `src/localization/german.rs` -- `src/localization/japanese.rs` -- `src/localization/korean.rs` -- `src/localization/traditional_chinese.rs` - -## Implementation steps - -1. **Add `toml = "0.8"`** to `Cargo.toml`. -2. **Create `src/usage/types.rs`** (struct definitions only — no impls yet). -3. **Create `src/usage/mod.rs`** with trait `UsageProvider` and `Error` enum. Leave it without any impls. -4. **Create `src/i18n/locales/en.toml`** first; verify TOML structure parses. -5. **Add `src/i18n/mod.rs` + `src/i18n/detect.rs`** with `I18n::load` reading only `en.toml`. -6. **Wire `mod i18n; mod usage;` into `main.rs`** and call `I18n::load(None)` from `app::run` (storing on `AppState`). Build should still compile (no usages downstream yet). -7. **Migrate `app.rs`** field-by-field from `Strings` to `LocaleStrings`. Run `cargo check` after each subsystem (menu, balloon, panel-data, tray-tooltip). -8. **Translate the other 8 locale TOMLs.** Use your own phrasings for the longer strings (e.g. `token_expired_body`) rather than direct copies of upstream's translations. -9. **Add the other 8 `include_str!` entries** to `i18n/mod.rs`. -10. **Migrate `panel.rs`** field renames (small). -11. **Migrate `app.rs` data model** from `AppUsageData` to `Vec`. This is the biggest single edit. Update `apply_data`, `apply_usage_update`, `build_panel_data_from`, `refresh_tray_icons`, `refresh_text_fields`. -12. **Delete `src/models.rs` + `src/localization/*`** once nothing references them. -13. **`cargo build --release`** — clean. - -## Todo checklist - -- [ ] `usage/types.rs` written -- [ ] `usage/mod.rs` written (trait + Error stubs) -- [ ] `i18n/mod.rs` + `detect.rs` written -- [ ] 9 TOML locale files written (translations are your own paraphrasings) -- [ ] `Cargo.toml` adds `toml` dep -- [ ] `main.rs` declares new modules + removes old ones -- [ ] `app.rs` migrated to `LocaleStrings` + `Vec` -- [ ] `panel.rs` field renames done -- [ ] `bubble.rs` confirmed unaffected -- [ ] Old `src/models.rs` + `src/localization/*` deleted -- [ ] `cargo build --release` clean -- [ ] App still runs (placeholder data since providers aren't wired yet) - -## Success criteria - -- TOML files parse cleanly at startup. -- App shows correct language strings based on Windows display language. -- No file in `src/` shares a name with upstream's `models.rs` or `localization/*`. -- Right-click → Language submenu lists 9 options (system default + 8 languages) and switching them updates UI immediately. - -## Risks + mitigations - -| Risk | Likelihood | Mitigation | -|---|---|---| -| TOML serde derive misalignment (typos in field names) | High | Use `#[serde(deny_unknown_fields)]` to catch typos at load time | -| Translations differ enough from upstream that meaning drifts | Medium | Compare meaning side-by-side before committing; ask a native speaker for the long strings if you can | -| Bubble/panel field references break in subtle places | Medium | `cargo check` after each consumer edit | -| App startup slows due to TOML parsing | Negligible | TOML files combined < 10 KB | - -## Security considerations - -- TOML strings are static, no eval. Parse failures fall back to English silently. No injection risk. -- No PII in locale files. - -## Next steps - -→ Phase 3: replace credential reading with `creds/` directory. - -## Open questions - -- **Translation copyright.** The upstream localization files contain ~30 short UI strings per language. These are utility translations of standard UI vocabulary and are unlikely to be copyrightable individually, but for full clean-room status, re-paraphrase the longest strings (`token_expired_body` and `chatgpt_token_expired_body`). Recommended: write your own phrasing for those two. diff --git a/plans/260516-0707-cleanroom-rewrite/phase-03-creds-module.md b/plans/260516-0707-cleanroom-rewrite/phase-03-creds-module.md deleted file mode 100644 index dbae008..0000000 --- a/plans/260516-0707-cleanroom-rewrite/phase-03-creds-module.md +++ /dev/null @@ -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>` ordered by priority lets future additions (e.g. an environment-variable-based source) drop in with no changes to the locator. -- Change-detection signatures (used by app's "watch for re-auth" loop) become a trait method. - -## Requirements - -### Functional - -- `creds::Token { access_token: String, expires_at_unix_ms: Option, account_id: Option }`. -- `trait creds::CredentialSource: Send + Sync`: - - `fn id(&self) -> &str` — stable identifier ("local-claude", "wsl:Ubuntu-22", "codex"). - - `fn read(&self) -> Result`. - - `fn signature(&self) -> Option` — opaque hash/key for change detection. - - `fn refresh_hint(&self) -> RefreshHint` — what command to spawn for refresh. -- `creds::CredentialLocator::default_claude()` builds a locator with local Windows path first, then all installed WSL distros. -- `creds::CredentialLocator::default_codex()` builds a locator with the local Codex path. -- `locator.first_available() -> Option<&dyn CredentialSource>`. -- `locator.signatures() -> Vec`. - -### Non-functional - -- WSL probe (which spawns `wsl.exe -l -q`) must complete in ≤ 5s or be timed out. -- WSL token-read must complete in ≤ 5s or be timed out. -- No blocking work in `signature()` (it's called frequently from the poll loop) — only stat/file-size, not file-read. - -## Architecture - -### `src/creds/mod.rs` - -```rust -use std::time::Duration; - -pub mod local_fs; -pub mod wsl_bridge; -pub mod codex_auth; - -#[derive(Debug, Clone)] -pub struct Token { - pub access_token: String, - pub expires_at_unix_ms: Option, - pub account_id: Option, -} - -#[derive(Debug, Clone)] -pub enum RefreshHint { - LocalCliCommand { exe: &'static str }, - WslCliCommand { distro: String }, - Codex, -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("credential file not found at {path}")] - NotFound { path: String }, - #[error("io: {0}")] - Io(#[from] std::io::Error), - #[error("invalid JSON: {0}")] - Json(#[from] serde_json::Error), - #[error("missing field in credential JSON: {0}")] - MissingField(&'static str), - #[error("WSL command failed: {0}")] - WslCommand(String), - #[error("timeout waiting for WSL command")] - WslTimeout, -} - -pub trait CredentialSource: Send + Sync { - fn id(&self) -> &str; - fn read(&self) -> Result; - fn signature(&self) -> Option; - fn refresh_hint(&self) -> RefreshHint; -} - -pub struct CredentialLocator { - sources: Vec>, -} - -impl CredentialLocator { - pub fn new(sources: Vec>) -> Self { - Self { sources } - } - - pub fn default_claude() -> Self { - let mut sources: Vec> = Vec::new(); - if let Some(local) = local_fs::LocalClaudeCreds::detect() { - sources.push(Box::new(local)); - } - for distro in wsl_bridge::list_distros() { - sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro))); - } - Self { sources } - } - - pub fn default_codex() -> Self { - let mut sources: Vec> = Vec::new(); - if let Some(codex) = codex_auth::LocalCodexCreds::detect() { - sources.push(Box::new(codex)); - } - Self { sources } - } - - pub fn first_available(&self) -> Option<&dyn CredentialSource> { - self.sources.iter().find(|s| s.signature().is_some()).map(Box::as_ref) - } - - pub fn signatures(&self) -> Vec { - self.sources.iter().filter_map(|s| s.signature()).collect() - } - - pub fn iter(&self) -> impl Iterator { - self.sources.iter().map(Box::as_ref) - } -} -``` - -### `src/creds/local_fs.rs` - -```rust -use std::path::PathBuf; - -pub struct LocalClaudeCreds { - path: PathBuf, - id: String, -} - -impl LocalClaudeCreds { - pub fn detect() -> Option { - let home = dirs::home_dir()?; - let path = home.join(".claude").join(".credentials.json"); - Some(Self { id: format!("local:{}", path.display()), path }) - } -} - -impl super::CredentialSource for LocalClaudeCreds { - fn id(&self) -> &str { &self.id } - fn read(&self) -> Result { - let content = std::fs::read_to_string(&self.path)?; - parse_claude_json(&content) - } - fn signature(&self) -> Option { - let meta = std::fs::metadata(&self.path).ok()?; - let modified = meta.modified().ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()).unwrap_or(0); - Some(format!("{}|{}|{}", self.id, meta.len(), modified)) - } - fn refresh_hint(&self) -> super::RefreshHint { - super::RefreshHint::LocalCliCommand { exe: "claude" } - } -} - -pub fn parse_claude_json(content: &str) -> Result { - let json: serde_json::Value = serde_json::from_str(content)?; - let oauth = json.get("claudeAiOauth") - .ok_or(super::Error::MissingField("claudeAiOauth"))?; - let access_token = oauth.get("accessToken") - .and_then(|v| v.as_str()) - .ok_or(super::Error::MissingField("accessToken"))? - .to_string(); - let expires_at_unix_ms = oauth.get("expiresAt").and_then(|v| v.as_i64()); - Ok(super::Token { access_token, expires_at_unix_ms, account_id: None }) -} -``` - -### `src/creds/wsl_bridge.rs` - -Spawns `wsl.exe -l -q` to enumerate distros, then per-distro spawns `wsl.exe -d -- sh -lc 'cat ~/.claude/.credentials.json'`. Includes UTF-16LE-aware text decoder for `wsl.exe -l -q` output. Uses `CREATE_NO_WINDOW` flag. 5s timeout per command via a `run_with_timeout` helper. - -Key public types: -- `pub fn list_distros() -> Vec` — empty Vec if WSL not installed. -- `pub struct WslClaudeCreds { distro: String, id: String }` implementing `CredentialSource`. - -### `src/creds/codex_auth.rs` - -Reads `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Token includes `account_id` from `tokens.account_id`. Mirrors `local_fs.rs` pattern. - -## Related code files - -**To create:** -- `src/creds/mod.rs` -- `src/creds/local_fs.rs` -- `src/creds/wsl_bridge.rs` -- `src/creds/codex_auth.rs` - -**To modify:** -- `src/main.rs` — `mod creds;` - -**To delete:** nothing (Phase 4 deletes `src/poller.rs` once `usage::*` providers go live). - -## Implementation steps - -1. Create `src/creds/mod.rs` with trait + `Token` + `Error` + `RefreshHint` + `CredentialLocator`. -2. Create `local_fs.rs` with `LocalClaudeCreds`. -3. Create `wsl_bridge.rs` with distro enumeration + per-distro creds source. Note `decode_wsl_text` handles the UTF-16LE encoding quirk on `wsl.exe -l -q`. -4. Create `codex_auth.rs` with `LocalCodexCreds`. -5. Wire `mod creds;` into `main.rs`. -6. `cargo build --release` clean. - -## Todo checklist - -- [ ] `creds/mod.rs` -- [ ] `creds/local_fs.rs` -- [ ] `creds/wsl_bridge.rs` -- [ ] `creds/codex_auth.rs` -- [ ] `main.rs` declares module -- [ ] `cargo build --release` clean -- [ ] Manual test (Windows): `CredentialLocator::default_claude().first_available()` finds a real credential file - -## Success criteria - -- Trait dispatch works; locator returns the right source based on priority. -- WSL probe doesn't hang the process when no WSL is installed. -- `signature()` is fast (<1 ms for local, <100 ms for WSL). - -## Risks + mitigations - -| Risk | Likelihood | Mitigation | -|---|---|---| -| WSL probe blocks for full 5s when WSL is uninstalled | Low | Test on a WSL-free VM; verify timeout works | -| UTF-16LE detection heuristic produces false positives | Low | Source has same heuristic and ships in production | -| `wsl.exe` not on PATH | Negligible on Win10+ | Return empty list silently | -| `dirs::home_dir()` returns None | Negligible on Windows | Return `None` from `detect()` and let locator skip | - -## Security considerations - -- Tokens stored as `String` in memory; not logged. -- WSL `sh -lc` arg is a constant — no user-controlled input → no shell injection. -- Don't `log::debug!` the token; log only `token len=N`. - -## Next steps - -→ Phase 4: providers + refresh orchestrator that USES this locator. diff --git a/plans/260516-0707-cleanroom-rewrite/phase-04-providers-and-refresh.md b/plans/260516-0707-cleanroom-rewrite/phase-04-providers-and-refresh.md deleted file mode 100644 index 3bd9485..0000000 --- a/plans/260516-0707-cleanroom-rewrite/phase-04-providers-and-refresh.md +++ /dev/null @@ -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`: - - Try `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer …` + `anthropic-beta: oauth-2025-04-20`. - - If primary returns 401/403 → `usage::Error::AuthRequired`. - - If primary returns 2xx but data is incomplete → fall back to Messages API. - - Messages-API fallback: `POST https://api.anthropic.com/v1/messages` with minimal payload; parse `anthropic-ratelimit-unified-{5h,7d}-utilization` headers + reset timestamps. -- `ChatGptProvider::new(locator: CredentialLocator) -> Self`. -- `ChatGptProvider::poll(http) -> Result`: - - `GET https://chatgpt.com/backend-api/wham/usage` with `Authorization: Bearer …` + `User-Agent: codex-cli` + optional `ChatGPT-Account-Id`. - - Parse `rate_limit.{primary_window,secondary_window}.used_percent` + `.reset_at` (Unix seconds). -- `RefreshOrchestrator::new(timeout: Duration) -> Self`. -- `RefreshOrchestrator::refresh(source: &dyn CredentialSource) -> RefreshOutcome` — spawns appropriate CLI, waits up to timeout, returns outcome. -- `usage::registry::Registry`: - - `Registry::new()` builds with default providers. - - `registry.enabled_providers(settings) -> Vec`. - - `registry.poll_one(id, http) -> Result`. - -### Non-functional - -- Total poll time (both providers) must stay under 60s even with refresh attempts. -- Refresh timeout is 8s (down from source's 30s) — verify UX feels snappy. -- HTTP retries are NOT done at this layer (app retains the retry/backoff loop). - -## Architecture - -### `src/usage/mod.rs` (expanded) - -```rust -pub mod types; -pub mod headers; -pub mod anthropic; -pub mod chatgpt; -pub mod refresh; -pub mod registry; - -pub use types::{ProviderId, Window, UsageWindows, ProviderSnapshot}; - -pub trait UsageProvider: Send { - fn id(&self) -> ProviderId; - fn poll(&mut self, http: &crate::net::winhttp::Client) -> Result; -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("authentication required")] - AuthRequired, - #[error("no credentials configured")] - NoCredentials, - #[error("token expired after refresh")] - TokenExpired, - #[error("network: {0}")] - Network(#[from] crate::net::Error), - #[error("response shape mismatch: {0}")] - BadResponse(String), - #[error("credential: {0}")] - Creds(#[from] crate::creds::Error), -} -``` - -### `src/usage/headers.rs` - -```rust -use super::{Window, UsageWindows}; -use crate::net::winhttp::Response; - -pub fn parse_anthropic_rate_limit(resp: &Response) -> UsageWindows { - let primary = Window { - utilization: header_f64(resp, "anthropic-ratelimit-unified-5h-utilization") * 100.0, - resets_at: unix_to_system_time(header_i64(resp, "anthropic-ratelimit-unified-5h-reset")), - }; - let secondary = Window { - utilization: header_f64(resp, "anthropic-ratelimit-unified-7d-utilization") * 100.0, - resets_at: unix_to_system_time(header_i64(resp, "anthropic-ratelimit-unified-7d-reset")), - }; - UsageWindows { primary, secondary } -} - -fn header_f64(resp: &Response, name: &str) -> f64 { - resp.header(name).and_then(|s| s.parse().ok()).unwrap_or(0.0) -} -fn header_i64(resp: &Response, name: &str) -> Option { - resp.header(name).and_then(|s| s.parse().ok()) -} -fn unix_to_system_time(secs: Option) -> Option { - let s = secs?; - if s < 0 { return None; } - Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(s as u64)) -} -``` - -### `src/usage/anthropic.rs` - -```rust -use crate::creds::CredentialLocator; -use crate::net::winhttp::Client; -use super::{UsageProvider, UsageWindows, Window, Error, ProviderId}; -use serde::Deserialize; - -const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; -const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; - -pub struct ClaudeProvider { - locator: CredentialLocator, -} - -impl ClaudeProvider { - pub fn new(locator: CredentialLocator) -> Self { Self { locator } } -} - -impl UsageProvider for ClaudeProvider { - fn id(&self) -> ProviderId { ProviderId::Claude } - - fn poll(&mut self, http: &Client) -> Result { - let source = self.locator.first_available().ok_or(Error::NoCredentials)?; - let token = source.read()?; - // … (try usage endpoint; fall back to messages; parse rate-limit headers) - } -} - -#[derive(Deserialize)] -struct OauthUsageResponse { - five_hour: Option, - seven_day: Option, -} -#[derive(Deserialize)] -struct Bucket { - utilization: f64, - resets_at: Option, // ISO 8601 -} - -fn try_usage_endpoint(http: &Client, token: &str) -> Result, Error> { /* … */ } -fn try_messages_endpoint(http: &Client, token: &str) -> Result { /* … */ } -fn parse_iso8601(s: &str) -> Option { /* … minimal date parser, same as source's */ } -``` - -### `src/usage/chatgpt.rs` - -Mirrors anthropic shape; parses Codex JSON. - -### `src/usage/refresh.rs` - -```rust -use crate::creds::{CredentialSource, RefreshHint}; -use std::process::{Command, Stdio}; -use std::time::Duration; - -#[derive(Debug, Clone, Copy)] -pub enum RefreshOutcome { Refreshed, StillExpired, CliMissing, Timeout } - -pub struct RefreshOrchestrator { timeout: Duration } - -impl RefreshOrchestrator { - pub fn new(timeout: Duration) -> Self { Self { timeout } } - - pub fn refresh(&self, source: &dyn CredentialSource) -> RefreshOutcome { - let signature_before = source.signature(); - let hint = source.refresh_hint(); - let spawn_ok = match hint { - RefreshHint::LocalCliCommand { exe } => self.spawn_local(exe), - RefreshHint::WslCliCommand { distro } => self.spawn_wsl(&distro), - RefreshHint::Codex => self.spawn_codex(), - }; - if !spawn_ok { return RefreshOutcome::CliMissing; } - - let start = std::time::Instant::now(); - loop { - if start.elapsed() > self.timeout { return RefreshOutcome::Timeout; } - std::thread::sleep(Duration::from_millis(500)); - if source.signature() != signature_before { - return RefreshOutcome::Refreshed; - } - } - } - - fn spawn_local(&self, exe: &str) -> bool { /* spawn `.cmd -p .` or ` -p .` */ } - fn spawn_wsl(&self, distro: &str) -> bool { /* wsl.exe -d -- bash -lic 'claude -p .' */ } - fn spawn_codex(&self) -> bool { /* spawn codex exec . */ } -} -``` - -### `src/usage/registry.rs` - -```rust -use super::{UsageProvider, ProviderId, UsageWindows, Error}; -use crate::net::winhttp::Client; -use crate::settings::Settings; - -pub struct Registry { - providers: Vec>, -} - -impl Registry { - pub fn with_defaults() -> Self { - let claude_locator = crate::creds::CredentialLocator::default_claude(); - let codex_locator = crate::creds::CredentialLocator::default_codex(); - Self { - providers: vec![ - Box::new(super::anthropic::ClaudeProvider::new(claude_locator)), - Box::new(super::chatgpt::ChatGptProvider::new(codex_locator)), - ], - } - } - - pub fn poll_enabled(&mut self, http: &Client, settings: &Settings) -> Vec<(ProviderId, Result)> { - let mut results = Vec::new(); - for p in self.providers.iter_mut() { - let enabled = match p.id() { - ProviderId::Claude => settings.show_claude_code, - ProviderId::ChatGpt => settings.show_codex, - }; - if !enabled { continue; } - results.push((p.id(), p.poll(http))); - } - results - } -} -``` - -### `app.rs` migration - -Replace `poller::poll(show_claude, show_codex)` with `registry.poll_enabled(&http_client, &settings)`. App now holds: -- `http_client: net::winhttp::Client` -- `registry: usage::registry::Registry` -- `refresh: usage::refresh::RefreshOrchestrator` - -Polling thread flow: -1. `let results = registry.poll_enabled(http, settings);` -2. For each `(id, Err(AuthRequired))`, call `refresh.refresh(source)` — needs locator access; expose via provider trait `fn try_refresh(orchestrator: &Orchestrator) -> RefreshOutcome` OR pass locator to app. -3. Post `WM_APP_USAGE_UPDATED`. - -(Detail: simplest is for the provider to expose its locator: `fn locator(&self) -> &CredentialLocator;` but that's leaky. Alternative: provider has an internal `fn refresh(&self, orch) -> RefreshOutcome` that owns the locator-access. Implement option B.) - -## Related code files - -**To create:** -- `src/usage/headers.rs` -- `src/usage/anthropic.rs` -- `src/usage/chatgpt.rs` -- `src/usage/refresh.rs` -- `src/usage/registry.rs` - -**To modify:** -- `src/usage/mod.rs` — expand with new module declarations -- `src/app.rs` — migrate poll-thread logic from `poller::*` to `registry::*` + `refresh::*`; remove `crate::poller` import -- `src/main.rs` — remove `mod poller;` - -**To delete:** -- `src/poller.rs` (1099 LOC removed) - -## Implementation steps - -1. **Implement `headers.rs`** — pure parsing, test in isolation. -2. **Implement `anthropic.rs`** in two parts: - - 2a. `try_usage_endpoint` — full JSON parse path. - - 2b. `try_messages_endpoint` — POST with model fallback chain + header parsing. -3. **Implement `chatgpt.rs`** — single endpoint, JSON parse. -4. **Implement `refresh.rs`** — orchestrator with 3 spawn paths. -5. **Implement `registry.rs`** — registry + `poll_enabled`. -6. **Migrate `app.rs::handle_poll_result`** to consume `Vec<(ProviderId, Result)>` instead of `Result`. -7. **Migrate `app.rs::apply_data`** to update `Vec` per provider. -8. **Add `fn try_refresh_for_provider(&mut self, id: ProviderId, orch: &Orchestrator) -> RefreshOutcome`** to `Registry`, so app can request refresh without touching internals. -9. **Wire `RefreshOrchestrator::new(Duration::from_secs(8))`** into app state. -10. **Delete `src/poller.rs`** + `mod poller;` line. -11. **`cargo build --release`** clean. -12. **End-to-end test on Windows**: - - Run app, sign-in via existing Claude CLI session, see polling work. - - Force token expiry (delete credentials file), see refresh succeed. - - Disconnect network, see graceful degradation. - -## Todo checklist - -- [ ] `headers.rs` -- [ ] `anthropic.rs` (usage endpoint) -- [ ] `anthropic.rs` (messages fallback) -- [ ] `chatgpt.rs` -- [ ] `refresh.rs` -- [ ] `registry.rs` -- [ ] `app.rs` poll-thread + apply_data migration -- [ ] `poller.rs` deleted -- [ ] `main.rs` updated -- [ ] `cargo build --release` clean -- [ ] Manual Windows e2e: Claude polls, Codex polls, token-refresh works -- [ ] Manual Windows e2e: network down → "..." indicator; back online → recovers - -## Success criteria - -- `src/poller.rs` no longer exists. -- App polls both providers concurrently (in poll thread). -- Token-expired flow refreshes within 8 s (or shows "..." gracefully if CLI missing). -- All ISO 8601 + Unix timestamps parse correctly (test edge cases: end-of-day, leap years). - -## Risks + mitigations - -| Risk | Likelihood | Mitigation | -|---|---|---| -| Anthropic API shape changes between dev and ship | Low | Test against live API; pin `anthropic-version: 2023-06-01` | -| Codex endpoint changes auth header | Low | Match source's header set exactly: Bearer + User-Agent + optional ChatGPT-Account-Id | -| Refresh races multiple poll attempts | Medium | Single refresh per source per poll cycle; signature-based completion detection | -| `wsl.exe bash -lic 'claude -p .'` outputs to TTY when no -p flag is recognized in WSL claude version | Medium | Test against actual installed Claude CLI in WSL; consider `--no-prompt` alternative | -| Long-running Messages API request | Medium | 30 s HTTP timeout in `net::winhttp::Client` | - -## Security considerations - -- Bearer token is included in HTTPS request → WinHTTP encrypts with TLS. -- Token never logged at INFO level; only `len=N` at DEBUG. -- CLI refresh spawns process with `CREATE_NO_WINDOW` to avoid console flash. - -## Next steps - -→ Phase 5: replace `tray_icon.rs` with `tray/` directory and tiny-skia badges. - -## Open questions - -- Does the Anthropic OAuth usage endpoint return `seven_day.utilization` consistently or do we still need the messages fallback for 7d data? Source code says yes-fallback-sometimes-needed. Keep the fallback for safety. -- Should `ChatGptProvider` skip the request if it has no `account_id` to avoid wasting bandwidth on a guaranteed-401? Source includes the header conditionally; we mirror that. diff --git a/plans/260516-0707-cleanroom-rewrite/phase-05-tray-badges.md b/plans/260516-0707-cleanroom-rewrite/phase-05-tray-badges.md deleted file mode 100644 index 9eeaa43..0000000 --- a/plans/260516-0707-cleanroom-rewrite/phase-05-tray-badges.md +++ /dev/null @@ -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, kind: BadgeKind, dpi: u32) -> HICON` produces an anti-aliased HICON. -- `tray::callback::handle(lparam: LPARAM) -> TrayAction` dispatches WM_APP_TRAY messages. -- `TrayIcon { id: TrayIconId, percent: Option, tooltip: String, kind: BadgeKind }`. - -### Non-functional - -- Badge render must complete in < 5 ms per icon (called on every poll cycle, ~1× per minute typically). -- Memory: each cached badge HICON is ~4 KB; we cache by `(percent_bucket, kind, dpi)` — at most ~100 entries × 4 KB = 400 KB cache size. - -## Architecture - -### `src/tray/mod.rs` - -```rust -use windows::Win32::Foundation::*; - -pub mod badge; -pub mod callback; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum BadgeKind { - Claude, - ChatGpt, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct TrayIconId(pub u32); - -pub const ID_CLAUDE: TrayIconId = TrayIconId(1); -pub const ID_CHATGPT: TrayIconId = TrayIconId(2); - -#[derive(Clone, Debug)] -pub struct TrayIcon { - pub id: TrayIconId, - pub percent: Option, - pub tooltip: String, - pub kind: BadgeKind, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TrayAction { - None, - LeftClick(TrayIconId), - RightClick(TrayIconId), -} - -pub struct Manager { - owner: HWND, - registered: std::collections::HashSet, -} - -impl Manager { - pub fn new(owner: HWND) -> Self { - Self { owner, registered: Default::default() } - } - - pub fn sync(&mut self, state: &[TrayIcon]) { - let target_ids: std::collections::HashSet<_> = state.iter().map(|i| i.id).collect(); - // Remove icons not in state - let to_remove: Vec<_> = self.registered.difference(&target_ids).copied().collect(); - for id in to_remove { self.remove(id); } - // Add or update - for icon in state { - if self.registered.contains(&icon.id) { - self.update(icon); - } else { - self.add(icon); - } - } - } - - pub fn notify(&self, id: TrayIconId, title: &str, body: &str) { /* NIM_MODIFY with NIF_INFO */ } - - fn add(&mut self, icon: &TrayIcon) { /* NIM_ADD + Shell_NotifyIconW */ } - fn update(&mut self, icon: &TrayIcon) { /* NIM_MODIFY */ } - fn remove(&mut self, id: TrayIconId) { /* NIM_DELETE */ } -} -``` - -### `src/tray/badge.rs` - -```rust -use windows::Win32::UI::WindowsAndMessaging::HICON; -use tiny_skia::*; - -pub fn render(percent: Option, kind: super::BadgeKind, dpi: u32) -> Option { - let size = match dpi { - d if d >= 192 => 32, - d if d >= 144 => 24, - _ => 16, - }; - let mut pixmap = Pixmap::new(size, size)?; - - // 1. Background fill (gradient from kind's tint colors) - let bg_color = base_color_for(kind, percent); - fill_circle(&mut pixmap, size, bg_color); - - // 2. Ring sweep for percent - if let Some(p) = percent { - draw_arc(&mut pixmap, size, p, kind); - } - - // 3. Center text "%" with size auto-fit - if let Some(p) = percent { - draw_percent_text(&mut pixmap, size, p); - } - - // 4. Convert BGRA pixmap to HICON via CreateIconIndirect - pixmap_to_hicon(&pixmap) -} - -fn fill_circle(pixmap: &mut Pixmap, size: u32, color: Color) { /* … */ } -fn draw_arc(pixmap: &mut Pixmap, size: u32, percent: f64, kind: super::BadgeKind) { /* … */ } -fn draw_percent_text(pixmap: &mut Pixmap, size: u32, percent: f64) { - // tiny-skia doesn't render text natively. Two options: - // a) Use cosmic-text (heavy) or ab_glyph (lighter). - // b) Pre-rasterize digits 0-9 + % glyph at build time into tiny PNGs and embed. - // c) Skip text — just use the ring sweep for usage indication. - // Choose (c) for simplicity; the bubble shows the exact percent already. -} - -fn pixmap_to_hicon(pixmap: &Pixmap) -> Option { - // Win32 ICONINFO with mask + color bitmaps; bitmaps from CreateDIBSection. - // BGRA layout matches what tiny-skia produces (premultiplied alpha). - // … -} -``` - -**Decision:** drop text from tray badges entirely. The bubble shows the exact percentage; tray badge is a coarse indicator (color + ring fill). This sidesteps the tiny-skia text-rendering hassle and keeps the badge image clearer at 16×16. - -### `src/tray/callback.rs` - -```rust -use windows::Win32::Foundation::LPARAM; -use super::{TrayAction, TrayIconId}; - -const WM_LBUTTONUP: u32 = 0x0202; -const WM_RBUTTONUP: u32 = 0x0205; - -pub fn handle(lparam: LPARAM) -> TrayAction { - let raw = lparam.0 as u32; - let event = raw & 0xFFFF; - let id_lo = (raw >> 16) & 0xFFFF; - let id = TrayIconId(id_lo); - match event { - WM_LBUTTONUP => TrayAction::LeftClick(id), - WM_RBUTTONUP => TrayAction::RightClick(id), - _ => TrayAction::None, - } -} -``` - -### Cargo.toml addition - -```toml -tiny-skia = "0.11" -``` - -(~250 KB added to binary; no text dependency since we dropped text from badges.) - -## Related code files - -**To create:** -- `src/tray/mod.rs` -- `src/tray/badge.rs` -- `src/tray/callback.rs` - -**To modify:** -- `Cargo.toml` — add `tiny-skia` -- `src/main.rs` — `mod tray;`; remove `mod tray_icon;` -- `src/app.rs` — replace `crate::tray_icon::{sync, add, update, remove, notify_balloon, handle_message, ...}` with `crate::tray::{Manager, TrayIcon, BadgeKind, TrayAction, ID_CLAUDE, ID_CHATGPT}`. Store `Manager` in `AppState`. Update all call sites. - -**To delete:** -- `src/tray_icon.rs` - -## Implementation steps - -1. **Add `tiny-skia` dep**. -2. **Implement `badge.rs`** — start with `fill_circle` (one path), verify pixmap saves to PNG correctly for visual debugging. -3. **Add `draw_arc`** — `PathBuilder::move_to + arc_to`. Use 0° = top (12 o'clock). -4. **Implement `pixmap_to_hicon`** — this is the trickiest part. Create AND/XOR DIB sections, populate from pixmap pixels (premultiplied BGRA), build `ICONINFO`, call `CreateIconIndirect`. -5. **Test badge rendering** — save 10 sample HICONs at different percents to disk and inspect. -6. **Implement `Manager`** with add/update/remove/sync. -7. **Implement `callback.rs`**. -8. **Migrate `app.rs`** to new API; replace `tray_icon::sync(...)` with `state.tray.sync(&icons)`. -9. **Delete `src/tray_icon.rs`** and remove from `main.rs`. -10. **`cargo build --release`** clean. -11. **Windows e2e**: run app, see tray icons appear with anti-aliased ring. Hover for tooltip. Left-click toggles bubble. Right-click opens menu. - -## Todo checklist - -- [ ] `tiny-skia` added to Cargo.toml -- [ ] `badge.rs::fill_circle` works (PNG inspection) -- [ ] `badge.rs::draw_arc` works (PNG inspection) -- [ ] `badge.rs::pixmap_to_hicon` produces valid HICON -- [ ] `tray/mod.rs::Manager` with add/update/remove/sync -- [ ] `callback.rs::handle` returns correct TrayAction -- [ ] `app.rs` migrated to new tray API -- [ ] `tray_icon.rs` deleted -- [ ] `cargo build --release` clean -- [ ] Tray icons appear with anti-aliased visuals on Windows - -## Success criteria - -- Badge looks visibly smoother than source's GDI version (anti-aliased ring). -- Add/update/remove is idempotent (no duplicate icons after `sync`). -- Tray callbacks fire correctly for left/right click. -- No `src/tray_icon.rs` remains. - -## Risks + mitigations - -| Risk | Likelihood | Mitigation | -|---|---|---| -| `pixmap_to_hicon` produces wrong-format icon (alpha channel issues) | High | Test by saving the source pixmap as PNG, then comparing to the rendered icon; iterate on BGRA channel order | -| `CreateIconIndirect` requires monochrome mask bitmap; we only have color | Medium | Pass `hbmMask = NULL` to let Windows auto-generate from alpha (works on Win10+) | -| 16×16 looks bad even with AA | Medium | Render at 32×32 then downsample with high-quality lanczos (tiny-skia doesn't include downsampling — use `image` crate's resize) | -| `tiny-skia` adds too much binary size | Low | Measured ~250 KB; acceptable for the UX win | - -## Security considerations - -- No external input drives badge rendering — all params are internal (`percent`, `kind`, `dpi`). No injection surface. -- HICON handles must be `DestroyIcon`'d when cache evicts (avoid handle leak — Windows limit is ~10,000 per process). - -## Next steps - -→ Phase 6: replace `updater.rs` and drop `NOTICE`. - -## Open questions - -- Keep the cached HICONs alive for the process lifetime, or destroy aggressively on each `update`? Source destroys + recreates each cycle (wasteful but simple). Recommend: cache by `(percent_rounded_to_5pct, kind, dpi)` and let the cache grow naturally. Max size ~100 entries × 4 KB = 400 KB. -- Need an icon for "no data" state (percent = None). Current spec says "fill_circle + no ring". Verify the visual reads correctly. diff --git a/plans/260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md b/plans/260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md deleted file mode 100644 index 9447951..0000000 --- a/plans/260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md +++ /dev/null @@ -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, Error>` — GitHub releases API call. -- `update::release::Release { version: Version, asset_url: String }` — parsed result. -- `update::install::begin(release: &Release) -> Result<(), Error>` — download + handoff. -- `update::install::run_cli(args: &[String]) -> Option` — handle `--apply-update` flag (still kept for parity if a user manually invokes it, even though we don't use the helper-exe path anymore). -- `update::channel::current() -> Channel` — returns `Channel::Portable` for now. -- `Version` type with parse + ordering. - -### Non-functional - -- Inline `cmd /c` invocation uses `CREATE_NO_WINDOW | DETACHED_PROCESS` — no console flash. -- Download timeout: 60 s. Total update apply time: < 30 s after the 2-second wait window. - -## Architecture - -### `src/update/mod.rs` - -```rust -pub mod channel; -pub mod release; -pub mod install; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("network: {0}")] - Network(#[from] crate::net::Error), - #[error("io: {0}")] - Io(#[from] std::io::Error), - #[error("no compatible release asset found")] - NoAsset, - #[error("install location not writable: {0}")] - NotWritable(String), - #[error("malformed version: {0}")] - BadVersion(String), -} - -pub use channel::{Channel, current as current_channel}; -pub use release::{Release, fetch_latest}; -pub use install::{begin, run_cli}; -``` - -### `src/update/channel.rs` - -```rust -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Channel { Portable, Winget } - -pub fn current() -> Channel { - // Until winget package exists, always Portable. - // Future: detect by checking if current_exe path is under - // %LOCALAPPDATA%\Microsoft\WinGet\Packages or %ProgramFiles%\WinGet\Packages. - Channel::Portable -} -``` - -### `src/update/release.rs` - -```rust -use crate::net::winhttp::Client; -use serde::Deserialize; - -const ASSET_NAME: &str = "claude-code-usage-bubble.exe"; - -#[derive(Clone, Debug)] -pub struct Release { - pub version: Version, - pub asset_url: String, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct Version { pub major: u32, pub minor: u32, pub patch: u32 } - -impl Version { - pub fn current() -> Self { /* env!("CARGO_PKG_VERSION") */ } - pub fn parse(s: &str) -> Option { /* … */ } -} - -pub fn fetch_latest(http: &Client) -> Result, super::Error> { - let url = format!("https://api.github.com/repos/{}/releases/latest", repo_path()); - let resp = http.get(&url) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header("User-Agent", user_agent()) - .send()?; - let body: GhRelease = resp.json()?; - let candidate = Version::parse(body.tag_name.trim_start_matches('v')) - .ok_or_else(|| super::Error::BadVersion(body.tag_name.clone()))?; - if candidate <= Version::current() { return Ok(None); } - let asset = body.assets.iter() - .find(|a| a.name.eq_ignore_ascii_case(ASSET_NAME)) - .ok_or(super::Error::NoAsset)?; - Ok(Some(Release { version: candidate, asset_url: asset.browser_download_url.clone() })) -} - -#[derive(Deserialize)] -struct GhRelease { tag_name: String, assets: Vec } -#[derive(Deserialize)] -struct GhAsset { name: String, browser_download_url: String } - -fn repo_path() -> &'static str { "tiennm99/claude-code-usage-bubble" } -fn user_agent() -> &'static str { concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) } -``` - -### `src/update/install.rs` - -```rust -use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::os::windows::process::CommandExt; - -const CREATE_NO_WINDOW: u32 = 0x08000000; -const DETACHED_PROCESS: u32 = 0x00000008; - -pub fn begin(http: &crate::net::winhttp::Client, release: &super::Release) -> Result<(), super::Error> { - let current = std::env::current_exe()?; - ensure_writable(¤t)?; - let staging = stage_path()?; - std::fs::create_dir_all(staging.parent().unwrap())?; - download(http, &release.asset_url, &staging)?; - spawn_handoff(&staging, ¤t)?; - Ok(()) -} - -fn download(http: &crate::net::winhttp::Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> { - let resp = http.get(url).header("User-Agent", super::release::user_agent()).send()?; - std::fs::write(to, resp.body())?; // assume Response exposes .body() -> &[u8] - Ok(()) -} - -fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(), super::Error> { - let cmd = format!( - r#"timeout /t 2 /nobreak >nul & move /y "{src}" "{tgt}" & start "" "{tgt}""#, - src = source.to_string_lossy(), - tgt = target.to_string_lossy(), - ); - Command::new("cmd.exe") - .args(["/c", &cmd]) - .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) - .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()) - .spawn()?; - Ok(()) -} - -pub fn run_cli(args: &[String]) -> Option { - // Keep this for parity: if the user runs the binary with `--apply-update ` - // (the source's old helper signature), the inline-cmd handoff has already done the work; - // we just exit 0. - if args.len() >= 2 && args[1] == "--apply-update" { return Some(0); } - None -} - -fn stage_path() -> Result { - let base = dirs::data_local_dir() - .ok_or_else(|| super::Error::NotWritable("no data dir".into()))?; - Ok(base.join("ClaudeCodeUsageBubble").join("updates").join("update.exe")) -} - -fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> { - let parent = target.parent().ok_or_else(|| super::Error::NotWritable("no parent".into()))?; - let probe = parent.join(".probe"); - std::fs::write(&probe, b"").map_err(|e| super::Error::NotWritable(e.to_string()))?; - let _ = std::fs::remove_file(&probe); - Ok(()) -} -``` - -### Migration in `app.rs` - -Replace `updater::*` imports: -- `updater::check_for_updates()` → `update::release::fetch_latest(&http_client)` -- `updater::begin_self_update(release)` → `update::install::begin(&http_client, release)` -- `updater::current_install_channel()` → `update::current_channel()` -- `updater::handle_cli_mode(args)` → `update::run_cli(args)` -- `updater::UpdateCheckResult` → `Option` (None = up to date, Some = available) -- `updater::ReleaseDescriptor` → `update::Release` -- `updater::InstallChannel` → `update::Channel` - -### Drop NOTICE + final attribution cleanup - -After the rewrite is complete and validated: - -1. Delete `NOTICE` file. -2. Update `LICENSE`: - - Remove the "Portions ported from …" paragraph (currently at the top of LICENSE). - - Keep just the Apache-2.0 text with `Copyright 2026 tiennm99`. -3. Update `README.md`: - - Replace "Differences vs upstream" section's "derivative of … with minor adaptations" wording with "inspired by [upstream link]". - - Remove the "License" section mention of NOTICE. -4. Update `Cargo.toml`: - - `license = "Apache-2.0"` (unchanged). - -## Related code files - -**To create:** -- `src/update/mod.rs` -- `src/update/channel.rs` -- `src/update/release.rs` -- `src/update/install.rs` - -**To modify:** -- `src/main.rs` — declare `mod update;`; remove `mod updater;` -- `src/app.rs` — migrate `updater::*` call sites -- `LICENSE` — drop the upstream-attribution paragraph -- `README.md` — drop "derivative of" wording, replace with "inspired by" -- `Cargo.toml` — no functional changes - -**To delete:** -- `src/updater.rs` -- `NOTICE` - -## Implementation steps - -1. **Create `update/channel.rs`** — trivial. -2. **Create `update/release.rs`** — Version type + fetch_latest. Test by hitting GitHub API. -3. **Create `update/install.rs`** — download + inline-cmd handoff. **Test on a throwaway VM** (the handoff replaces the binary, which is risky). -4. **Create `update/mod.rs`** — re-exports. -5. **Migrate `app.rs`** — replace all `updater::*` call sites. -6. **Delete `src/updater.rs`** + `mod updater;` line. -7. **`cargo build --release`** clean. -8. **End-to-end test on Windows**: - - Stage a v0.1.1 GitHub release with a deliberately-different .exe. - - Run v0.1.0 binary, trigger update → confirm new .exe replaces old, new app launches. -9. **AFTER end-to-end test succeeds:** - - Delete `NOTICE`. - - Edit `LICENSE` — drop the upstream-attribution paragraph at the top. - - Edit `README.md` — drop "derivative of" paragraph; replace with one-line "Inspired by [CodeZeno/Claude-Code-Usage-Monitor]" (no attribution-required phrasing). -10. **Final repo audit:** - - `grep -ri "CodeZeno" src/` → must return nothing. - - `grep -ri "Claude-Code-Usage-Monitor" src/` → must return nothing. - - File names: `find src -type f -name '*.rs' | xargs -I {} basename {} | sort` and compare against upstream's file list (`models.rs`, `diagnose.rs`, `theme.rs`, `poller.rs`, `updater.rs`, `tray_icon.rs`, `native_interop.rs`, `localization/*`). **No file name should match.** - - `git log --oneline` shows the initial-port commit + 6 rewrite commits — transparent history. -11. **Commit and push:** - - Commit message: `chore: complete clean-room rewrite; drop upstream attribution` - - Push to GitHub. - -## Todo checklist - -- [ ] `update/channel.rs` -- [ ] `update/release.rs` -- [ ] `update/install.rs` -- [ ] `update/mod.rs` -- [ ] `app.rs` migrated to new updater API -- [ ] `updater.rs` deleted -- [ ] `cargo build --release` clean -- [ ] End-to-end update tested on Windows -- [ ] `NOTICE` deleted -- [ ] `LICENSE` upstream-attribution paragraph removed -- [ ] `README.md` updated to drop "derivative" wording -- [ ] Grep verifies no upstream references remain in `src/` -- [ ] File-name overlap with upstream = 0 -- [ ] Final commit + push - -## Success criteria - -- App self-updates correctly using inline-cmd handoff. -- `NOTICE` file no longer exists in repo. -- Repo passes the "no upstream references" grep test. -- GitHub's auto-license detection still reports Apache-2.0. -- The README still credits inspiration but does not claim derivative status. - -## Risks + mitigations - -| Risk | Likelihood | Mitigation | -|---|---|---| -| Inline `cmd /c` flagged by antivirus | Medium | Most AVs allow `cmd.exe` execution; if flagged, fall back to a temp `.bat` | -| `move /y` fails if exe is still loaded by Windows | Medium-High | The 2s `timeout` gives parent time to exit, fully releasing file handle | -| User has unusual `cmd.exe` path | Negligible on Windows | Use full path `C:\Windows\System32\cmd.exe` if needed | -| Drop NOTICE prematurely (before phase done) | High if rushed | Phase order: rewrite first, then drop attribution. Never reorder. | -| Legal — is "inspired by" enough? | Low (we did rewrite everything) | This is the entire point of Phases 1-5. After full rewrite, no MIT code remains; attribution is courtesy, not required | - -## Security considerations - -- The inline `cmd /c` command string is built from `std::env::current_exe()` and `stage_path()` — both internal, no user-controlled input. No shell injection. -- The downloaded asset is over HTTPS to `api.github.com` → MITM-safe. -- Update fails closed: if `move /y` fails, the old exe is still in place; user can retry. - -## Next steps - -→ Project complete. Tag v0.2.0 with the clean-room rewrite as a milestone. - -## Open questions - -- Should we sign the binary with a code-signing certificate to satisfy AV heuristics around inline-cmd updates? Out of scope for this plan; future enhancement. -- After dropping NOTICE, should we add a small "Acknowledgements" section in README that mentions inspiration from CodeZeno's project without invoking MIT attribution language? Recommended: **yes**, that's the polite move and is legally untainting since we don't claim derivation. diff --git a/plans/260516-0707-cleanroom-rewrite/plan.md b/plans/260516-0707-cleanroom-rewrite/plan.md deleted file mode 100644 index fb7eed0..0000000 --- a/plans/260516-0707-cleanroom-rewrite/plan.md +++ /dev/null @@ -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> | -| Token refresh | `RefreshOrchestrator`, 8s timeout | -| i18n | TOML files via `include_str!` | -| Tray badges | `tiny-skia` anti-aliased | -| Updater | inline `cmd /c` handoff (no helper-exe) | -| Logging | `log` + `simplelog` | -| Build script | `embed-resource` crate | - -## New module layout - -``` -src/ - main.rs (kept — update imports only) - app.rs (kept — update imports + call-sites) - bubble.rs (kept — update imports only) - panel.rs (kept — update imports only) - settings.rs (kept — update imports only) - - diag/mod.rs — log facade + simplelog file appender - os/ — Win32 helpers (color, string, dpi, registry, theme) - net/ — WinHTTP HTTP client - usage/ — Provider trait + types + impls + refresh - creds/ — CredentialSource trait + impls - i18n/ — TOML loader + 9 locale files - tray/ — Anti-aliased tray badges - update/ — Self-updater (release check, download, handoff, channel) -``` - -## Phases - -| # | Phase | Hours | File | -|---|---|---|---| -| 1 | Infrastructure (`diag/`, `os/`, `net/winhttp.rs`, Cargo.toml) | ~9 | [`phase-01-infrastructure.md`](phase-01-infrastructure.md) | -| 2 | Types & i18n (`usage/types.rs`, `i18n/`) | ~6 | [`phase-02-types-and-i18n.md`](phase-02-types-and-i18n.md) | -| 3 | Credentials (`creds/`) | ~3 | [`phase-03-creds-module.md`](phase-03-creds-module.md) | -| 4 | Providers & refresh (`usage/anthropic.rs`, `chatgpt.rs`, `refresh.rs`) | ~8 | [`phase-04-providers-and-refresh.md`](phase-04-providers-and-refresh.md) | -| 5 | Tray badges (`tray/badge.rs`) | ~5 | [`phase-05-tray-badges.md`](phase-05-tray-badges.md) | -| 6 | Updater + remove NOTICE | ~7 | [`phase-06-updater-and-remove-notice.md`](phase-06-updater-and-remove-notice.md) | - -**Total:** ~38h core work + 4–8h Windows-side debugging. - -## Phase dependencies - -``` -Phase 1 (infra) - ├─→ Phase 2 (types + i18n) - │ └─→ Phase 4 (providers) - │ └─→ Phase 5 (tray) — needs UsageProvider results - │ - └─→ Phase 3 (creds) - └─→ Phase 4 (providers) — depends on creds API - └─→ Phase 6 (updater + cleanup) — last -``` - -Phases 2 and 3 can technically run in parallel after Phase 1, but -serial execution (1→2→3→4→5→6) is cleaner for solo work. - -## Out of scope - -- `bubble.rs`, `panel.rs`, `app.rs`, `settings.rs`, `main.rs` — these - stay; only their imports/call-sites get touched as new APIs come - online. -- Adding new features beyond what the current copied code supports. -- Changing `bubble.rs`/`panel.rs` rendering or interaction behavior. - -## External contracts that must NOT change - -- Anthropic endpoints + headers -- ChatGPT endpoint + `User-Agent: codex-cli` -- Credential file paths and JSON shapes -- WSL access via `wsl.exe` -- CLI-driven token refresh (must invoke `claude` / `codex`) -- GitHub releases JSON format -- Settings file location (`%APPDATA%\ClaudeCodeUsageBubble\settings.json`) -- Windows registry path for startup (`Software\Microsoft\Windows\CurrentVersion\Run`) -- Single-instance mutex (`Global\ClaudeCodeUsageBubble`) - -## Success criteria (cross-phase) - -After all 6 phases: - -- [ ] `cargo build --release` clean on Windows -- [ ] No file in `src/` shares a name with the upstream source's files -- [ ] `NOTICE` file removed from repo root -- [ ] `LICENSE` (Apache-2.0) header retained; copyright line updated -- [ ] `README.md` updated to drop the "derivative of" paragraph; replace - with "inspired by [upstream]" link -- [ ] App functional: bubble, panel, tray, polling, auth, updater all - working on Windows 10/11 -- [ ] No regressions vs current behavior (poll cadence, snap, click→panel, - Ctrl+Wheel resize, fullscreen auto-hide, dual-bubble) - -## Rollback strategy - -- Each phase lands as a separate commit (or PR). If a phase breaks - the build/app, `git revert` that commit to restore the prior phase's - state. -- Phase 6 is the only commit that removes upstream attribution — if any - of Phases 1–5 is incomplete or buggy at that point, **do not** drop - NOTICE; finish or revert first. - -## Open questions - -- Bump `windows-rs` from 0.58 → newer? (Brainstorm flagged this.) - Defer decision to Phase 1 — try with 0.58 first, bump only if needed. -- Keep Git history showing the initial port? Recommended: **yes**. - Transparent and consistent with the "I inspired/rewrote from X" framing - even after NOTICE is gone. diff --git a/plans/260516-1730-github-release-auto-update/phase-01-release-ci-workflow.md b/plans/260516-1730-github-release-auto-update/phase-01-release-ci-workflow.md deleted file mode 100644 index 675d283..0000000 --- a/plans/260516-1730-github-release-auto-update/phase-01-release-ci-workflow.md +++ /dev/null @@ -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. - - - - - -## 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. diff --git a/plans/260516-1730-github-release-auto-update/phase-02-end-to-end-update-test.md b/plans/260516-1730-github-release-auto-update/phase-02-end-to-end-update-test.md deleted file mode 100644 index 314d684..0000000 --- a/plans/260516-1730-github-release-auto-update/phase-02-end-to-end-update-test.md +++ /dev/null @@ -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. diff --git a/plans/260516-1730-github-release-auto-update/phase-03-docs-and-release-process.md b/plans/260516-1730-github-release-auto-update/phase-03-docs-and-release-process.md deleted file mode 100644 index 6a6d91b..0000000 --- a/plans/260516-1730-github-release-auto-update/phase-03-docs-and-release-process.md +++ /dev/null @@ -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 - - - ``` - -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). diff --git a/plans/260516-1730-github-release-auto-update/plan.md b/plans/260516-1730-github-release-auto-update/plan.md deleted file mode 100644 index 9e42b69..0000000 --- a/plans/260516-1730-github-release-auto-update/plan.md +++ /dev/null @@ -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 diff --git a/plans/260518-0945-menu-restart-button/phase-01-implement-restart-action.md b/plans/260518-0945-menu-restart-button/phase-01-implement-restart-action.md deleted file mode 100644 index 64f5395..0000000 --- a/plans/260518-0945-menu-restart-button/phase-01-implement-restart-action.md +++ /dev/null @@ -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 = ""`. - -**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 "" ""`. - - Spawn via `std::process::Command::new("cmd.exe")` with `.creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)` and a `raw_arg` payload `/c ""` (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). diff --git a/plans/260518-0945-menu-restart-button/plan.md b/plans/260518-0945-menu-restart-button/plan.md deleted file mode 100644 index 498eab4..0000000 --- a/plans/260518-0945-menu-restart-button/plan.md +++ /dev/null @@ -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 "" ""` 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. diff --git a/plans/260521-1530-silent-update-no-cmd/phase-01-foundation-cli-flags-native-spawn-helper-mutex-retry.md b/plans/260521-1530-silent-update-no-cmd/phase-01-foundation-cli-flags-native-spawn-helper-mutex-retry.md deleted file mode 100644 index 61fe8ab..0000000 --- a/plans/260521-1530-silent-update-no-cmd/phase-01-foundation-cli-flags-native-spawn-helper-mutex-retry.md +++ /dev/null @@ -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 ` and `--updated-to `, 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 ` and `--updated-to ` 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::().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 ` 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 | diff --git a/plans/260521-1530-silent-update-no-cmd/phase-02-restart-path-replace-restart-app-with-native-createprocessw.md b/plans/260521-1530-silent-update-no-cmd/phase-02-restart-path-replace-restart-app-with-native-createprocessw.md deleted file mode 100644 index 718445b..0000000 --- a/plans/260521-1530-silent-update-no-cmd/phase-02-restart-path-replace-restart-app-with-native-createprocessw.md +++ /dev/null @@ -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 | diff --git a/plans/260521-1530-silent-update-no-cmd/phase-03-update-install-rename-move-native-spawn.md b/plans/260521-1530-silent-update-no-cmd/phase-03-update-install-rename-move-native-spawn.md deleted file mode 100644 index 5484d1c..0000000 --- a/plans/260521-1530-silent-update-no-cmd/phase-03-update-install-rename-move-native-spawn.md +++ /dev/null @@ -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 | - - - -### 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.` 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 | diff --git a/plans/260521-1530-silent-update-no-cmd/phase-04-cleanup-tray-notification.md b/plans/260521-1530-silent-update-no-cmd/phase-04-cleanup-tray-notification.md deleted file mode 100644 index c141fe1..0000000 --- a/plans/260521-1530-silent-update-no-cmd/phase-04-cleanup-tray-notification.md +++ /dev/null @@ -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.` 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). - - - -## Requirements - -**Functional** -- On every startup, scan `current_exe().parent()` for files matching `.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)`. - - Store in a `OnceLock>` 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.` 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.` file that another running instance still depends on | Impossible by construction: only the spawning instance writes `.old.` 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 | diff --git a/plans/260521-1530-silent-update-no-cmd/phase-05-manual-end-to-end-verification.md b/plans/260521-1530-silent-update-no-cmd/phase-05-manual-end-to-end-verification.md deleted file mode 100644 index 263fdfe..0000000 --- a/plans/260521-1530-silent-update-no-cmd/phase-05-manual-end-to-end-verification.md +++ /dev/null @@ -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 ` (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 - - - -### Session 1 — TBD -- Group A: TBD -- Group B: TBD -- Group C: TBD -- Group D: TBD -- Flash observed: TBD -- Notes: TBD diff --git a/plans/260521-1530-silent-update-no-cmd/plan.md b/plans/260521-1530-silent-update-no-cmd/plan.md deleted file mode 100644 index 5a5df33..0000000 --- a/plans/260521-1530-silent-update-no-cmd/plan.md +++ /dev/null @@ -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.`). - → 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 diff --git a/plans/260523-2242-bubble-tail-bar-layout/phase-01-lock-geometry.md b/plans/260523-2242-bubble-tail-bar-layout/phase-01-lock-geometry.md deleted file mode 100644 index e9f6828..0000000 --- a/plans/260523-2242-bubble-tail-bar-layout/phase-01-lock-geometry.md +++ /dev/null @@ -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. diff --git a/plans/260523-2242-bubble-tail-bar-layout/phase-02-apply-renderer-change.md b/plans/260523-2242-bubble-tail-bar-layout/phase-02-apply-renderer-change.md deleted file mode 100644 index 114c5c1..0000000 --- a/plans/260523-2242-bubble-tail-bar-layout/phase-02-apply-renderer-change.md +++ /dev/null @@ -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. diff --git a/plans/260523-2242-bubble-tail-bar-layout/phase-03-validate-on-windows.md b/plans/260523-2242-bubble-tail-bar-layout/phase-03-validate-on-windows.md deleted file mode 100644 index b4373fa..0000000 --- a/plans/260523-2242-bubble-tail-bar-layout/phase-03-validate-on-windows.md +++ /dev/null @@ -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. diff --git a/plans/260523-2242-bubble-tail-bar-layout/plan.md b/plans/260523-2242-bubble-tail-bar-layout/plan.md deleted file mode 100644 index 941539b..0000000 --- a/plans/260523-2242-bubble-tail-bar-layout/plan.md +++ /dev/null @@ -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? diff --git a/plans/260523-ui-ux-improvement-plan/plan.md b/plans/260523-ui-ux-improvement-plan/plan.md deleted file mode 100644 index 99c9f16..0000000 --- a/plans/260523-ui-ux-improvement-plan/plan.md +++ /dev/null @@ -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?