mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-22 18:14:39 +00:00
docs: plan Tauri v2 cross-platform port
This commit is contained in:
1 parent
bfcf3b56cd
commit
bb09765be5
13 files changed
+1349
No files matched your search
+17
@@ -0,0 +1,17 @@
|
||||
# Roadmap
|
||||
|
||||
These items are undecided or planned for later. None of them is part of the
|
||||
current Tauri port.
|
||||
|
||||
- **Linux ARM64 builds.** Publish aarch64 Linux builds, including an AppImage,
|
||||
and add the `linux-aarch64` entry to the updater's `latest.json`. Linux ships
|
||||
x86_64 only for now.
|
||||
- **macOS build.** Ship an unsigned macOS build. The install notes must explain
|
||||
how to get past Gatekeeper with "Open Anyway" in System Settings, since the
|
||||
app is not notarized.
|
||||
- **Wayland-native positioning.** Position, snap and keep the bubble on top
|
||||
under native Wayland. Today these work only through Xwayland.
|
||||
- **Repository and product rename.** A new name to replace
|
||||
"claude-code-usage-bubble" is under discussion. Any rename must keep the
|
||||
legacy update asset names and the GitHub redirects working, so that installed
|
||||
copies can still find and download updates.
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Scaffold Tauri crate, JS toolchain and CI"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "5h"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 01: Scaffold Tauri Crate, JS Toolchain And CI
|
||||
|
||||
## Context
|
||||
|
||||
The crate today is Win32-only: `windows` 0.58, embed-resource and `res/icon.rc`
|
||||
(`Cargo.toml`, `build.rs:1-16`). It does not compile on Linux. This phase gives
|
||||
the package a Tauri skeleton (lib plus thin bin, D1) that compiles and runs on
|
||||
Linux and Windows, plus the CI that produces the Windows exe every later phase
|
||||
tests by hand. It detaches the Win32 modules from the module tree; their files
|
||||
stay on disk until the phase that replaces each one deletes it.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Single root package (D1). `[lib] name = "claude_code_usage_bubble_lib"`,
|
||||
`path = "src/lib.rs"`; `[[bin]] name = "claude-code-usage-bubble"`.
|
||||
`src/main.rs` only holds
|
||||
`#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]` and a call
|
||||
to `claude_code_usage_bubble_lib::run()`.
|
||||
- `tauri.conf.json` at the root: `build.frontendDist: "ui"`,
|
||||
`app.withGlobalTauri: true`, no `devUrl`, `app.windows: []`.
|
||||
- `productName`, `mainBinaryName` and `identifier` as in D16. No `version` key.
|
||||
- `app.security.csp`:
|
||||
`default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost`.
|
||||
- `app.macOSPrivateApi: true` **and** the `macos-private-api` feature on the
|
||||
`tauri` dependency. `tauri-build` checks that the config and the crate features
|
||||
match and fails `cargo check` otherwise; the feature is a no-op off macOS and
|
||||
keeps transparency for the later macOS port.
|
||||
- `bundle`: `active: true`, `targets: ["appimage", "deb"]` (used only by the
|
||||
`release.yml` Linux x64 job; Windows and every other build use `--no-bundle`,
|
||||
D8), **`createUpdaterArtifacts: false`** (release turns it on
|
||||
through `--config`, D8), `copyright` "Copyright (C) 2026 tiennm99",
|
||||
`publisher` "tiennm99", `icon` set to the generated list. No `plugins.updater`
|
||||
section yet (Phase 09).
|
||||
- `capabilities/default.json`: `windows: ["bubble-*", "panel"]`, permissions
|
||||
`core:default` and `core:window:allow-start-dragging`. Plugins are called from
|
||||
Rust only, so no plugin JS permissions.
|
||||
- Cargo deps on major tags (per `version-pinning.md`):
|
||||
- `tauri = { version = "2", features = ["tray-icon", "macos-private-api"] }`, `tauri-build = "2"`.
|
||||
- `tauri-plugin-single-instance = "2"` and `tauri-plugin-autostart = "2"` now.
|
||||
`tauri-plugin-notification` is added in Phase 05 and `tauri-plugin-updater`
|
||||
in Phase 09.
|
||||
- `ureq = "3"`, plus the existing serde, serde_json, dirs, log, simplelog,
|
||||
thiserror, tiny-skia, sha2 and self-replace.
|
||||
- `[target.'cfg(unix)'.dependencies] libc = "0.2"` (the `kill(pid, 0)` wait).
|
||||
- `[target.'cfg(target_os = "linux")'.dependencies] x11rb = "0.13"` and
|
||||
`libloading` (major tag; the tray library probe in Phase 05).
|
||||
- `[target.'cfg(windows)'.dependencies] windows`: the major must match the one
|
||||
tauri already pulls (`cargo tree -i windows`). Features, the full set later
|
||||
phases need: `Win32_Foundation`, `Win32_System_Registry`,
|
||||
`Win32_System_Threading`, `Win32_UI_WindowsAndMessaging`, `Win32_UI_Shell`
|
||||
(SHAppBarMessage, SetWindowSubclass, AUMID), `Win32_Graphics_Dwm`,
|
||||
`Win32_Graphics_Gdi` (MonitorFromWindow/GetMonitorInfoW). A later phase that
|
||||
needs one more feature appends it and re-runs `cargo tree -i windows`.
|
||||
- `webview2-com` (the major tauri pulls) for the WebView2 runtime check in
|
||||
Phase 04, only if tauri does not already re-export it.
|
||||
- Keep the release profile (`Cargo.toml:43-48`).
|
||||
- `package.json`: `"private": true`, `"type": "module"`, scripts `tauri`,
|
||||
`lint` (`eslint .`) and `test` (`node --test "tests/js/**/*.test.js"`).
|
||||
A glob that matches nothing exits 0 on Node 24 (checked on this host); a bare
|
||||
directory argument (`node --test ui/`) fails with "Cannot find module".
|
||||
devDependencies, installed with `@latest` so they take the current caret
|
||||
major: `@tauri-apps/cli`, `eslint`, `@eslint/js`, `eslint-plugin-jsdoc`,
|
||||
`globals`.
|
||||
- `eslint.config.js`: `js.configs.recommended` and
|
||||
`jsdoc.configs['flat/recommended-error']` everywhere; `ui/**/*.js` gets
|
||||
`globals.browser` plus a readonly `__TAURI__`; `tests/**`, `scripts/**` and
|
||||
`eslint.config.js` get `globals.node`; `target/`, `gen/` and `node_modules/`
|
||||
are ignored.
|
||||
- `.taurignore` (D1): `ui/`, `tests/`, `scripts/`, `plans/`, `docs/`,
|
||||
`node_modules/`.
|
||||
- `.github/workflows/ci.yml` (D17):
|
||||
- Triggers: pushes to `feat/tauri-port` and `main`, and `pull_request`.
|
||||
- Rows `windows-latest` and `ubuntu-22.04`: checkout,
|
||||
`dtolnay/rust-toolchain@stable` with clippy and rustfmt,
|
||||
`Swatinem/rust-cache@v2`, `actions/setup-node@v4` (node `lts/*`, npm cache).
|
||||
Linux apt list, the official Tauri v2 prerequisites:
|
||||
`libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev`.
|
||||
Then `npm ci`, `cargo fmt --check`,
|
||||
`cargo clippy --all-targets --locked -- -D warnings`, `cargo test --locked`,
|
||||
`npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`.
|
||||
- The Windows row uploads `target/release/claude-code-usage-bubble.exe` with
|
||||
`actions/upload-artifact` (name `bubble-exe-<sha>`, `retention-days: 7`).
|
||||
- Row `macos-latest`: toolchain, cache, `cargo check --all-targets --locked`
|
||||
only (the macOS compile gate; `ui/` must exist, nothing else is needed).
|
||||
|
||||
## Files
|
||||
|
||||
- Modify: `Cargo.toml`, `build.rs` (becomes `tauri_build::build()`), `src/main.rs`, `.gitignore` (add `/node_modules`, `/gen`)
|
||||
- Create: `src/lib.rs`, `tauri.conf.json`, `.taurignore`, `capabilities/default.json`, `package.json`, `package-lock.json`, `eslint.config.js`, `ui/bubble.html` (placeholder), `tests/js/.gitkeep`, `.github/workflows/ci.yml`, `icons/` (output of `npx tauri icon src/icons/256.svg`, with `icons/icon.ico` then overwritten by `src/icons/icon.ico` to keep the hand-tuned small sizes)
|
||||
- Delete: `res/icon.rc`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `npm init`, then `npm i -D @tauri-apps/cli@latest eslint@latest @eslint/js@latest eslint-plugin-jsdoc@latest globals@latest`.
|
||||
2. Write `tauri.conf.json`, the capabilities file and `.taurignore`.
|
||||
3. Create `src/lib.rs` with `pub mod diag;` and `run()`: diag init plus a `tauri::Builder` that opens one transparent test window loading `bubble.html` and handles `RunEvent::ExitRequested` as in D18. Reduce `src/main.rs` to the shim. Remove every `mod` line for Win32 modules (`app`, `bubble`, `panel`, `tray`, `os`, `net`, `update`, `usage`, `creds`, `settings`, `i18n`, `usage_color`). Later phases re-add modules to `src/lib.rs` as they port them.
|
||||
4. Generate icons. Verify that `tauri-build` embeds the icon, a VERSIONINFO built from the Cargo version, and a PerMonitorV2 manifest.
|
||||
5. Run `npx tauri info` to confirm the CLI resolves a root-level `tauri.conf.json`.
|
||||
6. Add `ci.yml`, run `actionlint` and `yamllint`, push to `feat/tauri-port` and make all three rows green.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, `npm run lint` and `npm test` (zero tests, exit 0) all pass.
|
||||
- Host: `npm run tauri build -- --no-bundle` builds the binary with no signing key in the environment. No bundle is built on this host (no patchelf); bundling is first exercised by the Phase 10 dry run.
|
||||
- CI: all three rows green; the Windows run has a downloadable `bubble-exe-<sha>` artifact that starts and shows the test window.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| The CLI or `tauri-build` assumes `src-tauri/` | L x M | `tauri info` in step 5. Fallback: `TAURI_CONFIG`/`--config` paths. Last resort is a `src-tauri/` move, recorded as a change to D1. |
|
||||
| Config/feature mismatch fails `build.rs` | M x H | `macOSPrivateApi` and the `macos-private-api` feature are set together; `cargo check` in Validation catches any other allowlist mismatch. |
|
||||
| Two `windows` crate versions get compiled | M x L | Align the major with `cargo tree -i windows`. |
|
||||
| The `macos-latest` check fails on a build script | L x M | It runs on real macOS, so no cross toolchain is needed. A failure there is a real macOS compile bug to fix behind `cfg`. |
|
||||
| The `gen/schemas` dir is regenerated on every build | L x L | Gitignore it. Capabilities point `$schema` at it only for editor help. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the phase commits. The Win32 sources are untouched on disk.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Extract portable core with tests"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "6h"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 02: Extract Portable Core With Tests
|
||||
|
||||
## Context
|
||||
|
||||
Most of the logic is already pure but sits inside Win32 files. Extract it into
|
||||
`src/core/`. The code is plain Rust with `Rect`/`Point` structs instead of
|
||||
`RECT`/`HWND`, so it can be unit-tested on Linux. There are only 6 tests today
|
||||
(`settings.rs` 2, `bubble.rs` 2 (`mod fullscreen_tests` at `src/bubble.rs:1208`),
|
||||
`i18n` 1, `update/install.rs` 1).
|
||||
|
||||
## Requirements
|
||||
|
||||
Move or port each item below, keeping its exact behaviour and constants.
|
||||
|
||||
- `core/geom.rs`: `Rect{l,t,r,b}`, `Point`, and `scale(px, dpi)` (`src/os/dpi.rs:29-31`).
|
||||
- Bubble sizing: `aspect_at_width` (`src/bubble.rs:68`) and `bubble_height_logical`, with `MIN 140 / MAX 360 / DEFAULT 200 / STEP 20` (`src/bubble.rs:50-53`).
|
||||
- `default_position(work_area, w, h, provider)`, with the raw 24 px gap and the `h+24` stagger (`src/bubble.rs:2012-2041`).
|
||||
- `clamp_into_work_area`, including the Codex bottom-right `h+24` lift (`src/bubble.rs:891-948`).
|
||||
- The snap functions over a `SnapInput{rect, work_area, dpi, taskbar: Option<(Edge, Rect)>, peers: Vec<Rect>}`: corner, then edge, then taskbar, then peer, then the final clamp (`src/bubble.rs:709-889`, `950-968`).
|
||||
- `point_in_rounded_rect` (`src/bubble.rs:637`), even though D4 does not use it for hit-testing. Phase 06 uses it to decide whether a drag started inside the pill.
|
||||
- `place_near(bubble, panel_size, bounds)` (`src/panel.rs:515-542`), and `position_on_any_monitor(x, y, monitors)` with a 140 px probe, replacing `src/settings.rs:114`.
|
||||
- `rect_covers_monitor`, and `window_style_bits_allow_fullscreen` as plain u32 bit logic.
|
||||
- `core/settings.rs`: move `src/settings.rs`. Split `load()` into `parse_and_normalise(&str)` and `drop_offscreen_positions(&mut, &[Rect])`; Phase 04 calls the second one after `available_monitors()`. Keep the path (`APP_DIR_NAME` `src/settings.rs:15`), the serde attributes, the atomic save and the toggle invariant.
|
||||
- `core/i18n.rs`: move `src/i18n/mod.rs`. Add `start_at_login`, "Start at login", and select it with `cfg(windows)`. Keep `format_window`, `format_countdown` and `time_until_display_change`.
|
||||
- `core/color.rs` and `core/usage_color.rs`: `Rgb` without `into_colorref`; `accent_color_for`, `bar_fill_color`, `brighten` and `pulse_triangle`; `Rgb::to_hex()`.
|
||||
- `core/state.rs`: `apply_results` with the 80/95 crossings, the `!` and `…` texts, `last_poll_ok` and the auth-failure list (`src/app.rs:513-569`), plus the 30-minute alert cooldown (`src/app.rs:56`, `841-898`). It returns a list of alerts instead of calling the tray. Also `tray_tooltip` (`src/app.rs:777-790`), `UpdateStatus` and `version_action_label` (`src/app.rs:1176-1192`), and the update-check due/remaining math from `last_update_check_unix` (`src/app.rs:1383-1405`): due when never checked or `now - last >= interval`, otherwise the remainder.
|
||||
- `core/payload.rs`: pure builders for `BubblePayload` and `PanelPayload` (plan.md, Data flow), from a provider snapshot plus `is_dark`, `canvas_w`, `canvas_h` and `dpi`. Add `format_pct(Option<f64>) -> String` (`'{:.0}%'` or `'—'`) to `core/i18n.rs`. Before the first poll the head label and weekly countdown are `'…'` (`src/app.rs:329-345`); on an error the bubble keeps the countdowns of the stored windows and never carries `'!'`.
|
||||
- `core/menu_model.rs`: a plain enum tree for the context menu (`src/app.rs:923-1193`) with string ids: `refresh`, `freq:<ms>`, `provider:<slug>`, `autostart`, `reset-position`, `version`, `update-interval:<secs|off>`, `size-smaller`, `size-larger`, `size-reset`, `toggle-widget`, `restart`, `exit`. It also carries the check and enabled flags and the greyed hint rows. Phase 05 maps it onto `tauri::menu`.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/core/mod.rs`, `src/core/geom.rs`, `src/core/settings.rs`, `src/core/i18n.rs`, `src/core/color.rs`, `src/core/usage_color.rs`, `src/core/state.rs`, `src/core/payload.rs`, `src/core/menu_model.rs`
|
||||
- Modify: `src/lib.rs` (`pub mod core;`). Also re-add `pub mod usage;`, but only for `usage/types.rs`, through a temporary `usage/mod.rs` reduced to `pub mod types;`. Phase 03 restores the rest. The `Serialize` derive on `ProviderId` lands here.
|
||||
- Delete: `src/settings.rs`, `src/i18n/mod.rs`, `src/usage_color.rs`, `src/os/color.rs`, `src/os/dpi.rs`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Copy each function verbatim, then swap the Win32 types for `geom` types.
|
||||
2. Port the existing tests, then add the tests named in the subsystem maps:
|
||||
- Aspect ratio and height at 140, 200, 220, 280, 300 and 360.
|
||||
- Corner snap tie order, the edge-zone boundaries, all four taskbar edges, peer alignment and the final clamp.
|
||||
- The Codex stagger lift. `place_near` flipping above, clamping left and right, and with a negative virtual origin.
|
||||
- Fill-colour band edges at 59.9, 60, 79.9, 80, 94.9 and 95.
|
||||
- Countdown buckets, including "now" and 1d23h showing "1d", and `time_until_display_change`.
|
||||
- Half-to-even rounding: 72.5 gives "72" and 73.5 gives "74".
|
||||
- Thresholds: 79→80 fires; 80→85 does not; 79→96 queues both, but the cooldown lets only one through.
|
||||
- The update schedule's due and remaining values. Settings JSON for the legacy two-provider and three-provider files and for a corrupt file. Probe validation against fake monitors.
|
||||
- The menu model's greyed sizes at 140, 360 and 200.
|
||||
- Payloads: `sessionPctText` `'—'` for `None`, `weeklyPctText` `null` for `None`, `'…'` placeholders before the first poll, stale countdowns (not `'!'`) in the bubble after an auth failure, and `'!'`/`'…'` in the panel texts.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test core::`, with all tests green and at least 40 of them.
|
||||
- Host: `cargo clippy --all-targets -- -D warnings`.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| Behaviour drifts while refactoring the snap code off `HWND` | M x H | Port line by line and add tests before deleting the source. The originals stay in git history for diffing. |
|
||||
| Peer order depended on `HashMap` iteration | L x L | Take peers in `LIVE_USAGE` order. The order is deterministic now, and only a tie can differ from today. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. `core` has no runtime consumers yet; because it is `pub` in the lib (D1), unused items do not trip `dead_code`.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Data layer on ureq"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: [2]
|
||||
---
|
||||
|
||||
# Phase 03: Data Layer On ureq
|
||||
|
||||
## Context
|
||||
|
||||
The providers (`src/usage/anthropic.rs`, `chatgpt.rs`, `headers.rs`,
|
||||
`registry.rs`, `refresh.rs`) and creds (`src/creds/*`) are portable. They have
|
||||
only two Win32 couplings: `src/net/winhttp.rs`, and the `CommandExt` import in
|
||||
`src/usage/refresh.rs`. None of these modules has tests.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `src/net/mod.rs` keeps the surface the providers use: `Client::new(ua)`,
|
||||
`get`/`post`, `header`, `json_body`, `send`, and
|
||||
`Response::{status, header, body, text, json}`. Swap the `Win` error variant
|
||||
for `Transport(String)`.
|
||||
- The ureq agent:
|
||||
- `http_status_as_error(false)`.
|
||||
- Timeouts: connect 60 s, receive 30 s, global 120 s.
|
||||
- gzip enabled.
|
||||
- TLS is rustls with the platform verifier, so corporate roots stay trusted.
|
||||
- User-Agent `claude-code-usage-bubble/<version>`.
|
||||
- A per-request User-Agent replaces the agent's, as the Codex provider requires.
|
||||
- Proxy:
|
||||
- Env proxies come from ureq.
|
||||
- `cfg(windows)`, only when no env proxy is set: `platform::windows::system_proxy()` reads `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings` (`ProxyEnable`, `ProxyServer`, `ProxyOverride`). Nothing when `ProxyEnable` is not 1.
|
||||
- Parsing, a pure function in `core` with tests: a `ProxyServer` of the form `http=h:p;https=h:p;…` uses the `https=` entry, else the `http=` entry; a bare `h:p` applies to all schemes. `ProxyOverride` is split on `;`; each entry is a case-insensitive glob (`*` wildcard) against the host, and `<local>` matches dotless hosts.
|
||||
- `net::Client` holds a proxied agent and a direct agent and picks per request: direct when the request host matches a `ProxyOverride` entry. The parsed proxy URL is also handed to the updater builder in Phase 09.
|
||||
- `refresh.rs`:
|
||||
- `cfg(windows)` keeps today's candidate list and `CREATE_NO_WINDOW`.
|
||||
- Unix tries `claude -p .` and `codex exec .`, searching `PATH` plus `~/.local/bin`, `~/.claude/local`, `/opt/homebrew/bin` and `/usr/local/bin`.
|
||||
- The child is reaped on a detached thread, so no zombies are left.
|
||||
- Keep `OpenCodeGo` non-live. `unreachable!()` must stay unreachable, so add a test asserting that `LIVE_USAGE` excludes it.
|
||||
- Never log tokens or request headers.
|
||||
|
||||
## Files
|
||||
|
||||
- Modify: `src/net/mod.rs`, `src/usage/mod.rs` (restore), `src/usage/refresh.rs`, `src/usage/types.rs` (serde already added), `src/lib.rs` (`pub mod net; pub mod creds; pub mod platform;`), `src/core/mod.rs` (`proxy` parsing)
|
||||
- Create: `src/platform/mod.rs`, `src/platform/windows.rs` (only `system_proxy` for now; later phases append to it), `tests/fixtures/*.json`
|
||||
- Delete: `src/net/winhttp.rs`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Implement the ureq-backed `net`, then compile the providers unchanged.
|
||||
2. Add fixture tests:
|
||||
- OAuth usage JSON: full, partial, null buckets, and null utilization (which goes to the fallback).
|
||||
- Rate-limit headers: fractions, missing values, negative resets.
|
||||
- The ChatGPT envelope: null `rate_limit`, null windows, negative `reset_at`.
|
||||
- ISO 8601 edge cases: `+0000` rejected, lowercase `t` rejected, years before 1970.
|
||||
- Credential JSON: missing fields, a float `expiresAt`, null `tokens`, and an empty `CODEX_HOME`.
|
||||
- Proxy strings: per-scheme with and without `https=`, bare `h:p`, and `ProxyOverride` matches for `*.anthropic.com`, `chatgpt.com` and `<local>`.
|
||||
3. Add a `#[ignore]` live smoke test that polls the real providers when credentials exist. Run it by hand.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test usage:: creds:: net::` is green.
|
||||
- Host: `cargo test -- --ignored live_poll` returns data or `NoCredentials`, and never panics.
|
||||
- CI Windows: compiles with the proxy reader.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| ureq returns 4xx/5xx as errors, which breaks the 401/403 handling and the Messages fallback | H x H | `http_status_as_error(false)`, plus a test against a fixture 429 response that carries headers. |
|
||||
| Users behind PAC lose data | L x M | Intentional change, documented in the README. The static proxy, its bypass list and env proxies still work. |
|
||||
| macOS has no credential file | H x L | Out of scope (keyring is deferred). macOS shows `…`. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. The WinHTTP file is recoverable from git.
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
phase: 4
|
||||
title: "App runtime: state, scheduler, CLI, single instance"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "5h"
|
||||
dependencies: [3]
|
||||
---
|
||||
|
||||
# Phase 04: App Runtime
|
||||
|
||||
## Context
|
||||
|
||||
`src/app.rs`, about 1500 lines, couples the Win32 message loop, timers and state.
|
||||
Replace it with Tauri setup, managed state, one scheduler thread (D14) and the
|
||||
command and event bridge. All of it lives in the lib (D1); `run()` is the entry.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Pre-Builder order inside `run()`:
|
||||
1. `--diagnose` sets up diag.
|
||||
2. `cfg(windows)`: WebView2 guard. If `GetAvailableCoreWebView2BrowserVersionString` fails or returns empty, `platform::windows::message_box_error` shows "Claude Code Usage Bubble needs the Microsoft Edge WebView2 Runtime" plus the Evergreen download URL, then the process exits 0.
|
||||
3. `--wait-pid <pid>` waits until that PID is gone, polling every 100 ms, capped at 15 s (D13). `OpenProcess`/`WaitForSingleObject` on Windows, `libc::kill(pid, 0)` on Unix. Log the wait and its outcome under `--diagnose`.
|
||||
4. Apply the Linux `GDK_BACKEND=x11` rule (D12).
|
||||
5. Register `tauri-plugin-single-instance` first, with a no-op callback.
|
||||
6. Register autostart and notification in Phase 05; register the updater in Phase 09. No other plugin is registered here, so no plugin needs config that does not exist yet.
|
||||
- `--apply-update` is **not** handled: no released version runs the downloaded binary that way (plan.md, Backwards compatibility). An unknown flag is ignored.
|
||||
- `setup()`:
|
||||
- Load settings, drop off-screen positions against `available_monitors()`, and read the theme.
|
||||
- Build `AppState` in a `Mutex`, plus a `ui_bridge` that holds the `AppHandle`. Provide `with_state(|s| …) -> T` returning owned data; it is the only way to read state before calling Tauri (D14).
|
||||
- Run the startup cleanup: delete `<exe filename>.old.*` siblings and, under `data_local_dir()/ClaudeCodeUsageBubble/updates/`, `update.exe` and `updater-helper-*.exe` (moved from `src/update/handoff.rs:83-108` and `src/update/install.rs:323-343` into `src/handoff.rs`, with a unit test on a temp dir). `update-error.log` is kept.
|
||||
- Start the scheduler thread and trigger the first poll.
|
||||
- `RunEvent::ExitRequested { code: None, api }` calls `api.prevent_exit()` (D18).
|
||||
- The scheduler thread receives messages over an `mpsc` channel with `recv_timeout`:
|
||||
- `PollNow`, and `SetPollInterval`, which re-arms from now without an immediate poll.
|
||||
- `RecomputeCountdown`.
|
||||
- **Update check deadline:** at startup and on `SetUpdateInterval`, use `core::state`'s due/remaining computed from `last_update_check_unix`: check now if due, otherwise sleep the remainder (`src/app.rs:1383-1405`, `1459-1476`). Only `UpdateCheckDone` re-arms for the full interval (intentional fix #6). `SetUpdateInterval(None)` cancels the deadline.
|
||||
- `Shutdown`.
|
||||
- Keep the `AtomicBool` poll gate (`src/app.rs:442-511`).
|
||||
- The poll worker runs registry poll, then `apply_results`, then credential refresh: `try_refresh` every auth-failed provider in order, 8 s each; raise the session-expired alert only for the first provider whose outcome is not `Refreshed`; no immediate re-poll (`src/app.rs:571-590`). Then `propagate()`.
|
||||
- `propagate()` snapshots under the lock, drops it, then builds the payloads (`core::payload`) and sends `bubble:update` with `emit_to` to each bubble label. It calls `tray::sync` and `menu::refresh` (Phase 05 hooks) and `panel::refresh_if_visible` (Phase 07), all outside the lock. Then it re-arms the countdown.
|
||||
- Commands:
|
||||
- `get_bubble_state(provider)` and `get_strings()`.
|
||||
- `log_js(level, msg)`.
|
||||
- `bubble_ready(label)`, which calls the window module's "show without activation" (Phase 06).
|
||||
- Handoff and exit:
|
||||
- `handoff::relaunch(extra_args) -> io::Result<()>` spawns `current_exe` (or `$APPIMAGE`) detached with `--wait-pid <pid>` plus the extra args. On Windows it uses `creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)`.
|
||||
- Restart: save settings, `relaunch(&[])`; on `Ok` call `app.exit(0)`, on `Err` log it and keep running (`src/app.rs:1478-1512`).
|
||||
- Exit is `app.exit(0)`.
|
||||
- Bubbles: `CloseRequested` calls `prevent_close`, so Alt+F4 does nothing (D18).
|
||||
- `--updated-to <v>` stores a pending "update applied" alert, which Phase 05 shows once the tray exists. It works with or without `--wait-pid` (v0.4.0 sends it alone).
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/runtime.rs` (setup, `AppState`, `with_state`, propagate), `src/scheduler.rs`, `src/commands.rs`, `src/handoff.rs` (relaunch, startup cleanup), `src/cli.rs`
|
||||
- Modify: `src/lib.rs`, `src/platform/windows.rs` (`wait_pid`, `webview2_available`, `message_box_error`), `src/platform/mod.rs` (Unix `wait_pid`)
|
||||
- Delete: `src/app.rs`, `src/update/handoff.rs` (its logic moves to `src/handoff.rs`)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Write `cli.rs` as a pure argv parser, with tests for `--diagnose`, `--wait-pid`, `--updated-to` alone and combined, and an ignored unknown flag.
|
||||
2. Build `AppState`, the scheduler and propagate. Use a fake emitter trait so the scheduler's deadline logic is unit-testable without a window, including "Weekly to Daily with the last check 2 days ago checks at once".
|
||||
3. Wire the commands. Leave bubble creation to Phase 06; the test window from Phase 01 renders the payload as raw JSON for now.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test cli:: scheduler:: handoff::` is green.
|
||||
- Linux desktop (X11, manual; this host has no display): `npm run tauri dev -- -- --diagnose` logs a successful poll (or `NoCredentials`), and the test window shows the payload JSON. A second launch exits at once.
|
||||
- Linux desktop: the Restart command relaunches the app, and the log shows the child waited for the old PID.
|
||||
- Linux desktop: closing the test window does not end the process; Exit does.
|
||||
- Windows (CI artifact): starts, polls, and Restart relaunches.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| Single instance kills the relaunched child while the parent is still exiting | M x M | The wait-until-gone loop (15 s cap) runs before the plugin registers. |
|
||||
| Deadlock between a worker holding the state lock and a main-thread handler | M x H | D14 rule, enforced by `with_state`; code review checks no Tauri call sits inside a lock scope. |
|
||||
| Window operations called off the main thread | M x M | Every window call from a worker goes through `run_on_main_thread`. |
|
||||
| The Linux tray or D-Bus is missing on a headless host | M x L | The app must not exit when tray creation fails (handled in Phase 05). |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. Nothing outside `src/` changes.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
phase: 5
|
||||
title: "Menu, tray, alerts, autostart"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "6h"
|
||||
dependencies: [4]
|
||||
---
|
||||
|
||||
# Phase 05: Menu, Tray, Alerts, Autostart
|
||||
|
||||
## Context
|
||||
|
||||
These replace the following Win32 pieces:
|
||||
|
||||
- `src/tray/mod.rs`: `Shell_NotifyIconW` add/modify/delete and balloons.
|
||||
- `src/tray/callback.rs`.
|
||||
- The `pixmap_to_hicon` block in `src/tray/badge.rs`.
|
||||
- The `TrackPopupMenu` menu (`src/app.rs:923-1193`), which calls `SetForegroundWindow(owner)` first (`src/app.rs:1151-1152`).
|
||||
- The Run-key autostart (`src/app.rs:1514-1527`, value name `ClaudeCodeUsageBubble` at `src/app.rs:51`).
|
||||
|
||||
The user approved `tauri-plugin-notification` with Windows AUMID registration
|
||||
for the alerts (D9).
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Menu** (`src/menu.rs`):
|
||||
- Map `core::menu_model` onto `tauri::menu` (`Submenu`, `CheckMenuItem`, `MenuItem` with `enabled(false)`, `PredefinedMenuItem::separator`), keeping the exact order and labels.
|
||||
- The checked state of "Start with Windows" / "Start at login" comes from `autolaunch().is_enabled()`.
|
||||
- OpenCode Go is a disabled `CheckMenuItem`.
|
||||
- The version item reads `"<status> · v<version>"` and is disabled while Checking or Applying. In the package-manager channel it is also disabled while Available (Phase 09).
|
||||
- `menu::refresh()` rebuilds the menu from a state snapshot (D14) and calls `set_menu` on every tray. It runs whenever any input changes (settings, update status, size, autostart) **and after every handled check-item event, including no-op ones** (re-selecting the current frequency or interval, unchecking the last live provider). Native check items toggle themselves on click, so only a rebuild from state keeps the marks right.
|
||||
- `show_context_menu(label)`: on Windows, call `SetForegroundWindow(bubble hwnd)` first (parity with `src/app.rs:1151`; the own-PID fullscreen check already ignores it), then `window.popup_menu(&menu)` on that bubble.
|
||||
- A single `on_menu_event` dispatches the string ids to the same actions as `on_menu_command`.
|
||||
- **Tray** (`src/tray.rs`):
|
||||
- One `TrayIconBuilder::with_id(slug)` per enabled live provider, in `LIVE_USAGE` order, with `show_menu_on_left_click(false)`.
|
||||
- A left-button `Click` with `button_state: Up` toggles the widget.
|
||||
- The badge is `badge::render_pixmap` (tiny-skia, kept), demultiplied into `Image::new_owned(rgba, 32, 32)`, with `is_dark=true` always (parity).
|
||||
- The tooltip comes from `core::state::tray_tooltip`, truncated to 127 UTF-16 units.
|
||||
- `sync()` diffs the set of icons. `set_icon` and `set_tooltip` run only when the cached `(pct, colour)` or the text changed.
|
||||
- Linux: before building any tray, probe with `libloading` for `libayatana-appindicator3.so.1`, then `libappindicator3.so.1`. If neither loads, log it and skip tray creation, because the tray library panics on a missing library and the release profile aborts on panic. Any other tray creation error is logged and the app continues.
|
||||
- **Alerts** (D9):
|
||||
- Windows spike first: a fresh portable exe (CI artifact, no Start-menu shortcut, outside `target/`) on Windows 10 and 11 calls `platform::windows::register_aumid()` and shows one toast. If no toast appears, stop and report to the user before writing more alert code.
|
||||
- `register_aumid()` runs at startup before any toast: write `HKCU\Software\Classes\AppUserModelId\io.github.tiennm99.claudecodeusagebubble` with `DisplayName` "Claude Code Usage Bubble" and `IconUri` pointing at a PNG written to `data_local_dir()/ClaudeCodeUsageBubble/icon.png`, then `SetCurrentProcessExplicitAppUserModelID` with the same id.
|
||||
- `tauri-plugin-notification` shows the alerts returned by `apply_results` and the pending "Update applied" alert from `--updated-to`. The update-applied alert ignores the cooldown.
|
||||
- **Autostart** (D10):
|
||||
- `tauri_plugin_autostart::Builder::new().app_name("ClaudeCodeUsageBubble")`, `MacosLauncher::LaunchAgent`, no args. Pin the Windows current-user mode if the Builder exposes it.
|
||||
- On the pinned plugin version, read its Windows source and record in this file: what data `enable()` writes, whether `is_enabled()` compares the data, and whether `enable()` writes StartupApproved. If `is_enabled()` ignores the data, no migration code is written. Otherwise add the one-time data rewrite from D10 (legacy data exactly `"<current exe>"` and different from the plugin format), done through `platform::windows`, never touching StartupApproved.
|
||||
- Confirm that the Linux `.desktop` `Exec` uses `$APPIMAGE` when that variable is set. Otherwise, write the entry ourselves.
|
||||
- **Provider toggle:** keeps the at-least-one invariant, creates or destroys the bubble (Phase 06's `bubble::create`/`destroy`, stubbed until then; D18), syncs the tray, refreshes the menu and polls.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/menu.rs`, `src/tray.rs`
|
||||
- Modify: `src/tray/badge.rs`, which moves to `src/badge.rs` and loses the HICON code. Also `src/runtime.rs` (hooks, plugin registration), `src/platform/windows.rs` (`register_aumid`, `set_foreground`, and the autostart data rewrite only if needed), `src/platform/linux.rs` (`appindicator_available`) and `src/lib.rs`. `Cargo.toml` gains `tauri-plugin-notification = "2"`.
|
||||
- Delete: `src/tray/mod.rs`, `src/tray/callback.rs`, `src/os/registry.rs` (only the theme, proxy and Run reads stay, inside `platform::windows`), `src/os/string.rs` (if no other user remains)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Port `badge.rs`. Add pixel-sampling tests: the disk colour at the centre, the track colour at radius 12.15 with no percent, and the arc colour at 12 o'clock for any percent above 0. Add a demultiply test.
|
||||
2. Build the menu mapping. Add a test that walks the `tauri::menu`-independent model, so it can run headless.
|
||||
3. Add the tray and autostart.
|
||||
4. Run the toast spike, then register the notification plugin and wire the alerts.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test badge:: menu_model::` is green.
|
||||
- Linux desktop (X11 session with AppIndicator, manual; this host has no display): one indicator per enabled provider, and the menu is complete with correct check states. Toggling a provider adds or removes an indicator. In a container without the appindicator library, the app runs without a tray instead of aborting.
|
||||
- Windows (CI artifact, manual):
|
||||
- Left-click toggles; right-click shows the native menu; the tooltip has 4 lines.
|
||||
- Re-click the checked frequency, re-click the checked auto-update interval, and uncheck the only enabled provider: the check marks still match state afterwards.
|
||||
- The bubble right-click menu dismisses on a click elsewhere.
|
||||
- With a legacy `ClaudeCodeUsageBubble` Run value preset, the item shows checked, and there is exactly one entry after two launches. After a Task Manager disable, the item stays unchecked across two launches.
|
||||
- Threshold, session-expired and update-applied toasts appear from a fresh portable exe with no shortcut.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| Multiple tray icons on Windows are unverified | M x H | Manual check in Validation. Fallback: one tray icon whose badge shows the provider with the higher usage. That would be a recorded parity change, so ask first. |
|
||||
| Toasts do not appear for the portable exe | M x H | AUMID registration, checked by the spike before the alert wiring. On failure, stop and ask the user. |
|
||||
| `app_name` is missing on the pinned autostart Builder | L x M | The value name would differ; then add the D10 migration (delete legacy value, `enable()` once, guarded by the legacy data format so it never runs twice). |
|
||||
| Stale menu state, because the tray menu has no on-open hook | M x M | Rebuild on every state mutation and after every check-item event. |
|
||||
| Deadlock from building menus while holding the state lock | M x H | D14 snapshot rule. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. The app keeps running without a tray or menu, as at the end of Phase 04.
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
phase: 6
|
||||
title: "Bubble window and UI"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "9h"
|
||||
dependencies: [5]
|
||||
---
|
||||
|
||||
# Phase 06: Bubble Window And UI
|
||||
|
||||
## Context
|
||||
|
||||
Today the bubble is a layered `WS_POPUP` drawn by tiny-skia and GDI
|
||||
(`src/bubble.rs`). It handles hit-testing, drag, snap, clamp, DPI, pulse and
|
||||
time-progress in `wnd_proc`. This phase rebuilds it as one transparent webview
|
||||
window per enabled live provider (D2 to D5, D18). The user approved dead
|
||||
corners for the hit area (D4).
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Window** (`src/bubble.rs`, rewritten):
|
||||
- Created with `WebviewWindowBuilder::new(app, "bubble-<slug>", WebviewUrl::App("bubble.html"))`.
|
||||
- Builder options: `transparent(true)`, `decorations(false)`, `shadow(false)`, `always_on_top(true)`, `skip_taskbar(true)`, `resizable(false)`, `maximizable(false)`, `minimizable(false)`, `focused(false)`, `focusable(false)`, `visible(false)`, `title("Claude Code Usage Bubble")`.
|
||||
- Size is `PhysicalSize(scale(w,dpi), scale(h_logical,dpi))`, recomputed on `ScaleFactorChanged`. Each size change re-sends the payload, whose `canvasW`, `canvasH` and `dpi` drive `layout.js` (D2).
|
||||
- Position is the saved position or `default_position` from the primary monitor's `work_area()`, then clamped.
|
||||
- Honour `widget_visible` when creating the window (intentional fix #7).
|
||||
- Removal is `destroy()` only. `create` waits for the previous window's `Destroyed` event when the same label still exists (fast off/on toggle) (D18).
|
||||
- Show: on Windows through `platform::windows::show_no_activate`, elsewhere `show()`. Triggered by `bubble_ready`, or by a 1.5 s fallback timer after creation if `bubble_ready` has not arrived (D5).
|
||||
- **Windows platform** (`src/platform/windows.rs`):
|
||||
- `apply_no_activate(hwnd)` ORs `WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW` into the extended style before the first show. `show_no_activate(hwnd)` (`SW_SHOWNOACTIVATE`) and `hide(hwnd)` (`SW_HIDE`) are the only show/hide paths for bubbles on Windows.
|
||||
- A `SetWindowSubclass` hook delivers `WM_EXITSIZEMOVE` as move end, `WM_SETTINGCHANGE` as a theme recheck plus reclamp, and `WM_DISPLAYCHANGE` as a reclamp.
|
||||
- `is_dark()` reads `SystemUsesLightTheme`; a missing value means dark.
|
||||
- `taskbar_rect()` uses `SHAppBarMessage(ABM_GETTASKBARPOS)`.
|
||||
- **Linux and macOS** (D3):
|
||||
- Move end is a 250 ms debounce on `Moved`, armed only by `bubble_drag_start`. On X11, when the debounce fires, `platform::linux::button1_down()` (x11rb `QueryPointer`) re-arms it while Button1 is still held.
|
||||
- Theme comes from `window.theme()`, defaulting to dark, and is rechecked on `ThemeChanged`.
|
||||
- There is no taskbar rect. On Wayland without Xwayland, skip snap, clamp and saving positions.
|
||||
- **Move end** (Rust): disarm, run `core::geom::snap` with the peers' `outer_position`/`outer_size` in `LIVE_USAGE` order, apply `set_position(Physical)` only if the position changed, then persist `bubble_positions[slug]`.
|
||||
- **Commands:**
|
||||
- `bubble_click(slug)` calls `panel::toggle` (Phase 07; stubbed until then).
|
||||
- `bubble_drag_start(slug)` arms the move-end debounce (no-op on Windows).
|
||||
- `bubble_resize_step(dir)` calls `set_bubble_size(±20)`. That resizes every bubble around its own centre, saves the size and refreshes the menu, with no snap and no position save (parity). Programmatic `set_position` never arms the debounce.
|
||||
- `bubble_ready(label)` shows the window without activation.
|
||||
- **Reset position** (D18): clear the saved positions and save, then `set_position` each existing bubble to its `default_position` (no destroy and recreate). Push the cached payload and poll.
|
||||
- **Visibility transitions** (pure function `core::visibility::next(state, event)`, tested in Phase 08), matching `src/bubble.rs:407-427` and the fullscreen path:
|
||||
|
||||
| Event | Result |
|
||||
|-------|--------|
|
||||
| User shows | `user_hidden = false`; show immediately, whatever `hidden_by_fullscreen` is |
|
||||
| User hides | `user_hidden = true`; hide |
|
||||
| Fullscreen on | `hidden_by_fullscreen = true`; hide |
|
||||
| Fullscreen off | `hidden_by_fullscreen = false`; show only if `!user_hidden` |
|
||||
|
||||
- **Hit area** (D4): `pointer-events: none` outside the pill, so the transparent corners are dead zones. No window region is set.
|
||||
- **Bubble palette** (`src/bubble.rs:1406-1427`, `1857-1866`), as CSS custom properties on `[data-theme=dark]` / `[data-theme=light]`:
|
||||
|
||||
| Token | Dark | Light |
|
||||
|-------|------|-------|
|
||||
| `--bg` (stadium) | `#1F1F1F` | `#F3F3F3` |
|
||||
| `--track` (ring and usage bar) | `#2C2C2C` | `#E2E2E2` |
|
||||
| `--time-track` (inner ring and time bar) | `#404040` | `#E0E0E0` |
|
||||
| `--time-fill` | `#B0B0B0` | `#666666` |
|
||||
| `--text` | `#EAEAEA` | `#1F1F1F` |
|
||||
| `--muted` | `#A8A8A8` | `#5E5E5E` |
|
||||
|
||||
Accent and fill colours come from the payload.
|
||||
- **Pulse** (`src/bubble.rs:340-368`, `1465-1473`, `1536-1541`, `1836-1852`, `1916-1923`):
|
||||
- The ring arc pulses only when `sessionPct >= 95`. The weekly bar fill and the weekly percent text pulse only when `weeklyPct >= 95`. The head big percent never pulses.
|
||||
- An 80 ms `setInterval` runs while either value is 95 or above. It increments `phase`; `t = 1 - |(phase % 24) - 12| / 12` (exactly the Rust code, so `t` is 0 at phase 0 and 1 at phase 12). The colour is `round(c + (255 - c) * t * 0.30)` per channel.
|
||||
- When neither value is 95 or above, the interval stops and `phase` resets to 0. Every `bubble:update` re-syncs the interval.
|
||||
- **UI** (`ui/bubble.html`, `ui/bubble.css`, `ui/bubble.js`, `ui/layout.js`):
|
||||
- `html, body { background: transparent; margin: 0; overflow: hidden; user-select: none }`.
|
||||
- The `.pill` has `cursor: move`.
|
||||
- `layout.js` exports pure functions with JSDoc types, all driven by `canvasW`, `canvasH` and `dpi` from the payload:
|
||||
- `headMetrics` and `tailLayout`, using the formulas from `src/bubble.rs:1249-1396` and `1450-1592` in physical px.
|
||||
- `ringDash`, where the session arc runs clockwise from 12 o'clock and the remaining arc ends at 12 o'clock.
|
||||
- `remainingFraction`.
|
||||
- `pulseT(phase)` and `brighten(hex, t)`.
|
||||
- Text comes only from the payload: `sessionPctText` in the head, `headLabel` as the head label (replaced by `'5H'` when empty or overflowing), `weeklyPctText` (skipped when `null`), `weeklyCountdown` (skipped when empty). JS never formats a number for display.
|
||||
- The font stack is `"Segoe UI", system-ui, sans-serif`, with weights 700, 600 and 400. Sizes come from `big`, `small` and `main` divided by `dpr`.
|
||||
- Timers: the pulse above, and a 60 s timer that advances the time progress while any `resets_at` is set. Recompute immediately on `visibilitychange`.
|
||||
- Events (D3):
|
||||
- `pointerdown` with button 0: `setPointerCapture`, record the point. `pointermove` requires `(e.buttons & 1) === 1`; at 3 or more physical px it invokes `bubble_drag_start`, clears the pressed state and calls `startDragging()`. `pointerup` before the threshold calls `bubble_click`. `pointercancel`, `lostpointercapture` and `blur` clear the pressed state.
|
||||
- `contextmenu` calls `preventDefault()` and `show_context_menu`.
|
||||
- A non-passive `wheel` handler with `ctrlKey` calls `preventDefault()` and invokes `bubble_resize_step` with `sign(-deltaY)`.
|
||||
- Listen to `bubble:update` and `theme`. Call `get_bubble_state` on load, apply it to the DOM, then invoke `bubble_ready` in the same promise callback (no `requestAnimationFrame`).
|
||||
- The JS test file is `tests/js/layout.test.js`.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `ui/bubble.html`, `ui/bubble.css`, `ui/bubble.js`, `ui/layout.js`, `ui/tauri-bridge.js` (thin JSDoc-typed wrappers over `window.__TAURI__`, shared with the panel), `tests/js/layout.test.js`, `src/core/visibility.rs`
|
||||
- Modify: `src/bubble.rs` (full rewrite), `src/platform/windows.rs`, `src/platform/linux.rs`, `src/platform/macos.rs`, `src/platform/mod.rs`, `src/commands.rs`, `src/runtime.rs`, `src/lib.rs`
|
||||
- Delete: `src/os/theme.rs`, `src/os/mod.rs`, `ui/` placeholder content from Phase 01
|
||||
|
||||
## Steps
|
||||
|
||||
1. Write `layout.js` and its tests first. Compare the outputs against hand-computed values from the Rust formulas at widths 200 and 360, at dpi 96 and 144 (including 300 px at 144 dpi, where `innerWidth * dpr` would drift).
|
||||
2. Build the window module and the platform hooks.
|
||||
3. Wire the HTML. Check visually against a v0.5.0 screenshot at 100% and 150% scale.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `npm test`, `npm run lint` and `cargo clippy --all-targets -- -D warnings` are green.
|
||||
- Linux desktop (X11, manual; this host has no display): bubbles appear at the bottom right, drag and snap work and persist, pausing mid-drag does not snap, Ctrl+wheel resizes without a snap, right-click opens the menu, the corners are transparent, and the theme switches.
|
||||
- Windows (CI artifact, manual): no focus is stolen when clicking or showing, and there is no white flash. Move end snaps exactly once, and the Codex bubble stacks above Claude. Mixed-DPI drags keep the size. A hover after a drag does not start another drag. Hide by fullscreen then show renders the bubble (not blank). Reset position moves both bubbles and does not quit the app with the panel never opened. Fast provider off/on recreates the bubble.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| WebView2 still activates the window on click | M x H | The `WS_EX_NOACTIVATE` patch. If focus is still taken, also return `MA_NOACTIVATE` for `WM_MOUSEACTIVATE` in the subclass hook. |
|
||||
| `startDragging` swallows `pointerup` | H x L | By design: the end comes from Rust; JS clears its pressed state when it starts the drag. |
|
||||
| A raw show leaves WebView2 blank | M x M | Show and hide both go through the raw path on Windows; the Validation row checks it. |
|
||||
| Ctrl+wheel zooms the page | M x M | `preventDefault` in a non-passive listener. Zoom hotkeys are off by default. |
|
||||
| Transparency fails (no Linux compositor; NVIDIA DMABUF) | M x M | Documented in the README. Do not apply the DMABUF workaround unconditionally. |
|
||||
| Text metrics differ on Linux | H x L | Accepted (intentional change #10). The "5H" fallback adapts. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. Phase 04's test window comes back.
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
phase: 7
|
||||
title: "Details panel window"
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: "3h"
|
||||
dependencies: [6]
|
||||
---
|
||||
|
||||
# Phase 07: Details Panel Window
|
||||
|
||||
## Context
|
||||
|
||||
`src/panel.rs` is a single opaque `WS_POPUP` toolwindow, fixed at 280x120
|
||||
logical px and GDI-painted. A bubble click toggles it, and losing focus
|
||||
dismisses it.
|
||||
|
||||
## Requirements
|
||||
|
||||
- One `panel` webview window, created hidden at startup with these builder
|
||||
options: `decorations(false)`, `always_on_top(true)`, `skip_taskbar(true)`,
|
||||
`resizable(false)`, `transparent(false)`, `shadow(false)`, `visible(false)`,
|
||||
`title("Usage panel")`, and `LogicalSize(280, 120)`. It is hidden and shown,
|
||||
never destroyed: `CloseRequested` (Alt+F4) calls `prevent_close` and `hide()`
|
||||
(D18).
|
||||
- Windows: `platform::windows::apply_toolwindow(hwnd)` ORs `WS_EX_TOOLWINDOW`
|
||||
(no `NOACTIVATE`, the panel must take focus) before the first show, so the
|
||||
panel has no Alt-Tab entry; `skip_taskbar` alone only removes the taskbar
|
||||
button. Keep `shadow(false)` unless the v0.5.0 screenshot comparison shows the
|
||||
original had a DWM shadow; record the result here.
|
||||
- `panel::toggle(slug)`:
|
||||
- If the panel is visible with the same provider, hide it.
|
||||
- Otherwise set the provider, compute the position with
|
||||
`core::geom::place_near`, using the bubble's outer rect, the panel size
|
||||
scaled by the bubble monitor's DPI, and the union of all monitor rects
|
||||
(physical 8 px gap, parity).
|
||||
- Emit `panel:update` with the `PanelPayload` (plan.md, Data flow), `show()`,
|
||||
then `set_focus()`.
|
||||
- Windows: re-apply `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND)`
|
||||
through `platform::windows::round_corners(hwnd)` on each show.
|
||||
- Dismissal: `WindowEvent::Focused(false)` hides the panel. Ignore a toggle-show
|
||||
that arrives within 150 ms of a blur-hide for the same provider, so a second
|
||||
click on the bubble closes the panel instead of reopening it. Test this on
|
||||
Windows first. If v0.5.0 behaves differently, match v0.5.0.
|
||||
- `refresh_if_visible(payload)` updates the panel in place, without moving it,
|
||||
only for the matching provider. Called from `propagate` outside the state lock
|
||||
(D14).
|
||||
- UI (`ui/panel.html`, `ui/panel.css`, `ui/panel.js`):
|
||||
- Absolute layout with the exact coordinates in `src/panel.rs:299-499`: 4 px
|
||||
accent stripe, a 13 px/600 header at (14,14), rows at y=38 and y=68, bars at
|
||||
x=46 that are 120 wide and 14 high, and right text at x=174, 96 wide,
|
||||
clipped with no ellipsis.
|
||||
- Square bars whose fill width is `round(clamp(pct)/100*120)`.
|
||||
- No pulse and no transitions.
|
||||
- Panel palette: dark `#1F1F1F`/`#EAEAEA`/`#3A3A3A`, light `#FAFAFA`/`#1F1F1F`/`#D6D6D6`.
|
||||
- Fill colours come from the payload.
|
||||
- `get_panel_state` is called on load so the first paint already has data.
|
||||
- Texts come from the payload (`sessionText`, `weeklyText`: `format_window`,
|
||||
`'!'`, `'…'` or `''`) and the header from `label`; JS formats nothing.
|
||||
- WebView defaults are neutralised, because today the panel ignores all
|
||||
input: `preventDefault` on `contextmenu`, on `wheel` with `ctrlKey`, and on
|
||||
`keydown` for browser accelerators (F5, F7, F12, Ctrl/Cmd+R, P, F, G, S, U,
|
||||
O, +, -, 0). If the pinned Tauri/wry exposes a WebView2 "browser accelerator
|
||||
keys" setting, also turn it off for the panel on Windows.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/panel.rs` (rewrite), `ui/panel.html`, `ui/panel.css`, `ui/panel.js`
|
||||
- Modify: `src/commands.rs` (`get_panel_state`), `src/runtime.rs` (create the panel), `src/platform/windows.rs` (`round_corners`, `apply_toolwindow`), `src/lib.rs`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Port `place_near` (already done in Phase 02) and wire the window.
|
||||
2. Write the HTML and CSS, then compare against a v0.5.0 screenshot.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `npm run lint` and `cargo clippy -D warnings` are green.
|
||||
- Linux desktop (X11, manual): clicking a bubble opens the panel below it, or above it near the bottom edge. Clicking elsewhere closes it, clicking the same bubble closes it, and clicking the other bubble swaps its content.
|
||||
- Windows (CI artifact, manual): layout and palette match a v0.5.0 screenshot; placement below the bubble and flipped above near the bottom edge; dismissal on blur and on a second click of the same bubble; rounded corners on Windows 11; no Alt-Tab or taskbar entry; live refresh while open; Alt+F4 hides and a later click reopens; right-click and F5 do nothing.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| Focus is refused (Windows foreground lock, GNOME), so the panel never auto-hides | M x M | Parity: clicking the bubble toggles it closed, as today. |
|
||||
| White flash on the first show in dark theme | M x L | Pre-create the panel, call `set_background_color` from the theme, and populate it before `show()`. |
|
||||
| Wayland ignores `set_position` | H x L | Best effort under D12. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. `bubble_click` goes back to being a stub.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
phase: 8
|
||||
title: "Fullscreen auto-hide"
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: "2h"
|
||||
dependencies: [6]
|
||||
---
|
||||
|
||||
# Phase 08: Fullscreen Auto-Hide
|
||||
|
||||
## Context
|
||||
|
||||
Each bubble runs a 1500 ms timer (`src/bubble.rs:970-1205`;
|
||||
`evaluate_foreground_fullscreen` at `src/bubble.rs:1008`). When a foreground
|
||||
window covers its monitor's full rect within 2 px, all bubbles hide, unless
|
||||
that window's class is in the ignore list, it is cloaked, or it is minimized.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `platform::foreground_is_fullscreen() -> (bool, &'static str reason)`:
|
||||
- **Windows:** the existing evaluation, copied verbatim, with one change: the
|
||||
`ClaudeCodeUsageBubble` class check becomes "the foreground window belongs
|
||||
to our own process" (`GetWindowThreadProcessId` compared with
|
||||
`std::process::id()`). The `Progman`, `WorkerW` and `Shell_TrayWnd` class
|
||||
checks stay. Uses `rect_covers_monitor` and the style bits from `core::geom`.
|
||||
- **Linux:** with an X11 connection (`x11rb`, connected once and cached),
|
||||
read root `_NET_ACTIVE_WINDOW`, then that window's `_NET_WM_STATE`. The
|
||||
result is true if the state contains `_NET_WM_STATE_FULLSCREEN` and the
|
||||
window is not ours (compare `_NET_WM_PID`). Without X11, return false.
|
||||
- **macOS:** stub that returns false, with a doc comment pointing to
|
||||
`CGWindowListCopyWindowInfo` for later.
|
||||
- One 1500 ms thread checks the state and applies `core::visibility::next`
|
||||
(Phase 06 table) to every bubble. Fullscreen sets `hidden_by_fullscreen` and
|
||||
hides the bubbles. When it clears, the bubbles show without activation unless
|
||||
`user_hidden` is set. A user show while fullscreen is active shows at once
|
||||
(parity with `src/bubble.rs:407-427`). The thread snapshots state and drops the
|
||||
lock before any window call (D14).
|
||||
- Log each decision with its reason under `--diagnose`.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/fullscreen.rs` (thread and state transitions)
|
||||
- Modify: `src/platform/windows.rs`, `src/platform/linux.rs`, `src/platform/macos.rs`, `src/lib.rs`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Move the Windows code and keep the two existing tests (in `core::geom`).
|
||||
2. Add the x11rb reader.
|
||||
3. Add unit tests for `core::visibility::next` covering every row of the Phase 06 table, including a user show during fullscreen and fullscreen off while user-hidden.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test fullscreen:: visibility::` is green.
|
||||
- Linux desktop (X11, manual): a fullscreen browser (F11) hides the bubbles within 1.5 s, and they come back when it exits.
|
||||
- Windows (CI artifact, manual): a fullscreen game or video hides them, a maximized window does not, the desktop does not, and opening the bubble menu or the panel does not.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| The panel being foreground is misread as fullscreen | L x M | The own-PID check. |
|
||||
| The X11 connection fails under Xwayland | L x L | Treat it as not fullscreen. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. Bubbles then stay visible over fullscreen apps.
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
phase: 9
|
||||
title: "Updater"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: [5]
|
||||
---
|
||||
|
||||
# Phase 09: Updater
|
||||
|
||||
## Context
|
||||
|
||||
How v0.5.0 updates today:
|
||||
|
||||
1. It queries the GitHub API (`src/update/release.rs:48-80`) and takes the
|
||||
asset named exactly `claude-code-usage-bubble.exe` (`release.rs:7`, `:68`),
|
||||
or else the first asset ending in `*.exe`.
|
||||
2. It checks the asset's sha256 digest.
|
||||
3. It `self_replace`s its own exe, relaunches with `--wait-pid <pid>` and
|
||||
`--updated-to <v>`, and exits.
|
||||
|
||||
Older versions hand over differently (plan.md, Backwards compatibility table).
|
||||
None of them runs the downloaded binary with `--apply-update`, so the Tauri exe
|
||||
has no such handler; the startup cleanup from Phase 04 removes the v0.3.13 to
|
||||
v0.4.0 helper leftovers.
|
||||
|
||||
The decision is D8: the plugin does the signed check and download, then the app
|
||||
self-replaces on Windows and runs the plugin's install on the AppImage.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Register `tauri_plugin_updater::Builder::new().build()` in this phase, in the
|
||||
same commit that adds `plugins.updater` to `tauri.conf.json`, because the
|
||||
plugin's config requires `pubkey` and plugin init fails without it:
|
||||
- `pubkey`: the content of the public key generated with `npx tauri signer generate`.
|
||||
- `endpoints`: `["https://github.com/tiennm99/claude-code-usage-bubble/releases/latest/download/latest.json"]`.
|
||||
- `tauri.conf.json` keeps `bundle.createUpdaterArtifacts: false`; `release.yml`
|
||||
enables it through `--config` (Phase 10).
|
||||
- `src/update/mod.rs` keeps the status machine (`core::state::UpdateStatus`).
|
||||
- **Check:** `app.updater_builder()` with
|
||||
`.version_comparator(|cur, rel| core::version::newer(rel, cur))`, which
|
||||
keeps the suffix-ignoring tuple compare (`release.rs:21-46`, moved to
|
||||
`core::version`), `.timeout(Duration::from_secs(30))`, and on Windows
|
||||
`.proxy(url)` from `platform::windows::system_proxy()` when no env proxy is
|
||||
set. Then `.build()?.check()`. `Some(update)` maps to Available, `None` to
|
||||
Up to date, `Err(TargetNotFound)` to Up to date, any other error to Failed.
|
||||
Record `last_update_check_unix` on every check, whatever the result, then
|
||||
send `UpdateCheckDone` to the scheduler.
|
||||
- **Apply:** build a second updater the same way but with
|
||||
`.timeout(Duration::from_secs(600))`, call `check()` again, and call
|
||||
`download(..)` on that result. The builder timeout is a total-request
|
||||
timeout that also covers the download, so the 30 s check timeout must not
|
||||
reach it. If the second check returns `None`, set Up to date. Then dispatch
|
||||
on the channel:
|
||||
- `Portable` (Windows): `ensure_writable` and `reject_unsafe_path` (kept),
|
||||
write the bytes to `data_local_dir()/ClaudeCodeUsageBubble/updates/update.exe`,
|
||||
call `self_replace`, delete the staged file, then
|
||||
`handoff::relaunch(&["--updated-to", v])`.
|
||||
- `AppImage` (Linux with `$APPIMAGE` set): `update.install(bytes)`, then
|
||||
`handoff::relaunch` with the same args.
|
||||
- `PackageManager` (Linux without `$APPIMAGE`): apply is disabled.
|
||||
- `Unsupported` (macOS for now): apply is disabled.
|
||||
- On relaunch `Ok`, `app.exit(0)`. On `Err`, the new binary is already in
|
||||
place but not running: set Failed, write `update-error.log`, and keep the
|
||||
current process running (the next Restart or login starts the new build).
|
||||
- **Failure:** Failed status, plus `write_update_error` to `update-error.log`
|
||||
in the existing format, with the plugin errors mapped onto the existing
|
||||
`Error` enum.
|
||||
- Remove the GitHub-API release lookup, the WinHTTP download and the legacy
|
||||
`--apply-update` helper (`src/update/install.rs:58-86`, `175-267`). Keep
|
||||
`parse_sha256_digest` only if something still uses it; otherwise delete it.
|
||||
- Capabilities: none. Everything runs in Rust.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/core/version.rs`
|
||||
- Modify: `src/update/mod.rs`, `src/update/install.rs` (portable apply and paths only), `src/update/channel.rs` (Portable, AppImage, PackageManager, Unsupported), `tauri.conf.json`, `Cargo.toml` (`tauri-plugin-updater = "2"`), `src/runtime.rs` (plugin registration), `src/lib.rs`
|
||||
- Delete: `src/update/release.rs`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Generate the key pair locally. Store the private key, and its password if
|
||||
one is used, as the GitHub secrets `TAURI_SIGNING_PRIVATE_KEY` and
|
||||
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. Keep an offline backup. **This is a
|
||||
user action:** the agent must not create the secret.
|
||||
2. Port the version tests: `v1.2.3-rc1` equals 1.2.3, `v1` is 1.0.0, `abc` is
|
||||
0.0.0, and there is no downgrade. Add tests for channel detection, the
|
||||
staging path and the error mapping (`TargetNotFound` to Up to date).
|
||||
3. Verify that the plugin's `Update::download` checks the signature, both in
|
||||
the docs.rs source and with a tampered `.sig`. If it does not, verify with
|
||||
`minisign-verify` against the same public key before calling `self_replace`.
|
||||
4. Verify in the pinned plugin source that the builder timeout is applied to the
|
||||
download request; if it is not, a single builder with the 30 s timeout is
|
||||
enough and step "Apply" drops the second check. Record the finding here.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test version:: update::` is green.
|
||||
- Linux desktop (X11, manual): `npm run tauri dev` starts with the updater registered, and a manual
|
||||
"Check for updates" against the real endpoint reports Up to date or Failed
|
||||
without crashing.
|
||||
- The handover from v0.4.0, v0.4.1 and v0.5.0 into the Tauri exe is checked
|
||||
statically in Phase 10 (legacy-client compatibility table). There is no
|
||||
end-to-end rehearsal; the residual risk is recorded there.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| The signing key is lost, which strands every Tauri install | L x H | Offline backup, documented in `docs/release-process.md`. |
|
||||
| An old client picks the wrong asset | L x H | No other `*.exe` asset is published. The exact name is required, and the Phase 10 table and `make-latest-json.js` guard check it. |
|
||||
| The plugin rejects a `latest.json` that lacks a platform | M x M | Phase 10's script emits every entry it builds and fails if one is missing. A deliberately Windows-only legacy release maps `TargetNotFound` to Up to date. |
|
||||
| A slow link times out the download | M x M | 10 min apply timeout, separate from the 30 s check. |
|
||||
| GitHub API rate limits still hit old clients | L x L | Unchanged. New clients use the CDN `latest.json`. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. If this ships broken, use the plan-level rollback
|
||||
(`release-legacy.yml`).
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
phase: 10
|
||||
title: "Release pipeline and legacy-client compatibility"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: [9]
|
||||
---
|
||||
|
||||
# Phase 10: Release Pipeline And Legacy-Client Compatibility
|
||||
|
||||
## Context
|
||||
|
||||
`.github/workflows/release.yml` builds one Windows exe on tag pushes, with a
|
||||
draft release on `workflow_dispatch`. `ci.yml` already exists from Phase 01.
|
||||
|
||||
v0.4.0 to v0.5.0 read `api.github.com/repos/tiennm99/claude-code-usage-bubble/releases/latest`,
|
||||
which skips drafts and prereleases, and draft assets are not downloadable
|
||||
without auth. No draft can exercise the migration, and the user decided against
|
||||
an end-to-end rehearsal and a throwaway repo. This phase instead checks
|
||||
statically, against the updater code at each released tag, that the new release
|
||||
assets satisfy every legacy client, and records the residual risk.
|
||||
|
||||
Linux ships x86_64 only (user decision). Bundles are built only in CI; this
|
||||
host has no patchelf and no display.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `release.yml`:
|
||||
- Triggers: tag `v*.*.*`, or `workflow_dispatch` with a `tag` input (a draft release). Keep the tag-equals-`Cargo.toml`-version check.
|
||||
- Every build job runs `npm ci` and passes
|
||||
`--config '{"bundle":{"createUpdaterArtifacts":true}}'` where bundles are
|
||||
built, with `TAURI_SIGNING_PRIVATE_KEY` and `_PASSWORD` in the environment.
|
||||
A signing step fails hard when the key variable is empty.
|
||||
- **win-x64** job (`windows-latest`):
|
||||
- `npm run tauri build -- --no-bundle`.
|
||||
- `npx tauri signer sign target/release/claude-code-usage-bubble.exe`. This
|
||||
writes a separate `.sig` and leaves the exe bytes untouched, so the GitHub
|
||||
asset digest that old clients verify is the digest of the exe they run.
|
||||
- Upload the exe and its `.sig`.
|
||||
- **linux-x64** job (`ubuntu-22.04`):
|
||||
- Install the Phase 01 apt list plus `patchelf`.
|
||||
- `npm run tauri build -- --bundles appimage,deb --config …`.
|
||||
- Rename every output to space-free kebab names before upload, because the
|
||||
bundler names them after `productName` and GitHub rewrites spaces in asset
|
||||
names: `claude-code-usage-bubble_<ver>_amd64.AppImage`, the matching
|
||||
`.AppImage.sig`, and `claude-code-usage-bubble_<ver>_amd64.deb`. The
|
||||
signature covers the content, not the name.
|
||||
- **publish** job:
|
||||
- Download the artifacts.
|
||||
- Run `node scripts/make-latest-json.js <tag> <dir>`. It builds the URLs
|
||||
from the renamed files and writes
|
||||
`{version, notes, pub_date, platforms:{"windows-x86_64", "linux-x86_64"}}`,
|
||||
where each entry is a release-download URL plus the `.sig` content. It
|
||||
fails on any missing entry, on any `*.exe` asset other than
|
||||
`claude-code-usage-bubble.exe`, and on any asset name with a space (a
|
||||
guard that runs after the rename).
|
||||
- Then `gh release create <tag> <assets…> latest.json --generate-notes`,
|
||||
adding `--draft` for a dispatch run. A tag run is non-draft,
|
||||
non-prerelease and `--latest`, because legacy clients only read
|
||||
`/releases/latest`.
|
||||
- `release-legacy.yml` (kept because it is one small file and the only
|
||||
rollback for Tauri clients; `workflow_dispatch` with `ref`, `version`, `tag`
|
||||
and a `draft` input that defaults to `true`): checks out `ref`, patches the
|
||||
`Cargo.toml` version, runs `cargo build --release --locked`, signs the exe
|
||||
with `tauri signer sign` (CLI from `npx --yes @tauri-apps/cli@2`, since the
|
||||
legacy ref has no `package.json`), runs `make-latest-json.js --windows-only`
|
||||
(from the current default branch) and publishes `claude-code-usage-bubble.exe`,
|
||||
its `.sig` and the Windows-only `latest.json`. Tauri Linux clients get
|
||||
`TargetNotFound`, which Phase 09 maps to Up to date. The v0.5.0 sources accept
|
||||
the Tauri relaunch args `--wait-pid <pid> --updated-to X`
|
||||
(`git show v0.5.0:src/main.rs`, lines 42 and 54).
|
||||
- `scripts/make-latest-json.js`: plain JS with JSDoc, linted, with
|
||||
`tests/js/make-latest-json.test.js` covering the shape (exactly the two
|
||||
platforms), a space-containing input that fails, a second `*.exe` that fails,
|
||||
renamed names that match their URLs, and `--windows-only`.
|
||||
- **Legacy-client compatibility table**, filled in by reading the updater code
|
||||
at each released tag (`git show <tag>:src/update/release.rs`,
|
||||
`git show <tag>:src/update/install.rs`, `git show <tag>:src/main.rs`) and
|
||||
checking every row against the dry-run draft. The rows below were verified at
|
||||
planning time; the implementer re-runs the `git show` reads and marks each row
|
||||
pass or fail. `src/update/release.rs` is byte-identical from v0.4.0 to v0.5.0
|
||||
(`git diff --stat v0.4.0 v0.5.0 -- src/update/release.rs` is empty).
|
||||
|
||||
| What the old client reads or needs | v0.4.0 | v0.4.1 | v0.5.0 | New release provides | Result |
|
||||
|------------------------------------|--------|--------|--------|----------------------|--------|
|
||||
| Endpoint: `GET /repos/tiennm99/claude-code-usage-bubble/releases/latest` (`release.rs:8-9`, `:49`) | same | same | same | The Tauri tag release is non-draft, non-prerelease and marked latest in the same repo | to check |
|
||||
| Release JSON fields: `tag_name`, `assets[].name`, `assets[].browser_download_url`, optional `assets[].digest` (`release.rs:104-119`) | same | same | same | Standard GitHub release object; nothing from `latest.json` is read | to check |
|
||||
| Version gate: `tag_name` parsed with the suffix-ignoring compare, must be greater than the running version (`release.rs:60-63`) | same | same | same | Tag `vX.Y.Z` above `v0.5.0`, equal to `Cargo.toml` (release.yml tag check) | to check |
|
||||
| Asset pick: name equal, ASCII case-insensitive, to `claude-code-usage-bubble.exe`, else the first asset ending in `.exe` (`release.rs:7`, `:65-74`) | same | same | same | Exactly one `*.exe` asset, named `claude-code-usage-bubble.exe`; `.exe.sig`, `.AppImage`, `.AppImage.sig`, `.deb` and `latest.json` do not end in `.exe` (`make-latest-json.js` guard) | to check |
|
||||
| Checksum: SHA-256 of the downloaded body must equal `digest` `sha256:<hex>` when present (`release.rs:78`, `:86-100`; `install.rs` `download`) | `install.rs:79` | `install.rs:88` | `install.rs:88` | GitHub computes `digest` for every uploaded asset (v0.5.0 shows one); the exe is uploaded unmodified after signing, so the digest matches | to check |
|
||||
| Download: 2xx after redirects from `browser_download_url`, whole body in memory | same | same | same | Same GitHub download URL form as today; the exe grows from about 0.75 MB to several MB, which the in-memory read handles | to check |
|
||||
| Swap and relaunch | Helper copy of the old exe runs `--apply-update` (`install.rs:44-45`, `:128`), waits for the parent, then starts the new exe with `--updated-to X` only (`install.rs:173`) | `self_replace` in-process, then `--wait-pid <pid> --updated-to X` (`install.rs:155-172`) | same as v0.4.1 (`install.rs:155-172`) | The Tauri exe accepts a bare start, `--updated-to X` alone, and `--wait-pid <pid> --updated-to X` (Phase 04 CLI). It never needs `--apply-update`. The v0.4.0 helper runs `run_cli` before any mutex (`main.rs:33`), and the Tauri lock is a different, per-session lock, so the helper cannot block the new instance | to check |
|
||||
| Leftovers | `<exe>.old.<pid>`, `updates/updater-helper-*.exe`, `updates/update.exe` | `updates/update.exe` | same as v0.4.1 | Phase 04 startup cleanup deletes all three patterns | to check |
|
||||
|
||||
Versions before v0.4.0 use the same `ASSET_NAME` constant (checked at
|
||||
v0.1.0, v0.1.9, v0.1.10, v0.2.0, v0.3.0, v0.3.12 and v0.3.13) and the
|
||||
hand-over modes in the plan.md backwards-compatibility table; they are not
|
||||
re-checked row by row.
|
||||
|
||||
- **Residual risk** (record it in this file and in `docs/release-process.md`):
|
||||
the legacy-to-Tauri swap is never run end to end before release. Static
|
||||
reading cannot prove runtime behaviour such as antivirus or SmartScreen
|
||||
reactions to a self-replaced, larger, unsigned exe, WebView2 start-up from a
|
||||
detached relaunch, or the v0.4.0 helper timing. The first Tauri release is the
|
||||
first real run. Mitigations: the WebView2 guard (Phase 04), `--diagnose`
|
||||
logging, `update-error.log`, and the `release-legacy.yml` rollback, which is
|
||||
itself unproven end to end.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `.github/workflows/release-legacy.yml`, `scripts/make-latest-json.js`, `tests/js/make-latest-json.test.js`
|
||||
- Modify: `.github/workflows/release.yml`, this phase file (the filled-in table and the residual risk)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Write the `latest.json` script and its test.
|
||||
2. Rewrite `release.yml` and add `release-legacy.yml`. Run `actionlint` and `yamllint`.
|
||||
3. With the user's OK (it creates a draft release in this repo), run a
|
||||
`workflow_dispatch` dry run of `release.yml` with a throwaway tag
|
||||
(`v0.0.0-test`) and inspect the draft's asset list. Run `release-legacy.yml`
|
||||
once with `draft: true` and the v0.5.0 sources to check that it builds,
|
||||
signs and writes a Windows-only `latest.json`. Delete both drafts afterwards.
|
||||
4. Re-run the `git show` reads, fill in the Result column of the compatibility
|
||||
table against the dry-run asset list, and write the residual-risk note.
|
||||
|
||||
## Validation
|
||||
|
||||
- `actionlint` and `yamllint` are clean on all workflows.
|
||||
- `npm test` covers `make-latest-json.js`, including the two-platform shape and the second-`*.exe` failure.
|
||||
- The dry-run draft contains exactly `claude-code-usage-bubble.exe`, its `.exe.sig`, one renamed `_amd64.AppImage` with its `.sig`, one renamed `_amd64.deb` and a `latest.json` with only `windows-x86_64` and `linux-x86_64`, whose URLs match the asset names exactly.
|
||||
- The `release-legacy.yml` draft contains the exe, its `.sig` and a Windows-only `latest.json`.
|
||||
- Every row of the compatibility table reads pass, with the tag and `file:line` it was checked against, and the residual risk is written down.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| The legacy-to-Tauri swap fails in the field (unproven end to end) | L x H | The static table above; WebView2 guard; `update-error.log`; `release-legacy.yml` rollback. Accepted by the user in place of a rehearsal. |
|
||||
| A missing secret silently produces an unsigned build | L x H | The signing step fails hard when the env var is empty. |
|
||||
| An old client picks the wrong asset | L x H | Exactly one `*.exe` asset, enforced by `make-latest-json.js`. |
|
||||
| The Tauri release is not the one `/releases/latest` returns | L x H | Tag runs publish non-draft, non-prerelease, `--latest`; check with `gh api repos/tiennm99/claude-code-usage-bubble/releases/latest -q .tag_name` after publishing. |
|
||||
| A draft dry run reaches users | L x H | `/releases/latest` skips drafts; both drafts are deleted after inspection. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the workflow commits. Already published releases are unaffected.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
phase: 11
|
||||
title: "Cleanup, docs, parity sign-off"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "3h"
|
||||
dependencies: [7, 8, 10]
|
||||
---
|
||||
|
||||
# Phase 11: Cleanup, Docs, Parity Sign-Off
|
||||
|
||||
## Context
|
||||
|
||||
Earlier phases deleted each Win32 file when they replaced it. This phase
|
||||
confirms that nothing is left, updates the user-facing docs and runs the Windows
|
||||
parity checklist before the first Tauri release.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `grep -rn "windows::" src` must find matches only in `src/platform/windows.rs`.
|
||||
No untracked leftovers may remain under `src/os`, `src/tray` or `res/`.
|
||||
- `README.md`:
|
||||
- Platform badges for Windows and Linux x86_64, plus a note that Linux ARM64 and macOS are not yet supported (see `ROADMAP.md`).
|
||||
- The WebView2 requirement on Windows 10.
|
||||
- Linux runtime deps (webkit2gtk-4.1 and libayatana-appindicator3), the need for a compositor, Wayland limits (positioning works only via Xwayland) and the tray limits (menu only, no tooltip).
|
||||
- Settings and log paths per OS, plus the WebView2/WebKit profile dir under the identifier.
|
||||
- The privacy list, unchanged apart from removing WinHTTP.
|
||||
- The intentional changes from plan.md that users can see (dead corners, toasts, PAC).
|
||||
- Fix the drift: tray icons are not optional.
|
||||
- Rewrite "Build from source" (`README.md:67-72`, today `cargo build --release`): Node/npm prerequisite, `npm ci`, `npm run tauri build -- --no-bundle` on Windows, `npm run tauri build -- --no-bundle` or `--bundles deb,appimage` on Linux x86_64, the Linux apt list from Phase 01 plus `patchelf` for AppImage, and a note that local bundles are unsigned for the updater (`createUpdaterArtifacts` is off).
|
||||
- `docs/release-process.md`:
|
||||
- The version lives in `Cargo.toml` only (`tauri.conf.json` inherits it). Bump it, then run `cargo build` to refresh `Cargo.lock`.
|
||||
- Signing-key custody and secrets, with an offline backup.
|
||||
- The asset contract: the exact `claude-code-usage-bubble.exe`, with no other `*.exe`, plus `.sig` files and `latest.json`.
|
||||
- The dry-run steps, the asset rename rule, the legacy-client asset contract from the Phase 10 compatibility table, its residual risk (no end-to-end rehearsal), and the `release-legacy.yml` rollback procedure.
|
||||
- No plan IDs or phase numbers in code comments or commit messages.
|
||||
|
||||
## Windows parity checklist (manual, Windows 10 and 11, at 100% and 150% scale)
|
||||
|
||||
- [ ] The bubble shows bottom right, with Codex above Claude. It has no taskbar or Alt-Tab entry, stays topmost, and never steals focus.
|
||||
- [ ] A saved position on an unplugged monitor resets to the default position at startup.
|
||||
- [ ] Rings, bars, text, colours and the 95% pulse match a v0.5.0 screenshot. The light and dark switch applies live.
|
||||
- [ ] A click toggles the panel and a drag moves the bubble. Corner, edge, taskbar and peer snaps match, and the position persists across restarts. A hover right after a drag does not start a new drag.
|
||||
- [ ] Panel: layout and palette match v0.5.0; it opens below the bubble and flips above near the bottom edge; it hides on blur and on a second click of the same bubble; clicking the other bubble swaps content; no Alt-Tab or taskbar entry; Windows 11 corners round; values refresh live while open; right-click, F5 and Ctrl+wheel do nothing.
|
||||
- [ ] Ctrl+wheel resizes within 140 to 360 around the centre and resizes every bubble. The menu size items grey out at the limits.
|
||||
- [ ] Right-click menu: the full structure, check marks and every action work. Re-clicking a checked frequency or interval and unchecking the last provider leave the marks correct. The menu dismisses on a click elsewhere.
|
||||
- [ ] Tray: one icon per provider, the badge ring, the 4-line tooltip, left-click toggle and right-click menu.
|
||||
- [ ] Alerts: threshold, session-expired and update-applied notifications appear from a fresh portable exe with no Start-menu shortcut, with the cooldown respected.
|
||||
- [ ] Fullscreen auto-hide works, as do the DPI change and monitor unplug cases (the bubble is re-clamped).
|
||||
- [ ] Autostart: a legacy v0.5.0 entry shows checked, there is one entry after two launches, the toggle works, and a Task Manager disable survives two launches.
|
||||
- [ ] The Phase 10 legacy-client compatibility table has every row marked pass for v0.4.0, v0.4.1 and v0.5.0, and the residual risk is recorded.
|
||||
- [ ] A second launch exits silently. Restart relaunches (and the old process is gone). Exit ends the process.
|
||||
- [ ] Without the WebView2 runtime (Windows Sandbox or an LTSC VM), the exe shows the message box with the download link and exits.
|
||||
- [ ] `--diagnose` writes `%TEMP%\claude-code-usage-bubble.log`.
|
||||
|
||||
## Files
|
||||
|
||||
- Modify: `README.md`, `docs/release-process.md`
|
||||
- Delete: any leftover detached Win32 files (expected: none)
|
||||
|
||||
## Validation
|
||||
|
||||
- The checklist is complete. Every failure is either fixed or recorded as an intentional change with user sign-off.
|
||||
- Every link in the README and release doc resolves, and each claim matches the code.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| A parity failure is found late | M x M | Each earlier phase already runs its Windows manual check. This phase only confirms them. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the doc commits. No code changes in this phase.
|
||||
@@ -0,0 +1,259 @@
|
||||
---
|
||||
title: Port the usage bubble to Tauri v2
|
||||
description: >-
|
||||
Rebuild the Win32 bubble, panel, tray and updater on Tauri v2 with a plain
|
||||
HTML/CSS/SVG/JS UI, keeping Windows parity, running on Linux x86_64 and cfg-gating macOS.
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: 51h
|
||||
branch: feat/tauri-port
|
||||
tags:
|
||||
- tauri
|
||||
- cross-platform
|
||||
- port
|
||||
- updater
|
||||
created: '2026-10-21'
|
||||
---
|
||||
|
||||
# Port The Usage Bubble To Tauri v2
|
||||
|
||||
## Outcome
|
||||
|
||||
The app runs on Tauri v2. Windows behaves the same as v0.5.0, and existing
|
||||
v0.4.0+ users self-update into the Tauri build without losing settings or
|
||||
autostart. Linux x86_64 builds and runs (X11 full, Wayland best-effort). macOS
|
||||
compiles behind cfg-gated stubs so it can be finished later. Linux ARM64, a macOS
|
||||
build, Wayland-native positioning and a rename are later work in the root
|
||||
`ROADMAP.md`, not part of this plan.
|
||||
|
||||
## Stack (approved)
|
||||
|
||||
- Rust core plus Tauri v2. Approved plugins: tray (core `tray-icon` feature),
|
||||
updater, autostart, single-instance and notification (approved for the alerts,
|
||||
with Windows AUMID registration, D9).
|
||||
- UI: plain HTML/CSS/SVG and plain JavaScript ES modules in a static `ui/`
|
||||
folder, with `withGlobalTauri`. No framework, bundler or TypeScript.
|
||||
- ESLint flat config with `eslint-plugin-jsdoc`. Pure JS modules are tested with
|
||||
`node --test`, which adds no dependency.
|
||||
- npm only. `@tauri-apps/cli` is a local devDependency.
|
||||
- `ureq` 3 (rustls plus platform verifier) replaces WinHTTP.
|
||||
- macOS Keychain credentials (keyring) are deferred, as the user requested.
|
||||
|
||||
## User decisions (2026-10-21)
|
||||
|
||||
- Alerts use `tauri-plugin-notification` with Windows AUMID registration (D9).
|
||||
- The transparent stadium corners become dead corners (D4).
|
||||
- There is no end-to-end update rehearsal and no throwaway repo. Phase 10
|
||||
instead checks, statically and against the code at each released tag, that the
|
||||
new release assets satisfy every legacy client. The end-to-end path stays
|
||||
unproven until the first real release (residual risk, Phase 10).
|
||||
- Linux ships x86_64 only. There are no arm64 CI rows, release jobs or
|
||||
`linux-aarch64` `latest.json` entry.
|
||||
- This host (headless Linux ARM64, no display, no patchelf, no xvfb) verifies with
|
||||
`cargo fmt --check`, `cargo clippy`, `cargo test`, `npm run lint`, `npm test`
|
||||
and `npm run tauri build -- --no-bundle` (or `cargo build`). Bundles (AppImage,
|
||||
`.deb`) are built only in CI, where the runner installs patchelf. Checks that
|
||||
need a display are marked "Linux desktop" and run by hand on an X11 desktop.
|
||||
|
||||
## Decisions
|
||||
|
||||
| ID | Decision | Reason |
|
||||
|----|----------|--------|
|
||||
| D1 | **Layout: one package at the repo root, lib plus thin bin.** `Cargo.toml`, `build.rs`, `tauri.conf.json`, `.taurignore`, `capabilities/`, `icons/`, `src/`, `ui/`, `tests/js/` and `package.json` live at the root. There is no `src-tauri/`. `src/lib.rs` (`[lib] name = "claude_code_usage_bubble_lib"`) declares every module `pub` and exposes `run()`. `src/main.rs` only calls `run()` and carries `windows_subsystem`. Portable logic goes in `src/core/`, OS code in `src/platform/{windows,linux,macos}.rs`. JS tests live in `tests/js/`, so they are not embedded in the binary. `.taurignore` lists `ui/`, `tests/`, `scripts/`, `plans/`, `docs/` and `node_modules/`, so UI edits do not trigger a cargo rebuild. | Git history stays on `src/`. Unused `pub` items in a lib do not trip `dead_code`, so `clippy -D warnings` holds while later phases are still wiring consumers. The `_lib` suffix avoids the Windows pdb name clash between a lib and a bin of the same name (the Tauri template does the same). |
|
||||
| D2 | **Rendering:** the bubble is an HTML stadium pill (`border-radius: 9999px`). Head rings are SVG circles drawn with `stroke-dasharray`, tail bars are pill divs. Rust sets the window to the exact integer physical size and sends `canvasW`, `canvasH` (physical) and `dpi` in the payload; `ui/layout.js` works from those, never from `innerWidth * devicePixelRatio`. Stroke clamps (ring 2..4, time ring 2..3 physical px) carry over. Text widths come from canvas `measureText`. The bubble palette (Phase 06 table, `src/bubble.rs:1406-1427`, `1857-1866`) is CSS custom properties keyed by `[data-theme]`. Fill colours come from Rust `bar_fill_color` as hex. **Every displayed string is formatted in Rust** (half-to-even percent, countdowns, placeholders); JS never formats a number for display. The pulse is a JS 80 ms step with the exact per-element rules in Phase 06. | One owner per rule. Rust owns colours and strings; JS owns pixels. |
|
||||
| D3 | **Interaction:** pointerdown (button 0) calls `setPointerCapture` and records the screen point. Pointermove requires `(e.buttons & 1) === 1`; once the pointer has moved 3 or more physical px, JS invokes `bubble_drag_start`, clears its pressed state and calls `startDragging()`. Pressed state also clears on `pointercancel`, `lostpointercapture` and `blur`. A release before the threshold invokes `bubble_click`. On Windows, a `SetWindowSubclass` hook on the bubble HWND catches `WM_EXITSIZEMOVE` (move end), `WM_SETTINGCHANGE` (theme recheck and reclamp) and `WM_DISPLAYCHANGE` (reclamp). On Linux and macOS, move end is a 250 ms debounce on `WindowEvent::Moved` that is **armed only by `bubble_drag_start`**; on X11 the debounce also checks Button1 with x11rb `QueryPointer` and re-arms while it is still held. Snap disarms before its own `set_position`, so programmatic moves (snap, clamp, resize recentre, Reset position) never snap or persist. `contextmenu` invokes `show_context_menu`. Ctrl+wheel uses a non-passive `wheel` listener that invokes `bubble_resize_step`. | Matches the HTCAPTION move loop on Windows, avoids mid-drag snaps on X11 and keeps "no snap or save after resize or clamp". |
|
||||
| D4 | **Hit area (user-approved):** the transparent stadium corners become dead zones (CSS `pointer-events: none` outside the pill) instead of click-through. | WebView windows take input across their whole rectangle. `SetWindowRgn` loses the anti-aliased edge. The area involved is about 0.21·h² (roughly 920 px² at 66 px). |
|
||||
| D5 | **No activation (Windows):** bubbles are built with `focused(false)` and `focusable(false)`. `platform::windows::apply_no_activate` ORs `WS_EX_NOACTIVATE \| WS_EX_TOOLWINDOW` into bubble HWNDs; `apply_toolwindow` ORs only `WS_EX_TOOLWINDOW` into the panel HWND (it must take focus). Both run before the first show. On Windows, bubble show and hide both go through `platform::windows` (`SW_SHOWNOACTIVATE` / `SW_HIDE`), never mixed with Tauri `show()`/`hide()`. Bubbles are created hidden; JS invokes `bubble_ready` right after it applies the first `get_bubble_state` result to the DOM (no `requestAnimationFrame`), and Rust shows the bubble anyway 1.5 s after creation if `bubble_ready` has not arrived. | Keeps today's never-steals-focus behaviour, keeps the panel out of Alt-Tab, avoids a white flash, and cannot leave a bubble invisible because a hidden webview throttles rendering. |
|
||||
| D6 | **Data layer:** the `usage/` and `creds/` modules stay. `net/` keeps the same `Client`/`Response` surface, backed by ureq with `http_status_as_error(false)`, gzip, a 60 s connect timeout, a 30 s receive timeout, the same User-Agent and the platform verifier. A Windows system proxy is read from `HKCU\…\Internet Settings` with per-scheme parsing and `ProxyOverride` bypass (Phase 03); PAC/WPAD is dropped (intentional change). `refresh.rs` gates `CREATE_NO_WINDOW` and the `cmd.exe`/`powershell` candidates behind `cfg(windows)`; Unix uses the plain `claude` and `codex` commands. | Providers compile unchanged and 401/403 handling and header fallbacks keep working. |
|
||||
| D7 | **Settings: no migration.** The path stays `dirs::config_dir()/ClaudeCodeUsageBubble/settings.json`, and the schema, key names and physical-px positions are byte-compatible. No new keys. Tauri `app_config_dir` and the store plugin are not used. Monitor validation moves to a pure function fed by `available_monitors()`. | Existing users keep their position, size, providers and intervals without any conversion step. |
|
||||
| D8 | **Updater: hybrid on one key.** All platforms use `tauri-plugin-updater` for `check()` and `download()`, which verifies the minisign signature against `latest.json`. A custom `version_comparator` keeps the suffix-ignoring comparison. **Check** uses a builder with a 30 s timeout. **Apply** builds a fresh updater with a 10 min timeout, re-runs `check()` and calls `download()` on that result, because the builder timeout is a total-request timeout that also covers the download. On Windows both builders get the parsed system proxy. A `TargetNotFound` error from `check()` maps to Up to date. Windows stays a **portable exe**: the downloaded bytes go through `self_replace`. The Linux AppImage uses the plugin's `install()`. In both cases the app relaunches itself with `--wait-pid <pid> --updated-to X.Y.Z` and exits only if the spawn succeeded. A `.deb` install is a package-manager channel: the check runs, apply is greyed out. `tauri.conf.json` keeps `createUpdaterArtifacts: false`; only `release.yml` turns it on through `--config`, so local bundle builds need no key. The updater plugin is registered only from Phase 09, together with its `pubkey` and endpoint. No NSIS installer is published. | The portable model is part of Windows parity. Old clients pick the exact asset name through the GitHub API, so they swap straight into the Tauri exe. No released version ever ran the downloaded binary as `--apply-update` (see Backwards compatibility), so that handler is dropped. |
|
||||
| D9 | **Notifications (user-approved):** `tauri-plugin-notification` carries the threshold, session-expired and update-applied alerts. On Windows, before the first toast, `platform::windows::register_aumid()` writes `HKCU\Software\Classes\AppUserModelId\io.github.tiennm99.claudecodeusagebubble` with `DisplayName` "Claude Code Usage Bubble" and `IconUri` (a PNG written to `data_local_dir()/ClaudeCodeUsageBubble/icon.png`), then sets that AUMID for the process. A Windows spike at the start of the Phase 05 alerts work checks that a fresh portable exe with no Start-menu shortcut shows a toast on Windows 10 and 11; if it does not, stop and report to the user before writing more alert code. The 30-minute cooldown logic stays in Rust. | Tauri tray icons have no balloon API, and these alerts are part of Windows parity. Windows drops toasts for an unregistered AUMID. |
|
||||
| D10 | **Autostart:** `tauri-plugin-autostart` with `app_name("ClaudeCodeUsageBubble")`, the same Run value name as v0.5.0, so a legacy entry is already the plugin's entry. No migration code runs by default. Phase 05 verifies on the pinned plugin that `is_enabled()` does not compare the value data, that `enable()` writes HKCU (pin the current-user mode if the Builder exposes it) and what data `enable()` writes. Only if `is_enabled()` rejects the legacy `"<exe>"` data does a one-time rewrite happen: when the Run value data equals the legacy quoted path exactly and differs from the plugin format, overwrite the data directly in `platform::windows`. StartupApproved is never written by our code. The label is "Start with Windows" on Windows and "Start at login" elsewhere. | Avoids a double launch, a wrongly unchecked item, and silently undoing a Task Manager disable on every start. |
|
||||
| D11 | **Fullscreen auto-hide:** Windows reuses `evaluate_foreground_fullscreen` verbatim, with the bubble-class check replaced by "foreground window owned by our PID". Linux X11/Xwayland uses `x11rb` with `_NET_ACTIVE_WINDOW` and `_NET_WM_STATE_FULLSCREEN`. Native Wayland and macOS return `false` (stubs). A 1500 ms thread drives all of them. | The proven logic is kept as-is. The PID check also covers the panel, the popup menu and WebView child windows. |
|
||||
| D12 | **Wayland:** when `WAYLAND_DISPLAY` is set and `GDK_BACKEND` is unset, set `GDK_BACKEND=x11` before Tauri starts. If no X server is present, positioning features become no-ops and a `(0,0)` position is never saved. | Positioning, snapping and topmost only work under Xwayland. |
|
||||
| D13 | **Single instance:** `tauri-plugin-single-instance` is registered first, and its callback does nothing. With `--wait-pid <pid>`, the pre-Builder wait loops until that PID is gone, capped at 15 s (today: 5 s wait plus about 3 s of mutex retries), and logs under `--diagnose`. `handoff::relaunch` returns `Result`; callers call `app.exit(0)` only on `Ok`. **Intentional change:** the lock is per user session instead of the `Global\` machine-wide mutex. | The plugin cannot retry, so the wait must outlast a slow WebView2 teardown. A failed spawn must not leave the user with no app. |
|
||||
| D14 | **Timers and threading:** one `std::thread` scheduler owns the poll deadline, the countdown deadline and the update-check deadline, fed by an `mpsc` channel with `recv_timeout`. The `AtomicBool` in-flight poll gate is kept. Polls run on worker threads. **Hard rule:** no Tauri window, tray, menu or emit API is called while the `AppState` mutex is held. Code takes an owned snapshot through `with_state(\|s\| …)`, drops the guard, then calls Tauri. `propagate`, `menu::refresh`, `tray::sync`, the fullscreen thread and every command follow it. | Plain std, no tokio dependency. Tauri tray, menu and window calls from a worker block on the main thread, so holding the lock across them deadlocks. |
|
||||
| D15 | **Logging:** keep `diag` (simplelog) behind the `--diagnose` gate, writing to `temp_dir()/claude-code-usage-bubble.log` and truncating on each start. It is initialised before the Builder. A `log_js` command forwards `window.onerror`. | The existing behaviour is kept with no new plugin. |
|
||||
| D16 | **Identity:** `productName` "Claude Code Usage Bubble", `mainBinaryName` "claude-code-usage-bubble", `identifier` "io.github.tiennm99.claudecodeusagebubble". `tauri.conf.json` has no `version`, so it inherits it from `Cargo.toml`. Release assets are renamed to space-free kebab names (Phase 10). | One version source for the CI tag check. The identifier is permanent, because it names the WebView data dir, the D-Bus single-instance name and the AUMID. |
|
||||
| D17 | **CI:** `ci.yml` lands in **Phase 01** and runs on pushes to `feat/tauri-port` and `main` and on PRs. Rows: `windows-latest` and `ubuntu-22.04` run fmt, clippy, test, lint, `npm test` and `npm run tauri build -- --no-bundle`; the Windows row uploads the unsigned exe as an artifact (7-day retention), which is the input for every phase's Windows manual check. A `macos-latest` row runs only `cargo check --all-targets` and is the macOS compile gate. `release.yml` (Phase 10) is a matrix covering Windows x64 and Linux x64, plus a publish job. Only CI builds bundles (AppImage needs patchelf, which the runner installs). | Every phase can be verified on Windows without a Windows build host. |
|
||||
| D18 | **Window lifecycle:** bubbles are removed only with `destroy()`. A new window with the same label is created only after the old one's `Destroyed` event. Reset position moves the existing bubbles with `set_position` instead of recreating them. Bubbles get `prevent_close` on `CloseRequested`; the panel gets `prevent_close` plus `hide()`. `run()` handles `RunEvent::ExitRequested { code: None, api }` with `api.prevent_exit()`, so only an explicit `app.exit(0)` ends the process. | `close()` would be blocked by our own handler, re-using a label before destruction completes fails, and destroying the last window would otherwise quit the app. |
|
||||
|
||||
### Intentionally changed behaviour
|
||||
|
||||
1. Corner click-through is replaced by dead corners (D4).
|
||||
2. The single-instance lock is per session, not machine-wide (D13).
|
||||
3. Windows PAC/WPAD proxy discovery is dropped. The static system proxy, its bypass list and env proxies are still honoured (D6).
|
||||
4. Balloon notifications become toasts, and the warning/info icon distinction is lost (D9).
|
||||
5. **Bug fix:** a theme change now reaches already-open bubbles. Today `update_dark_mode` is never called (`src/bubble.rs:396`).
|
||||
6. **Bug fix:** the auto-update check now repeats every interval. Today the timer is re-armed from a worker thread (`src/app.rs:1450-1455`).
|
||||
7. **Bug fix:** a saved `widget_visible=false` is applied when bubbles are created at startup and on provider toggle (`src/app.rs:331-353`).
|
||||
8. The autostart checked state now honours a Task Manager disable, because the plugin reads StartupApproved.
|
||||
9. Windows file properties now show the real version instead of the stale `0.1.0` (`res/icon.rc`).
|
||||
10. Fonts: Segoe UI stays first in the stack. Linux and macOS fall back to `system-ui`, so text metrics differ there.
|
||||
11. The app refuses to start without the WebView2 runtime and shows a message box with the download link instead (Phase 04).
|
||||
|
||||
Everything else in the subsystem maps is kept as-is. The quirks are kept too:
|
||||
the global `last_poll_ok`, the first-poll threshold alert, the unscaled 24 px
|
||||
Codex stagger, panel placement against the virtual-screen union with a physical
|
||||
8 px gap, the tray badge always rendering dark, and OpenCode Go staying a greyed,
|
||||
non-live entry.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Effort | Depends on | Status |
|
||||
|---|-------|--------|------------|--------|
|
||||
| 01 | [Scaffold Tauri crate, JS toolchain and CI](phase-01-scaffold-tauri-and-toolchain.md) | 5h | none | pending |
|
||||
| 02 | [Extract portable core with tests](phase-02-portable-core.md) | 6h | 01 | pending |
|
||||
| 03 | [Data layer on ureq](phase-03-data-layer-ureq.md) | 4h | 02 | pending |
|
||||
| 04 | [App runtime: state, scheduler, CLI, single instance](phase-04-app-runtime.md) | 5h | 03 | pending |
|
||||
| 05 | [Menu, tray, alerts, autostart](phase-05-menu-tray-notify-autostart.md) | 6h | 04 | pending |
|
||||
| 06 | [Bubble window and UI](phase-06-bubble-window-ui.md) | 9h | 05 | pending |
|
||||
| 07 | [Details panel window](phase-07-panel-window.md) | 3h | 06 | pending |
|
||||
| 08 | [Fullscreen auto-hide](phase-08-fullscreen-auto-hide.md) | 2h | 06 | pending |
|
||||
| 09 | [Updater](phase-09-updater-migration.md) | 4h | 05 | pending |
|
||||
| 10 | [Release pipeline and legacy-client compatibility](phase-10-ci-release.md) | 4h | 09 | pending |
|
||||
| 11 | [Cleanup, docs, parity sign-off](phase-11-cleanup-docs-parity.md) | 3h | 07, 08, 10 | pending |
|
||||
|
||||
Run the phases strictly in order. Phases 07, 08 and 09 all edit
|
||||
`src/platform/windows.rs` and `src/lib.rs`, so they must not run in parallel.
|
||||
Every phase's Windows manual check runs the exe artifact from the `ci.yml` run
|
||||
of that phase's last commit.
|
||||
|
||||
## Feature-to-phase map
|
||||
|
||||
| Feature (subsystem maps) | Phase |
|
||||
|--------------------------|-------|
|
||||
| Bubble window styles, topmost, no taskbar, no activation, move cursor | 06 (D5) |
|
||||
| Stadium shape, size 140..360 step 20, aspect ratios | 02 (math), 06 (UI) |
|
||||
| Head rings, inner time ring, tail bars, text and fonts, '5H' fallback, placeholders | 02 (strings), 04 (payload), 06 |
|
||||
| Colours, theme (SystemUsesLightTheme), re-theming of open bubbles | 02, 06 |
|
||||
| Critical pulse at 95% and above, 60 s time-progress refresh | 06 |
|
||||
| Drag, click vs drag, snap (corner, edge, taskbar, peer), clamp | 02 (math), 06 |
|
||||
| Offscreen validation on load, clamp on settings/display change, Codex stagger | 02, 06 |
|
||||
| Default position, DPI changes, Ctrl+wheel resize around the centre | 02, 06 |
|
||||
| Hit area | 06 (D4) |
|
||||
| Fullscreen auto-hide | 08 |
|
||||
| User show/hide, `user_hidden` and `hidden_by_fullscreen` flags | 04, 06, 08 |
|
||||
| Panel: toggle, single window, 280x120 layout, bars, colours, theme, placement, focus dismissal, no Alt-Tab, Win11 corners, live refresh | 07 |
|
||||
| Tray icon per live provider, badge, tooltip, left-click toggle, menu | 05 |
|
||||
| Context menu (full structure, check and grey states, ids) | 05 |
|
||||
| Threshold, session-expired and update-applied alerts, 30 min cooldown | 02 (logic), 05 (D9) |
|
||||
| Poll scheduling, in-flight gate, apply results, credential refresh | 02, 03, 04 |
|
||||
| Countdown re-render scheduling | 02, 04 |
|
||||
| Provider toggle and its invariant, OpenCode Go greyed | 02, 05 |
|
||||
| Reset position, Restart, Exit, CLI flags, `--diagnose`, WebView2 guard | 04, 06 |
|
||||
| Single instance | 04 (D13) |
|
||||
| Settings schema, path, atomic save, load normalisation | 02 (D7) |
|
||||
| Start with Windows (same value name, D10) | 05 |
|
||||
| Updater: check, status machine, schedule, signature, self-replace, relaunch, startup cleanup, error log | 02, 04 (cleanup), 09 (D8) |
|
||||
| Usage providers, creds, HTTP, proxy, TLS | 03 (D6) |
|
||||
| Icons, version info, manifest | 01 |
|
||||
| CI, release, legacy-client compatibility table, release-process doc, README | 01, 10, 11 |
|
||||
|
||||
## Data flow
|
||||
|
||||
1. Scheduler thread sends a poll tick to a worker. The worker calls
|
||||
`Registry::poll_enabled`, which runs ureq requests against the provider
|
||||
endpoints and returns `Vec<(ProviderId, Result)>`.
|
||||
2. `core::state::apply_results` updates the snapshots, the alerts to fire and the
|
||||
auth failures. Auth failures go to the refresh orchestrator, which spawns the
|
||||
CLI for every failed provider.
|
||||
3. `propagate` snapshots state under the lock, drops it (D14), and builds two
|
||||
payloads in `core::payload` (pure, unit-tested):
|
||||
- `BubblePayload` → `emit_to("bubble-<slug>", "bubble:update")`:
|
||||
`{provider, sessionPct|null, weeklyPct|null, sessionPctText, weeklyPctText|null, headLabel, weeklyCountdown, sessionResetsAtMs|null, weeklyResetsAtMs|null, primaryDurSecs, secondaryDurSecs, sessionFill, weeklyFill, accent, isDark, canvasW, canvasH, dpi}`.
|
||||
`sessionPctText` is `'{:.0}%'` or `'—'` when there is no value.
|
||||
`weeklyPctText` is `null` when there is no weekly value (the text is not
|
||||
drawn). `headLabel` is the session countdown only (`format_countdown`), `'…'`
|
||||
before the first poll; JS replaces an empty or overflowing label with `'5H'`.
|
||||
`weeklyCountdown` is countdown-only, `'…'` before the first poll, `''` hides
|
||||
it. On a poll error the bubble keeps its last values and countdowns; **the
|
||||
bubble never shows `'!'`**.
|
||||
- `PanelPayload` → `emit_to("panel", "panel:update")` only when the panel is
|
||||
visible and showing that provider, and returned by `get_panel_state`:
|
||||
`{provider, label, isDark, accent, sessionPct, weeklyPct, sessionText, weeklyText, sessionFill, weeklyFill}`.
|
||||
The texts are `format_window` output (`'73% · 2h'`), `'!'` after an auth
|
||||
failure, `'…'` after a transient error, `''` before the first poll.
|
||||
- The tray gets `set_icon`/`set_tooltip`, but only when a cached key changed.
|
||||
The tooltip uses the same panel texts, with `'...'` for an empty one.
|
||||
4. In the other direction, JS calls `invoke` with `bubble_ready`, `bubble_click`,
|
||||
`bubble_drag_start`, `bubble_resize_step`, `show_context_menu`,
|
||||
`get_bubble_state`, `get_panel_state` and `log_js`. On Windows, the subclass
|
||||
hook delivers move end, setting changes and display changes to Rust. On other
|
||||
platforms the armed Moved debounce does the same.
|
||||
5. Persistence: every change runs `Settings::save` (atomic write through tmp and
|
||||
rename). Other writes: the update staging file and `update-error.log` under
|
||||
`data_local_dir()/ClaudeCodeUsageBubble/updates/`, the AUMID icon PNG next to
|
||||
them and the HKCU AppUserModelId key.
|
||||
|
||||
## Backwards compatibility and migration
|
||||
|
||||
- **Settings:** same file and keys (D7). An old build reading a file written by
|
||||
the new one still loads, because there are no new keys.
|
||||
- **Autostart:** same Run value name, so no migration by default (D10).
|
||||
- **Updates:** how each released version hands over to the Tauri exe (verified
|
||||
with `git show <tag>:src/update/install.rs`):
|
||||
|
||||
| Old version | Who swaps the file | How the Tauri exe is started |
|
||||
|-------------|--------------------|------------------------------|
|
||||
| v0.1.9 and earlier | `cmd.exe` one-liner (2 s wait, `move`, `start`) | no args |
|
||||
| v0.1.10 – v0.3.12 | the old app, in-process rename | `--wait-pid <pid> --updated-to X` |
|
||||
| v0.3.13 – v0.4.0 | `updater-helper-<pid>.exe`, a copy of the **old** exe, run as `--apply-update`; leaves `<exe>.old.<pid>` | `--updated-to X` only |
|
||||
| v0.4.1 – v0.5.0 | `self_replace` in the old app | `--wait-pid <pid> --updated-to X` |
|
||||
|
||||
No version runs the downloaded binary with `--apply-update`, so the Tauri exe
|
||||
drops that handler. It still accepts `--wait-pid`, `--updated-to` and a bare
|
||||
start, and its startup cleanup deletes `<exe>.old.*` and
|
||||
`updates/updater-helper-*.exe`. Phase 10 checks statically, against the
|
||||
updater code at the v0.4.0, v0.4.1 and v0.5.0 tags, that the new release
|
||||
assets satisfy each of them. The end-to-end swap is not rehearsed.
|
||||
- **WebView2:** before the Builder, `cfg(windows)` checks
|
||||
`GetAvailableCoreWebView2BrowserVersionString`. If the runtime is missing, a
|
||||
`MessageBoxW` shows the Evergreen download link and the process exits 0. An
|
||||
auto-updated user on such a machine therefore sees why and how to fix it,
|
||||
instead of a silent dead app. The README documents the same requirement.
|
||||
|
||||
## Test matrix
|
||||
|
||||
| Level | What | Where |
|
||||
|-------|------|-------|
|
||||
| Unit (Rust) | Geometry (aspect, height, snap, clamp, default position, `place_near`, `rect_covers_monitor`, style bits), settings load/normalise/validate and legacy JSON, i18n `format_window`/`format_countdown`/`time_until_display_change`/half-to-even, `usage_color` band edges, `apply_results` thresholds and cooldown, payload builders, update schedule, version parsing, badge pixel sampling, provider JSON/header/ISO fixtures, proxy string parsing, menu model, visibility transitions, CLI parsing | `cargo test` on Linux host and CI Windows/Linux |
|
||||
| Unit (JS) | `ui/layout.js` geometry, pulse triangle and brighten, remaining fraction, `scripts/make-latest-json.js` | `npm test` (`node --test "tests/js/**/*.test.js"`) |
|
||||
| Static | `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `npm run lint` | host plus CI |
|
||||
| Build | `npm run tauri build -- --no-bundle` (or `cargo build`) on the host, on `windows-latest` and on `ubuntu-22.04`; `cargo check --all-targets` on `macos-latest`; AppImage and `.deb` bundles only in the `release.yml` Linux x64 job (patchelf on the runner) | host plus CI |
|
||||
| Static compatibility | The legacy-client table in Phase 10: the asset name, digest and release JSON fields that v0.4.0, v0.4.1 and v0.5.0 read, checked against their tagged code, plus `make-latest-json.js` guards | Phase 10 (written review plus `npm test`) |
|
||||
| E2E (manual) | Windows parity checklist (Phase 11), Linux X11 smoke run | Windows machine with the CI artifact, Linux x86_64 X11 desktop |
|
||||
|
||||
No end-to-end update rehearsal runs. The legacy-to-Tauri swap is first
|
||||
exercised by real users of the first Tauri release (residual risk, Phase 10).
|
||||
|
||||
## Rollback
|
||||
|
||||
- Each phase is one or more focused commits on `feat/tauri-port`. Rolling a
|
||||
phase back means reverting its commits; `main` is untouched until merge.
|
||||
- After release, if the Tauri build is broken in the field, run
|
||||
`release-legacy.yml` (Phase 10) with a ref holding the v0.5.0 sources and a
|
||||
higher version. It builds with plain `cargo build --release`, signs the exe
|
||||
with the same key and publishes a Windows-only `latest.json`. Old clients take
|
||||
it through the GitHub API, Tauri Windows clients through the plugin, and Tauri
|
||||
Linux clients get `TargetNotFound`, which maps to Up to date. The workflow is
|
||||
kept because it is one small file. Phase 10 only lints it and checks its build
|
||||
and signing in a draft dry run; the rollback swap itself is unproven.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- [ ] Every row of the Windows parity checklist (Phase 11) passes on Windows 10 and 11.
|
||||
- [ ] The Phase 10 legacy-client compatibility table is filled in from `git show <tag>:src/update/…` for v0.4.0, v0.4.1 and v0.5.0, every row passes against the dry-run asset list, and the residual risk is recorded.
|
||||
- [ ] `cargo fmt --check`, `cargo test`, `clippy -D warnings`, `npm run lint`, `npm test` and `npm run tauri build -- --no-bundle` are green on `windows-latest`, `ubuntu-22.04` and this host.
|
||||
- [ ] The `release.yml` dry-run draft holds exactly `claude-code-usage-bubble.exe`, its `.sig`, one x86_64 AppImage with `.sig`, one x86_64 `.deb` and a `latest.json` with only `windows-x86_64` and `linux-x86_64`.
|
||||
- [ ] The `macos-latest` `cargo check --all-targets` CI row is green.
|
||||
- [ ] The Linux x86_64 build runs on an X11 desktop, showing the bubble, data, menu, drag and snap.
|
||||
- [ ] No `windows` crate use exists outside `src/platform/windows.rs`.
|
||||
|
||||
## Open questions
|
||||
|
||||
None. Later and undecided work lives in the root `ROADMAP.md`.
|
||||
|
||||
User actions (not questions): generate and store the signing key (Phase 09), and
|
||||
approve the Phase 10 `workflow_dispatch` dry run, which creates (and afterwards
|
||||
deletes) a draft release in this repo.
|
||||
Reference in new issue
Block a user