Compare commits

...
108 Commits
Author SHA1 Message Date
tiennm99 88d39feeb1 docs: add the remaining release steps for the Tauri port 2026-10-21 21:28:18 +07:00
tiennm99 909013789b fix: keep updater plugin debug logs, which include the proxy URL, out of the diagnostic log 2026-10-21 19:38:59 +07:00
tiennm99 65d667b5e1 fix: accept CRLF checkouts when patching the legacy release lockfile version
Git for Windows checks Cargo.lock out with CRLF endings on the windows-latest runner, so the whole-line sed and grep anchors matched nothing and the rollback build stopped at its own check. The patterns now allow a trailing CR and keep it, and the check runs on a CR-stripped copy.
2026-10-21 19:30:07 +07:00
tiennm99 747de03eb2 fix: send update checks and downloads through a SOCKS environment proxy
The updater left an environment proxy to the plugin's reqwest, which was
built without SOCKS support, so a SOCKS ALL_PROXY broke updates and, on
Windows, displaced a working system proxy. Enable reqwest's socks feature
and pass the chosen environment or system proxy to the plugin explicitly.
2026-10-21 19:22:02 +07:00
tiennm99 2e13618837 docs: add the cross-platform framework research and final port review reports 2026-10-21 19:10:54 +07:00
tiennm99 c7ddf71797 docs: plan the usage-monitor rename and sync the Tauri port plan status
The rename is decided, so it moves from ROADMAP.md to its own plan. The port
plan now marks phases whose code is done but await manual checks or user
actions as in-progress, and records the Windows autostart and update-apply
deviations.
2026-10-21 19:10:54 +07:00
tiennm99 1afb0f6981 ci: keep checkout tokens off disk in release builds and patch the legacy lockfile instead of re-resolving it
The release build jobs run npm install scripts, so their checkouts no longer persist the token in .git/config. The legacy rollback sets the new version on the package's own Cargo.lock entry instead of running cargo update, so the --locked build resolves nothing and fails if the lockfile differs in anything else.
2026-10-21 19:02:49 +07:00
tiennm99 2c1d10c47f fix: route SOCKS environment proxies through ureq's socks-proxy support
Clash and v2ray set ALL_PROXY=socks5h://..., which ureq reads before
HTTPS_PROXY. Enable the socks-proxy feature so that entry is used instead
of skipped, and cover a socks5h tunnel with a fake SOCKS5 server.
2026-10-21 18:50:57 +07:00
tiennm99 6407e49499 docs: say only the Tauri release workflow checks the updater public key 2026-10-21 18:10:50 +07:00
tiennm99 fa889c176a docs: pin the release dry run to a branch carrying the Tauri workflow 2026-10-21 18:00:29 +07:00
tiennm99 08e61e6755 docs: refresh Cargo.lock in the release dry-run steps 2026-10-21 17:52:56 +07:00
tiennm99 13fae90fd8 docs: record the cleanup and docs status and the open parity checks 2026-10-21 17:45:00 +07:00
tiennm99 2671d5d89e docs: cover the version source, signing-key custody, dry run and legacy rollback in the release process 2026-10-21 17:45:00 +07:00
tiennm99 37aa3cd2be docs: document Linux support, the WebView2 requirement and the Tauri build in the README 2026-10-21 17:45:00 +07:00
tiennm99 fdf30ac005 docs: describe the credentials path for every OS in the local credentials reader 2026-10-21 17:45:00 +07:00
tiennm99 9a9b01ff07 ci: scope the updater signing key to signing steps and split the legacy publish job
The signing key moves from job-level to step-level env, so npm install scripts, setup actions and crate build scripts never see it. Linux compiles without the key and signs in a separate tauri bundle step. The legacy rollback builds and signs under a read-only, unpersisted token with the locked Tauri CLI, and a separate job holds the write token to publish.
2026-10-21 17:29:56 +07:00
tiennm99 81f29f2e4f ci: publish only plain vX.Y.Z tags so suffixed dry-run tags stay drafts 2026-10-21 17:19:23 +07:00
tiennm99 e712978ff6 docs: record the release pipeline status and legacy-client checks 2026-10-21 17:11:57 +07:00
tiennm99 6c7771e04c docs: describe the release assets, their name contract and the residual update risk 2026-10-21 17:06:21 +07:00
tiennm99 94019a13a3 ci: add a Windows-only legacy release workflow for rollback 2026-10-21 17:06:05 +07:00
tiennm99 435797ad91 ci: release a signed Windows exe, Linux AppImage and deb with latest.json 2026-10-21 17:06:05 +07:00
tiennm99 14bdf95bc0 ci: add a yamllint config for the workflow files 2026-10-21 17:06:04 +07:00
tiennm99 51d20b16b1 build: generate the updater latest.json and guard the release asset set 2026-10-21 16:58:48 +07:00
tiennm99 94722f2741 ci: refuse a release while the updater public key is empty 2026-10-21 16:45:27 +07:00
tiennm99 4918a5e3bd docs: note the green CI run for the updater 2026-10-21 16:35:20 +07:00
tiennm99 2397d48682 docs: record the updater status and decisions 2026-10-21 16:27:07 +07:00
tiennm99 31cca4cdda build: refuse a signed build that lacks the updater public key
With TAURI_SIGNING_PRIVATE_KEY set, the build script now requires a
non-empty plugins.updater.pubkey (from tauri.conf.json or a --config
override). A release without it could never verify, and so never install,
a later update.
2026-10-21 16:24:35 +07:00
tiennm99 d5fe3d8f27 feat: check and apply updates through the Tauri updater plugin
The plugin fetches latest.json and downloads the platform entry, verifying
its minisign signature. The suffix-ignoring version comparison moves to
core::version and drives the plugin comparator; a manifest without an entry
for this platform counts as up to date. The Windows portable exe swaps
itself in with self_replace, an AppImage goes through the plugin install,
and both relaunch with --updated-to. A package-manager install keeps
checking but greys the apply item.

The GitHub API lookup, the WinHTTP download and the --apply-update helper
are gone, along with the sha2 dependency they needed.
2026-10-21 16:24:32 +07:00
tiennm99 43e0d825a7 docs: record the fullscreen auto-hide status and decisions 2026-10-21 16:02:18 +07:00
tiennm99 8d7a07b237 feat: hide the bubbles while another app is fullscreen
A 1.5 s thread asks the platform whether the foreground window is a
fullscreen window of another process and feeds the result through the
visibility state machine. Windows keeps the proven evaluation with an
own-process check in place of the bubble class check; Linux reads the
EWMH active window state over X11; native Wayland and macOS report false.
2026-10-21 15:58:02 +07:00
tiennm99 9c7ade8ea3 fix: reload the panel for browser keys only after its first page load 2026-10-21 15:44:00 +07:00
tiennm99 388305de04 docs: record the details panel status and decisions 2026-10-21 15:34:01 +07:00
tiennm99 2180efaf85 feat: draw the details panel from the payload and suppress webview shortcuts 2026-10-21 15:29:26 +07:00
tiennm99 768fb1e4ab feat: toggle a details panel window from bubble clicks with focus dismissal and live refresh 2026-10-21 15:29:26 +07:00
tiennm99 e805f8de8f fix: keep the dark default on Linux when the portal states no theme preference 2026-10-21 15:12:40 +07:00
tiennm99 1370c15048 fix: size reset bubbles for the primary monitor before placing them 2026-10-21 14:57:42 +07:00
tiennm99 0d35c87d1e docs: record the bubble window and UI status and decisions 2026-10-21 14:44:04 +07:00
tiennm99 094b6bdcb0 feat: draw the bubble from the payload in HTML, CSS and SVG
The stadium pill holds SVG usage and time rings, pill-shaped tail bars and
the payload's texts, laid out in physical px from canvasW, canvasH and dpi
with the GDI bubble's formulas (ui/layout.js, unit-tested). The palette is
CSS custom properties per theme; fills come from Rust. The ring arc, weekly
fill and weekly percent pulse at 95% and above on an 80 ms step, and the
time progress advances every minute.

Only the pill takes pointer input, so the corners are dead. A press that
moves 3 physical px starts an OS drag after arming the move end; an earlier
release is a click. Right-click opens the native menu and Ctrl+wheel resizes.
2026-10-21 14:40:18 +07:00
tiennm99 340464de58 feat: build the bubble windows with snapping, no-activate show and visibility state
Each enabled live provider gets a transparent, undecorated, always-on-top
bubble webview, created hidden at its exact physical size and its saved or
default position, clamped into the work area. It is shown without taking
focus once the page reports ready, or 1.5 s after creation.

On Windows a subclass hook reports move ends, setting changes (theme
recheck and reclamp) and display changes, answers WM_MOUSEACTIVATE with
MA_NOACTIVATE, and bubbles are shown and hidden only through
SW_SHOWNOACTIVATE and SW_HIDE. Elsewhere a 250 ms debounce armed by the
page's drag start ends the drag; on X11 it waits while Button1 is held.
Move end snaps once and saves the position; resize, clamp and reset only
move the windows. Native Wayland skips positioning.

User show/hide goes through a pure visibility state shared with the coming
fullscreen auto-hide, and the saved widget_visible now applies at startup.
A theme change reaches open bubbles. The unused os module is removed.
2026-10-21 14:40:13 +07:00
tiennm99 4da57949ee fix: register the tray click handler app-wide and probe unversioned AppIndicator names
A per-icon tray handler runs while Tauri holds the per-icon listener
table; the inline refresh could build a missing icon that locks the same
table. The app-level handler avoids that. The AppIndicator probe now
matches the tray backend's backcompat names, which some AppImages need,
and keeps the loaded library open.
2026-10-21 14:16:02 +07:00
tiennm99 bee9565c35 docs: keep the menu and alerts phase open until the Windows toast check passes 2026-10-21 14:05:06 +07:00
tiennm99 974d9c1afd fix: build bubbles off event handlers, snapshot tray state on the main thread, keep Windows autostart per user
Building a webview window inside a menu or window event handler deadlocks
on Windows, so provider toggles and the Destroyed recreate now build the
bubble on a short-lived thread, serialised so only one of them creates it.

refresh_shell now takes its state snapshot inside the main-thread closure,
so a refresh queued by the poll worker cannot re-apply a tree older than
one a menu click already applied.

On Windows the start-at-login entry is written through auto-launch in
current-user mode with the quoted exe path, so an elevated run can no
longer write an all-users entry with an unquoted path that a normal run
cannot remove. Linux and macOS keep the autostart plugin.
2026-10-21 14:05:06 +07:00
tiennm99 17d77bde82 docs: record the menu, tray, alerts and autostart status and decisions 2026-10-21 13:46:45 +07:00
tiennm99 f2063bda4f feat: add tray icons, the native context menu, notifications and start at login
Each enabled live provider gets a tray icon with the usage badge, a four-line tooltip and the context menu; a left click toggles the widget. Menus are rebuilt from state after every click so native check marks stay right, and a bubble right-click pops the same menu.

Alerts go through the notification plugin. On Windows the app id is registered under HKCU with a name and icon first, so a portable exe without a Start-menu shortcut can show toasts.

Start at login uses the autostart plugin under the Run value name earlier releases wrote, so an existing entry is picked up as is. On Linux the tray is skipped when no AppIndicator library loads instead of aborting.
2026-10-21 13:45:59 +07:00
tiennm99 539f26019b feat: render the tray badge as straight RGBA for Tauri tray icons 2026-10-21 13:45:49 +07:00
tiennm99 a395c66248 docs: record the app runtime status and decisions 2026-10-21 13:24:58 +07:00
tiennm99 83f60d1c1c feat: run the app on a Tauri runtime with shared state and commands
run() keeps the pre-builder order: diagnostic log, the Windows WebView2 guard
(a message box with the download link, then exit 0), the --wait-pid wait
capped at 15 s, GDK_BACKEND=x11 under Wayland when Xwayland is present, and
the single-instance plugin registered first with a no-op callback.

setup() loads the settings, drops positions on missing monitors, reads the
theme, cleans up after earlier updates, starts the scheduler and polls. The
state sits behind one mutex reached only through with_state, so no Tauri call
runs under the lock. The poll worker keeps the in-flight gate, applies the
results, runs the CLI credential refresh and pushes bubble payloads from the
main thread.

Commands: get_bubble_state, get_strings, log_js and bubble_ready. Bubble
windows ignore close requests; Restart saves the settings and exits only once
the new instance spawned. The scaffold window now renders its payload as JSON.

The Win32 app module is removed.
2026-10-21 13:20:15 +07:00
tiennm99 6d84f33e59 feat: add the scheduler thread for polls, countdowns and update checks
One std thread owns the poll, countdown and update-check deadlines and is fed
over an mpsc channel. The deadline rules are a pure struct tested with
fabricated instants; the thread hands each due action to a host trait.

An update check that fires stays disarmed until the check reports back, and
only that report re-arms the full interval, so the automatic check repeats
instead of running once. Changing the interval checks at once when the last
check is older than the new interval.
2026-10-21 13:19:37 +07:00
tiennm99 607a3103d3 feat: parse the CLI flags, wait for the previous instance and clean up after updates
The argv parser accepts --diagnose, --wait-pid and --updated-to and ignores
anything else, including --apply-update, which no released version passes to
the downloaded binary. Arguments are read lossily, so a non-UTF-8 argument no
longer panics.

The wait for a parent pid polls kill(pid, 0) on Unix and waits on the process
handle on Windows, capped by the caller. The Windows platform module gains the
WebView2 runtime probe, an error message box and the theme read.

handoff::relaunch spawns the AppImage or the current executable detached with
--wait-pid and returns the spawn error to the caller. The startup cleanup of
<exe>.old.* siblings and staged updater executables moves out of the Win32
update module.
2026-10-21 13:19:15 +07:00
tiennm99 4ca4f075f3 docs: fix a duplicated phrase in the data layer proxy notes 2026-10-21 13:00:57 +07:00
tiennm99 b3afcdc04e fix: skip SOCKS environment proxies so an HTTP or system proxy applies
ureq is built without socks-proxy, so an ALL_PROXY=socks5h entry failed
every request and a socks5 entry silently connected directly, while also
suppressing the Windows system proxy. Pick the first HTTP or HTTPS proxy
from the environment ourselves, keep NO_PROXY on it, and fall back to the
system proxy when none is usable.
2026-10-21 13:00:50 +07:00
tiennm99 b8e672f750 fix: give the refresh CLI the search PATH and reject non-HTTP system proxies 2026-10-21 12:52:22 +07:00
tiennm99 91d2741028 docs: record the data layer status and decisions 2026-10-21 12:45:52 +07:00
tiennm99 9e2666ff0e feat: port the data layer to ureq with the Windows system proxy
Replace the WinHTTP client with a ureq agent that keeps the same
Client/Response surface: status errors come back as responses, gzip,
60 s connect / 30 s receive / 120 s total timeouts, rustls with the
platform verifier and a per-request User-Agent override.

Environment proxies come from ureq. On Windows, when none is set, the
static Internet Settings proxy is read from HKCU and parsed by a pure
core module (per-scheme ProxyServer, ProxyOverride globs and <local>);
bypassed hosts go through a direct agent. PAC/WPAD is not evaluated.

The usage providers and credential sources compile again on every
platform. Refresh keeps the Windows candidates and CREATE_NO_WINDOW;
Unix spawns claude/codex from PATH plus the usual per-user install
directories and reaps the child on a detached thread. An empty
CODEX_HOME now counts as unset and a float expiresAt is truncated
instead of ignored.

Fixture tests cover the OAuth usage body, rate-limit headers (including
a 429 over loopback), the ChatGPT envelope, ISO 8601 edge cases,
credential JSON and proxy strings. An ignored live_poll test polls the
real providers.
2026-10-21 12:41:08 +07:00
tiennm99 10c43d76af docs: record the portable core status and decisions 2026-10-21 12:24:08 +07:00
tiennm99 76ee0203cb feat: add the portable state, payload and menu model
Poll results, threshold crossings and the alert cooldown now return
values instead of calling the tray, so they run under unit tests. The
bubble and panel payloads carry every display string and colour from
Rust, and the context menu is a plain tree with string ids that parse
back into commands. The update-check schedule and countdown delay are
plain functions of the stored times.
2026-10-21 12:23:53 +07:00
tiennm99 bc1a098875 refactor: move settings, strings, colours and geometry into a portable core
Settings, the English strings, the colour ramp and the bubble geometry no
longer touch Win32 types, so they compile and test on every platform.
Snapping, clamping, default placement, panel placement and the monitor
probe take plain rects; settings loading is split into parsing and an
offscreen check fed with the monitor list. ProviderId now serialises as
its slug.
2026-10-21 12:23:15 +07:00
tiennm99 d8f339208d fix: list the desktop icons in bundle.icon
The bundler has no icon fallback, so an empty list breaks the AppImage
step and ships a deb without hicolor icons. Put icon.png first so it stays
the default window icon on Linux and macOS, and bundle the deb in the
Linux CI row to catch a broken list before release.
2026-10-21 12:04:54 +07:00
tiennm99 ffaa6c17a9 docs: record the Tauri scaffold status and decisions 2026-10-21 11:56:30 +07:00
tiennm99 4e70aa1d22 ci: build and check on Windows, Linux and macOS
Windows and Ubuntu 22.04 run fmt, clippy, tests, lint, the JS tests and
a no-bundle release build. The Windows row checks the exe version
resource and uploads the exe as a 7-day artifact. macOS runs cargo check
as the compile gate.
2026-10-21 11:45:20 +07:00
tiennm99 2adbeeb613 build: add the npm toolchain with ESLint, JSDoc and node --test
@tauri-apps/cli is a local devDependency. ESLint runs the recommended
and JSDoc rules, with browser globals for ui/ and node globals for tests
and scripts. npm test runs node --test over tests/js.
2026-10-21 11:45:20 +07:00
tiennm99 f64d81e6a1 build: scaffold the Tauri v2 crate
Turn the package into a lib plus thin bin on Tauri v2 with a root-level
tauri.conf.json, capabilities, generated icons (keeping the hand-tuned
icon.ico) and a placeholder bubble page. The Win32 modules leave the
module tree until each one is ported; their sources stay on disk.

tauri-build now embeds the icon, a VERSIONINFO from the Cargo version and
a PerMonitorV2 manifest, replacing res/icon.rc and its stale 0.1.0.
2026-10-21 11:45:14 +07:00
tiennm99 bb09765be5 docs: plan Tauri v2 cross-platform port 2026-10-21 11:28:47 +07:00
tiennm99 bfcf3b56cd chore: remove implemented plan docs 2026-06-03 09:31:46 +07:00
tiennm99 4022d3f2de feat: drop WSL and multi-language support 2026-06-02 16:32:21 +07:00
tiennm99 67f3b8a7ec chore: bump version to 0.4.1 2026-06-02 15:52:03 +07:00
tiennm99 c46ab57f73 fix(update): use self-replace for portable updates 2026-06-02 15:50:45 +07:00
tiennm99 0ac698e2d1 chore: bump version to 0.4.0 2026-06-02 15:11:26 +07:00
tiennm99 2c67dd1217 feat(providers): prepare provider terminology and metadata 2026-06-02 15:09:50 +07:00
tiennm99 38752ae5c5 chore: bump version to 0.3.13 2026-06-02 11:09:35 +07:00
tiennm99 e099f0a91f fix(update): restore helper-based self-update 2026-06-02 10:27:31 +07:00
tiennm99 4e9a66cbc8 chore: bump version to 0.3.12 2026-06-01 16:55:59 +07:00
tiennm99 1d9c59b43b chore: bump version to 0.3.11 2026-06-01 16:10:43 +07:00
tiennm99 7039bf7e98 fix: pass updater executable path to CreateProcess 2026-06-01 16:09:28 +07:00
tiennm99 cc73c3f9b4 chore: bump version to 0.3.10 2026-06-01 15:36:26 +07:00
tiennm99 a68d80415b chore: bump version to 0.3.9 2026-06-01 15:05:35 +07:00
tiennm99 cce22cc3b7 fix: run self-update through detached helper 2026-06-01 15:04:16 +07:00
tiennm99 5e00009e5a chore: bump version to 0.3.8 2026-06-01 14:37:26 +07:00
tiennm99 c91dc996f8 fix: tighten fullscreen auto-hide detection 2026-06-01 14:35:26 +07:00
tiennm99 b2e48cc119 chore: bump version to 0.3.7 2026-05-25 11:37:34 +07:00
tiennm99 bee9f3cfa0 fix(ui): use theme-aware monochrome accent for Codex 2026-05-25 11:37:29 +07:00
tiennm99 860eb62d1b chore: bump version to 0.3.6 2026-05-25 11:15:13 +07:00
tiennm99 c424b17bd7 fix(ui): reverse remaining-time direction to read like a clock
Inner ring's consumed wedge now grows clockwise from 12 o'clock (mirroring
a clock-hand countdown) and the tail remaining-time bar shrinks toward the
right edge instead of the left. Usage ring and weekly usage bar unchanged.
2026-05-25 11:15:07 +07:00
tiennm99 3b0f96f51c chore: bump version to 0.3.5 2026-05-23 23:36:41 +07:00
tiennm99 5976181cb2 fix(ui): improve bubble visual hierarchy and contrast
- Lift inner time ring above WCAG 1.4.11 (track #303030 -> #404040,
  stroke floor 1 -> 2 logical, ring gap 3 -> 4).
- Breathe tail bar/text with bar_text_gap=8 (was pad=6); right inset
  12 -> 14 logical so text clears the stadium end-cap.
- Reweight typography: head percent FW_BOLD, "5H" tag and tail percent
  FW_SEMIBOLD, tail countdown stays normal but takes muted color so
  the percent reads as the headline.
- Tone down usage track (#3A3A3A/#D6D6D6 -> #2C2C2C/#E2E2E2) so fill
  dominates at low percentages.
- Differentiate lane mass: usage bar 9%/5..12 -> 10%/6..12, time bar
  5%/3..7 -> 4%/3..6, lane gap 5 -> 6. Time bar now reads as supporting
  context, not a competing quota.
- Min-fill guard on weekly bar: sub-cap fills floor at one cap-diameter
  so 1% renders as a recognizable dot.
- head_pad 4 -> 5; big-font ratio 26% -> 24% of head diameter (BOLD
  compensates for the size cut).
2026-05-23 23:36:34 +07:00
tiennm99 f2b31d3211 fix(ui): align tail bar text layout 2026-05-23 22:53:36 +07:00
tiennm99 5c2b14fc03 feat(ui): show remaining time progress 2026-05-23 22:31:37 +07:00
tiennm99 51889d3c39 fix(ui): scale weekly bar thickness 2026-05-23 21:43:11 +07:00
tiennm99 6661a7a10b chore: bump version to 0.3.1 2026-05-23 21:07:05 +07:00
tiennm99 6cafffc883 fix(bubble): show 7d percent at default size and 125% DPI
v0.3.0 introduced a tail 7d% reading but the layout-collapse guard
fired at every common bubble configuration — at the default 200-logical
size on both 100% and 125% DPI, after reserving the CJK worst-case
countdown column ("999시간") and the "100%" text rect, the tail had
less than 20 logical of bar room left. The guard collapsed the % rect
to zero width and the paint code's `if rect.right > rect.left` skip
ran on every frame, so the feature was effectively dead on arrival
for the majority of users.

The 20-logical bar minimum was the pre-feature bar floor, used to
guarantee a readable bar at very small bubble sizes. It does not need
to apply when the % is shown — the % is the actual data and the bar
becomes secondary visual context. Split into two thresholds:

- `bar_min_with_pct = 8 logical` decides whether the % can fit. With
  8 logical of bar room the bar still renders as a short pill.
- `bar_min = 20 logical` only applies on the fallback (140-logical
  minimum bubble) path where the % has been dropped — preserving
  the pre-feature readable-bar behavior at the smallest size.

The bar's render floor now follows the active path (`bar_render_min`)
so a thin bar in the pct-active case does not overlap the countdown.
2026-05-23 21:06:26 +07:00
tiennm99 b58811bfe0 chore: bump version to 0.3.0 2026-05-23 20:33:10 +07:00
tiennm99 e50aa3522a feat(bubble): show 5h countdown in head and 7d percent in tail
The stadium bubble previously dropped the 5h reset countdown (only the
ring + percent were visible in the head) and never showed the 7d percent
as a number (only the tail bar fill suggested it). Two more glanceable
data points now live on the bubble face without reopening the panel.

Head: the small "5h" tag is replaced by the live 5h countdown (e.g.
"2h14m"). Falls back to the literal "5h" when no countdown is available
yet (cold start) or when the localized string would overflow the rect —
DT_NOCLIP would otherwise leak wide CJK glyphs ("4시간 32분") onto the
ring stroke at the 140-logical minimum width.

Tail: a new "X%" reading sits between the "7d" label and the bar
(layout reads "7d  62%  ▰▰▰▰▰▱▱▱   6d4h"). Foreground text color —
not the bar accent — because Codex teal #10A37F on the light theme
background only hits ~3.2:1 contrast, below WCAG AA for small text;
adjacency to the bar carries the visual grouping without hue. The text
brightens in sync with the bar fill when weekly_pct >= 95%.

compute_bubble_layout reserves room for a "100%"-sized rect between
label and bar; if that would push the bar below the 20-logical
minimum, the % rect collapses to zero width and the layout falls back
to the original label→bar→countdown geometry, so the 140-logical
bubble keeps its bar.

No new graphics dependencies; tiny-skia + GDI hybrid render path
unchanged. session_text plumbing in src/app.rs was already wired but
unused in the render — now consumed.

cargo check: clean. cargo test: 2/2. cargo clippy: 13 warnings
(unchanged baseline).
2026-05-23 20:32:31 +07:00
tiennm99 a3f1323154 chore: bump version to 0.2.0 2026-05-23 18:18:34 +07:00
tiennm99 081a70a537 feat(ui): improve bubble controls discoverability 2026-05-23 18:17:25 +07:00
tiennm99 391ad0cba2 chore: bump version to 0.1.15 2026-05-23 12:54:46 +07:00
tiennm99 77325b1e00 fix(bubble): restore alpha after GDI text so glyphs aren't transparent
User report on v0.1.14: text appears semi-transparent, desktop wallpaper
bleeds through glyph pixels.

Root cause: GDI's DrawTextW writes only RGB into 32bpp BI_RGB DIBs — the
"reserved" alpha byte (byte 3) is not preserved per the BITMAPINFOHEADER
contract. When UpdateLayeredWindow later composites with AC_SRC_ALPHA, it
reads alpha=0 at every glyph pixel and shows them as fully transparent.

The pre-v0.1.13 pipeline worked around this with an apply_alpha_mask
post-pass that OR'd 0xFF000000 into every pixel inside the rounded rect.
The stadium-shape rewrite (526786b) removed it on the false assumption
that tiny-skia's per-pixel alpha would "stick" through subsequent GDI
writes — but GDI runs *after* tiny-skia in the pipeline, so any pixel
GDI text writes to loses the alpha that tiny-skia set.

Fix: re-stamp the alpha channel from the original Pixmap after the GDI
text overlay. This restores tiny-skia's exact alpha values (255 in the
stadium interior, partial on the AA curved perimeter, 0 outside),
including the AA fade at the stadium's rounded ends.

Implementation:
- new helper `restore_alpha_from_pixmap(pixmap, dst)` next to the
  existing `copy_pixmap_to_dib`
- hoist `pixmap` out of the if-let arm in render() so it survives until
  after `paint_bubble_text`
- call `restore_alpha_from_pixmap` post-text

Two parallel reviewers (debugger + code-reviewer) converged on the same
diagnosis; the debugger preferred this approach for its simplicity and
because it's robust to any GDI behavior (whether alpha is zeroed,
untouched, or scribbled on, we overwrite with the known-good value).

Build clean.
2026-05-23 12:54:13 +07:00
tiennm99 c3d01f36d2 chore: bump version to 0.1.14 2026-05-23 12:30:52 +07:00
tiennm99 7bbf80e5f7 fix(bubble): tune head proportions — smaller percent glyph, more breathing room
User feedback on v0.1.13: design works, but the 5h percent glyph in the
head crowds the ring at small bubble sizes (the "100%" string was wider
than the ring's inner clear at MIN_BUBBLE_SIZE).

Two parallel UI/UX reviewers converged on:

- big_font_px ratio:   head_diameter × 26/100 (was 35/100), floor 11
- small_font_px ratio: big × 55/100         (was 45/100), floor 9
- head_pad:            4 logical px         (was 6) — recovers 4px
                       of inner clear at small sizes
- ring_stroke_w:       clamped to [2, 4]    (was floor 2 only)
- label/glyph gap:     big × 15/100, floor 2 (was implicit 0)
- tail_bar_h:          5 logical px         (was 6) — restores
                       proportion against the 3-px head ring stroke

Worked example at MIN_BUBBLE_SIZE=140 (head_diameter=47):
  before: "100%" glyph ≈ 32px wide vs 28px ring inner — overflow
  after:  "100%" glyph ≈ 23px wide vs 32px ring inner — comfortable

Worked example at MAX_BUBBLE_SIZE=360 (head_diameter=138):
  glyph ≈ 70px wide in 124px inner clear (~57%) — confident not crowding

Deliberately not applying:
- drop "7d" label (one reviewer wanted it): rejected — symmetry with
  "5h" matters for self-explanation at a glance, and the ~14px cost is
  acceptable
- head_diameter bump to canvas_h × 1.08: rejected — only useful coupled
  with the label drop

Build clean.
2026-05-23 12:30:18 +07:00
tiennm99 8cbc3dda5b chore: bump version to 0.1.13 2026-05-23 12:09:36 +07:00
tiennm99 526786b902 feat(bubble): new stadium shape with ring head + tail bar
Phase 2 lite. Replaces the horizontal pill (two stacked progress bars)
with a stadium-shaped bubble: a circle "head" on the left showing the
5h percentage as a big glyph surrounded by a stroked progress ring,
plus a "tail" extending right with the 7d label, a thin progress bar,
and the 7d countdown.

The bubble's primary metric (5h window) is now glanceable from across
the room — a thick ring sweeping around a big number reads at a much
greater distance than two thin horizontal bars. The 7d window remains
visible as supporting context. The expanded panel (left-click) still
shows both windows in full.

Implementation notes:
- Hybrid render: tiny-skia (already a Cargo dep for tray badge) paints
  the AA shape into a Pixmap. The pixmap is copied byte-for-byte into
  the 32bpp BI_RGB DIB; GDI overlays ClearType text on top;
  UpdateLayeredWindow blits with per-pixel alpha as before.
- Stadium outline: corner_radius = height/2 so point_in_rounded_rect
  exactly approximates the capsule shape for hit-test.
- Pulse animation on ≥95% applies to both the ring sweep (5h) and the
  tail bar fill (7d) independently.
- Codex teal #10A37F and Claude orange #D97757 carry across the ring,
  the tail bar, and the tray badge sweep via crate::usage_color.

Removed (dead after pipeline swap):
- per-pixel paint_background / paint_accent_stripe / paint_bars /
  paint_one_bar / apply_alpha_mask / row_band / rgb_to_dib / blend
- BarLayout struct + compute_layout
- old paint_text_layer / draw_label / draw_percent / draw_countdown
- Breakpoint struct + breakpoint_for_width_logical (font sizes now
  derive from head_diameter directly)
- luminance / use_dark_text_over (text was over bar fills; new tail
  bar carries no overlaid text)
- constants ACCENT_STRIPE_W_LOGICAL, LABEL_PAD_LOGICAL,
  PERCENT_TEMPLATE

Build: cargo build --release clean. Clippy 13 warnings (was 11); the
2 new ones are field-assign-after-Default::default() on tiny-skia
Stroke setup, matching the existing pattern in src/tray/badge.rs.

Known follow-up: BubbleState.session_text + BubbleConfig.session_text
plumbing is now unused (head shows percent only, no 5h countdown on
the bubble). Removing it is a multi-file chain through app.rs and
panel.rs; deferred.
2026-05-23 12:09:03 +07:00
tiennm99 f96d88074a chore: bump version to 0.1.12 2026-05-23 11:23:14 +07:00
tiennm99 e243c589a8 refactor: fix latent bugs, invert bubble→app deps, unify color path
Whole-project review pass over the entire crate. No new features.
All function definitions preserved; layout and visibility reorganised.

Bug fixes:
- bubble: ExtractIconExW HICON pair was leaked per bubble toggle.
  Extract once into a process-wide OnceLock, reuse forever (bounded).
- usage/anthropic: parse_iso8601 was stripping the UTC offset without
  applying it — negative-offset users saw countdowns up to 14h wrong.
  Now parses signed minutes and computes utc_secs = local - off*60.
  Also rejects y<1970, mo∉[1,12], d∉[1,max_day(mo,y)] up front so
  malformed API responses can't index DAYS_IN_MONTH out of bounds.
- usage: clamp utilization to [0,100] at all four Window construction
  sites so a misbehaving server can't render "121%".
- bubble: GetDC and CreateCompatibleDC results weren't checked. Guard
  both; release the screen DC on the CreateCompatibleDC failure path.

Refactor:
- Drop type TrayIconKind = ProviderId aliases (5 sites); use ProviderId
  directly everywhere. Inline the identity-function kind_to_provider.
- Delete panel::bar_color_for shim (was just argument-reorder glue).
- Replace local scale_to_dpi fns in bubble.rs and panel.rs with
  use crate::os::dpi::scale as scale_to_dpi (brings os::dpi into the
  live import graph; was unused before).
- Delete dead PCWSTR import + #[allow(dead_code)] sentinel in
  tray/badge.rs; fold the trailing `use BOOL` into the top imports.
- Inline app::primary_dpi() to crate::os::dpi::for_system().

app.rs:
- Add update_settings(|s: &mut AppState|) helper that locks state,
  runs the closure, snapshots Settings, drops the lock, then saves
  to disk. Convert four pure-mutate-then-save callsites.

Layering: bubble.rs no longer reaches upward into crate::app::.
Introduce bubble::Callbacks (fn-pointers), OnceLock<Callbacks>, and
bubble::install_callbacks(). The wnd_proc dispatches the six prior
upward calls via a private dispatch() helper. app::run installs
callbacks once at startup; the six on_bubble_* / recheck_theme fns
are demoted from pub fn to fn.

Resource-warning logs added: dispatch() warns on uninstalled
callbacks; app_icons() warns when ExtractIconExW returns nulls.

Build: cargo build --release clean; cargo clippy reports zero new
warnings (11 pre-existing, all in untouched code).
2026-05-23 11:22:40 +07:00
tiennm99 a5dec52aaa feat(ui): unify usage colors, fix per-bar coloring, round panel corners
Phase 0 of UI/UX polish pass. Surgical changes, no substrate migration yet.

- Extract bar_fill_color + accent_color_for into new src/usage_color.rs so
  the bubble, panel, and tray badge agree on a single 4-band usage ramp.
- Panel: color each bar from its own percent (was using max(5h, 7d) for
  both rows, so a healthy 5h bar turned red whenever 7d was full).
- Light-mode amber #B47A20 (was #E0A040, failed WCAG AA at 2.4:1).
- Codex identity: switch from white/charcoal to OpenAI teal #10A37F
  across bubble, panel stripe, and tray sweep so the surfaces share one
  brand color and the tray badge stops reading as "loading spinner".
- Panel: drop WS_BORDER, add DwmSetWindowAttribute(DWMWCP_ROUND) for
  Win11 rounded corners. Idempotent re-apply on every show() so the
  attribute survives any future destroy/recreate path. Silently no-ops
  on Win10.
2026-05-23 09:18:42 +07:00
tiennm99 4e0f32591b chore: bump version to 0.1.11 2026-05-21 16:51:46 +07:00
tiennm99 3e1af07ec2 feat(i18n): switch supported languages to en/ja/ko/vi/zh-TW
Drop nl/es/fr/de locales (no native-speaker maintenance) and add
Vietnamese. The supported set is now the languages with active
users we can support: English, Japanese, Korean, Vietnamese, and
Traditional Chinese.
2026-05-21 16:51:15 +07:00
tiennm99 27aa935a9b chore: bump version to 0.1.10 2026-05-21 16:29:05 +07:00
tiennm99 1cd5b778f4 feat(update): replace cmd.exe handoff with native Win32 spawn
Both the in-app restart and the auto-update install previously
shelled out to cmd.exe so the new instance could wait for the old
one to release the singleton mutex and the locked exe file. On
some Windows configurations the `start ""` inside `cmd /c ...` can
flash a console window despite CREATE_NO_WINDOW + DETACHED_PROCESS
flags. The replacement spawns the child binary directly via
CreateProcessW; since the main exe is built with
windows_subsystem = "windows", no console is ever allocated.

- New `src/update/handoff.rs` exposes `spawn_detached`,
  `wait_for_parent_exit`, and `cleanup_stale_old_exes`.
- New CLI flags `--wait-pid <pid>` and `--updated-to <version>`
  parsed early in `main`; the child waits up to 5s on the parent
  PID via OpenProcess+WaitForSingleObject before falling through
  to a 3s mutex-acquisition retry.
- `restart_app` and `install::begin` both spawn detached children
  using the new helper.
- Update install now uses MoveFileExW twice (rename running exe
  sideways, then move staged exe into place with
  MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED so portable
  installs on non-system drives still work). Rollback restores
  the backup if either the swap OR the post-swap detached spawn
  fails, and a MessageBoxW modal surfaces the backup path if the
  rollback itself fails.
- First launch after an auto-update shows a blue-info tray
  balloon "Updated to vX.Y.Z" via a new `tray::notify_info` (the
  existing `tray::notify` is split into `notify_warning` +
  `notify_info` sharing a `notify_inner`).
- Startup sweeps stale `<exe>.old.<pid>` siblings left by past
  in-place updates.
- Three new `LocaleStrings` fields translated across all 8
  supported locales (en/nl/es/fr/de/ja/ko/zh-TW).
2026-05-21 16:28:25 +07:00
tiennm99 1ba2883989 ci: switch back to windows-latest
Prefer staying on the floating tag — accept GitHub's auto-redirect to
windows-2025-vs2026 in mid-2026 rather than pin and chase.
2026-05-18 13:23:37 +07:00
169 changed files with 24794 additions and 9823 deletions

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
name: CI
on:
push:
branches: [main, feat/tauri-port]
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-22.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v7
with:
node-version: lts/*
cache: npm
- name: Install Linux build prerequisites
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
- run: npm ci
- run: cargo fmt --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
- run: npm run lint
- run: npm test
- run: npm run tauri build -- --no-bundle
# The bundler has no icon fallback, so a bad bundle.icon list only
# breaks packaging; the deb needs no patchelf and catches it early.
- name: Bundle the deb
if: runner.os == 'Linux'
run: npm run tauri build -- --bundles deb
- name: Check the exe version resource
if: runner.os == 'Windows'
shell: pwsh
run: |
$match = Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1
$cargoVersion = $match.Matches.Groups[1].Value
$info = (Get-Item target/release/claude-code-usage-bubble.exe).VersionInfo
$info | Format-List ProductName, CompanyName, FileDescription, FileVersion, ProductVersion, LegalCopyright
if ($info.ProductVersion -ne $cargoVersion) {
Write-Error "ProductVersion '$($info.ProductVersion)' does not match Cargo.toml version '$cargoVersion'"
exit 1
}
- name: Upload the Windows exe
if: runner.os == 'Windows'
uses: actions/upload-artifact@v7
with:
name: bubble-exe-${{ github.sha }}
path: target/release/claude-code-usage-bubble.exe
retention-days: 7
if-no-files-found: error
macos-check:
name: macos-latest (cargo check)
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo check --all-targets --locked
+173
View File
@@ -0,0 +1,173 @@
name: Release legacy
# Rollback for a broken Tauri release: rebuild the Win32 sources at `ref`
# under a higher version and publish them as a Windows-only release. Legacy
# clients take the exe through the GitHub API, Tauri Windows clients through
# the signed `latest.json`, and Tauri Linux clients find no entry for their
# platform, which they report as up to date. The Win32 app accepts the
# `--wait-pid <pid> --updated-to X` relaunch the Tauri updater uses.
#
# The build job holds a read-only token that is never written to disk, and
# only the steps that check or use the updater signing key see it. The write
# token lives in the publish job, which builds nothing.
on:
workflow_dispatch:
inputs:
ref:
description: 'Git ref holding the Win32 sources (e.g. v0.5.0)'
required: true
version:
description: 'Version to publish as X.Y.Z, higher than every release in the field'
required: true
tag:
description: 'Release tag, v<version>'
required: true
draft:
description: 'Publish as a draft'
type: boolean
default: true
permissions:
contents: read
jobs:
win-x64:
name: Windows x64 (legacy)
runs-on: windows-latest
outputs:
sha: ${{ steps.sha.outputs.sha }}
env:
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
defaults:
run:
shell: bash
steps:
- name: Check the inputs and the signing key
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::version '$VERSION' is not X.Y.Z."
exit 1
fi
if [ "$TAG" != "v$VERSION" ]; then
echo "::error::tag '$TAG' must be v$VERSION."
exit 1
fi
if [ -z "${TAURI_SIGNING_PRIVATE_KEY//[[:space:]]/}" ]; then
echo "::error::The TAURI_SIGNING_PRIVATE_KEY secret is empty; an unsigned build cannot be released."
exit 1
fi
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
persist-credentials: false
# The legacy ref predates package.json and the release script, so take
# both from the default branch.
- uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
path: current
persist-credentials: false
- name: Record the commit the release points at
id: sha
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v7
with:
node-version: lts/*
cache: npm
cache-dependency-path: current/package-lock.json
# The Tauri CLI that signs the exe comes from the reviewed lockfile,
# not from a version resolved at run time.
- run: npm ci --prefix current
# Only the package's own version changes, in Cargo.toml and its
# Cargo.lock entry, so the `--locked` build below resolves nothing and
# fails if the lockfile differs in anything else. Git for Windows checks
# the files out with CRLF endings, so the patterns accept a trailing CR
# and keep it.
- name: Set the Cargo.toml and Cargo.lock version
run: |
sed -i -E '0,/^version[[:space:]]*=/s/^version[[:space:]]*=[[:space:]]*"[^"]*"/version = "'"$VERSION"'"/' Cargo.toml
actual=$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' Cargo.toml | head -n 1)
if [ "$actual" != "$VERSION" ]; then
echo "::error::Cargo.toml version is '$actual' after patching, expected '$VERSION'."
exit 1
fi
sed -i -E '/^name = "claude-code-usage-bubble"\r?$/{n;s/^version = "[^"]*"(\r?)$/version = "'"$VERSION"'"\1/}' Cargo.lock
if ! tr -d '\r' < Cargo.lock | grep -A1 -x 'name = "claude-code-usage-bubble"' | grep -qx "version = \"$VERSION\""; then
echo "::error::Cargo.lock has no claude-code-usage-bubble entry at version '$VERSION' after patching."
exit 1
fi
- run: cargo build --release --locked
# `--no` refuses to download a CLI that the lockfile did not install.
- name: Sign the exe
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
exe=target/release/claude-code-usage-bubble.exe
npm exec --prefix current --no -- tauri signer sign --app-version "$VERSION" "$exe"
test -s "$exe.sig"
- uses: actions/upload-artifact@v7
with:
name: release-legacy-win-x64
path: |
target/release/claude-code-usage-bubble.exe
target/release/claude-code-usage-bubble.exe.sig
retention-days: 7
if-no-files-found: error
publish:
name: Publish
needs: win-x64
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
TARGET: ${{ needs.win-x64.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: lts/*
- uses: actions/download-artifact@v8
with:
name: release-legacy-win-x64
path: dist
- name: Write the Windows-only latest.json and check the asset set
run: node scripts/make-latest-json.js --windows-only "$TAG" dist
- name: Create the release
env:
DRAFT: ${{ inputs.draft }}
run: |
if [ "$DRAFT" = true ]; then
mode=(--draft)
else
mode=(--latest)
fi
gh release create "$TAG" dist/* --repo "$GITHUB_REPOSITORY" --target "$TARGET" \
--title "$TAG" --generate-notes "${mode[@]}"
+242 -42
View File
@@ -1,65 +1,265 @@
name: Release
# A pushed plain vX.Y.Z tag publishes the release that every client updates
# from: legacy Win32 clients (v0.5.0 and earlier) through the GitHub API's
# `/releases/latest`, Tauri clients through its `latest.json` asset. A
# dispatch run builds the same assets into a draft, which neither reaches.
# Suffixed tags (vX.Y.Z-suffix) are dispatch-only: both client comparators
# ignore the suffix, so publishing one would ship an rc as a stable update
# and hide the final vX.Y.Z behind it.
on:
push:
tags: ['v*.*.*']
tags: ['v[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (must already exist, e.g. v0.1.1)'
description: 'Existing tag to build into a draft release (e.g. v0.0.0-test)'
required: true
permissions:
contents: write
contents: read
jobs:
build:
# Pinned explicitly: `windows-latest` is being redirected to a newer image
# by GitHub in mid-2026 — pin the current Windows Server 2025 image so
# release behavior is deterministic across that transition.
runs-on: windows-2025
prepare:
name: Check the tag and the updater key
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
version: ${{ steps.tag.outputs.version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.tag || github.ref }}
ref: ${{ inputs.tag || github.ref }}
persist-credentials: false
- name: Verify Cargo.toml version matches tag
id: tag
env:
INPUT_TAG: ${{ inputs.tag }}
run: |
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
tag="${GITHUB_REF#refs/tags/}"
else
tag="$INPUT_TAG"
fi
if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Tag '$tag' is not of the form vX.Y.Z or vX.Y.Z-suffix."
exit 1
fi
if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ] && [[ "$tag" == *-* ]]; then
echo "::error::Tag '$tag' has a suffix; suffixed tags are for draft dispatch runs only and are never published."
exit 1
fi
expected="${tag#v}"
cargo_version=$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' Cargo.toml | head -n 1)
if [ "$cargo_version" != "$expected" ]; then
echo "::error::Tag ($tag -> $expected) does not match Cargo.toml version ($cargo_version). Bump Cargo.toml before tagging."
exit 1
fi
echo "Cargo version $cargo_version matches tag $tag"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$expected" >> "$GITHUB_OUTPUT"
# An exe without the updater public key can never verify, and so never
# install, a later update; every install of it would be stranded.
# build.rs checks this only for a build that has the signing key in its
# environment, so check it here, before any build runs.
- name: Refuse an empty updater public key
run: |
pubkey=$(jq -r '.plugins.updater.pubkey // ""' tauri.conf.json)
if [ -z "${pubkey//[[:space:]]/}" ]; then
echo "::error::plugins.updater.pubkey in tauri.conf.json is empty: put the updater public key there before releasing."
exit 1
fi
win-x64:
name: Windows x64
needs: prepare
runs-on: windows-latest
# The updater signing key decides what every installed client will
# install, so only the steps that check or use it see it: never
# `npm ci` install scripts, the setup actions or crate build scripts.
steps:
- name: Refuse a missing signing key
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY//[[:space:]]/}" ]; then
echo "::error::The TAURI_SIGNING_PRIVATE_KEY secret is empty; an unsigned build cannot be released."
exit 1
fi
- uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.tag }}
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Resolve tag
id: tag
shell: pwsh
run: |
$tag = if ($env:GITHUB_REF -like 'refs/tags/*') {
$env:GITHUB_REF -replace '^refs/tags/',''
} else {
'${{ github.event.inputs.tag }}'
}
"tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- uses: actions/setup-node@v7
with:
node-version: lts/*
cache: npm
- name: Verify Cargo.toml version matches tag
shell: pwsh
run: |
$tag = '${{ steps.tag.outputs.tag }}'
$expected = $tag -replace '^v',''
$cargoVersion = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1).Matches.Groups[1].Value
if ($cargoVersion -ne $expected) {
Write-Error "Tag ($tag -> $expected) does not match Cargo.toml version ($cargoVersion). Bump Cargo.toml before tagging."
exit 1
}
Write-Host "Cargo version $cargoVersion matches tag $tag"
- run: npm ci
- name: Build release
run: cargo build --release --locked
# The Windows release is the portable exe; no installer is built.
- run: npm run tauri build -- --no-bundle -- --locked
- name: Create release
# `signer sign` writes a separate .sig and leaves the exe bytes as
# built, so the digest GitHub reports for the uploaded exe, which
# legacy clients verify, is the digest of the file they run.
- name: Sign the exe
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
VERSION: ${{ needs.prepare.outputs.version }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
$tag = '${{ steps.tag.outputs.tag }}'
$asset = 'target/release/claude-code-usage-bubble.exe'
if ('${{ github.event_name }}' -eq 'workflow_dispatch') {
gh release create $tag $asset --title $tag --generate-notes --draft
} else {
gh release create $tag $asset --title $tag --generate-notes
exe=target/release/claude-code-usage-bubble.exe
npx tauri signer sign --app-version "$VERSION" "$exe"
test -s "$exe.sig"
- uses: actions/upload-artifact@v7
with:
name: release-win-x64
path: |
target/release/claude-code-usage-bubble.exe
target/release/claude-code-usage-bubble.exe.sig
retention-days: 7
if-no-files-found: error
linux-x64:
name: Linux x64
needs: prepare
runs-on: ubuntu-22.04
# As on Windows, only the steps that check or use the signing key see it.
steps:
- name: Refuse a missing signing key
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY//[[:space:]]/}" ]; then
echo "::error::The TAURI_SIGNING_PRIVATE_KEY secret is empty; an unsigned build cannot be released."
exit 1
fi
- uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.tag }}
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v7
with:
node-version: lts/*
cache: npm
- name: Install Linux build prerequisites
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- run: npm ci
# Compile without the key, then bundle and sign with it: `tauri bundle`
# packages the binary already built and runs no cargo build scripts.
- run: npm run tauri build -- --no-bundle -- --locked
- name: Bundle the AppImage and deb with updater signatures
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: >-
npm run tauri bundle --
--bundles appimage,deb
--config '{"bundle":{"createUpdaterArtifacts":true}}'
# The bundler names outputs after productName, which has spaces, and
# GitHub rewrites spaces in asset names, so the URLs in latest.json
# would no longer match. A signature covers the content, not the name.
- name: Rename the bundles to space-free names
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
shopt -s nullglob
one() {
if [ "$#" -ne 1 ]; then
echo "::error::Expected exactly one bundle, found $#: $*" >&2
return 1
fi
printf '%s' "$1"
}
appimage=$(one target/release/bundle/appimage/*_amd64.AppImage)
deb=$(one target/release/bundle/deb/*_amd64.deb)
name="claude-code-usage-bubble_${VERSION}_amd64"
mkdir -p dist
cp "$appimage" "dist/$name.AppImage"
cp "$appimage.sig" "dist/$name.AppImage.sig"
cp "$deb" "dist/$name.deb"
test -s "dist/$name.AppImage.sig"
ls -l dist
- uses: actions/upload-artifact@v7
with:
name: release-linux-x64
path: dist/
retention-days: 7
if-no-files-found: error
publish:
name: Publish
needs: [prepare, win-x64, linux-x64]
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.tag }}
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: lts/*
- uses: actions/download-artifact@v8
with:
pattern: release-*
path: dist
merge-multiple: true
- name: Write latest.json and check the asset set
run: node scripts/make-latest-json.js "$TAG" dist
# Legacy clients read only `/releases/latest`, so a tag release must be
# a non-draft, non-prerelease release marked latest.
- name: Create the release
run: |
if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then
mode=(--draft)
else
mode=(--latest)
fi
gh release create "$TAG" dist/* --verify-tag --title "$TAG" --generate-notes "${mode[@]}"
- name: Check that /releases/latest is this release
if: github.event_name != 'workflow_dispatch'
run: |
latest=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" -q .tag_name)
if [ "$latest" != "$TAG" ]; then
echo "::error::/releases/latest returns $latest, not $TAG; legacy clients would not see this release."
exit 1
fi
+2
View File
@@ -1 +1,3 @@
/target
/node_modules
/gen
+6
View File
@@ -0,0 +1,6 @@
ui/
tests/
scripts/
plans/
docs/
node_modules/
+10
View File
@@ -0,0 +1,10 @@
# GitHub Actions workflows: `on:` is a key, not a boolean, the files carry
# no document marker, and long shell lines read better unwrapped.
extends: default
rules:
document-start: disable
truthy:
check-keys: false
line-length:
max: 140
Generated
+5020 -182
View File
File diff suppressed because it is too large. Load diff
+58 -18
View File
@@ -1,42 +1,82 @@
[package]
name = "claude-code-usage-bubble"
version = "0.1.9"
version = "0.5.0"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows"
description = "Floating bubble showing Claude Code and Codex usage"
homepage = "https://github.com/tiennm99/claude-code-usage-bubble"
repository = "https://github.com/tiennm99/claude-code-usage-bubble"
[lib]
name = "claude_code_usage_bubble_lib"
path = "src/lib.rs"
[[bin]]
name = "claude-code-usage-bubble"
path = "src/main.rs"
[dependencies]
tauri = { version = "2", features = ["tray-icon", "macos-private-api"] }
tauri-plugin-single-instance = "2"
# rustls with the OS verifier keeps corporate roots trusted; gzip is a default.
# socks-proxy serves SOCKS environment proxies such as ALL_PROXY=socks5h://.
ureq = { version = "3", features = ["platform-verifier", "socks-proxy"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6"
log = "0.4"
simplelog = "0.12"
thiserror = "2"
toml = "0.8"
tiny-skia = "0.11"
sha2 = "0.10"
self-replace = "1.5"
tauri-plugin-notification = "2"
tauri-plugin-updater = "2"
# Not used directly: Cargo's feature unification turns `socks` on for the
# updater plugin's reqwest, so update requests can use a SOCKS env proxy.
# Keep the major in step with the plugin's (`cargo tree -i reqwest`).
reqwest = { version = "0.13", default-features = false, features = ["socks"] }
[dependencies.windows]
version = "0.58"
features = [
[target.'cfg(not(windows))'.dependencies]
tauri-plugin-autostart = "2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
x11rb = "0.14"
libloading = "0.9"
# Same major as the copy tao pulls; reads the portal colour scheme.
dbus = "0.9"
[target.'cfg(windows)'.dependencies]
# Keep the major in step with the `windows` crate tauri pulls
# (`cargo tree -i windows`), so only one copy is compiled.
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Globalization",
"Win32_Graphics_Gdi",
"Win32_System_LibraryLoader",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_Security",
"Win32_UI_HiDpi",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_Networking_WinHttp",
]
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
] }
# Same major as tauri's; used for the WebView2 runtime check.
webview2-com = "0.39"
# Same major as the copy auto-launch pulls; reads the system proxy.
windows-registry = "0.6"
# The start-at-login entry, pinned to the current user (the autostart
# plugin's builder cannot set that mode).
auto-launch = "0.6"
[dev-dependencies]
flate2 = "1"
# The crypto provider the updater plugin installs before its reqwest client
# is built; the SOCKS update test builds such a client itself.
rustls = { version = "0.23", default-features = false, features = ["ring"] }
[build-dependencies]
embed-resource = "3"
tauri-build = { version = "2", features = [] }
serde_json = "1"
[profile.release]
opt-level = "z"
+146 -43
View File
@@ -1,16 +1,19 @@
![Windows](https://img.shields.io/badge/platform-Windows-blue)
![Linux x86_64](https://img.shields.io/badge/platform-Linux_x86__64-blue)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
# Claude Code Usage Bubble
A floating, draggable circular bubble that shows your Claude Code and/or
Codex usage on Windows — inspired by the floating "memory boost ball" UX
of 360 Security and IObit Advanced SystemCare.
Codex usage on Windows and Linux — inspired by the floating "memory boost
ball" UX of 360 Security and IObit Advanced SystemCare.
Drop it anywhere on screen, drag it around, snap it to a monitor edge,
left-click for a panel with both your 5-hour and 7-day windows, right-click
for the menu.
Linux ARM64 and macOS are not supported yet; see [ROADMAP.md](ROADMAP.md).
## Acknowledgements
Inspired by [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor),
@@ -18,7 +21,7 @@ which solves the same "how close am I to the Claude Code limit?" problem
with a horizontal taskbar widget. This project takes the UX in a different
direction — a floating, draggable circular bubble that the user can place
anywhere on screen — and is a clean-room implementation: the HTTP client,
provider polling, credential discovery, localisation, tray rendering, and
provider polling, credential discovery, tray rendering, and
self-updater are all written from scratch against the same public APIs
(Anthropic, ChatGPT, GitHub Releases).
@@ -28,34 +31,61 @@ self-updater are all written from scratch against the same public APIs
Codex usage as a percentage and a colored progress ring
- Drag anywhere — the bubble snaps to monitor work-area edges when
released
- Resize with `Ctrl + MouseWheel` on the bubble (32–128 pixels)
- Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls** →
**Make smaller / Make larger / Reset size** from the right-click menu
(140–360 logical pixels)
- Left-click the bubble for an expanded panel with both **5h** and **7d**
bars plus reset countdowns
- Right-click for refresh, displayed models, update frequency, language,
startup, updates, exit
- Optional system tray icons (one per enabled model)
- Right-click for refresh, displayed providers, update frequency, startup,
updates, exit
- A system tray icon per enabled provider, with a usage badge
- Desktop notifications when usage crosses a threshold, when a sign-in
expires and after an update was applied
- Auto-hide when a fullscreen app is in the foreground (games, video,
presentations) — reappears when you leave fullscreen
## Who this is for
Windows 10/11 users who already have **Claude Code (CLI or App) installed
and signed in**. Codex support is optional — install and sign in to the
Codex CLI, then enable Codex from the right-click **Models** menu.
If you use Claude Code through WSL, that is supported too. The monitor
can read your Claude Code credentials from Windows or from your WSL
environment.
Windows 10/11 and Linux x86_64 users who already have **Claude Code (CLI or
App) installed and signed in** on the same machine. Codex support is
optional — install and sign in to the Codex CLI, then enable Codex from the
right-click **Providers** menu.
## Requirements
- Windows 10 or Windows 11
- Windows 10 or Windows 11, or Linux x86_64 (see below)
- Claude Code (CLI or App) installed and authenticated
- Optional: Codex CLI installed and authenticated, if you want Codex usage
### Windows
The app draws its windows with the Microsoft Edge **WebView2 runtime**.
Windows 11 ships it, and most up-to-date Windows 10 machines already have it.
If it is missing, the app shows a message with the download link and exits;
install the
[Evergreen WebView2 runtime](https://developer.microsoft.com/microsoft-edge/webview2/)
and start the app again.
### Linux
- **Runtime libraries:** WebKitGTK 4.1 (`libwebkit2gtk-4.1-0` on
Debian/Ubuntu) and, for the tray icons, `libayatana-appindicator3-1`.
The `.deb` pulls these in. Without an AppIndicator library the app runs
without tray icons.
- **A compositing window manager.** The bubble is a transparent window;
without a compositor its corners are drawn opaque.
- **X11 is the supported session.** Under Wayland the app runs through
Xwayland, where positioning, snapping and always-on-top work. Without
Xwayland (or with `GDK_BACKEND=wayland` set) the bubbles still show, but
they cannot be positioned, snapped, kept on top or saved where you drop
them.
- **Tray limits:** Linux tray icons (AppIndicator) only open the menu. They
show no tooltip, and a left click does not toggle the bubble; use the
menu instead.
## Install
### Download the latest release
### Windows
Grab `claude-code-usage-bubble.exe` from the
[Releases page](https://github.com/tiennm99/claude-code-usage-bubble/releases/latest).
@@ -66,65 +96,139 @@ run it. The app self-updates from the same Releases feed.
"Windows protected your PC". Click **More info** → **Run anyway**. Code
signing is on the roadmap.
### Linux x86_64
From the same
[Releases page](https://github.com/tiennm99/claude-code-usage-bubble/releases/latest),
pick one:
- `claude-code-usage-bubble_X.Y.Z_amd64.AppImage`: make it executable
(`chmod +x`) and run it. The AppImage self-updates in place.
- `claude-code-usage-bubble_X.Y.Z_amd64.deb`: install it with
`sudo apt install ./claude-code-usage-bubble_X.Y.Z_amd64.deb`. The app
still checks for updates, but installing one is left to you: download the
new `.deb` and install it the same way.
### Build from source
Prerequisites: the [Rust toolchain](https://www.rust-lang.org/tools/install)
and [Node.js](https://nodejs.org/) with npm. The Tauri CLI is a local dev
dependency, so `npm ci` installs it.
On Windows:
```powershell
git clone https://github.com/tiennm99/claude-code-usage-bubble
cd claude-code-usage-bubble
cargo build --release
npm ci
npm run tauri build -- --no-bundle
```
The binary lands at `target/release/claude-code-usage-bubble.exe`.
The binary lands at `target\release\claude-code-usage-bubble.exe`.
On Linux x86_64, install the build dependencies first (Debian/Ubuntu):
```bash
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
git clone https://github.com/tiennm99/claude-code-usage-bubble
cd claude-code-usage-bubble
npm ci
npm run tauri build -- --no-bundle
```
The binary lands at `target/release/claude-code-usage-bubble`. To build the
`.deb` and AppImage instead, also install `patchelf` (the AppImage needs it)
and run `npm run tauri build -- --bundles deb,appimage`; they land under
`target/release/bundle/`.
Local builds carry no updater signature (`createUpdaterArtifacts` is off in
`tauri.conf.json`), so they cannot be published as an update. Only the
release workflow signs builds; see
[docs/release-process.md](docs/release-process.md).
## Use
Run `claude-code-usage-bubble.exe`. The bubble appears near the bottom-right
corner of your primary monitor on first launch. Drag it where you want it,
release to snap to the nearest edge if you let go close to one.
Run the app. The bubble appears near the bottom-right corner of your
primary monitor on first launch. Drag it where you want it, release to snap
to the nearest edge if you let go close to one.
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
- **Right-click** for refresh, models, refresh frequency, language, "Start
with Windows", auto-update check (Disabled / Hourly / Daily / Weekly),
manual "Check for updates", exit
- **Right-click** for refresh, providers, refresh frequency, "Start with
Windows" ("Start at login" on Linux), controls, auto-update check
(Disabled / Hourly / Daily / Weekly), manual "Check for updates", exit
- **Drag** anywhere — it floats on top of all other windows
- **Ctrl + MouseWheel** on the bubble to resize it
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
right-click opens the same menu
- **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click
menu, to resize it
- **Tray icon:** left-click toggles the bubble visibility, right-click opens
the same menu (on Linux, any click opens the menu)
### Models
### Providers
Use the right-click **Models** menu to choose what is shown:
Use the right-click **Providers** menu to choose what is shown:
- **Claude Code** is enabled by default
- **Codex** can be enabled alongside Claude Code or shown by itself
- **OpenCode Go** is listed for future support; usage bars remain disabled
until OpenCode exposes a stable quota source or console integration is
explicitly enabled
When both models are shown, each gets its own bubble that you can position
When multiple providers are shown, each gets its own bubble that you can position
independently.
### Changes from the v0.5.0 Windows build
The app moved from a hand-written Win32 UI to Tauri. Most behaviour is the
same; these differences are visible:
- The transparent corners around the round bubble no longer pass clicks
through to the window below; they just ignore clicks.
- Alerts are Windows toast notifications instead of tray balloons, so they
no longer use a warning or info icon.
- Proxy auto-configuration (PAC scripts and WPAD) is no longer used. A
static system proxy, its bypass list and the `HTTPS_PROXY`-style
environment variables still are, including a SOCKS `ALL_PROXY` such as
`socks5h://127.0.0.1:7890`, for usage requests and updates alike.
- Only one copy runs per signed-in user session, instead of one per machine.
## Diagnostics
```powershell
claude-code-usage-bubble.exe --diagnose
```
This writes a log file to:
```text
%TEMP%\claude-code-usage-bubble.log
claude-code-usage-bubble.exe --diagnose # Windows
claude-code-usage-bubble --diagnose # Linux .deb (pass the same flag to the AppImage)
```
This writes a log file, truncated on each start, to:
| OS | Log file |
|----|----------|
| Windows | `%TEMP%\claude-code-usage-bubble.log` |
| Linux | `$TMPDIR/claude-code-usage-bubble.log`, usually `/tmp/claude-code-usage-bubble.log` |
Settings are saved to:
```text
%APPDATA%\ClaudeCodeUsageBubble\settings.json
```
| OS | Settings file |
|----|---------------|
| Windows | `%APPDATA%\ClaudeCodeUsageBubble\settings.json` |
| Linux | `$XDG_CONFIG_HOME/ClaudeCodeUsageBubble/settings.json`, usually `~/.config/ClaudeCodeUsageBubble/settings.json` |
Other files the app writes:
| What | Windows | Linux |
|------|---------|-------|
| Update downloads and `update-error.log` | `%LOCALAPPDATA%\ClaudeCodeUsageBubble\updates\` | `~/.local/share/ClaudeCodeUsageBubble/updates/` |
| Web view profile (cache, storage) | `%LOCALAPPDATA%\io.github.tiennm99.claudecodeusagebubble\` (WebView2) | `~/.local/share/io.github.tiennm99.claudecodeusagebubble/` (WebKitGTK) |
On Windows the app also writes its notification icon to
`%LOCALAPPDATA%\ClaudeCodeUsageBubble\icon.png` and registers it under
`HKCU\Software\Classes\AppUserModelId\io.github.tiennm99.claudecodeusagebubble`,
which Windows needs to show toasts from a portable exe.
## Privacy and security
What the app reads:
- Your local Claude Code OAuth credentials from `~/.claude/.credentials.json`
- If needed, the same credentials file inside an installed WSL distro
- If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json`
or `~/.codex/auth.json`
@@ -139,9 +243,8 @@ What the app stores locally:
- Bubble position(s) per model
- Bubble size
- Polling frequency
- Language preference
- Last update check time
- Displayed model preferences
- Displayed provider preferences
What it does **not** do: send credentials to any third-party server, run a
backend service, collect analytics, upload your project files, or write to
+13
View File
@@ -0,0 +1,13 @@
# 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.
+22
View File
@@ -0,0 +1,22 @@
# TODO
Steps left before the Tauri v2 port on `feat/tauri-port` can ship. Do them in
order.
1. **Create the updater signing key.** Run `npx tauri signer generate`, put the
public key in `plugins.updater.pubkey` in `tauri.conf.json`, add the
`TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
repository secrets, and keep an offline backup of the key pair. Until then
the release workflow refuses to build and the app rejects every downloaded
update.
2. **Test on Windows 10 and 11.** Download the exe artifact from the latest CI
run on `feat/tauri-port` and work through the checklist in
[the final port review](plans/reports/code-reviewer-261021-1028-tauri-port-final.md).
Check toast notifications first: a freshly downloaded exe may not show them.
3. **Run the release dry run.** Follow the dry-run steps in
[docs/release-process.md](docs/release-process.md). It creates a draft
release and deletes it afterwards.
4. **Merge into `main`.** This reaches production: the next tag publishes the
Tauri build to existing users through self-update.
5. **Rename to `usage-monitor`.** Follow
[the rename plan](plans/261021-1226-rename-to-usage-monitor/plan.md).
+65 -12
View File
@@ -1,16 +1,69 @@
// Compiles `res/icon.rc` into the binary as Windows PE resources.
// `tauri-build` validates `tauri.conf.json` against the enabled crate
// features and generates the capability schemas under `gen/`. On Windows it
// also embeds the icon, a VERSIONINFO and the application manifest below.
//
// `embed-resource` shells out to `rc.exe` (when a Windows SDK is on PATH) or
// `windres` (MinGW) to produce a .res object that the linker bakes into the
// .exe alongside our Rust code. The .rc file references `src/icons/icon.ico`
// via a relative path; keep that path stable.
// `tauri.conf.json` has no `version`, so `Cargo.toml` stays the only version
// source. `tauri-build` writes VERSIONINFO only from the config version, so
// the Cargo version is merged in through `TAURI_CONFIG`, which `try_build`
// reads after the config file. Any `TAURI_CONFIG` passed by the CLI
// (`--config`) is kept.
//
// A build that has the updater signing key in its environment is a release
// build, so it must also carry the matching public key: without one, the
// installed app could never verify, and so never install, a later update.
//
// The manifest keeps tauri's default Common-Controls v6 dependency and adds
// PerMonitorV2 DPI awareness, so the process is DPI aware before any window
// exists instead of relying on a runtime call.
fn main() {
// `compile` returns a `CompilationResult` annotated `#[must_use]` on
// embed-resource 3.x. `manifest_optional()` collapses Linux/Mac no-op
// cases (where there's no `rc.exe`) into Ok while still surfacing real
// failures on Windows.
embed_resource::compile("res/icon.rc", embed_resource::NONE)
.manifest_optional()
.expect("Failed to compile Windows resources");
inject_cargo_version();
require_updater_pubkey_when_signing();
let windows = tauri_build::WindowsAttributes::new()
.app_manifest(include_str!("res/windows-app-manifest.xml"));
tauri_build::try_build(tauri_build::Attributes::new().windows_attributes(windows))
.expect("failed to run the tauri build script");
}
fn inject_cargo_version() {
let mut config = match std::env::var("TAURI_CONFIG") {
Ok(raw) => serde_json::from_str::<serde_json::Value>(&raw)
.expect("TAURI_CONFIG must be a JSON object"),
Err(_) => serde_json::json!({}),
};
let object = config
.as_object_mut()
.expect("TAURI_CONFIG must be a JSON object");
object
.entry("version")
.or_insert_with(|| env!("CARGO_PKG_VERSION").into());
std::env::set_var("TAURI_CONFIG", config.to_string());
}
fn require_updater_pubkey_when_signing() {
println!("cargo:rerun-if-env-changed=TAURI_SIGNING_PRIVATE_KEY");
let signing = std::env::var("TAURI_SIGNING_PRIVATE_KEY").is_ok_and(|v| !v.trim().is_empty());
if !signing {
return;
}
let raw = std::fs::read_to_string("tauri.conf.json").expect("tauri.conf.json must be readable");
let file: serde_json::Value =
serde_json::from_str(&raw).expect("tauri.conf.json must be valid JSON");
// `--config` overrides arrive through TAURI_CONFIG and win over the file.
let overrides: serde_json::Value = std::env::var("TAURI_CONFIG")
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default();
const PUBKEY: &str = "/plugins/updater/pubkey";
let pubkey = overrides
.pointer(PUBKEY)
.or_else(|| file.pointer(PUBKEY))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
assert!(
!pubkey.trim().is_empty(),
"plugins.updater.pubkey in tauri.conf.json is empty, but TAURI_SIGNING_PRIVATE_KEY is set: \
put the public key of that signing key there before building a release"
);
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Core IPC and window dragging for the bubble and panel windows. Plugins are driven from Rust only.",
"windows": ["bubble-*", "panel"],
"permissions": [
"core:default",
"core:window:allow-start-dragging"
]
}
+180 -17
View File
@@ -1,14 +1,52 @@
# Cutting a release
The `release.yml` workflow builds and publishes on every pushed tag
matching `v*.*.*`. The workflow asserts that the pushed tag matches
The `release.yml` workflow builds and publishes on every pushed plain
`vX.Y.Z` tag. Pushing a tag with a suffix (`vX.Y.Z-rc1`) publishes
nothing: the updaters ignore suffixes, so an rc would reach every user
as a stable release. The workflow asserts that the pushed tag matches
`Cargo.toml` `version` and **fails fast on mismatch**, so the order
below matters: bump Cargo *before* you tag.
## Version source
The version lives in `Cargo.toml` only. `tauri.conf.json` has no
`version` field, so the app, the Windows file properties (written by
`build.rs`) and the bundles all inherit the Cargo version. Never add a
`version` to `tauri.conf.json`.
## Signing key and secrets
Every release is signed with one minisign key pair, the updater key.
Installed copies verify each update against the public key they were
built with, so losing the private key means no installed copy can ever
update again.
- The public key is `plugins.updater.pubkey` in `tauri.conf.json`.
`release.yml` refuses to build while it is empty. `release-legacy.yml`
does not read it, because the Win32 sources it builds have no
`tauri.conf.json`; it checks only the private key.
- The private key and its password are the repository secrets
`TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`.
Only the workflow steps that check or sign see them; dependency
installs and compilation run without them. A signing step fails hard
when the key is empty, so no unsigned build is published.
- No job keeps the checkout token on disk, and only the publish jobs,
which build nothing, hold the `contents: write` token.
- Keep an **offline backup** of the private key file and its password
(for example an encrypted copy on removable media and the password in
a password manager). GitHub secrets cannot be read back, so the
backup is the only copy that survives a lost or rotated secret.
- Generate the pair once with `npx tauri signer generate`. Rotating it
strands every installed copy on the old key, so do it only if the
private key leaks.
- `build.rs` refuses to build when `TAURI_SIGNING_PRIVATE_KEY` is set
and the effective `pubkey` is empty, so a local build with the key in
the environment cannot produce a release that never verifies updates.
## Steps for a new version
1. Bump `Cargo.toml` `version` (`X.Y.Z`).
2. `cargo build --release` locally to refresh `Cargo.lock`.
2. Run `cargo build` locally to refresh `Cargo.lock`.
3. Commit: `chore: bump version to X.Y.Z`.
4. Tag and push:
@@ -18,16 +56,135 @@ below matters: bump Cargo *before* you tag.
git push origin vX.Y.Z
```
5. Watch the "Release" workflow run; it creates the GitHub Release
with `claude-code-usage-bubble.exe` attached and auto-generated
notes.
5. Watch the "Release" workflow run. It creates a non-draft,
non-prerelease GitHub Release marked latest, with auto-generated
notes and the assets listed below, then fails if
`/releases/latest` does not return the new tag.
## Testing without a real tag
## Release assets
Use the workflow's `workflow_dispatch` input with a throwaway tag
like `v0.0.0-test`. The release is created as a **draft**, so it
does not show up on the public Releases feed or trigger self-updates
for users.
A release carries exactly these files and nothing else:
| Asset | Built by | Read by |
|-------|----------|---------|
| `claude-code-usage-bubble.exe` | Windows job, `npm run tauri build -- --no-bundle` | Legacy Win32 clients (GitHub API) and Tauri Windows clients (`latest.json`) |
| `claude-code-usage-bubble.exe.sig` | `tauri signer sign` on the exe | Tauri Windows clients |
| `claude-code-usage-bubble_X.Y.Z_amd64.AppImage` | Linux job, `tauri bundle` | Tauri AppImage clients |
| `claude-code-usage-bubble_X.Y.Z_amd64.AppImage.sig` | the bundler, with the signing key | Tauri AppImage clients |
| `claude-code-usage-bubble_X.Y.Z_amd64.deb` | Linux job, `tauri bundle` | Users installing by hand (`.deb` installs check for updates but do not apply them) |
| `latest.json` | `scripts/make-latest-json.js` in the publish job | Tauri clients, through the updater endpoint in `tauri.conf.json` |
`latest.json` holds `version`, `notes`, `pub_date` and exactly two
platforms, `windows-x86_64` and `linux-x86_64`, each with the asset's
release-download URL and the content of its `.sig`. There is no
Linux ARM64 or macOS entry.
### Rename rule
The bundler names the Linux outputs after `productName`
("Claude Code Usage Bubble"), and GitHub rewrites spaces in asset
names, which would break the `latest.json` URLs. The Linux job
therefore copies the bundles to space-free kebab names before upload:
`claude-code-usage-bubble_<version>_amd64.AppImage`, its `.sig`, and
`claude-code-usage-bubble_<version>_amd64.deb`. The signature covers
the file content, not its name, so renaming keeps it valid. The
bundler also signs the deb, but that signature is not published,
because no updater entry uses it.
### Legacy-client asset contract
Clients from v0.5.0 and earlier read `/releases/latest` through the
GitHub API, which skips drafts and prereleases. Checked against their
updater code at v0.4.0, v0.4.1 and v0.5.0, they need:
- A non-draft, non-prerelease release marked latest in this repo.
- A `tag_name` of plain `vX.Y.Z`, higher than the running version
(suffixes are ignored when comparing). The first Tauri release must
therefore be above v0.5.0.
- An asset named `claude-code-usage-bubble.exe` (matched
case-insensitively), else the first asset ending in `.exe`. The
release must carry exactly one `.exe` asset, under that name. The
`.exe.sig`, AppImage, deb and `latest.json` do not end in `.exe`.
- The exe uploaded unmodified, so the `digest` (`sha256:<hex>`) that
GitHub computes matches the downloaded body they hash. `tauri signer
sign` writes a separate `.sig` and leaves the exe bytes untouched.
- A new exe that starts from any of their hand-over modes: v0.4.0 runs
it with `--updated-to X` only, v0.4.1 and v0.5.0 with
`--wait-pid <pid> --updated-to X`, and v0.1.9 and earlier with no
arguments. The Tauri exe accepts all three, and its startup cleanup
deletes the `<exe>.old.<pid>`, `updates/update.exe` and
`updates/updater-helper-*.exe` files they leave behind.
`scripts/make-latest-json.js` enforces the asset rules before
`gh release create` runs: it fails on a missing platform entry or
signature, on any `.exe` other than `claude-code-usage-bubble.exe`
(in any case), on any asset name with a space, and on any file outside
the exact set above. Tauri clients read only `latest.json`, whose URLs
must equal the asset names exactly.
## Testing without a real tag (dry run)
A dry run creates a **draft** release, which does not show up on the
public Releases feed and does not reach any client, legacy or Tauri.
1. Pick a throwaway tag with a suffix, like `v0.0.0-test`. The Tauri
`release.yml` triggers tag pushes only on plain `vX.Y.Z` tags and
refuses a suffixed tag outside a dispatch run, so the suffix keeps
the tag push from starting a publishing run. That guard exists only
in commits that carry the Tauri `release.yml`: the Win32
`release.yml` it replaced triggers on `v*.*.*`, suffix included, and
publishes a non-draft release that GitHub marks latest. Never use a
plain `vX.Y.Z` tag for a dry run, because pushing it publishes a
release marked latest.
2. Cut a scratch branch from a commit that contains the Tauri
`release.yml`: the Tauri port branch before it merges, `main` after.
On it, set `Cargo.toml` `version` to `0.0.0-test`, run `cargo build`
locally to refresh `Cargo.lock`, and commit both files. The release
jobs build with `--locked`, so a stale `Cargo.lock` fails them
before any draft exists. The tag check also runs for drafts, so the
tag must point at a commit whose `Cargo.toml` version matches it.
3. Tag that commit and push the tag, then start the "Release" workflow
(`workflow_dispatch`) from the scratch branch with that tag as input:
in the Actions tab, select the scratch branch under "Use workflow
from", or run
`gh workflow run release.yml --ref <scratch-branch> -f tag=v0.0.0-test`.
The default ref is `main`, which runs whatever `release.yml` `main`
holds; before the merge that is the Win32 pipeline, which builds one
exe and no `latest.json`, so the check in step 4 cannot pass.
4. Check the draft: it must hold exactly the six assets in the table
above (with `0.0.0-test` as the version), and `latest.json` must list
only `windows-x86_64` and `linux-x86_64`, with URLs equal to the
asset names.
5. Delete the draft release, the tag and the scratch branch.
## Rollback to the Win32 build
If a Tauri release is broken in the field, publish the Win32 sources
again under a higher version with the "Release legacy" workflow
(`release-legacy.yml`). GitHub dispatches a workflow only once its
file is on the default branch, so this workflow, and a dry run of it,
is available only after the Tauri port merges into `main`:
1. Pick a version `X.Y.Z` higher than every release in the field, so
every client sees it as an update.
2. Start "Release legacy" from the Actions tab with `ref` set to a ref
holding the Win32 sources (for example `v0.5.0`), `version` set to
`X.Y.Z`, `tag` set to `vX.Y.Z`, and `draft` left at `true`.
3. The workflow patches the version at that ref in `Cargo.toml` and in
the package's own `Cargo.lock` entry, so the build resolves no
dependency. It builds with `cargo build --release --locked`, signs
the exe with the same updater key and writes a Windows-only
`latest.json`. Check the draft:
it holds `claude-code-usage-bubble.exe`, its `.sig` and `latest.json`.
4. Publish it: either edit the draft into a release marked latest, or
delete the draft and re-run the workflow with `draft` set to `false`,
which publishes it as latest.
Legacy clients take the exe through the GitHub API, Tauri Windows
clients through the signed `latest.json`, and Tauri Linux clients find
no entry for their platform, which they report as up to date. The
Win32 app accepts the `--wait-pid <pid> --updated-to X` relaunch the
Tauri updater uses.
## Versioning
@@ -36,10 +193,16 @@ breaking changes (e.g. `settings.json` schema change). The in-app
updater compares `Version { major, minor, patch }` lexicographically,
so a higher tuple wins.
## Asset name contract
## Residual risk
The updater matches the release asset by exact filename
`claude-code-usage-bubble.exe` (case-insensitive). Cargo's
`name = "claude-code-usage-bubble"` already produces that name in
`target/release/`, so the workflow uploads the file as-is — do not
rename it.
The hand-over from a legacy Win32 release to the Tauri exe has been
checked only by reading the updater code at v0.4.0, v0.4.1 and v0.5.0;
it has never run end to end before a 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, the
`--diagnose` log, `update-error.log`, and the `release-legacy.yml`
rollback, which rebuilds the Win32 sources at a given ref under a
higher version as a Windows-only release. That rollback is itself
unproven end to end.
+26
View File
@@ -0,0 +1,26 @@
import js from '@eslint/js';
import jsdoc from 'eslint-plugin-jsdoc';
import globals from 'globals';
export default [
{
ignores: ['target/', 'gen/', 'node_modules/'],
},
js.configs.recommended,
jsdoc.configs['flat/recommended-error'],
{
files: ['ui/**/*.js'],
languageOptions: {
globals: {
...globals.browser,
__TAURI__: 'readonly',
},
},
},
{
files: ['tests/**/*.js', 'scripts/**/*.js', 'eslint.config.js'],
languageOptions: {
globals: globals.node,
},
},
];
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 515 B

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+1785
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "claude-code-usage-bubble",
"private": true,
"description": "Floating bubble showing Claude Code and Codex usage",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"tauri": "tauri",
"lint": "eslint .",
"test": "node --test \"tests/js/**/*.test.js\""
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "^2.12.3",
"eslint": "^10.13.0",
"eslint-plugin-jsdoc": "^65.4.0",
"globals": "^17.13.0"
}
}
@@ -1,121 +0,0 @@
# Phase 1: Bootstrap Repo
## Context Links
- Source `Cargo.toml`: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/Cargo.toml`
- Source `build.rs`: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/build.rs`
- Source icons: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/icons/`
## Overview
- **Priority:** Must-first (every other phase depends on this)
- **Status:** pending
- **Description:** Create the new repo's foundation: `Cargo.toml` with the right windows-rs features, `build.rs` for icon embedding, LICENSE (MIT, dual attribution), README with attribution to source, fresh icon assets, project layout.
## Key Insights
- Source uses `windows = 0.58` with 12 feature flags. New repo can **drop** `Win32_UI_Accessibility` (no more `SetWinEventHook` on TrayNotifyWnd) and **drop** `Win32_UI_Input_KeyboardAndMouse` if drag is handled via `WM_NCHITTEST` + `HTCAPTION` instead of manual `SetCapture`.
- Source uses `ureq` + `native-tls` + `serde` + `dirs` — all keep verbatim.
- Source release profile: `opt-level="z"`, `lto=true`, `strip=true`, `codegen-units=1`, `panic="abort"` — keep all (produces ~2MB binary).
- Package name change: `claude-code-usage-bubble` (binary name `claude-code-usage-bubble.exe`).
## Requirements
### Functional
- `cargo build --release` produces a single-file `.exe`
- `winres` embeds icon resource so the .exe has a proper Windows icon
- README clearly attributes original repo + MIT license
- Project compiles with no implementation yet (empty `main.rs` returning `()`)
### Non-functional
- Binary size target: < 3 MB stripped
- No Linux/Mac build (Windows-only `#![windows_subsystem]` in main)
## Architecture
```
claude-code-usage-bubble/
├── Cargo.toml
├── build.rs
├── LICENSE # MIT, with attribution clause
├── README.md # Attribution + usage
├── src/
│ ├── main.rs # entry, mod declarations only
│ └── icons/
│ ├── icon.ico
│ ├── 16x16.png, 32x32.png, 48x48.png, 256x256.png
│ └── *.svg sources
└── plans/ # this directory
```
## Related Code Files
**To create:**
- `Cargo.toml`
- `build.rs`
- `LICENSE`
- `README.md`
- `src/main.rs` (stub)
- `src/icons/*` (placeholder copies from source; can be replaced with bubble-specific art later)
- `.gitignore`
**To modify:** none (greenfield repo)
## Implementation Steps
1. **Create `Cargo.toml`** with package metadata, the same `windows-rs` features minus accessibility + keyboard/mouse:
```toml
[package]
name = "claude-code-usage-bubble"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Floating bubble showing Claude Code / Codex usage on Windows"
repository = "<set this>"
```
Features to include: `Win32_Foundation`, `Win32_Globalization`, `Win32_Graphics_Gdi`, `Win32_System_LibraryLoader`, `Win32_UI_Shell`, `Win32_UI_WindowsAndMessaging`, `Win32_System_Registry`, `Win32_System_Threading`, `Win32_Security`, `Win32_UI_HiDpi`.
2. **Create `build.rs`** mirroring source `build.rs`; embed `src/icons/icon.ico` via `winres`.
3. **Copy `src/icons/*`** from source verbatim (placeholder; designer can replace).
4. **Write `LICENSE`** — MIT text with a header line crediting CodeZeno/Claude-Code-Usage-Monitor.
5. **Write `README.md`** — short, includes:
- One-paragraph what-it-is
- Attribution: "This project ports usage-polling, updater, and tray-icon code from [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) (MIT)."
- Install/run section (placeholder)
6. **Stub `src/main.rs`**:
```rust
#![windows_subsystem = "windows"]
fn main() {}
```
7. **Run `cargo build`** — must compile clean.
8. **Run `cargo build --release`** — confirm binary produced, check size.
## Todo List
- [ ] Cargo.toml with correct features
- [ ] build.rs with winres
- [ ] Icons copied from source
- [ ] LICENSE with attribution
- [ ] README.md with attribution
- [ ] src/main.rs stub
- [ ] .gitignore (target/, Cargo.lock for libs only — keep Cargo.lock for binaries)
- [ ] `cargo build` succeeds
- [ ] `cargo build --release` succeeds, binary < 3 MB
## Success Criteria
- `cargo build --release` on Windows produces a runnable .exe with embedded icon
- README links source repo
- LICENSE includes attribution clause
## Risk Assessment
- **Low.** Pure configuration; no logic.
- Cross-compile note: if developer is on Linux/Mac, will need MinGW or Windows machine for `winres` step. Document this in README.
## Security Considerations
- N/A in this phase.
## Next Steps
→ Phase 2: port portable modules into `src/`
@@ -1,124 +0,0 @@
# Phase 2: Port Portable Modules
## Context Links
- Source: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/`
- Modules to port verbatim (or near-verbatim): `models.rs`, `diagnose.rs`, `theme.rs`, `poller.rs`, `updater.rs`, `tray_icon.rs`, `localization/*`
- Module to trim: `native_interop.rs`
## Overview
- **Priority:** High (must precede phases 3-4)
- **Status:** pending
- **Description:** Bring over the portable subsystems from the source repo with minimal changes. These represent ~2,700 lines of working, tested code — the goal is to keep them intact and only edit what's required for the new project name and the simplified Win32 surface.
## Key Insights
- `poller.rs` (1099 lines) is fully self-contained — depends only on `serde`, `ureq`, `native-tls`, `dirs`, plus `diagnose` and `models` from the same crate.
- `updater.rs` (510 lines) embeds `env!("CARGO_PKG_REPOSITORY")` to resolve GitHub owner/repo automatically — no hardcoded references.
- `tray_icon.rs` uses `WM_APP_TRAY` (`WM_APP + 3`) and `IDM_TOGGLE_WIDGET = 50`. Keep the constants; `app.rs` (phase 4) will own dispatch.
- `native_interop.rs` is the trimming target: drop `find_taskbar`, `find_child_window`, `get_taskbar_rect`, `embed_in_taskbar`, `set_tray_event_hook`, `get_window_thread_id`, `unhook_win_event`. Keep `wide_str`, `colorref`, `Color`, timer-ID constants, custom-message constants.
## Requirements
### Functional
- `mod models; mod diagnose; mod theme; mod poller; mod updater; mod tray_icon; mod localization; mod native_interop;` all compile against current `main.rs`
- `cargo check` passes with zero warnings beyond unused-symbol warnings (which will resolve in phases 3-4)
- All `pub` symbols documented above remain reachable
### Non-functional
- No behavioral changes vs source — diffs limited to module boundaries
## Architecture
```
src/
├── main.rs (stub from phase 1)
├── models.rs COPIED
├── diagnose.rs COPIED
├── theme.rs COPIED
├── poller.rs COPIED
├── updater.rs COPIED + 1-line stub for current_install_channel
├── tray_icon.rs COPIED
├── native_interop.rs TRIMMED (~80 lines vs source 179)
└── localization/
├── mod.rs COPIED
├── english.rs COPIED
├── dutch.rs COPIED
├── french.rs COPIED
├── german.rs COPIED
├── japanese.rs COPIED
├── korean.rs COPIED
├── spanish.rs COPIED
└── traditional_chinese.rs COPIED
```
## Related Code Files
**To create (copy from source):**
- All files listed in Architecture section above.
**To modify after copying:**
- `src/updater.rs` — replace `current_install_channel()` body with `InstallChannel::Portable` while keeping the rest of the function intact (preserves code for future winget enablement).
- `src/native_interop.rs` — delete taskbar/WinEvent functions and their imports.
- `src/main.rs` — declare modules; do NOT yet call `window::run` (window module doesn't exist yet).
**To delete:** none.
## Implementation Steps
1. **Copy modules verbatim:**
```
cp -r ../Claude-Code-Usage-Monitor/src/{models,diagnose,theme,poller,updater,tray_icon}.rs src/
cp -r ../Claude-Code-Usage-Monitor/src/localization src/
```
2. **Copy & trim `native_interop.rs`:**
- Keep: `wide_str`, `colorref`, `Color`, `TIMER_*` constants, `WM_APP_*` constants, `get_window_rect_safe`, `move_window`
- Delete: `WS_POPUP_STYLE`, `WS_CHILD_STYLE`, `WS_CLIPSIBLINGS_STYLE` (bubble uses standard windows-rs constants), `EVENT_OBJECT_LOCATIONCHANGE`, `WINEVENT_OUTOFCONTEXT`, `find_taskbar`, `find_child_window`, `get_taskbar_rect`, `embed_in_taskbar`, `set_tray_event_hook`, `get_window_thread_id`, `unhook_win_event`
- Drop imports for `Accessibility`, `Shell::SHAppBarMessage`/`APPBARDATA`, `Foundation::RECT` (if no longer used after trim)
3. **Stub winget detection in `updater.rs`:**
```rust
pub fn current_install_channel() -> InstallChannel {
// Bubble repo is not yet published to winget; once it is, restore the
// is_winget_install_path probe by reading the source repo's logic.
InstallChannel::Portable
}
```
Keep `is_winget_install_path` + `winget_install_roots` + `normalize_path` as `#[allow(dead_code)]` to preserve the code path.
4. **Update `src/main.rs`** to declare the modules:
```rust
#![windows_subsystem = "windows"]
mod diagnose; mod localization; mod models; mod native_interop;
mod poller; mod theme; mod tray_icon; mod updater;
fn main() { /* phase-04 will wire this */ }
```
5. **Run `cargo check`** — expect warnings about unused public items; should be zero errors.
## Todo List
- [ ] Copy 7 source modules + localization directory
- [ ] Trim `native_interop.rs` to ~80 lines (drop taskbar/WinEvent helpers)
- [ ] Stub `updater::current_install_channel`
- [ ] Wire `mod` declarations in `main.rs`
- [ ] `cargo check` clean (only dead-code warnings)
- [ ] `cargo build --release` still produces a binary
## Success Criteria
- All ported modules compile in the new crate without modification beyond what is listed above
- No warnings about missing imports
- Source code license headers (if any) are preserved
## Risk Assessment
- **Low.** Copy-with-rename operation; the trimming of `native_interop.rs` is the only judgment call.
- Edge case: `tray_icon.rs` imports `crate::native_interop::WM_APP_TRAY` — verify constant survives the trim.
## Security Considerations
- `poller.rs` reads OAuth credentials from `~/.claude/.credentials.json` and (optionally) WSL distros. No new attack surface vs source.
- `updater.rs` downloads .exe from GitHub. Same trust model as source. Until the new repo has releases published, this code is dormant.
## Next Steps
→ Phase 3: build the bubble window (replaces 2847 lines of `window.rs`)
@@ -1,166 +0,0 @@
# Phase 3: Build Floating Bubble Window
## Context Links
- Source `window.rs` painting + drag logic: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/window.rs` (lines around `UpdateLayeredWindow`, `WM_LBUTTONDOWN`, `SetCapture`)
- Reference UX: 360 Security floating ball, IObit Advanced SystemCare RAM-boost ball — both are circular, top-most, draggable-anywhere with edge snap.
## Overview
- **Priority:** Critical — this is the heart of the new UX
- **Status:** pending
- **Description:** Build a circular floating bubble window that floats on top of everything, can be dragged anywhere, snaps to monitor edges, and shows usage percentage in the center over a colored progress ring. Replaces the 2847-line `window.rs` taskbar embedding code.
## Key Insights
- Use `WS_POPUP | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW` — `TOOLWINDOW` keeps it out of Alt+Tab; `NOACTIVATE` prevents focus theft.
- Drag-anywhere = handle `WM_NCHITTEST`, return `HTCAPTION` for the entire bubble area. The OS handles drag automatically, including with proper cursor and Win+drag behavior. No need for `SetCapture`.
- Circular alpha mask: render to a DIB section with per-pixel alpha. Pixels outside the circle = `0x00000000` (fully transparent). Then `UpdateLayeredWindow` with `ULW_ALPHA`. Click-through outside the circle happens automatically because alpha=0 doesn't hit-test (default behavior of layered windows with `WS_EX_LAYERED` + per-pixel alpha; verify via test).
- Snap to edge: in `WM_EXITSIZEMOVE`, query current position via `GetWindowRect`, find nearest monitor via `MonitorFromPoint`, get its work area via `GetMonitorInfo`. If center is within 12px of any work-area edge, snap that edge.
- HiDPI: use `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` early in `main`. Query `GetDpiForWindow` on `WM_DPICHANGED` and rescale bubble size + font.
- GDI ring drawing: parametric — sweep angle proportional to percentage. Use `Polygon` filled with brush, or `AngleArc` with thick pen. For clean anti-aliased look on layered window, draw into DIB section manually with alpha-weighted line algorithm. Simpler path: use GDI+ via `gdiplus` crate, or accept GDI-aliased look for v1.
- Bubble size default: **56×56 px** at 100% DPI (matches reference apps). Allow user to tweak in `Cargo.toml` constant for v1.
## Requirements
### Functional
- Window appears as a circular bubble, can be dragged anywhere on any monitor
- Bubble shows percentage text (e.g. "73%") in center
- Colored progress ring around the percentage; color matches the source app's color stops (orange → red gradient from 50% to 100%)
- Top-most: stays visible over other windows
- No taskbar entry, no Alt-Tab entry
- Left-click → posts `WM_APP_PANEL_TOGGLE` to self (panel implementation in phase 4)
- Right-click → context menu (phase 4 owns menu items; bubble owns the right-click detection)
- Drag releases → snap to nearest monitor edge if within 12 px of it
- Repaints when percentage / theme / DPI changes
### Non-functional
- 60 FPS not required; redraws on data update only (every 60s poll cycle plus countdown ticks)
- Per-monitor DPI aware
- Visible on dark and light Windows themes (use `theme::is_dark_mode` for ring background tint)
## Architecture
```
src/
├── bubble.rs NEW — Window class, message loop owner, GDI painting,
│ drag + snap, DPI, hit-testing
└── (other modules unchanged from phase 2)
```
Bubble owns:
- HWND lifecycle (`RegisterClassExW` + `CreateWindowExW`)
- DIB section + `UpdateLayeredWindow` call
- Percentage state (`Option<f64>` for each enabled model)
- Drag state (managed by OS via `HTCAPTION`)
- Snap math
- DPI scale factor cache
Bubble delegates:
- Polling → `poller::poll` (phase 4 wires the background thread)
- Panel toggle → `app::on_panel_toggle` (phase 4)
- Right-click menu → `app::on_show_context_menu` (phase 4)
- Settings → `settings` module (phase 4)
## Related Code Files
**To create:**
- `src/bubble.rs` (target: 400–700 lines)
**To modify:**
- `src/main.rs` — eventually call `bubble::run()` (wired in phase 4)
- `src/native_interop.rs` — may add helpers if hit-testing geometry math gets gnarly
**To delete:** none.
## Implementation Steps
1. **Window class registration:**
- Class name: `ClaudeCodeUsageBubble`
- Style: `CS_DBLCLKS` (allow `WM_LBUTTONDBLCLK` if we want double-click later)
- WndProc: `bubble_wnd_proc`
2. **Window creation:**
- `WS_POPUP`, ext `WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW`
- Initial position: load from settings (phase 4); fall back to "near bottom-right corner of primary monitor"
- Size: 56×56 logical px scaled by current DPI
3. **DPI awareness:**
- In `bubble::run`, call `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)`.
- On `WM_CREATE`, cache DPI via `GetDpiForWindow`.
- On `WM_DPICHANGED`, update scale and resize.
4. **Painting (the hard part):**
- On every state change (percentage, DPI, theme), call `redraw()`.
- `redraw()`:
- Create DIB section sized to bubble pixel dimensions (`CreateDIBSection` with `BI_RGB` and 32bpp).
- Clear to fully transparent (`0x00000000`).
- For each pixel inside the circle radius, write background fill (theme-adjusted: dark theme → semi-opaque dark with high alpha; light theme → semi-opaque white).
- Stroke progress ring: for the sweep angle proportional to current percentage, draw a thick arc using either GDI `AngleArc` with rounded `Pen`, or manual pixel writes (4 px ring thickness at 100% DPI).
- Draw percentage text in center via `DrawTextW` with `DT_CENTER | DT_VCENTER | DT_SINGLELINE`. Font: bold 14 pt at 100% DPI, scaled by DPI factor.
- Call `UpdateLayeredWindow` with `ULW_ALPHA` and the DIB.
5. **Drag-anywhere via `WM_NCHITTEST`:**
```rust
WM_NCHITTEST => {
// Convert lparam (screen coords) to client coords
let p = screen_to_client(hwnd, lparam);
if inside_circle(p, radius) { LRESULT(HTCAPTION as isize) }
else { LRESULT(HTTRANSPARENT as isize) }
}
```
OS handles drag + cursor. `HTTRANSPARENT` outside the circle ensures clicks pass through.
6. **Snap on drag release:**
- `WM_EXITSIZEMOVE` → snap logic.
- `MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)` → `GetMonitorInfo` → work area rect.
- Compare bubble's center to each edge of work area. If distance < 12 logical px (scaled by DPI), adjust window position to snap.
- Persist new position via `app::on_bubble_moved(model, x, y)` (phase 4).
7. **Click handling:**
- `WM_LBUTTONUP` → if no drag occurred (compare with `WM_LBUTTONDOWN` position), `PostMessageW(WM_APP_PANEL_TOGGLE)`.
- `WM_RBUTTONUP` → call into `app::show_context_menu(hwnd, screen_pos)` (phase 4 implements).
8. **Public API:**
```rust
pub fn run(initial: BubbleConfig) -> ! { /* never returns; spins message loop */ }
pub struct BubbleConfig {
pub model: TrayIconKind, // Claude or Codex
pub initial_position: Option<(i32, i32)>,
pub initial_percentage: Option<f64>,
}
pub fn update_percentage(hwnd: HWND, percentage: Option<f64>); // called from poll thread via PostMessage
```
For dual-bubble mode, phase 4 spawns one `bubble::run` per enabled model on separate threads (each with its own message loop) — simpler than juggling two HWNDs in one thread.
## Todo List
- [ ] Window class + creation with correct styles
- [ ] Per-monitor DPI awareness on entry
- [ ] DIB section + layered window painting pipeline
- [ ] Circle fill with theme-aware background
- [ ] Progress ring painted at correct sweep angle, correct color stop
- [ ] Percentage text drawn centered
- [ ] `WM_NCHITTEST` returns `HTCAPTION` inside circle, `HTTRANSPARENT` outside
- [ ] Drag works smoothly across monitors
- [ ] Snap on release within 12px of work-area edge
- [ ] Left-click (no drag) posts panel-toggle message
- [ ] Right-click posts context-menu request
- [ ] Public API `run`, `update_percentage`
- [ ] Manual test: bubble visible, draggable, snaps, percentage updates
## Success Criteria
- Bubble appears on Windows 10/11 with a Visual Studio-clean cargo build
- Drag works smoothly with no flicker
- Edge snap engages reliably from 12 px
- Bubble survives display reconnection (laptop → external monitor → unplug)
- Percentage text remains crisp on 100%, 125%, 150%, 175% DPI
## Risk Assessment
- **High** — this is novel code with no exact analog in source.
- Risk: ClearType sub-pixel text rendering on a per-pixel-alpha layered window looks bad. Source repo's `window.rs` solved this with a black background-pixel hack (`alpha = 0x01` so it's nearly transparent but still gets ClearType). Apply the same trick for the circle's interior fill region.
- Risk: GDI `AngleArc` doesn't anti-alias. Mitigation: either accept aliased v1, or render to a 2x supersampled DIB and downsample.
- Risk: Snap math wrong on rotated taskbar or unusual DPI configurations. Mitigation: clamp to monitor work area only, ignore taskbar position.
## Security Considerations
- N/A in this phase; bubble does not handle user input beyond mouse position and clicks.
## Next Steps
→ Phase 4: expanded panel, settings persistence, polling thread, orchestration
@@ -1,190 +0,0 @@
# Phase 4: Expanded Panel + Settings Persistence + Orchestration
## Context Links
- Source `window.rs` — borrow message-loop dispatch, polling-thread orchestration, settings persistence pattern
- Source `poller.rs` — `poll`, `credential_watch_snapshot`, `format_line`, `time_until_display_change`
- Source `tray_icon.rs` — `add/update/remove/sync`, `handle_message`
## Overview
- **Priority:** High
- **Status:** pending
- **Description:** Build (a) the expanded panel that appears on bubble click and shows both 5h and 7d bars with countdowns, (b) settings persistence to `%APPDATA%\ClaudeCodeUsageBubble\settings.json`, (c) the orchestrating `app.rs` module that owns polling, message routing, context menus, and dual-bubble lifecycle.
## Key Insights
- Panel is a separate window: `WS_POPUP | WS_EX_LAYERED | WS_EX_TOPMOST`, opaque background, shown adjacent to bubble. Source's draw code for the horizontal bars can be ported almost directly (it already draws progress bars + countdown text via GDI).
- Settings file location matches source pattern: `%APPDATA%\ClaudeCodeUsageBubble\settings.json` (renamed dir). Use `dirs::config_dir()`.
- Polling: background `std::thread` spawned in `app::run`. Posts `WM_APP_USAGE_UPDATED` to each bubble window when data refreshes. Source's poll-loop logic is copy-friendly.
- Context menu: built via `CreatePopupMenu` + `AppendMenuW` + `TrackPopupMenu`. Source has the full menu structure — port it but remove "Reset position" → rename to "Reset bubble position" (per model).
- Dual-bubble: each enabled model gets its own HWND + tray icon + bubble window. Settings stores `bubble_positions: { claude: {x, y}, codex: {x, y} }`.
## Requirements
### Functional
- Settings persist across restarts: window positions, polling frequency, enabled models, language, "Start with Windows" state, last update check
- Expanded panel: shows session bar + weekly bar + countdowns + reset times for the model whose bubble was clicked
- Panel auto-closes on focus loss or after a brief timeout (optional)
- Right-click menu mirrors source's menu structure: Refresh, Models, Update frequency, Language, Start with Windows, Reset position, Updates, Exit
- Single-instance enforced via named mutex `Global\ClaudeCodeUsageBubble`
- Polling runs in background, posts updates via `PostMessageW(WM_APP_USAGE_UPDATED, ...)`
- Countdown timer adapts to display granularity (`time_until_display_change`)
### Non-functional
- Settings file is atomically written (write to `.tmp`, rename)
- Polling thread cannot block UI thread
- Mutex released on clean shutdown
## Architecture
```
src/
├── app.rs NEW — orchestrator: spawns bubbles, polls, routes messages,
│ owns tray icons, owns context menu builder
├── panel.rs NEW — expanded panel window (one per model on demand)
├── settings.rs NEW — load/save settings.json, schema
└── main.rs modified — calls app::run
```
Message flow:
```
poll thread ────PostMessage(WM_APP_USAGE_UPDATED)──▶ bubble HWND
└─▶ updates percentage, redraws
bubble click ──PostMessage(WM_APP_PANEL_TOGGLE)─▶ app handler (in bubble wndproc)
└─▶ panel::show_for(model)
right-click ──app::show_context_menu(hwnd)──▶ TrackPopupMenu ─▶ WM_COMMAND
└─▶ menu action dispatch
tray icon ────WM_APP_TRAY───────────▶ tray_icon::handle_message ─▶ TrayAction
└─▶ toggle/ shutdown / refresh
```
## Related Code Files
**To create:**
- `src/app.rs` (target: 500–800 lines)
- `src/panel.rs` (target: 300–500 lines)
- `src/settings.rs` (target: 150–250 lines)
**To modify:**
- `src/main.rs`:
```rust
#![windows_subsystem = "windows"]
mod app; mod bubble; mod diagnose; mod localization; mod models;
mod native_interop; mod panel; mod poller; mod settings; mod theme;
mod tray_icon; mod updater;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--diagnose") {
if let Ok(path) = diagnose::init() {
diagnose::log(format!("startup args={args:?} log_path={}", path.display()));
}
}
if let Some(exit_code) = updater::handle_cli_mode(&args) {
std::process::exit(exit_code);
}
app::run();
}
```
**To delete:** none.
## Implementation Steps
1. **`settings.rs`** — define schema:
```rust
#[derive(Serialize, Deserialize, Default)]
pub struct Settings {
pub show_claude_code: bool, // default true
pub show_codex: bool, // default false
pub bubble_positions: BubblePositions,
pub poll_minutes: u32, // default 5
pub language: Option<String>, // None = system
pub start_with_windows: bool,
pub last_update_check_unix: Option<i64>,
}
#[derive(Serialize, Deserialize, Default)]
pub struct BubblePositions {
pub claude: Option<(i32, i32)>,
pub codex: Option<(i32, i32)>,
}
pub fn load() -> Settings { /* dirs::config_dir + read + serde + atomic */ }
pub fn save(s: &Settings) { /* write .tmp + rename */ }
```
2. **`panel.rs`** — port the existing horizontal-bar painting code from source `window.rs`:
- Window class `ClaudeCodeUsageBubblePanel`
- On `WM_LBUTTONDOWN` outside → close
- On `WM_KILLFOCUS` → close (with debounce so it doesn't close instantly when bubble is clicked again to toggle off)
- Paints two rows (5h, 7d) for the model that was clicked; uses `poller::format_line` for the countdown text
- Position: anchor next to bubble; flip side if would go off-screen
3. **`app.rs`** — orchestrator:
- `pub fn run() -> !`:
1. Acquire single-instance mutex; if already running, exit.
2. `SetProcessDpiAwarenessContext`.
3. Load settings.
4. Resolve language (`localization::resolve_language`).
5. Start polling thread.
6. For each enabled model, spawn a bubble window thread.
7. Run main message loop on UI thread (the bubble windows can be on the same thread — easier than multi-thread message pumps).
- Polling thread:
```rust
loop {
match poller::poll(show_claude, show_codex) {
Ok(data) => post_update_to_bubbles(data),
Err(e) => post_error_to_app(e),
}
sleep(poll_interval);
}
```
- Context menu builder: replicate source's menu structure verbatim; localized strings via `Strings`. Map `WM_COMMAND` IDs to handlers (refresh, toggle model, change interval, etc.).
- "Start with Windows": registry write to `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run\ClaudeCodeUsageBubble`.
- Token-expired flow: if `PollError::TokenExpired`, show tray balloon via `tray_icon::notify_balloon` with localized title/body.
4. **Wire `bubble.rs` events back to `app.rs`:**
- `WM_APP_PANEL_TOGGLE` handler in bubble wndproc → call `panel::show_for_model(model)` (registered via callback or via `app::handle_panel_toggle`).
- `WM_RBUTTONUP` → call `app::show_context_menu_at(point, model)`.
- `WM_EXITSIZEMOVE` → call `app::on_bubble_moved(model, x, y)` which updates settings.
5. **Reuse `tray_icon.rs`** as the source-app's secondary indicator:
- In `app::run`, after creating bubbles, call `tray_icon::sync(hwnd, &[TrayIconData{kind: Claude, percent: …, tooltip: …}, …])`.
- Tray icon clicked → `tray_icon::handle_message` → if `ToggleWidget`, toggle bubble visibility (set `WS_VISIBLE` style); if `ShowContextMenu`, dispatch to `app::show_context_menu_at`.
## Todo List
- [ ] `settings.rs` with atomic save
- [ ] `panel.rs` with bar painting + auto-close
- [ ] `app.rs` orchestrator
- [ ] Single-instance mutex acquisition + release
- [ ] Polling thread spawning + `PostMessage` updates
- [ ] Context menu localized + dispatchable
- [ ] Start-with-Windows registry roundtrip
- [ ] Tray icons synced from polling updates
- [ ] Bubble-to-app event callbacks wired
- [ ] Dual-bubble mode tested (both Claude + Codex enabled)
- [ ] `cargo build --release` → working binary
## Success Criteria
- Launching the app twice: second launch exits silently
- Bubble + panel + tray icon all show correct usage after first poll
- Right-click menu functional for all items
- Toggle Claude/Codex via menu: bubbles appear/disappear; settings persist
- Restart app: bubble reappears at last saved position
- Token-expired triggers tray balloon
- Poll frequency change takes effect within one poll cycle
## Risk Assessment
- **Medium.** Most logic is structural — message routing and state management. Source repo has all the patterns.
- Risk: dual-bubble on same UI thread with two HWNDs — should work, but verify message routing keys off `hwnd` parameter.
- Risk: panel auto-close races with bubble re-click. Mitigation: 200 ms debounce on `WM_KILLFOCUS` before destroying panel; if bubble clicked within that window, cancel close.
## Security Considerations
- Settings file written to `%APPDATA%`, user-scoped, no privileged ops.
- Single-instance mutex name `Global\ClaudeCodeUsageBubble` — distinct from source app to allow coexistence.
## Next Steps
→ Phase 5: polish (HiDPI testing, multi-monitor, README, attribution, etc.)
@@ -1,120 +0,0 @@
# Phase 5: Polish & Finishing
## Context Links
- All prior phases
- Source README for attribution / language list
## Overview
- **Priority:** Required before any release
- **Status:** pending
- **Description:** Multi-monitor and HiDPI verification, accessibility checks, README, license attribution, version 0.1.0 tag, optional CI workflow.
## Requirements
### Functional
- Bubble renders crisply at 100% / 125% / 150% / 175% / 200% DPI
- Bubble survives display add/remove (laptop dock/undock)
- Bubble respects monitor work area when snapping (does not overlap taskbar)
- README has install + run + uninstall sections
- LICENSE has CodeZeno attribution paragraph
- `--diagnose` flag works (writes `%TEMP%\claude-code-usage-bubble.log`)
### Non-functional
- Cargo build is fully reproducible
- No clippy warnings on `cargo clippy -- -D warnings` (or document the ones you keep)
## Architecture
No new modules.
## Related Code Files
**To modify:**
- `README.md` — fill out final content
- `LICENSE` — final attribution paragraph
- Maybe `.github/workflows/build.yml` for CI Windows build
**To delete:** none.
## Implementation Steps
1. **HiDPI manual test matrix:**
- Windows 10 at 100% DPI: bubble visible, text readable, ring smooth
- Windows 11 at 150%: same
- 4K monitor at 200%: same
- Mixed-DPI dual monitor: drag bubble between monitors → verify rescale on `WM_DPICHANGED`
2. **Multi-monitor edge tests:**
- Snap bubble to right edge of secondary monitor → settings saved with correct coords
- Disconnect monitor → bubble should reposition to primary monitor's work area on next start
- Test with taskbar on top / left / right (not just default bottom)
3. **README content checklist:**
- One-paragraph what-it-is + screenshot/gif placeholder
- **Attribution section** (required by source MIT license):
> This project is a derivative of [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) (MIT, © 2026 Code Zeno Pty Ltd). The usage-polling, updater, tray-icon, and localization modules are ported from that codebase with minor adaptations; the floating-bubble UI is original to this project.
- Install: cargo build instructions; future winget block
- Use: bubble + panel + tray icon described
- Models: same content as source
- Diagnostics: `--diagnose` flag, log path
- Privacy: same content as source (credentials read locally, GitHub for updates)
- License: MIT
4. **LICENSE file** — include both:
```
MIT License
Copyright (c) 2026 <your name>
Portions of this software are derived from Claude Code Usage Monitor,
Copyright (c) 2026 Code Zeno Pty Ltd, licensed under the MIT License.
<rest of MIT license text>
```
5. **Optional CI** (`.github/workflows/build.yml`):
- Runs `cargo fmt --check`, `cargo clippy`, `cargo build --release` on `windows-latest`
- Uploads artifact on tag
6. **Smoke test before tagging:**
- Run `claude-code-usage-bubble.exe`
- Verify bubble appears with placeholder data (or real data if Claude CLI signed in)
- Drag, snap, expand, menu, exit — all work
- Re-launch → second instance exits silently
- `claude-code-usage-bubble.exe --diagnose` → log file populated
7. **Tag v0.1.0** (only after the above passes):
- `git tag v0.1.0`
- Push to GitHub
- Create release with the .exe artifact attached (so `updater.rs` works for future versions)
## Todo List
- [ ] HiDPI matrix tested
- [ ] Multi-monitor edge tests done
- [ ] README.md final
- [ ] LICENSE attribution finalized
- [ ] Diagnostic log verified
- [ ] Clippy clean
- [ ] CI workflow (optional)
- [ ] Smoke test green
- [ ] v0.1.0 tagged
## Success Criteria
- App can be downloaded fresh, built once, and used end-to-end
- Source repo attribution is unambiguous
- No regressions vs phases 1-4
## Risk Assessment
- **Low.** Polish phase.
- Possible regression: HiDPI bug discovered late — fix in `bubble.rs` painting code.
## Security Considerations
- Verify `updater.rs` `current_install_channel()` still returns `Portable`. Re-enabling winget detection is a future task — not part of v0.1.0.
## Next Steps
→ v0.1.0 release; future tasks (out of scope for this port):
- Winget package submission (when ready)
- Custom bubble art per model (Claude orange, Codex green) replacing inherited icons
- Optional bubble-size setting (S/M/L) in right-click menu
- Auto-hide when fullscreen apps active
@@ -1,83 +0,0 @@
# Plan: claude-code-usage-bubble — port from CodeZeno/Claude-Code-Usage-Monitor
**Mode:** `/ck:xia --port`
**Source repo:** `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor` (Rust ~5.8k LOC, MIT)
**Target repo:** `/config/workspace/CodeZeno/claude-code-usage-bubble` (new)
**Date:** 2026-05-15
## Source Manifest
- Path: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor`
- Branch: `main` @ `b5f038d` (v1.4.1)
- License: MIT (attribution required in new repo README)
- Scope: portable subsystems only — see `phase-02-port-portable-modules.md`
## Decision Matrix (Approved)
| Decision | Choice |
|---|---|
| Platform | Windows-only (Win32 GDI + layered window) |
| WSL credential reading | Keep |
| Snap-to-edge | On, 12px zone, monitor work area |
| Click behavior | Left-click = toggle panel, right-click = menu |
| Dual-model layout | Two independent bubbles, positions persisted per model |
| Winget channel | Code kept, `current_install_channel` stubbed to Portable |
| Single-instance mutex | `Global\ClaudeCodeUsageBubble` |
| Auto-hide when fullscreen | **Yes** (added to phase 3) — detect via `SHQueryUserNotificationState` or `MonitorFromWindow + window-rect == monitor-rect` against foreground HWND |
| Bubble size customization | **Yes**, free range 32–128 px persisted in settings.json; resize via Ctrl+MouseWheel on bubble (no S/M/L menu) |
| Per-model bubble art | **No** — both models share the same bubble look; differentiation only via usage-percentage ring color |
## Dependency Matrix (Source → New)
| Source file | LOC | Action | Target file |
|---|---|---|---|
| `src/models.rs` | 19 | COPY | `src/models.rs` |
| `src/diagnose.rs` | 52 | COPY | `src/diagnose.rs` |
| `src/theme.rs` | 52 | COPY | `src/theme.rs` |
| `src/poller.rs` | 1099 | COPY | `src/poller.rs` |
| `src/updater.rs` | 510 | COPY + stub channel | `src/updater.rs` |
| `src/tray_icon.rs` | 441 | COPY | `src/tray_icon.rs` |
| `src/localization/*` | ~620 | COPY | `src/localization/*` |
| `src/native_interop.rs` | 179 | ADAPT (drop taskbar/WinEvent helpers) | `src/native_interop.rs` |
| `src/main.rs` | 40 | ADAPT (call `bubble::run` instead of `window::run`, rename single-instance mutex) | `src/main.rs` |
| `src/window.rs` | 2847 | **REWRITE** as `bubble.rs` + `panel.rs` + `settings.rs` + `app.rs` | NEW |
| `build.rs`, `Cargo.toml`, `src/icons/*` | — | ADAPT | NEW |
## Phases
| # | Phase | Status | File |
|---|---|---|---|
| 1 | Bootstrap repo (Cargo.toml, build.rs, LICENSE, README, icons) | pending | `phase-01-bootstrap-repo.md` |
| 2 | Port portable modules verbatim | pending | `phase-02-port-portable-modules.md` |
| 3 | Build floating bubble window (layered alpha, GDI ring, drag-anywhere, snap) | pending | `phase-03-build-bubble-window.md` |
| 4 | Build expanded panel + settings persistence + orchestration | pending | `phase-04-panel-and-orchestration.md` |
| 5 | Polish: HiDPI, multi-monitor, startup registry, mutex, tray icon wiring, README | pending | `phase-05-polish-and-finishing.md` |
## Risk Score
**Medium.** Highest-risk surface is **phase 3** — circular layered window with HiDPI-aware GDI ring drawing. Source codebase has no precedent for that exact pattern; needs fresh implementation. All other phases are straightforward ports or thin orchestration.
| Risk | Severity | Mitigation |
|---|---|---|
| GDI ring + ClearType text on layered alpha window | High | Reference `window.rs` UpdateLayeredWindow + DIB section pattern (lines around layered painting); keep ring math simple (parametric arc) |
| Drag + snap interaction on multi-monitor | Medium | Use `MonitorFromPoint` per move; clamp to nearest monitor work area |
| Two-bubble position state | Low | Independent `BubbleState` structs in settings.json |
| WSL credential read regressions | Low | Verbatim port; no behavioral changes |
## Estimated Effort
- Phase 1: 1–2h
- Phase 2: 1h (mostly file copies + import path fixes)
- Phase 3: 6–10h (the heavy lift)
- Phase 4: 3–5h
- Phase 5: 2–4h
**Total:** ~15–22h of focused implementation.
## Rollback Strategy
The new repo is greenfield — rollback means `rm -rf /config/workspace/CodeZeno/claude-code-usage-bubble`. No source-repo changes; this plan does not modify the source app.
## Open Questions
- None. All three formerly-deferred items resolved by user on 2026-05-15 (see Decision Matrix rows 8–10).
@@ -1,285 +0,0 @@
---
phase: 1
status: pending
estimated_hours: 9
---
# Phase 1 — Infrastructure
## Context links
- Brainstorm: [`../reports/brainstorm-260516-0707-cleanroom-reimplementation.md`](../reports/brainstorm-260516-0707-cleanroom-reimplementation.md) (axes 1, 2, 9, 10 + os/)
- Source files to be REPLACED later: `src/diagnose.rs`, `src/theme.rs`, `src/native_interop.rs` (do not delete this phase — Phase 2+ depends on them until then)
## Overview
- **Priority:** Critical (every other phase depends on these primitives)
- **Status:** pending
- **Brief:** Stand up the foundation modules — logging, Win32 helpers, WinHTTP client, and the build-script swap — without touching any business logic. End of this phase: `cargo build --release` succeeds with the new modules compiled in but not yet referenced by `app.rs` (so behavior is unchanged).
## Key insights from brainstorm
- WinHTTP via `windows-rs::Win32::Networking::WinHttp` removes `ureq` + `native-tls` (~600 KB binary savings) and respects the system proxy automatically.
- WinHTTP is verbose. Encapsulate in a single ~150 LOC `net::winhttp::Client` so the rest of the codebase stays clean.
- `log` + `simplelog` replaces the bespoke `OnceLock<Mutex<File>>` logger. Standard ecosystem, same one-line ergonomics.
- `embed-resource` replaces `winres`. Same purpose, different file shape (`res/icon.rc` instead of builder API).
- `os/` directory consolidates color, wide-string, DPI, registry, theme helpers (currently scattered across `theme.rs` and `native_interop.rs`).
## Requirements
### Functional
- `diag::init(enabled: bool)` writes a log file at `%TEMP%\claude-code-usage-bubble.log` when called with `true`; no-op otherwise.
- `log::info!` / `log::warn!` / `log::error!` macros work and route to the file.
- `os::wide_str(&str) -> Vec<u16>` returns a NUL-terminated UTF-16 vector.
- `os::Color` provides hex parsing + COLORREF conversion.
- `os::dpi::for_window(hwnd)` returns u32 DPI ≥ 96.
- `os::registry::read_string(hkey, path, name)` returns Option<String>.
- `os::registry::write_string(hkey, path, name, value)` returns Result.
- `os::registry::delete_value(hkey, path, name)` returns Result.
- `os::theme::is_dark()` returns bool from registry.
- `net::winhttp::Client::new()` constructs a client with a user-agent string.
- `client.get(url).header(k, v).send()` returns `Result<Response, net::Error>`.
- `client.post(url).header(k, v).json_body(value).send()` returns `Result<Response, net::Error>`.
- `Response::status() -> u32`, `Response::header(&str) -> Option<&str>`, `Response::text() -> Result<String, net::Error>`, `Response::json<T>() -> Result<T, net::Error>`.
- Build script (`build.rs`) embeds `res/icon.ico` and version info via `embed-resource`.
### Non-functional
- Binary size after this phase: ≤ current (we add `tiny-skia`/`embed-resource`/`simplelog`/`thiserror` deps in later phases; this phase should not balloon).
- No new behavior changes vs current build.
- All new modules pass `cargo clippy -- -W clippy::all` with zero new warnings.
## Architecture
### `diag/mod.rs` (~30 LOC)
```rust
use std::path::PathBuf;
use simplelog::{Config, LevelFilter, WriteLogger};
use std::fs::File;
pub fn init(enabled: bool) -> Result<Option<PathBuf>, std::io::Error> {
if !enabled { return Ok(None); }
let path = std::env::temp_dir().join("claude-code-usage-bubble.log");
WriteLogger::init(LevelFilter::Debug, Config::default(), File::create(&path)?)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
log::info!("diagnostic logging enabled");
Ok(Some(path))
}
```
Callers use `log::info!` etc directly — no `diagnose::log(...)` indirection.
### `os/` directory
| File | Responsibility | LOC |
|---|---|---|
| `mod.rs` | `pub use` re-exports + module declarations | ~10 |
| `color.rs` | `Color { r, g, b }`, `from_hex`, `to_colorref()` | ~30 |
| `string.rs` | `wide_str(&str) -> Vec<u16>` | ~5 |
| `dpi.rs` | `for_window(hwnd)`, `for_system()`, `scale(logical, dpi)` | ~20 |
| `registry.rs` | typed wrapper over `RegOpenKeyExW`/`RegQueryValueExW`/`RegSetValueExW`/`RegDeleteValueW` | ~80 |
| `theme.rs` | `is_dark()` → reads `SystemUsesLightTheme` via `os::registry` | ~15 |
### `net/winhttp.rs` (~150 LOC)
```rust
pub struct Client {
session: HINTERNET,
user_agent: Vec<u16>,
}
pub struct RequestBuilder<'a> {
client: &'a Client,
method: Method,
url: Url,
headers: Vec<(Vec<u16>, Vec<u16>)>,
body: Option<Vec<u8>>,
}
pub struct Response {
status: u32,
headers: HashMap<String, String>,
body: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("WinHTTP error {code}: {context}")]
Win(u32, String),
#[error("HTTP {status}")]
Status(u32),
#[error("JSON parse: {0}")]
Json(#[from] serde_json::Error),
#[error("invalid URL: {0}")]
Url(String),
#[error("UTF-8 conversion failed")]
Utf8,
}
```
Internally chains: `WinHttpOpen` → `WinHttpCrackUrl` → `WinHttpConnect` → `WinHttpOpenRequest` → `WinHttpSendRequest` → `WinHttpReceiveResponse` → `WinHttpQueryHeaders` → `WinHttpReadData`.
### `Cargo.toml` updates
```toml
[dependencies]
# REMOVED: ureq, native-tls
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6"
log = "0.4"
simplelog = "0.12"
thiserror = "2"
[dependencies.windows]
version = "0.58"
features = [
# existing features +
"Win32_Networking_WinHttp",
]
[build-dependencies]
# REMOVED: winres
embed-resource = "3"
```
### `build.rs`
```rust
fn main() {
embed_resource::compile("res/icon.rc", embed_resource::NONE);
}
```
### `res/icon.rc` (new file)
```
#include <winver.h>
1 ICON "..\\src\\icons\\icon.ico"
1 VERSIONINFO
FILEVERSION 0,1,0,0
PRODUCTVERSION 0,1,0,0
FILEOS 0x40004L
FILETYPE 0x1L
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904E4"
BEGIN
VALUE "ProductName", "Claude Code Usage Bubble\0"
VALUE "FileDescription", "Claude Code Usage Bubble\0"
VALUE "OriginalFilename", "claude-code-usage-bubble.exe\0"
VALUE "InternalName", "ClaudeCodeUsageBubble\0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1252
END
END
```
## Related code files
**To create:**
- `src/diag/mod.rs`
- `src/os/mod.rs`
- `src/os/color.rs`
- `src/os/string.rs`
- `src/os/dpi.rs`
- `src/os/registry.rs`
- `src/os/theme.rs`
- `src/net/mod.rs`
- `src/net/winhttp.rs`
- `res/icon.rc`
**To modify:**
- `Cargo.toml` (add new deps, remove `ureq`+`native-tls`+`winres`, add `Win32_Networking_WinHttp`)
- `build.rs` (replace winres with embed-resource)
- `src/main.rs` (add `mod diag; mod os; mod net;` declarations — do NOT remove old `mod diagnose; mod theme; mod native_interop;` yet)
**To delete:** nothing in this phase (Phase 2 removes `theme.rs`, etc.)
## Implementation steps
1. **Add new dependencies** to `Cargo.toml`: `log`, `simplelog`, `thiserror`, `embed-resource`. Add `Win32_Networking_WinHttp` to `windows` features. Leave `ureq`, `native-tls`, `winres` in place for now.
2. **Create `res/icon.rc`** referencing `src/icons/icon.ico`.
3. **Replace `build.rs`** with `embed-resource::compile`.
4. **`cargo build --release`** — verify icon embedding works (PE has icon resource).
5. **Create `src/os/string.rs`** with `wide_str()`. Trivial.
6. **Create `src/os/color.rs`** with `Color` struct + `from_hex` + `to_colorref`.
7. **Create `src/os/registry.rs`** with `read_string`, `read_u32`, `write_string`, `delete_value` — `unsafe` wrappers over Win32 registry APIs returning `Result<T, RegistryError>`.
8. **Create `src/os/theme.rs`** calling `registry::read_u32(HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", "SystemUsesLightTheme")` and inverting.
9. **Create `src/os/dpi.rs`** with `for_window`, `for_system`, `scale`.
10. **Create `src/os/mod.rs`** with `pub mod color; pub mod string; pub mod dpi; pub mod registry; pub mod theme;` + re-exports of common items (`Color`, `wide_str`).
11. **Create `src/diag/mod.rs`** with `init(bool)` that initialises simplelog. Add `log::set_max_level` if not handled by simplelog.
12. **Create `src/net/winhttp.rs`** in 4 commits:
- 12a. `Client::new`, `Drop` for `WinHttpCloseHandle`.
- 12b. `RequestBuilder` + GET path.
- 12c. POST + JSON body.
- 12d. `Response::header` + `Response::text` + `Response::json`.
13. **Create `src/net/mod.rs`** with `pub mod winhttp;` + `pub use winhttp::{Client, Error, Response};`.
14. **Wire modules into `src/main.rs`**:
```rust
mod diag;
mod net;
mod os;
// OLD ones still present for now:
mod diagnose;
mod theme;
mod native_interop;
```
15. **`cargo build --release`** — must compile clean. Binary should run identically to current build (we haven't replaced anything yet).
16. **`cargo clippy --release`** — fix any clippy warnings on new modules.
17. **Manual smoke test on Windows** (or note as deferred to Phase 4 testing):
- Run `--diagnose`, confirm log file exists at `%TEMP%\claude-code-usage-bubble.log` (but it's empty for now since nothing calls `log::info!` yet — that's OK).
- Run a tiny dev-only test binary or `cargo test` that exercises `net::winhttp::Client.get("https://api.github.com").send()` to validate the HTTP wrapper end-to-end.
## Todo checklist
- [ ] Cargo.toml deps updated
- [ ] `res/icon.rc` created
- [ ] `build.rs` swapped to embed-resource
- [ ] Build produces .exe with embedded icon
- [ ] `src/os/string.rs`
- [ ] `src/os/color.rs`
- [ ] `src/os/registry.rs`
- [ ] `src/os/theme.rs`
- [ ] `src/os/dpi.rs`
- [ ] `src/os/mod.rs`
- [ ] `src/diag/mod.rs`
- [ ] `src/net/winhttp.rs` (incremental, 4 sub-commits)
- [ ] `src/net/mod.rs`
- [ ] `src/main.rs` declares new modules
- [ ] `cargo build --release` clean
- [ ] `cargo clippy` clean
- [ ] WinHTTP smoke test passes against `api.github.com`
## Success criteria
- Phase ends with a runnable binary that behaves exactly like the previous version (no business logic changes).
- `net::winhttp::Client` can successfully GET `https://api.github.com/repos/tiennm99/claude-code-usage-bubble/releases/latest` and parse JSON.
- `log::info!("test")` from anywhere writes to `%TEMP%\claude-code-usage-bubble.log` when `--diagnose` is passed.
- Binary size: not larger than current (we've removed `ureq`+`native-tls`, added smaller crates).
## Risks + mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| WinHTTP TLS handshake fails on older Windows | Low | Test on Win10/Win11; WinHTTP supports TLS 1.2+ since Win10 1607 |
| `WinHttpCrackUrl` is awkward; URL parsing has edge cases | Medium | Use `url` crate (~50 KB) for parsing, then pass components to WinHTTP |
| `embed-resource` doesn't match `winres`'s VERSIONINFO output exactly | Low | Verify with `mt /inspect output.exe` |
| `log` + `simplelog` collide with another logger init | None | App owns the only init |
| Chunked transfer / compression auto-decode disabled | Medium | Set `WINHTTP_OPTION_DECOMPRESSION` flag |
## Security considerations
- WinHTTP enforces certificate validation by default — don't disable.
- `registry` module writes only to `HKEY_CURRENT_USER` (user-scoped). No admin escalation.
- Log file path is `%TEMP%` — user-scoped. No secrets logged (verify in Phase 4 when adding token-handling logs).
## Next steps
→ Phase 2: replace `models.rs` + `localization/*` with `usage/types.rs` + `i18n/` directory.
@@ -1,403 +0,0 @@
---
phase: 2
status: pending
estimated_hours: 6
---
# Phase 2 — Types & i18n
## Context links
- Brainstorm: axes 3 (provider types) + 6 (localization)
- Source files to be REPLACED: `src/models.rs`, `src/localization/*` (9 files)
## Overview
- **Priority:** High (Phase 4 providers depend on these types; bubble/panel/app render using them)
- **Status:** pending
- **Brief:** Define the new provider-result data types and replace the 9 hand-coded localization Rust files with one Rust loader + 9 TOML files. Refactor consumer imports (`bubble.rs`, `panel.rs`, `app.rs`, `settings.rs`) to the new shape. End of phase: source's `models.rs` and `localization/*` deleted.
## Key insights from brainstorm
- Source's `UsageData { session, weekly }` is one shape; `UsageWindows { primary, secondary }` (or a `HashMap<Window, Reset>`) is structurally different and works for both Anthropic (5h/7d) and Codex (which already uses "primary/secondary" terminology in its API response).
- Source dispatches localization via `enum LanguageId` + matching const tables. Embedded TOML via `include_str!` + parsed-at-startup HashMap is structurally different and easier for translators.
- `LocaleStrings` becomes a `serde::Deserialize` struct keyed by TOML section.
## Requirements
### Functional
- `usage::types::UsageWindows` carries `primary: Window`, `secondary: Window`, both `pub`.
- `usage::types::Window { utilization: f64, resets_at: Option<SystemTime> }`.
- `usage::types::ProviderId` enum: `Claude`, `ChatGpt` (note: renamed from "Codex" internally; menu label stays "Codex" via i18n).
- `usage::types::ProviderSnapshot { id: ProviderId, windows: Result<UsageWindows, usage::Error> }` for app-level results.
- `i18n::I18n::load(active_code: Option<&str>) -> Self` parses all embedded TOMLs at startup.
- `i18n::I18n::strings() -> &LocaleStrings` returns the active language's strings.
- `i18n::LocaleStrings` is a single struct with all UI strings as named fields (matches what bubble/panel/app need).
- `i18n::detect::detect_system_locale() -> Option<String>` mirrors source's `GetUserPreferredUILanguages` chain.
### Non-functional
- Adding a new language = drop a new TOML file in `src/i18n/locales/` + add one line in `i18n/mod.rs` `include_str!` map.
- TOML parsing happens once at startup; ~9 small files combined < 10 KB; parse time < 5 ms.
## Architecture
### `src/usage/types.rs`
```rust
use std::time::SystemTime;
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub enum ProviderId {
Claude,
ChatGpt,
}
impl ProviderId {
pub fn as_str(self) -> &'static str {
match self { Self::Claude => "claude", Self::ChatGpt => "chatgpt" }
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Window {
pub utilization: f64, // 0.0–100.0
pub resets_at: Option<SystemTime>,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct UsageWindows {
pub primary: Window, // 5h for Claude / primary_window for ChatGPT
pub secondary: Window, // 7d for Claude / secondary_window for ChatGPT
}
#[derive(Clone, Debug)]
pub struct ProviderSnapshot {
pub id: ProviderId,
pub windows: UsageWindows,
}
```
### `src/usage/mod.rs` (Phase 2 portion — provider trait stub goes here, real impls in Phase 4)
```rust
pub mod types;
pub use types::{ProviderId, Window, UsageWindows, ProviderSnapshot};
// Provider trait lives here; impls (anthropic.rs, chatgpt.rs) come in Phase 4.
pub trait UsageProvider: Send {
fn id(&self) -> ProviderId;
fn poll(&mut self, http: &crate::net::Client) -> Result<UsageWindows, Error>;
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("authentication required")]
AuthRequired,
#[error("no credentials configured")]
NoCredentials,
#[error("token expired and refresh failed")]
TokenExpired,
#[error("network: {0}")]
Network(#[from] crate::net::Error),
#[error("response shape mismatch: {0}")]
BadResponse(String),
#[error("credential read: {0}")]
Creds(#[from] crate::creds::Error), // forward-declared, real type in Phase 3
}
```
In Phase 2 we leave `creds::Error` and the impls as `todo!()` stubs that won't link until Phase 3/4.
### `src/i18n/mod.rs`
```rust
use std::collections::HashMap;
use serde::Deserialize;
pub mod detect;
#[derive(Clone, Deserialize)]
pub struct LocaleStrings {
pub window_title: String,
pub refresh: String,
pub update_frequency: String,
pub one_minute: String,
pub five_minutes: String,
pub fifteen_minutes: String,
pub one_hour: String,
pub models: String,
pub claude_label: String, // was claude_code_model
pub chatgpt_label: String, // was codex_model
pub settings: String,
pub start_with_windows: String,
pub reset_position: String,
pub language: String,
pub system_default: String,
pub check_for_updates: String,
pub checking_for_updates: String,
pub up_to_date: String,
pub update_failed: String,
pub applying_update: String,
pub update_available: String,
pub update_via_winget: String, // was update_via_winget_label
pub exit: String,
pub show_widget: String,
pub session_window: String,
pub weekly_window: String,
pub now: String,
pub day_suffix: String,
pub hour_suffix: String,
pub minute_suffix: String,
pub second_suffix: String,
pub token_expired_title: String,
pub token_expired_body: String,
pub chatgpt_token_expired_title: String,
pub chatgpt_token_expired_body: String,
}
#[derive(Deserialize)]
struct LocaleFile {
code: String,
native_name: String,
#[serde(flatten)]
strings: LocaleStrings,
}
pub struct I18n {
available: HashMap<String, (String, LocaleStrings)>, // code → (native_name, strings)
active: String,
}
impl I18n {
pub fn load(active_code: Option<&str>) -> Self {
let raw = [
("en", include_str!("locales/en.toml")),
("nl", include_str!("locales/nl.toml")),
("es", include_str!("locales/es.toml")),
("fr", include_str!("locales/fr.toml")),
("de", include_str!("locales/de.toml")),
("ja", include_str!("locales/ja.toml")),
("ko", include_str!("locales/ko.toml")),
("zh-TW", include_str!("locales/zh-TW.toml")),
];
let mut available = HashMap::new();
for (code, body) in raw {
if let Ok(file) = toml::from_str::<LocaleFile>(body) {
available.insert(code.to_string(), (file.native_name, file.strings));
}
}
let active = match active_code {
Some(c) if available.contains_key(c) => c.to_string(),
_ => detect::detect_system_locale()
.and_then(|s| Self::normalize(&s, &available))
.unwrap_or_else(|| "en".to_string()),
};
Self { available, active }
}
pub fn strings(&self) -> &LocaleStrings {
&self.available[&self.active].1
}
pub fn active_code(&self) -> &str { &self.active }
pub fn available(&self) -> impl Iterator<Item = (&str, &str)> {
self.available.iter().map(|(code, (name, _))| (code.as_str(), name.as_str()))
}
fn normalize(code: &str, available: &HashMap<String, (String, LocaleStrings)>) -> Option<String> {
// "en-US" → "en", "zh-Hant-TW" → "zh-TW", etc.
// Exact match first, then prefix.
let lower = code.to_ascii_lowercase().replace('_', "-");
if available.contains_key(&lower) { return Some(lower); }
let prefix = lower.split('-').next().unwrap_or("");
if prefix == "zh" && (lower.contains("tw") || lower.contains("hant")) {
return Some("zh-TW".into());
}
available.keys()
.find(|k| k.split('-').next() == Some(prefix))
.cloned()
}
}
```
### `src/i18n/detect.rs`
Mirrors source's `preferred_ui_languages` + `default_ui_locale` + `default_locale_name` chain via Win32 globalization APIs, but in one function:
```rust
pub fn detect_system_locale() -> Option<String> {
preferred().or_else(default_ui).or_else(default_user)
}
fn preferred() -> Option<String> { /* GetUserPreferredUILanguages */ }
fn default_ui() -> Option<String> { /* GetUserDefaultUILanguage + LCIDToLocaleName */ }
fn default_user() -> Option<String> { /* GetUserDefaultLocaleName */ }
```
### `src/i18n/locales/en.toml`
```toml
code = "en"
native_name = "English"
window_title = "Claude Code Usage Bubble"
refresh = "Refresh"
update_frequency = "Update frequency"
one_minute = "1 minute"
five_minutes = "5 minutes"
fifteen_minutes = "15 minutes"
one_hour = "1 hour"
models = "Models"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Settings"
start_with_windows = "Start with Windows"
reset_position = "Reset position"
language = "Language"
system_default = "System default"
check_for_updates = "Check for updates"
checking_for_updates = "Checking for updates…"
up_to_date = "Up to date"
update_failed = "Update failed"
applying_update = "Applying update…"
update_available = "Update available"
update_via_winget = "via WinGet"
exit = "Exit"
show_widget = "Show widget"
session_window = "5h"
weekly_window = "7d"
now = "now"
day_suffix = "d"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Claude Code session expired"
token_expired_body = "Sign in again to keep usage reporting."
chatgpt_token_expired_title = "Codex session expired"
chatgpt_token_expired_body = "Sign in again to keep usage reporting."
```
The 8 other locale files mirror this shape with translated strings. **Important:** copy the translations from `src/localization/*.rs` content (the strings themselves are utilitarian/factual translations and not copyright-eligible the way code is — but for safety, re-translate the most unique strings using your own phrasing).
### Consumer refactors (in this phase)
**`src/app.rs`:**
- `use crate::localization::{LanguageId, Strings, resolve_language}` → `use crate::i18n::{I18n, LocaleStrings}`
- `s.language.strings()` → `s.i18n.strings()`
- `LanguageId::ALL.iter()` → `s.i18n.available()`
- All field renames: `claude_code_model` → `claude_label`, `codex_model` → `chatgpt_label`, etc.
**`src/bubble.rs`:** no localization access; only depends on bubble-specific data. Unaffected.
**`src/panel.rs`:**
- `data.strings.session_window` works unchanged (field name preserved).
- `data.strings.claude_code_model` → `data.strings.claude_label`.
**`src/settings.rs`:** unchanged (it stores `language: Option<String>` already, which holds a locale code).
**`src/models.rs`:** delete. Replace `crate::models::{AppUsageData, UsageData, UsageSection}` consumers:
- `AppUsageData` → `Vec<ProviderSnapshot>`
- `UsageData` → `UsageWindows`
- `UsageSection` → `Window`
Migration map for `app.rs`:
- `s.data.claude_code.as_ref()` → `s.snapshots.iter().find(|sn| sn.id == ProviderId::Claude)`
- `c.session.percentage` → `sn.windows.primary.utilization`
- `c.weekly.percentage` → `sn.windows.secondary.utilization`
## Related code files
**To create:**
- `src/usage/mod.rs`
- `src/usage/types.rs`
- `src/i18n/mod.rs`
- `src/i18n/detect.rs`
- `src/i18n/locales/en.toml`
- `src/i18n/locales/nl.toml`
- `src/i18n/locales/es.toml`
- `src/i18n/locales/fr.toml`
- `src/i18n/locales/de.toml`
- `src/i18n/locales/ja.toml`
- `src/i18n/locales/ko.toml`
- `src/i18n/locales/zh-TW.toml`
**To modify:**
- `Cargo.toml` — add `toml = "0.8"` (with default features)
- `src/main.rs` — declare `mod usage; mod i18n;`; remove `mod models; mod localization;`
- `src/app.rs` — migrate all `crate::models::*` and `crate::localization::*` imports
- `src/panel.rs` — field renames
- `src/bubble.rs` — only if it references `LanguageId` (it shouldn't)
**To delete:**
- `src/models.rs`
- `src/localization/mod.rs`
- `src/localization/english.rs`
- `src/localization/dutch.rs`
- `src/localization/spanish.rs`
- `src/localization/french.rs`
- `src/localization/german.rs`
- `src/localization/japanese.rs`
- `src/localization/korean.rs`
- `src/localization/traditional_chinese.rs`
## Implementation steps
1. **Add `toml = "0.8"`** to `Cargo.toml`.
2. **Create `src/usage/types.rs`** (struct definitions only — no impls yet).
3. **Create `src/usage/mod.rs`** with trait `UsageProvider` and `Error` enum. Leave it without any impls.
4. **Create `src/i18n/locales/en.toml`** first; verify TOML structure parses.
5. **Add `src/i18n/mod.rs` + `src/i18n/detect.rs`** with `I18n::load` reading only `en.toml`.
6. **Wire `mod i18n; mod usage;` into `main.rs`** and call `I18n::load(None)` from `app::run` (storing on `AppState`). Build should still compile (no usages downstream yet).
7. **Migrate `app.rs`** field-by-field from `Strings` to `LocaleStrings`. Run `cargo check` after each subsystem (menu, balloon, panel-data, tray-tooltip).
8. **Translate the other 8 locale TOMLs.** Use your own phrasings for the longer strings (e.g. `token_expired_body`) rather than direct copies of upstream's translations.
9. **Add the other 8 `include_str!` entries** to `i18n/mod.rs`.
10. **Migrate `panel.rs`** field renames (small).
11. **Migrate `app.rs` data model** from `AppUsageData` to `Vec<ProviderSnapshot>`. This is the biggest single edit. Update `apply_data`, `apply_usage_update`, `build_panel_data_from`, `refresh_tray_icons`, `refresh_text_fields`.
12. **Delete `src/models.rs` + `src/localization/*`** once nothing references them.
13. **`cargo build --release`** — clean.
## Todo checklist
- [ ] `usage/types.rs` written
- [ ] `usage/mod.rs` written (trait + Error stubs)
- [ ] `i18n/mod.rs` + `detect.rs` written
- [ ] 9 TOML locale files written (translations are your own paraphrasings)
- [ ] `Cargo.toml` adds `toml` dep
- [ ] `main.rs` declares new modules + removes old ones
- [ ] `app.rs` migrated to `LocaleStrings` + `Vec<ProviderSnapshot>`
- [ ] `panel.rs` field renames done
- [ ] `bubble.rs` confirmed unaffected
- [ ] Old `src/models.rs` + `src/localization/*` deleted
- [ ] `cargo build --release` clean
- [ ] App still runs (placeholder data since providers aren't wired yet)
## Success criteria
- TOML files parse cleanly at startup.
- App shows correct language strings based on Windows display language.
- No file in `src/` shares a name with upstream's `models.rs` or `localization/*`.
- Right-click → Language submenu lists 9 options (system default + 8 languages) and switching them updates UI immediately.
## Risks + mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| TOML serde derive misalignment (typos in field names) | High | Use `#[serde(deny_unknown_fields)]` to catch typos at load time |
| Translations differ enough from upstream that meaning drifts | Medium | Compare meaning side-by-side before committing; ask a native speaker for the long strings if you can |
| Bubble/panel field references break in subtle places | Medium | `cargo check` after each consumer edit |
| App startup slows due to TOML parsing | Negligible | TOML files combined < 10 KB |
## Security considerations
- TOML strings are static, no eval. Parse failures fall back to English silently. No injection risk.
- No PII in locale files.
## Next steps
→ Phase 3: replace credential reading with `creds/` directory.
## Open questions
- **Translation copyright.** The upstream localization files contain ~30 short UI strings per language. These are utility translations of standard UI vocabulary and are unlikely to be copyrightable individually, but for full clean-room status, re-paraphrase the longest strings (`token_expired_body` and `chatgpt_token_expired_body`). Recommended: write your own phrasing for those two.
@@ -1,253 +0,0 @@
---
phase: 3
status: pending
estimated_hours: 3
---
# Phase 3 — `creds/` module (credential discovery)
## Context links
- Brainstorm: axis 4 (credential discovery) + 5 (refresh — only the discovery part lives here; orchestrator lives in Phase 4)
- Source file to be REPLACED: parts of `src/poller.rs` (credential reading/discovery), `src/native_interop.rs` (WSL command execution)
## Overview
- **Priority:** Medium — Phase 4 providers depend on this.
- **Status:** pending
- **Brief:** Introduce a `trait CredentialSource` with three impls (local Claude, WSL Claude, local Codex). Replace the source's `enum CredentialSource { Windows, Wsl }` + serial fallback with a registry-pattern + iterator.
## Key insights from brainstorm
- Trait-based discovery is structurally different from source's enum + match dispatch.
- `Vec<Box<dyn CredentialSource>>` ordered by priority lets future additions (e.g. an environment-variable-based source) drop in with no changes to the locator.
- Change-detection signatures (used by app's "watch for re-auth" loop) become a trait method.
## Requirements
### Functional
- `creds::Token { access_token: String, expires_at_unix_ms: Option<i64>, account_id: Option<String> }`.
- `trait creds::CredentialSource: Send + Sync`:
- `fn id(&self) -> &str` — stable identifier ("local-claude", "wsl:Ubuntu-22", "codex").
- `fn read(&self) -> Result<Token, Error>`.
- `fn signature(&self) -> Option<String>` — opaque hash/key for change detection.
- `fn refresh_hint(&self) -> RefreshHint` — what command to spawn for refresh.
- `creds::CredentialLocator::default_claude()` builds a locator with local Windows path first, then all installed WSL distros.
- `creds::CredentialLocator::default_codex()` builds a locator with the local Codex path.
- `locator.first_available() -> Option<&dyn CredentialSource>`.
- `locator.signatures() -> Vec<String>`.
### Non-functional
- WSL probe (which spawns `wsl.exe -l -q`) must complete in ≤ 5s or be timed out.
- WSL token-read must complete in ≤ 5s or be timed out.
- No blocking work in `signature()` (it's called frequently from the poll loop) — only stat/file-size, not file-read.
## Architecture
### `src/creds/mod.rs`
```rust
use std::time::Duration;
pub mod local_fs;
pub mod wsl_bridge;
pub mod codex_auth;
#[derive(Debug, Clone)]
pub struct Token {
pub access_token: String,
pub expires_at_unix_ms: Option<i64>,
pub account_id: Option<String>,
}
#[derive(Debug, Clone)]
pub enum RefreshHint {
LocalCliCommand { exe: &'static str },
WslCliCommand { distro: String },
Codex,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("credential file not found at {path}")]
NotFound { path: String },
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("invalid JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("missing field in credential JSON: {0}")]
MissingField(&'static str),
#[error("WSL command failed: {0}")]
WslCommand(String),
#[error("timeout waiting for WSL command")]
WslTimeout,
}
pub trait CredentialSource: Send + Sync {
fn id(&self) -> &str;
fn read(&self) -> Result<Token, Error>;
fn signature(&self) -> Option<String>;
fn refresh_hint(&self) -> RefreshHint;
}
pub struct CredentialLocator {
sources: Vec<Box<dyn CredentialSource>>,
}
impl CredentialLocator {
pub fn new(sources: Vec<Box<dyn CredentialSource>>) -> Self {
Self { sources }
}
pub fn default_claude() -> Self {
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
if let Some(local) = local_fs::LocalClaudeCreds::detect() {
sources.push(Box::new(local));
}
for distro in wsl_bridge::list_distros() {
sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro)));
}
Self { sources }
}
pub fn default_codex() -> Self {
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
if let Some(codex) = codex_auth::LocalCodexCreds::detect() {
sources.push(Box::new(codex));
}
Self { sources }
}
pub fn first_available(&self) -> Option<&dyn CredentialSource> {
self.sources.iter().find(|s| s.signature().is_some()).map(Box::as_ref)
}
pub fn signatures(&self) -> Vec<String> {
self.sources.iter().filter_map(|s| s.signature()).collect()
}
pub fn iter(&self) -> impl Iterator<Item = &dyn CredentialSource> {
self.sources.iter().map(Box::as_ref)
}
}
```
### `src/creds/local_fs.rs`
```rust
use std::path::PathBuf;
pub struct LocalClaudeCreds {
path: PathBuf,
id: String,
}
impl LocalClaudeCreds {
pub fn detect() -> Option<Self> {
let home = dirs::home_dir()?;
let path = home.join(".claude").join(".credentials.json");
Some(Self { id: format!("local:{}", path.display()), path })
}
}
impl super::CredentialSource for LocalClaudeCreds {
fn id(&self) -> &str { &self.id }
fn read(&self) -> Result<super::Token, super::Error> {
let content = std::fs::read_to_string(&self.path)?;
parse_claude_json(&content)
}
fn signature(&self) -> Option<String> {
let meta = std::fs::metadata(&self.path).ok()?;
let modified = meta.modified().ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs()).unwrap_or(0);
Some(format!("{}|{}|{}", self.id, meta.len(), modified))
}
fn refresh_hint(&self) -> super::RefreshHint {
super::RefreshHint::LocalCliCommand { exe: "claude" }
}
}
pub fn parse_claude_json(content: &str) -> Result<super::Token, super::Error> {
let json: serde_json::Value = serde_json::from_str(content)?;
let oauth = json.get("claudeAiOauth")
.ok_or(super::Error::MissingField("claudeAiOauth"))?;
let access_token = oauth.get("accessToken")
.and_then(|v| v.as_str())
.ok_or(super::Error::MissingField("accessToken"))?
.to_string();
let expires_at_unix_ms = oauth.get("expiresAt").and_then(|v| v.as_i64());
Ok(super::Token { access_token, expires_at_unix_ms, account_id: None })
}
```
### `src/creds/wsl_bridge.rs`
Spawns `wsl.exe -l -q` to enumerate distros, then per-distro spawns `wsl.exe -d <distro> -- sh -lc 'cat ~/.claude/.credentials.json'`. Includes UTF-16LE-aware text decoder for `wsl.exe -l -q` output. Uses `CREATE_NO_WINDOW` flag. 5s timeout per command via a `run_with_timeout` helper.
Key public types:
- `pub fn list_distros() -> Vec<String>` — empty Vec if WSL not installed.
- `pub struct WslClaudeCreds { distro: String, id: String }` implementing `CredentialSource`.
### `src/creds/codex_auth.rs`
Reads `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Token includes `account_id` from `tokens.account_id`. Mirrors `local_fs.rs` pattern.
## Related code files
**To create:**
- `src/creds/mod.rs`
- `src/creds/local_fs.rs`
- `src/creds/wsl_bridge.rs`
- `src/creds/codex_auth.rs`
**To modify:**
- `src/main.rs` — `mod creds;`
**To delete:** nothing (Phase 4 deletes `src/poller.rs` once `usage::*` providers go live).
## Implementation steps
1. Create `src/creds/mod.rs` with trait + `Token` + `Error` + `RefreshHint` + `CredentialLocator`.
2. Create `local_fs.rs` with `LocalClaudeCreds`.
3. Create `wsl_bridge.rs` with distro enumeration + per-distro creds source. Note `decode_wsl_text` handles the UTF-16LE encoding quirk on `wsl.exe -l -q`.
4. Create `codex_auth.rs` with `LocalCodexCreds`.
5. Wire `mod creds;` into `main.rs`.
6. `cargo build --release` clean.
## Todo checklist
- [ ] `creds/mod.rs`
- [ ] `creds/local_fs.rs`
- [ ] `creds/wsl_bridge.rs`
- [ ] `creds/codex_auth.rs`
- [ ] `main.rs` declares module
- [ ] `cargo build --release` clean
- [ ] Manual test (Windows): `CredentialLocator::default_claude().first_available()` finds a real credential file
## Success criteria
- Trait dispatch works; locator returns the right source based on priority.
- WSL probe doesn't hang the process when no WSL is installed.
- `signature()` is fast (<1 ms for local, <100 ms for WSL).
## Risks + mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| WSL probe blocks for full 5s when WSL is uninstalled | Low | Test on a WSL-free VM; verify timeout works |
| UTF-16LE detection heuristic produces false positives | Low | Source has same heuristic and ships in production |
| `wsl.exe` not on PATH | Negligible on Win10+ | Return empty list silently |
| `dirs::home_dir()` returns None | Negligible on Windows | Return `None` from `detect()` and let locator skip |
## Security considerations
- Tokens stored as `String` in memory; not logged.
- WSL `sh -lc` arg is a constant — no user-controlled input → no shell injection.
- Don't `log::debug!` the token; log only `token len=N`.
## Next steps
→ Phase 4: providers + refresh orchestrator that USES this locator.
@@ -1,346 +0,0 @@
---
phase: 4
status: pending
estimated_hours: 8
---
# Phase 4 — Providers & refresh orchestrator
## Context links
- Brainstorm: axes 3 (provider trait) + 5 (refresh)
- Source file to be REPLACED entirely: `src/poller.rs` (~1100 LOC)
- Phase deps: 1 (`net::winhttp`), 2 (`usage::types`), 3 (`creds`)
## Overview
- **Priority:** Critical — replaces the largest single source file.
- **Status:** pending
- **Brief:** Implement `ClaudeProvider` + `ChatGptProvider` against the trait from Phase 2, plus the `RefreshOrchestrator` that spawns local CLIs to refresh expired tokens. Replace `crate::poller::*` calls in `app.rs` with `usage::registry::poll_all`. End of phase: `src/poller.rs` is gone.
## Key insights from brainstorm
- The two providers share rate-limit-header parsing logic — extract to `usage::headers`.
- Anthropic's primary endpoint returns the dedicated `oauth/usage` JSON; fallback is the Messages API with rate-limit headers. Both code paths go in `ClaudeProvider`.
- ChatGPT's `wham/usage` endpoint is shaped differently (`rate_limit.primary_window.used_percent`) — separate parser.
- `RefreshOrchestrator::refresh(source)` uses `RefreshHint` to know which CLI to spawn. 8-second timeout, not 30 — UX wins.
## Requirements
### Functional
- `ClaudeProvider::new(locator: CredentialLocator) -> Self`.
- `ClaudeProvider::poll(http) -> Result<UsageWindows, usage::Error>`:
- Try `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer …` + `anthropic-beta: oauth-2025-04-20`.
- If primary returns 401/403 → `usage::Error::AuthRequired`.
- If primary returns 2xx but data is incomplete → fall back to Messages API.
- Messages-API fallback: `POST https://api.anthropic.com/v1/messages` with minimal payload; parse `anthropic-ratelimit-unified-{5h,7d}-utilization` headers + reset timestamps.
- `ChatGptProvider::new(locator: CredentialLocator) -> Self`.
- `ChatGptProvider::poll(http) -> Result<UsageWindows, usage::Error>`:
- `GET https://chatgpt.com/backend-api/wham/usage` with `Authorization: Bearer …` + `User-Agent: codex-cli` + optional `ChatGPT-Account-Id`.
- Parse `rate_limit.{primary_window,secondary_window}.used_percent` + `.reset_at` (Unix seconds).
- `RefreshOrchestrator::new(timeout: Duration) -> Self`.
- `RefreshOrchestrator::refresh(source: &dyn CredentialSource) -> RefreshOutcome` — spawns appropriate CLI, waits up to timeout, returns outcome.
- `usage::registry::Registry`:
- `Registry::new()` builds with default providers.
- `registry.enabled_providers(settings) -> Vec<ProviderId>`.
- `registry.poll_one(id, http) -> Result<UsageWindows, usage::Error>`.
### Non-functional
- Total poll time (both providers) must stay under 60s even with refresh attempts.
- Refresh timeout is 8s (down from source's 30s) — verify UX feels snappy.
- HTTP retries are NOT done at this layer (app retains the retry/backoff loop).
## Architecture
### `src/usage/mod.rs` (expanded)
```rust
pub mod types;
pub mod headers;
pub mod anthropic;
pub mod chatgpt;
pub mod refresh;
pub mod registry;
pub use types::{ProviderId, Window, UsageWindows, ProviderSnapshot};
pub trait UsageProvider: Send {
fn id(&self) -> ProviderId;
fn poll(&mut self, http: &crate::net::winhttp::Client) -> Result<UsageWindows, Error>;
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("authentication required")]
AuthRequired,
#[error("no credentials configured")]
NoCredentials,
#[error("token expired after refresh")]
TokenExpired,
#[error("network: {0}")]
Network(#[from] crate::net::Error),
#[error("response shape mismatch: {0}")]
BadResponse(String),
#[error("credential: {0}")]
Creds(#[from] crate::creds::Error),
}
```
### `src/usage/headers.rs`
```rust
use super::{Window, UsageWindows};
use crate::net::winhttp::Response;
pub fn parse_anthropic_rate_limit(resp: &Response) -> UsageWindows {
let primary = Window {
utilization: header_f64(resp, "anthropic-ratelimit-unified-5h-utilization") * 100.0,
resets_at: unix_to_system_time(header_i64(resp, "anthropic-ratelimit-unified-5h-reset")),
};
let secondary = Window {
utilization: header_f64(resp, "anthropic-ratelimit-unified-7d-utilization") * 100.0,
resets_at: unix_to_system_time(header_i64(resp, "anthropic-ratelimit-unified-7d-reset")),
};
UsageWindows { primary, secondary }
}
fn header_f64(resp: &Response, name: &str) -> f64 {
resp.header(name).and_then(|s| s.parse().ok()).unwrap_or(0.0)
}
fn header_i64(resp: &Response, name: &str) -> Option<i64> {
resp.header(name).and_then(|s| s.parse().ok())
}
fn unix_to_system_time(secs: Option<i64>) -> Option<std::time::SystemTime> {
let s = secs?;
if s < 0 { return None; }
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(s as u64))
}
```
### `src/usage/anthropic.rs`
```rust
use crate::creds::CredentialLocator;
use crate::net::winhttp::Client;
use super::{UsageProvider, UsageWindows, Window, Error, ProviderId};
use serde::Deserialize;
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
pub struct ClaudeProvider {
locator: CredentialLocator,
}
impl ClaudeProvider {
pub fn new(locator: CredentialLocator) -> Self { Self { locator } }
}
impl UsageProvider for ClaudeProvider {
fn id(&self) -> ProviderId { ProviderId::Claude }
fn poll(&mut self, http: &Client) -> Result<UsageWindows, Error> {
let source = self.locator.first_available().ok_or(Error::NoCredentials)?;
let token = source.read()?;
// … (try usage endpoint; fall back to messages; parse rate-limit headers)
}
}
#[derive(Deserialize)]
struct OauthUsageResponse {
five_hour: Option<Bucket>,
seven_day: Option<Bucket>,
}
#[derive(Deserialize)]
struct Bucket {
utilization: f64,
resets_at: Option<String>, // ISO 8601
}
fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>, Error> { /* … */ }
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> { /* … */ }
fn parse_iso8601(s: &str) -> Option<std::time::SystemTime> { /* … minimal date parser, same as source's */ }
```
### `src/usage/chatgpt.rs`
Mirrors anthropic shape; parses Codex JSON.
### `src/usage/refresh.rs`
```rust
use crate::creds::{CredentialSource, RefreshHint};
use std::process::{Command, Stdio};
use std::time::Duration;
#[derive(Debug, Clone, Copy)]
pub enum RefreshOutcome { Refreshed, StillExpired, CliMissing, Timeout }
pub struct RefreshOrchestrator { timeout: Duration }
impl RefreshOrchestrator {
pub fn new(timeout: Duration) -> Self { Self { timeout } }
pub fn refresh(&self, source: &dyn CredentialSource) -> RefreshOutcome {
let signature_before = source.signature();
let hint = source.refresh_hint();
let spawn_ok = match hint {
RefreshHint::LocalCliCommand { exe } => self.spawn_local(exe),
RefreshHint::WslCliCommand { distro } => self.spawn_wsl(&distro),
RefreshHint::Codex => self.spawn_codex(),
};
if !spawn_ok { return RefreshOutcome::CliMissing; }
let start = std::time::Instant::now();
loop {
if start.elapsed() > self.timeout { return RefreshOutcome::Timeout; }
std::thread::sleep(Duration::from_millis(500));
if source.signature() != signature_before {
return RefreshOutcome::Refreshed;
}
}
}
fn spawn_local(&self, exe: &str) -> bool { /* spawn `<exe>.cmd -p .` or `<exe> -p .` */ }
fn spawn_wsl(&self, distro: &str) -> bool { /* wsl.exe -d <d> -- bash -lic 'claude -p .' */ }
fn spawn_codex(&self) -> bool { /* spawn codex exec . */ }
}
```
### `src/usage/registry.rs`
```rust
use super::{UsageProvider, ProviderId, UsageWindows, Error};
use crate::net::winhttp::Client;
use crate::settings::Settings;
pub struct Registry {
providers: Vec<Box<dyn UsageProvider>>,
}
impl Registry {
pub fn with_defaults() -> Self {
let claude_locator = crate::creds::CredentialLocator::default_claude();
let codex_locator = crate::creds::CredentialLocator::default_codex();
Self {
providers: vec![
Box::new(super::anthropic::ClaudeProvider::new(claude_locator)),
Box::new(super::chatgpt::ChatGptProvider::new(codex_locator)),
],
}
}
pub fn poll_enabled(&mut self, http: &Client, settings: &Settings) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
let mut results = Vec::new();
for p in self.providers.iter_mut() {
let enabled = match p.id() {
ProviderId::Claude => settings.show_claude_code,
ProviderId::ChatGpt => settings.show_codex,
};
if !enabled { continue; }
results.push((p.id(), p.poll(http)));
}
results
}
}
```
### `app.rs` migration
Replace `poller::poll(show_claude, show_codex)` with `registry.poll_enabled(&http_client, &settings)`. App now holds:
- `http_client: net::winhttp::Client`
- `registry: usage::registry::Registry`
- `refresh: usage::refresh::RefreshOrchestrator`
Polling thread flow:
1. `let results = registry.poll_enabled(http, settings);`
2. For each `(id, Err(AuthRequired))`, call `refresh.refresh(source)` — needs locator access; expose via provider trait `fn try_refresh(orchestrator: &Orchestrator) -> RefreshOutcome` OR pass locator to app.
3. Post `WM_APP_USAGE_UPDATED`.
(Detail: simplest is for the provider to expose its locator: `fn locator(&self) -> &CredentialLocator;` but that's leaky. Alternative: provider has an internal `fn refresh(&self, orch) -> RefreshOutcome` that owns the locator-access. Implement option B.)
## Related code files
**To create:**
- `src/usage/headers.rs`
- `src/usage/anthropic.rs`
- `src/usage/chatgpt.rs`
- `src/usage/refresh.rs`
- `src/usage/registry.rs`
**To modify:**
- `src/usage/mod.rs` — expand with new module declarations
- `src/app.rs` — migrate poll-thread logic from `poller::*` to `registry::*` + `refresh::*`; remove `crate::poller` import
- `src/main.rs` — remove `mod poller;`
**To delete:**
- `src/poller.rs` (1099 LOC removed)
## Implementation steps
1. **Implement `headers.rs`** — pure parsing, test in isolation.
2. **Implement `anthropic.rs`** in two parts:
- 2a. `try_usage_endpoint` — full JSON parse path.
- 2b. `try_messages_endpoint` — POST with model fallback chain + header parsing.
3. **Implement `chatgpt.rs`** — single endpoint, JSON parse.
4. **Implement `refresh.rs`** — orchestrator with 3 spawn paths.
5. **Implement `registry.rs`** — registry + `poll_enabled`.
6. **Migrate `app.rs::handle_poll_result`** to consume `Vec<(ProviderId, Result<UsageWindows, Error>)>` instead of `Result<AppUsageData, PollError>`.
7. **Migrate `app.rs::apply_data`** to update `Vec<ProviderSnapshot>` per provider.
8. **Add `fn try_refresh_for_provider(&mut self, id: ProviderId, orch: &Orchestrator) -> RefreshOutcome`** to `Registry`, so app can request refresh without touching internals.
9. **Wire `RefreshOrchestrator::new(Duration::from_secs(8))`** into app state.
10. **Delete `src/poller.rs`** + `mod poller;` line.
11. **`cargo build --release`** clean.
12. **End-to-end test on Windows**:
- Run app, sign-in via existing Claude CLI session, see polling work.
- Force token expiry (delete credentials file), see refresh succeed.
- Disconnect network, see graceful degradation.
## Todo checklist
- [ ] `headers.rs`
- [ ] `anthropic.rs` (usage endpoint)
- [ ] `anthropic.rs` (messages fallback)
- [ ] `chatgpt.rs`
- [ ] `refresh.rs`
- [ ] `registry.rs`
- [ ] `app.rs` poll-thread + apply_data migration
- [ ] `poller.rs` deleted
- [ ] `main.rs` updated
- [ ] `cargo build --release` clean
- [ ] Manual Windows e2e: Claude polls, Codex polls, token-refresh works
- [ ] Manual Windows e2e: network down → "..." indicator; back online → recovers
## Success criteria
- `src/poller.rs` no longer exists.
- App polls both providers concurrently (in poll thread).
- Token-expired flow refreshes within 8 s (or shows "..." gracefully if CLI missing).
- All ISO 8601 + Unix timestamps parse correctly (test edge cases: end-of-day, leap years).
## Risks + mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| Anthropic API shape changes between dev and ship | Low | Test against live API; pin `anthropic-version: 2023-06-01` |
| Codex endpoint changes auth header | Low | Match source's header set exactly: Bearer + User-Agent + optional ChatGPT-Account-Id |
| Refresh races multiple poll attempts | Medium | Single refresh per source per poll cycle; signature-based completion detection |
| `wsl.exe bash -lic 'claude -p .'` outputs to TTY when no -p flag is recognized in WSL claude version | Medium | Test against actual installed Claude CLI in WSL; consider `--no-prompt` alternative |
| Long-running Messages API request | Medium | 30 s HTTP timeout in `net::winhttp::Client` |
## Security considerations
- Bearer token is included in HTTPS request → WinHTTP encrypts with TLS.
- Token never logged at INFO level; only `len=N` at DEBUG.
- CLI refresh spawns process with `CREATE_NO_WINDOW` to avoid console flash.
## Next steps
→ Phase 5: replace `tray_icon.rs` with `tray/` directory and tiny-skia badges.
## Open questions
- Does the Anthropic OAuth usage endpoint return `seven_day.utilization` consistently or do we still need the messages fallback for 7d data? Source code says yes-fallback-sometimes-needed. Keep the fallback for safety.
- Should `ChatGptProvider` skip the request if it has no `account_id` to avoid wasting bandwidth on a guaranteed-401? Source includes the header conditionally; we mirror that.
@@ -1,264 +0,0 @@
---
phase: 5
status: pending
estimated_hours: 5
---
# Phase 5 — Tray badges with tiny-skia
## Context links
- Brainstorm: axis 7 (tray icon drawing)
- Source file to be REPLACED entirely: `src/tray_icon.rs` (441 LOC)
## Overview
- **Priority:** Medium — replaces a non-critical-path file but visibly improves UX (anti-aliased badges).
- **Status:** pending
- **Brief:** Replace GDI-drawn tray icons with `tiny-skia` path-rendered, anti-aliased badges. Wrap the Win32 tray-notification calls (`Shell_NotifyIconW`) in a new `tray/` module with cleaner add/update/remove semantics.
## Key insights from brainstorm
- Source's GDI rendering uses primitive rectangles + text. Output looks aliased on HiDPI.
- `tiny-skia` renders vector paths with anti-aliasing in pure Rust. ~200 KB added to binary; UX clearly better.
- Tray icons are 16×16 / 24×24 / 32×32 depending on DPI. Render at largest size, downsample.
- The tray-icon "add vs update" inefficiency flagged in Phase 4 code review (R5) is fixed here by tracking which icons are registered in module state.
## Requirements
### Functional
- `tray::Manager::new(owner_hwnd: HWND) -> Self`.
- `manager.sync(state: &[TrayIcon])` adds/updates/removes icons to match the given state.
- `manager.notify(id: TrayIconId, title: &str, body: &str)` shows a balloon for an existing icon.
- `tray::badge::render(percent: Option<f64>, kind: BadgeKind, dpi: u32) -> HICON` produces an anti-aliased HICON.
- `tray::callback::handle(lparam: LPARAM) -> TrayAction` dispatches WM_APP_TRAY messages.
- `TrayIcon { id: TrayIconId, percent: Option<f64>, tooltip: String, kind: BadgeKind }`.
### Non-functional
- Badge render must complete in < 5 ms per icon (called on every poll cycle, ~1× per minute typically).
- Memory: each cached badge HICON is ~4 KB; we cache by `(percent_bucket, kind, dpi)` — at most ~100 entries × 4 KB = 400 KB cache size.
## Architecture
### `src/tray/mod.rs`
```rust
use windows::Win32::Foundation::*;
pub mod badge;
pub mod callback;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum BadgeKind {
Claude,
ChatGpt,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct TrayIconId(pub u32);
pub const ID_CLAUDE: TrayIconId = TrayIconId(1);
pub const ID_CHATGPT: TrayIconId = TrayIconId(2);
#[derive(Clone, Debug)]
pub struct TrayIcon {
pub id: TrayIconId,
pub percent: Option<f64>,
pub tooltip: String,
pub kind: BadgeKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrayAction {
None,
LeftClick(TrayIconId),
RightClick(TrayIconId),
}
pub struct Manager {
owner: HWND,
registered: std::collections::HashSet<TrayIconId>,
}
impl Manager {
pub fn new(owner: HWND) -> Self {
Self { owner, registered: Default::default() }
}
pub fn sync(&mut self, state: &[TrayIcon]) {
let target_ids: std::collections::HashSet<_> = state.iter().map(|i| i.id).collect();
// Remove icons not in state
let to_remove: Vec<_> = self.registered.difference(&target_ids).copied().collect();
for id in to_remove { self.remove(id); }
// Add or update
for icon in state {
if self.registered.contains(&icon.id) {
self.update(icon);
} else {
self.add(icon);
}
}
}
pub fn notify(&self, id: TrayIconId, title: &str, body: &str) { /* NIM_MODIFY with NIF_INFO */ }
fn add(&mut self, icon: &TrayIcon) { /* NIM_ADD + Shell_NotifyIconW */ }
fn update(&mut self, icon: &TrayIcon) { /* NIM_MODIFY */ }
fn remove(&mut self, id: TrayIconId) { /* NIM_DELETE */ }
}
```
### `src/tray/badge.rs`
```rust
use windows::Win32::UI::WindowsAndMessaging::HICON;
use tiny_skia::*;
pub fn render(percent: Option<f64>, kind: super::BadgeKind, dpi: u32) -> Option<HICON> {
let size = match dpi {
d if d >= 192 => 32,
d if d >= 144 => 24,
_ => 16,
};
let mut pixmap = Pixmap::new(size, size)?;
// 1. Background fill (gradient from kind's tint colors)
let bg_color = base_color_for(kind, percent);
fill_circle(&mut pixmap, size, bg_color);
// 2. Ring sweep for percent
if let Some(p) = percent {
draw_arc(&mut pixmap, size, p, kind);
}
// 3. Center text "%" with size auto-fit
if let Some(p) = percent {
draw_percent_text(&mut pixmap, size, p);
}
// 4. Convert BGRA pixmap to HICON via CreateIconIndirect
pixmap_to_hicon(&pixmap)
}
fn fill_circle(pixmap: &mut Pixmap, size: u32, color: Color) { /* … */ }
fn draw_arc(pixmap: &mut Pixmap, size: u32, percent: f64, kind: super::BadgeKind) { /* … */ }
fn draw_percent_text(pixmap: &mut Pixmap, size: u32, percent: f64) {
// tiny-skia doesn't render text natively. Two options:
// a) Use cosmic-text (heavy) or ab_glyph (lighter).
// b) Pre-rasterize digits 0-9 + % glyph at build time into tiny PNGs and embed.
// c) Skip text — just use the ring sweep for usage indication.
// Choose (c) for simplicity; the bubble shows the exact percent already.
}
fn pixmap_to_hicon(pixmap: &Pixmap) -> Option<HICON> {
// Win32 ICONINFO with mask + color bitmaps; bitmaps from CreateDIBSection.
// BGRA layout matches what tiny-skia produces (premultiplied alpha).
// …
}
```
**Decision:** drop text from tray badges entirely. The bubble shows the exact percentage; tray badge is a coarse indicator (color + ring fill). This sidesteps the tiny-skia text-rendering hassle and keeps the badge image clearer at 16×16.
### `src/tray/callback.rs`
```rust
use windows::Win32::Foundation::LPARAM;
use super::{TrayAction, TrayIconId};
const WM_LBUTTONUP: u32 = 0x0202;
const WM_RBUTTONUP: u32 = 0x0205;
pub fn handle(lparam: LPARAM) -> TrayAction {
let raw = lparam.0 as u32;
let event = raw & 0xFFFF;
let id_lo = (raw >> 16) & 0xFFFF;
let id = TrayIconId(id_lo);
match event {
WM_LBUTTONUP => TrayAction::LeftClick(id),
WM_RBUTTONUP => TrayAction::RightClick(id),
_ => TrayAction::None,
}
}
```
### Cargo.toml addition
```toml
tiny-skia = "0.11"
```
(~250 KB added to binary; no text dependency since we dropped text from badges.)
## Related code files
**To create:**
- `src/tray/mod.rs`
- `src/tray/badge.rs`
- `src/tray/callback.rs`
**To modify:**
- `Cargo.toml` — add `tiny-skia`
- `src/main.rs` — `mod tray;`; remove `mod tray_icon;`
- `src/app.rs` — replace `crate::tray_icon::{sync, add, update, remove, notify_balloon, handle_message, ...}` with `crate::tray::{Manager, TrayIcon, BadgeKind, TrayAction, ID_CLAUDE, ID_CHATGPT}`. Store `Manager` in `AppState`. Update all call sites.
**To delete:**
- `src/tray_icon.rs`
## Implementation steps
1. **Add `tiny-skia` dep**.
2. **Implement `badge.rs`** — start with `fill_circle` (one path), verify pixmap saves to PNG correctly for visual debugging.
3. **Add `draw_arc`** — `PathBuilder::move_to + arc_to`. Use 0° = top (12 o'clock).
4. **Implement `pixmap_to_hicon`** — this is the trickiest part. Create AND/XOR DIB sections, populate from pixmap pixels (premultiplied BGRA), build `ICONINFO`, call `CreateIconIndirect`.
5. **Test badge rendering** — save 10 sample HICONs at different percents to disk and inspect.
6. **Implement `Manager`** with add/update/remove/sync.
7. **Implement `callback.rs`**.
8. **Migrate `app.rs`** to new API; replace `tray_icon::sync(...)` with `state.tray.sync(&icons)`.
9. **Delete `src/tray_icon.rs`** and remove from `main.rs`.
10. **`cargo build --release`** clean.
11. **Windows e2e**: run app, see tray icons appear with anti-aliased ring. Hover for tooltip. Left-click toggles bubble. Right-click opens menu.
## Todo checklist
- [ ] `tiny-skia` added to Cargo.toml
- [ ] `badge.rs::fill_circle` works (PNG inspection)
- [ ] `badge.rs::draw_arc` works (PNG inspection)
- [ ] `badge.rs::pixmap_to_hicon` produces valid HICON
- [ ] `tray/mod.rs::Manager` with add/update/remove/sync
- [ ] `callback.rs::handle` returns correct TrayAction
- [ ] `app.rs` migrated to new tray API
- [ ] `tray_icon.rs` deleted
- [ ] `cargo build --release` clean
- [ ] Tray icons appear with anti-aliased visuals on Windows
## Success criteria
- Badge looks visibly smoother than source's GDI version (anti-aliased ring).
- Add/update/remove is idempotent (no duplicate icons after `sync`).
- Tray callbacks fire correctly for left/right click.
- No `src/tray_icon.rs` remains.
## Risks + mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| `pixmap_to_hicon` produces wrong-format icon (alpha channel issues) | High | Test by saving the source pixmap as PNG, then comparing to the rendered icon; iterate on BGRA channel order |
| `CreateIconIndirect` requires monochrome mask bitmap; we only have color | Medium | Pass `hbmMask = NULL` to let Windows auto-generate from alpha (works on Win10+) |
| 16×16 looks bad even with AA | Medium | Render at 32×32 then downsample with high-quality lanczos (tiny-skia doesn't include downsampling — use `image` crate's resize) |
| `tiny-skia` adds too much binary size | Low | Measured ~250 KB; acceptable for the UX win |
## Security considerations
- No external input drives badge rendering — all params are internal (`percent`, `kind`, `dpi`). No injection surface.
- HICON handles must be `DestroyIcon`'d when cache evicts (avoid handle leak — Windows limit is ~10,000 per process).
## Next steps
→ Phase 6: replace `updater.rs` and drop `NOTICE`.
## Open questions
- Keep the cached HICONs alive for the process lifetime, or destroy aggressively on each `update`? Source destroys + recreates each cycle (wasteful but simple). Recommend: cache by `(percent_rounded_to_5pct, kind, dpi)` and let the cache grow naturally. Max size ~100 entries × 4 KB = 400 KB.
- Need an icon for "no data" state (percent = None). Current spec says "fill_circle + no ring". Verify the visual reads correctly.
@@ -1,312 +0,0 @@
---
phase: 6
status: pending
estimated_hours: 7
---
# Phase 6 — Updater + remove NOTICE
## Context links
- Brainstorm: axis 8 (updater architecture)
- Source file to be REPLACED entirely: `src/updater.rs` (512 LOC)
## Overview
- **Priority:** Final — this phase removes the last copied module and drops the attribution.
- **Status:** pending
- **Brief:** Replace the source's helper-exe-handoff updater with an inline `cmd /c …` handoff (no duplicate-exe pattern). Replace `winres` legacy paths. Delete `NOTICE`, update README + LICENSE comment so the project no longer claims attribution.
## Key insights from brainstorm
- Source spawns a copy of itself as `updater-helper.exe`, which waits for the parent to exit and swaps the binary. Genuinely-different alternative: spawn `cmd.exe` directly with an inline command string that does the same dance.
- No temp `.bat` file needed — inline command via `cmd /c "..."` works.
- The `Portable` vs `Winget` channel split is kept (we may publish to winget later); the channel detection function returns `Portable` for now (already stubbed in current code).
## Requirements
### Functional
- `update::release::fetch_latest() -> Result<Option<Release>, Error>` — GitHub releases API call.
- `update::release::Release { version: Version, asset_url: String }` — parsed result.
- `update::install::begin(release: &Release) -> Result<(), Error>` — download + handoff.
- `update::install::run_cli(args: &[String]) -> Option<i32>` — handle `--apply-update` flag (still kept for parity if a user manually invokes it, even though we don't use the helper-exe path anymore).
- `update::channel::current() -> Channel` — returns `Channel::Portable` for now.
- `Version` type with parse + ordering.
### Non-functional
- Inline `cmd /c` invocation uses `CREATE_NO_WINDOW | DETACHED_PROCESS` — no console flash.
- Download timeout: 60 s. Total update apply time: < 30 s after the 2-second wait window.
## Architecture
### `src/update/mod.rs`
```rust
pub mod channel;
pub mod release;
pub mod install;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("network: {0}")]
Network(#[from] crate::net::Error),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("no compatible release asset found")]
NoAsset,
#[error("install location not writable: {0}")]
NotWritable(String),
#[error("malformed version: {0}")]
BadVersion(String),
}
pub use channel::{Channel, current as current_channel};
pub use release::{Release, fetch_latest};
pub use install::{begin, run_cli};
```
### `src/update/channel.rs`
```rust
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Channel { Portable, Winget }
pub fn current() -> Channel {
// Until winget package exists, always Portable.
// Future: detect by checking if current_exe path is under
// %LOCALAPPDATA%\Microsoft\WinGet\Packages or %ProgramFiles%\WinGet\Packages.
Channel::Portable
}
```
### `src/update/release.rs`
```rust
use crate::net::winhttp::Client;
use serde::Deserialize;
const ASSET_NAME: &str = "claude-code-usage-bubble.exe";
#[derive(Clone, Debug)]
pub struct Release {
pub version: Version,
pub asset_url: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version { pub major: u32, pub minor: u32, pub patch: u32 }
impl Version {
pub fn current() -> Self { /* env!("CARGO_PKG_VERSION") */ }
pub fn parse(s: &str) -> Option<Self> { /* … */ }
}
pub fn fetch_latest(http: &Client) -> Result<Option<Release>, super::Error> {
let url = format!("https://api.github.com/repos/{}/releases/latest", repo_path());
let resp = http.get(&url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", user_agent())
.send()?;
let body: GhRelease = resp.json()?;
let candidate = Version::parse(body.tag_name.trim_start_matches('v'))
.ok_or_else(|| super::Error::BadVersion(body.tag_name.clone()))?;
if candidate <= Version::current() { return Ok(None); }
let asset = body.assets.iter()
.find(|a| a.name.eq_ignore_ascii_case(ASSET_NAME))
.ok_or(super::Error::NoAsset)?;
Ok(Some(Release { version: candidate, asset_url: asset.browser_download_url.clone() }))
}
#[derive(Deserialize)]
struct GhRelease { tag_name: String, assets: Vec<GhAsset> }
#[derive(Deserialize)]
struct GhAsset { name: String, browser_download_url: String }
fn repo_path() -> &'static str { "tiennm99/claude-code-usage-bubble" }
fn user_agent() -> &'static str { concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) }
```
### `src/update/install.rs`
```rust
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
const DETACHED_PROCESS: u32 = 0x00000008;
pub fn begin(http: &crate::net::winhttp::Client, release: &super::Release) -> Result<(), super::Error> {
let current = std::env::current_exe()?;
ensure_writable(&current)?;
let staging = stage_path()?;
std::fs::create_dir_all(staging.parent().unwrap())?;
download(http, &release.asset_url, &staging)?;
spawn_handoff(&staging, &current)?;
Ok(())
}
fn download(http: &crate::net::winhttp::Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> {
let resp = http.get(url).header("User-Agent", super::release::user_agent()).send()?;
std::fs::write(to, resp.body())?; // assume Response exposes .body() -> &[u8]
Ok(())
}
fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(), super::Error> {
let cmd = format!(
r#"timeout /t 2 /nobreak >nul & move /y "{src}" "{tgt}" & start "" "{tgt}""#,
src = source.to_string_lossy(),
tgt = target.to_string_lossy(),
);
Command::new("cmd.exe")
.args(["/c", &cmd])
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
.spawn()?;
Ok(())
}
pub fn run_cli(args: &[String]) -> Option<i32> {
// Keep this for parity: if the user runs the binary with `--apply-update <target> <source> <pid>`
// (the source's old helper signature), the inline-cmd handoff has already done the work;
// we just exit 0.
if args.len() >= 2 && args[1] == "--apply-update" { return Some(0); }
None
}
fn stage_path() -> Result<PathBuf, super::Error> {
let base = dirs::data_local_dir()
.ok_or_else(|| super::Error::NotWritable("no data dir".into()))?;
Ok(base.join("ClaudeCodeUsageBubble").join("updates").join("update.exe"))
}
fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> {
let parent = target.parent().ok_or_else(|| super::Error::NotWritable("no parent".into()))?;
let probe = parent.join(".probe");
std::fs::write(&probe, b"").map_err(|e| super::Error::NotWritable(e.to_string()))?;
let _ = std::fs::remove_file(&probe);
Ok(())
}
```
### Migration in `app.rs`
Replace `updater::*` imports:
- `updater::check_for_updates()` → `update::release::fetch_latest(&http_client)`
- `updater::begin_self_update(release)` → `update::install::begin(&http_client, release)`
- `updater::current_install_channel()` → `update::current_channel()`
- `updater::handle_cli_mode(args)` → `update::run_cli(args)`
- `updater::UpdateCheckResult` → `Option<Release>` (None = up to date, Some = available)
- `updater::ReleaseDescriptor` → `update::Release`
- `updater::InstallChannel` → `update::Channel`
### Drop NOTICE + final attribution cleanup
After the rewrite is complete and validated:
1. Delete `NOTICE` file.
2. Update `LICENSE`:
- Remove the "Portions ported from …" paragraph (currently at the top of LICENSE).
- Keep just the Apache-2.0 text with `Copyright 2026 tiennm99`.
3. Update `README.md`:
- Replace "Differences vs upstream" section's "derivative of … with minor adaptations" wording with "inspired by [upstream link]".
- Remove the "License" section mention of NOTICE.
4. Update `Cargo.toml`:
- `license = "Apache-2.0"` (unchanged).
## Related code files
**To create:**
- `src/update/mod.rs`
- `src/update/channel.rs`
- `src/update/release.rs`
- `src/update/install.rs`
**To modify:**
- `src/main.rs` — declare `mod update;`; remove `mod updater;`
- `src/app.rs` — migrate `updater::*` call sites
- `LICENSE` — drop the upstream-attribution paragraph
- `README.md` — drop "derivative of" wording, replace with "inspired by"
- `Cargo.toml` — no functional changes
**To delete:**
- `src/updater.rs`
- `NOTICE`
## Implementation steps
1. **Create `update/channel.rs`** — trivial.
2. **Create `update/release.rs`** — Version type + fetch_latest. Test by hitting GitHub API.
3. **Create `update/install.rs`** — download + inline-cmd handoff. **Test on a throwaway VM** (the handoff replaces the binary, which is risky).
4. **Create `update/mod.rs`** — re-exports.
5. **Migrate `app.rs`** — replace all `updater::*` call sites.
6. **Delete `src/updater.rs`** + `mod updater;` line.
7. **`cargo build --release`** clean.
8. **End-to-end test on Windows**:
- Stage a v0.1.1 GitHub release with a deliberately-different .exe.
- Run v0.1.0 binary, trigger update → confirm new .exe replaces old, new app launches.
9. **AFTER end-to-end test succeeds:**
- Delete `NOTICE`.
- Edit `LICENSE` — drop the upstream-attribution paragraph at the top.
- Edit `README.md` — drop "derivative of" paragraph; replace with one-line "Inspired by [CodeZeno/Claude-Code-Usage-Monitor]" (no attribution-required phrasing).
10. **Final repo audit:**
- `grep -ri "CodeZeno" src/` → must return nothing.
- `grep -ri "Claude-Code-Usage-Monitor" src/` → must return nothing.
- File names: `find src -type f -name '*.rs' | xargs -I {} basename {} | sort` and compare against upstream's file list (`models.rs`, `diagnose.rs`, `theme.rs`, `poller.rs`, `updater.rs`, `tray_icon.rs`, `native_interop.rs`, `localization/*`). **No file name should match.**
- `git log --oneline` shows the initial-port commit + 6 rewrite commits — transparent history.
11. **Commit and push:**
- Commit message: `chore: complete clean-room rewrite; drop upstream attribution`
- Push to GitHub.
## Todo checklist
- [ ] `update/channel.rs`
- [ ] `update/release.rs`
- [ ] `update/install.rs`
- [ ] `update/mod.rs`
- [ ] `app.rs` migrated to new updater API
- [ ] `updater.rs` deleted
- [ ] `cargo build --release` clean
- [ ] End-to-end update tested on Windows
- [ ] `NOTICE` deleted
- [ ] `LICENSE` upstream-attribution paragraph removed
- [ ] `README.md` updated to drop "derivative" wording
- [ ] Grep verifies no upstream references remain in `src/`
- [ ] File-name overlap with upstream = 0
- [ ] Final commit + push
## Success criteria
- App self-updates correctly using inline-cmd handoff.
- `NOTICE` file no longer exists in repo.
- Repo passes the "no upstream references" grep test.
- GitHub's auto-license detection still reports Apache-2.0.
- The README still credits inspiration but does not claim derivative status.
## Risks + mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| Inline `cmd /c` flagged by antivirus | Medium | Most AVs allow `cmd.exe` execution; if flagged, fall back to a temp `.bat` |
| `move /y` fails if exe is still loaded by Windows | Medium-High | The 2s `timeout` gives parent time to exit, fully releasing file handle |
| User has unusual `cmd.exe` path | Negligible on Windows | Use full path `C:\Windows\System32\cmd.exe` if needed |
| Drop NOTICE prematurely (before phase done) | High if rushed | Phase order: rewrite first, then drop attribution. Never reorder. |
| Legal — is "inspired by" enough? | Low (we did rewrite everything) | This is the entire point of Phases 1-5. After full rewrite, no MIT code remains; attribution is courtesy, not required |
## Security considerations
- The inline `cmd /c` command string is built from `std::env::current_exe()` and `stage_path()` — both internal, no user-controlled input. No shell injection.
- The downloaded asset is over HTTPS to `api.github.com` → MITM-safe.
- Update fails closed: if `move /y` fails, the old exe is still in place; user can retry.
## Next steps
→ Project complete. Tag v0.2.0 with the clean-room rewrite as a milestone.
## Open questions
- Should we sign the binary with a code-signing certificate to satisfy AV heuristics around inline-cmd updates? Out of scope for this plan; future enhancement.
- After dropping NOTICE, should we add a small "Acknowledgements" section in README that mentions inspiration from CodeZeno's project without invoking MIT attribution language? Recommended: **yes**, that's the polite move and is legally untainting since we don't claim derivation.
-134
View File
@@ -1,134 +0,0 @@
---
status: pending
created: 2026-05-16
mode: standard (non-TDD)
brainstorm: ../reports/brainstorm-260516-0707-cleanroom-reimplementation.md
---
# Clean-room rewrite of ported modules
Replace ~2,700 LOC of code copied from `CodeZeno/Claude-Code-Usage-Monitor` with genuinely-original implementations, then drop the `NOTICE` attribution file.
## Source of truth
All design decisions live in
[`../reports/brainstorm-260516-0707-cleanroom-reimplementation.md`](../reports/brainstorm-260516-0707-cleanroom-reimplementation.md).
Do not re-debate them during implementation. If a phase reveals a flaw,
note it in that phase's "Open questions" and ask the user before
deviating.
## Architecture (locked)
| Axis | Decision |
|---|---|
| HTTP | WinHTTP via `windows-rs` |
| Errors | `thiserror` per-module enums |
| Providers | `trait UsageProvider` + ClaudeProvider/ChatGptProvider |
| Credentials | `trait CredentialSource` + Vec<Box<dyn ...>> |
| Token refresh | `RefreshOrchestrator`, 8s timeout |
| i18n | TOML files via `include_str!` |
| Tray badges | `tiny-skia` anti-aliased |
| Updater | inline `cmd /c` handoff (no helper-exe) |
| Logging | `log` + `simplelog` |
| Build script | `embed-resource` crate |
## New module layout
```
src/
main.rs (kept — update imports only)
app.rs (kept — update imports + call-sites)
bubble.rs (kept — update imports only)
panel.rs (kept — update imports only)
settings.rs (kept — update imports only)
diag/mod.rs — log facade + simplelog file appender
os/ — Win32 helpers (color, string, dpi, registry, theme)
net/ — WinHTTP HTTP client
usage/ — Provider trait + types + impls + refresh
creds/ — CredentialSource trait + impls
i18n/ — TOML loader + 9 locale files
tray/ — Anti-aliased tray badges
update/ — Self-updater (release check, download, handoff, channel)
```
## Phases
| # | Phase | Hours | File |
|---|---|---|---|
| 1 | Infrastructure (`diag/`, `os/`, `net/winhttp.rs`, Cargo.toml) | ~9 | [`phase-01-infrastructure.md`](phase-01-infrastructure.md) |
| 2 | Types & i18n (`usage/types.rs`, `i18n/`) | ~6 | [`phase-02-types-and-i18n.md`](phase-02-types-and-i18n.md) |
| 3 | Credentials (`creds/`) | ~3 | [`phase-03-creds-module.md`](phase-03-creds-module.md) |
| 4 | Providers & refresh (`usage/anthropic.rs`, `chatgpt.rs`, `refresh.rs`) | ~8 | [`phase-04-providers-and-refresh.md`](phase-04-providers-and-refresh.md) |
| 5 | Tray badges (`tray/badge.rs`) | ~5 | [`phase-05-tray-badges.md`](phase-05-tray-badges.md) |
| 6 | Updater + remove NOTICE | ~7 | [`phase-06-updater-and-remove-notice.md`](phase-06-updater-and-remove-notice.md) |
**Total:** ~38h core work + 4–8h Windows-side debugging.
## Phase dependencies
```
Phase 1 (infra)
├─→ Phase 2 (types + i18n)
│ └─→ Phase 4 (providers)
│ └─→ Phase 5 (tray) — needs UsageProvider results
│
└─→ Phase 3 (creds)
└─→ Phase 4 (providers) — depends on creds API
└─→ Phase 6 (updater + cleanup) — last
```
Phases 2 and 3 can technically run in parallel after Phase 1, but
serial execution (1→2→3→4→5→6) is cleaner for solo work.
## Out of scope
- `bubble.rs`, `panel.rs`, `app.rs`, `settings.rs`, `main.rs` — these
stay; only their imports/call-sites get touched as new APIs come
online.
- Adding new features beyond what the current copied code supports.
- Changing `bubble.rs`/`panel.rs` rendering or interaction behavior.
## External contracts that must NOT change
- Anthropic endpoints + headers
- ChatGPT endpoint + `User-Agent: codex-cli`
- Credential file paths and JSON shapes
- WSL access via `wsl.exe`
- CLI-driven token refresh (must invoke `claude` / `codex`)
- GitHub releases JSON format
- Settings file location (`%APPDATA%\ClaudeCodeUsageBubble\settings.json`)
- Windows registry path for startup (`Software\Microsoft\Windows\CurrentVersion\Run`)
- Single-instance mutex (`Global\ClaudeCodeUsageBubble`)
## Success criteria (cross-phase)
After all 6 phases:
- [ ] `cargo build --release` clean on Windows
- [ ] No file in `src/` shares a name with the upstream source's files
- [ ] `NOTICE` file removed from repo root
- [ ] `LICENSE` (Apache-2.0) header retained; copyright line updated
- [ ] `README.md` updated to drop the "derivative of" paragraph; replace
with "inspired by [upstream]" link
- [ ] App functional: bubble, panel, tray, polling, auth, updater all
working on Windows 10/11
- [ ] No regressions vs current behavior (poll cadence, snap, click→panel,
Ctrl+Wheel resize, fullscreen auto-hide, dual-bubble)
## Rollback strategy
- Each phase lands as a separate commit (or PR). If a phase breaks
the build/app, `git revert` that commit to restore the prior phase's
state.
- Phase 6 is the only commit that removes upstream attribution — if any
of Phases 1–5 is incomplete or buggy at that point, **do not** drop
NOTICE; finish or revert first.
## Open questions
- Bump `windows-rs` from 0.58 → newer? (Brainstorm flagged this.)
Defer decision to Phase 1 — try with 0.58 first, bump only if needed.
- Keep Git history showing the initial port? Recommended: **yes**.
Transparent and consistent with the "I inspired/rewrote from X" framing
even after NOTICE is gone.
@@ -1,176 +0,0 @@
---
phase: 1
title: "Release CI workflow"
status: in_progress
priority: P1
effort: "2h"
dependencies: []
---
# Phase 1: Release CI workflow
## Overview
Ship `.github/workflows/release.yml`. On a pushed tag matching `v*`,
the workflow verifies the tag matches `Cargo.toml`'s `version` field
(fail-fast if not), builds `cargo build --release` on `windows-latest`,
renames/copies the binary to the exact asset name the updater expects
(`claude-code-usage-bubble.exe`), and creates a GitHub Release with
that asset attached and auto-generated notes. Also supports
`workflow_dispatch` for dry-run testing without cutting a real tag.
<!-- Updated: Validation Session 1 — version-tag match enforcement added per plan.md Validation Log decision 1 -->
<!-- Updated: Validation Session 1 — *.exe fallback at src/update/release.rs:67-69 is out of scope per plan.md Validation Log decision 3 -->
## Requirements
### Functional
- Trigger on tag push matching `v*.*.*` (and `workflow_dispatch` for testing).
- **Verify the tag matches `Cargo.toml` `version`** before building. Workflow aborts on mismatch.
- Build on `windows-latest` with stable Rust toolchain, `x86_64-pc-windows-msvc` target.
- Cache cargo registry + target dir to keep wall time under ~5 min.
- Upload `target/release/claude-code-usage-bubble.exe` as a Release asset.
- Use `gh release create` with `--generate-notes` for the body.
- Use `--draft` on `workflow_dispatch` runs so test runs don't become public.
- On real tag runs (`vX.Y.Z`), publish immediately (not draft).
### Non-functional
- Workflow file under ~100 lines.
- No third-party Marketplace actions other than `actions/checkout` and `Swatinem/rust-cache` (or just `actions/cache`). Avoid `softprops/action-gh-release`-style wrappers — `gh` CLI is preinstalled on the runner and is one less supply-chain risk.
- Default `GITHUB_TOKEN` permissions, with explicit `contents: write` only on the release job.
## Architecture
### Flow
```
push tag v0.1.1
└─→ release.yml (job: build, runs-on: windows-latest)
├─ checkout (at the tag)
├─ Swatinem/rust-cache (uses runner-default stable Rust)
├─ resolve tag (from refs/tags or workflow_dispatch input)
├─ verify Cargo.toml version == tag (strip leading 'v') → abort on mismatch
├─ cargo build --release --locked
├─ gh release create v0.1.1 target/release/claude-code-usage-bubble.exe \
│ --title "v0.1.1" --generate-notes [--draft on workflow_dispatch]
└─ done
```
### Asset name verification
The updater's primary matcher is `eq_ignore_ascii_case("claude-code-usage-bubble.exe")`
(`src/update/release.rs:64`). Cargo's `name = "claude-code-usage-bubble"`
already produces that exe name in `target/release/`, so no rename is
needed — just upload the file as-is.
## Related Code Files
- Create: `.github/workflows/release.yml`
- Reference (do not modify in this phase): `src/update/release.rs`, `Cargo.toml`
## Implementation Steps
1. Create `.github/workflows/release.yml` with this shape:
```yaml
name: Release
on:
push:
tags: ['v*.*.*']
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (must already exist, e.g. v0.1.1)'
required: true
permissions:
contents: write
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- uses: Swatinem/rust-cache@v2
- name: Resolve tag
id: tag
shell: pwsh
run: |
$tag = if ($env:GITHUB_REF -like 'refs/tags/*') {
$env:GITHUB_REF -replace '^refs/tags/',''
} else {
'${{ github.event.inputs.tag }}'
}
"tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Verify Cargo.toml version matches tag
shell: pwsh
run: |
$tag = '${{ steps.tag.outputs.tag }}'
$expected = $tag -replace '^v',''
$cargoVersion = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1).Matches.Groups[1].Value
if ($cargoVersion -ne $expected) {
Write-Error "Tag ($tag → $expected) does not match Cargo.toml version ($cargoVersion). Bump Cargo.toml before tagging."
exit 1
}
Write-Host "Cargo version $cargoVersion matches tag $tag"
- name: Build release
run: cargo build --release --locked
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$tag = '${{ steps.tag.outputs.tag }}'
$asset = 'target/release/claude-code-usage-bubble.exe'
$draft = if ('${{ github.event_name }}' -eq 'workflow_dispatch') { '--draft' } else { '' }
gh release create $tag $asset --title $tag --generate-notes $draft
```
2. Sanity-check: `gh workflow list` after pushing the file shows the new "Release" workflow.
3. Verify the workflow YAML lints clean by viewing it in the GitHub UI (or with `actionlint` locally if installed).
## Todo List
- [x] `.github/workflows/release.yml` written
- [x] `permissions: contents: write` set
- [x] Tag-push trigger and `workflow_dispatch` both wired
- [x] Cargo.toml-version-vs-tag check step added and fails on mismatch
- [x] Asset path is `target/release/claude-code-usage-bubble.exe` exactly
- [x] `--generate-notes` enabled
- [ ] Committed and pushed to `main`
## Success Criteria
- [ ] Workflow appears under Actions tab on GitHub.
- [ ] Manually dispatching with a throwaway tag (`v0.0.0-test`) produces a **draft** release with the `.exe` attached.
- [ ] Pushing a tag whose value disagrees with `Cargo.toml` fails the workflow before `cargo build` runs (verify by intentionally mismatching once on a throwaway dispatch).
- [ ] No third-party actions beyond `actions/checkout@v4` and `Swatinem/rust-cache@v2`.
- [ ] Workflow file is under ~100 lines including blank lines.
- [ ] Out of scope (do not touch): `*.exe` fallback at `src/update/release.rs:67-69`. Tracked as future cleanup once multi-arch ships.
## Risk Assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| `Cargo.lock` drift causes `--locked` to fail | Low | Lockfile is committed; bump it locally before tagging if deps changed |
| Build time >10 min and cache cold | Low | `rust-cache` covers cargo registry + `target/`; first run is slow, subsequent fast |
| Tag pushed without prior `Cargo.toml` version bump | Was Medium → now Mitigated | CI now fails fast in the "Verify Cargo.toml version matches tag" step; maintainer cannot accidentally ship a version-mismatched binary |
| `gh release create` fails because tag does not exist for workflow_dispatch | Medium | Workflow_dispatch input takes a tag string and `actions/checkout` is pinned to it — if the tag does not exist, checkout fails fast with a clear error |
| Pre-release tag like `v0.1.0-rc1` triggers workflow but does not match Cargo's stable version | Low | Tag-match check uses string equality after `^v` strip; `0.1.0-rc1 != 0.1.0` fails fast. If pre-releases are wanted later, change Cargo `version` and the check still works |
## Security Considerations
- `permissions: contents: write` is the minimum scope needed to create a release; no `id-token` or package perms requested.
- `GH_TOKEN` is the default `GITHUB_TOKEN`, scoped to this repo only.
- No secrets are echoed; `gh` reads `GH_TOKEN` from env.
- The published `.exe` is unsigned. SmartScreen will show "Unknown publisher" the first time a user runs it. Document this in Phase 3; code signing is out of scope.
@@ -1,120 +0,0 @@
---
phase: 2
title: "End-to-end update test"
status: pending
priority: P1
effort: "2h"
dependencies: [1]
---
# Phase 2: End-to-end update test
## Overview
Cut a real test release pair and prove the in-app updater finds it,
downloads it, swaps the running .exe, and relaunches into the new
version. The updater code already exists; this phase is about
flushing out integration bugs between CI output and the updater's
expectations (asset name casing, redirect behavior, file lock release
timing, version-comparison edge cases).
## Requirements
### Functional
- Cut `v0.1.0` from current `main` → CI uploads asset.
- Bump `Cargo.toml` to `0.1.1`, commit, push, tag `v0.1.1` → CI uploads asset.
- On a Windows test machine, run the v0.1.0 binary downloaded from the v0.1.0 release.
- From the right-click menu, "Check for updates" must transition `Idle → Checking → Available`.
- Clicking "Update available" must transition `Available → Applying` and exit the process.
- ~2 seconds later, v0.1.1 must be running (verify via right-click menu showing "Up to date" after re-check, or via file properties on the .exe).
- The `version_action` apply branch (`src/app.rs:1037-1066`) must succeed: `update::install::begin` returns `Ok(())` and the process posts `WM_QUIT` (`PostQuitMessage(0)` at `src/app.rs:1056`).
### Non-functional
- Test machine has no admin privileges → confirms `ensure_writable` (`src/update/install.rs:81-89`) works for `%LOCALAPPDATA%` install.
- Run from an install path that contains a space (e.g. `C:\Users\test user\bin\`) to validate `spawn_handoff` quoting (`src/update/install.rs:53-69`).
## Architecture
### Test matrix
| Scenario | Where exe lives | Expected |
|---|---|---|
| Vanilla user-local install | `%LOCALAPPDATA%\ClaudeCodeUsageBubble\` | Succeeds |
| Path with spaces | `C:\Users\test user\bin\` | Succeeds (cmd /c quoting) |
| Read-only install dir (e.g. `C:\Program Files\…`) | `C:\Program Files\Bubble\` | `Failed` status surfaces; no crash |
| Offline | n/a | `Failed` status, no crash, retries on next 24h timer |
| Already on latest | n/a | `UpToDate` status, no download |
### Observability
- Run with `claude-code-usage-bubble.exe --diagnose` to capture the
log at `%TEMP%\claude-code-usage-bubble.log`. Look for `update apply
failed:` lines (`src/app.rs:1058`).
- After update, the new process is started by `cmd.exe` (detached);
Task Manager parent column will show no parent — that's expected.
## Related Code Files
- Reference only (no edits expected): `src/update/release.rs`, `src/update/install.rs`, `src/app.rs` (lines 1025-1135), `Cargo.toml`
## Implementation Steps
1. **Cut v0.1.0:**
```bash
git -C D:/tiennm99/claude-code-usage-bubble tag -a v0.1.0 -m "v0.1.0"
git -C D:/tiennm99/claude-code-usage-bubble push origin v0.1.0
```
Wait for the Phase-1 workflow to produce `v0.1.0` release with `claude-code-usage-bubble.exe`. Download the asset locally — this is the "old" binary.
2. **Smoke-test the v0.1.0 download** on a Windows machine: run it, confirm the bubble appears, right-click → "Check for updates" returns "Up to date" (no v0.1.1 yet).
3. **Cut v0.1.1:**
- Bump `Cargo.toml` `version = "0.1.0"` → `"0.1.1"`.
- `cargo build --release` locally to refresh `Cargo.lock`.
- Commit: `chore: bump version to 0.1.1`.
- Tag: `git tag -a v0.1.1 -m "v0.1.1"`.
- Push both: `git push origin main && git push origin v0.1.1`.
4. **Run the v0.1.0 binary** (still installed from step 1) and right-click → "Check for updates". Status should transition to "Update available". Click it. The process exits, ~2 s pass, the new v0.1.1 binary should launch automatically.
5. **Verify v0.1.1 is running:** right-click → "Check for updates" should now return "Up to date". Cross-check `claude-code-usage-bubble.exe --diagnose` log for the version line, or check file properties in Explorer.
6. **Cleanup if it goes wrong:**
- Stuck "Applying" status with no swap → kill the detached `cmd.exe` in Task Manager, manually copy `%LOCALAPPDATA%\ClaudeCodeUsageBubble\updates\update.exe` over the running exe location.
- `cmd /c` quoting broke → fix in `src/update/install.rs:58-60` and retag as `v0.1.2`.
7. **Run negative scenarios** (table above): path-with-spaces, read-only install dir, offline. Each must fail-soft without crashing.
## Todo List
- [ ] v0.1.0 release cut and asset downloaded
- [ ] v0.1.0 binary verified runnable on Windows
- [ ] Cargo.toml bumped to 0.1.1, committed, tagged, pushed
- [ ] v0.1.1 release produced by CI
- [ ] v0.1.0 binary self-updates to v0.1.1 successfully
- [ ] Post-update, "Check for updates" returns "Up to date"
- [ ] Negative scenario: install in path with space succeeds
- [ ] Negative scenario: read-only install dir surfaces "Failed" status, no crash
- [ ] Negative scenario: offline → "Failed", retry timer rearmed
## Success Criteria
- [ ] A v0.1.0 download → click update → v0.1.1 running with no manual file copying.
- [ ] No SmartScreen kill (it will warn on first run; that's expected and documented).
- [ ] `%TEMP%\claude-code-usage-bubble.log` contains no `update apply failed` lines after the successful run.
- [ ] All negative scenarios fail without crashing the bubble.
## Risk Assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| File lock not released in 2 s window | Low-Medium | The 2 s `timeout` in `spawn_handoff` is conservative; if it ever races, bump to 3 s |
| GitHub CDN redirect not followed by WinHTTP | Very Low | WinHTTP follows redirects by default (no `WINHTTP_OPTION_DISABLE_FEATURE` set); will surface in step 4 if broken |
| Antivirus quarantines the freshly-written staging exe | Medium | Document the workaround (allowlist the install dir); future signing fixes this |
| Test pollutes real release feed | Low | If you must test with throwaway tags, use `workflow_dispatch` (creates draft) instead of pushing the tag |
## Security Considerations
- The downloaded asset is fetched over HTTPS from a `*.githubusercontent.com` CDN; WinHTTP validates certs against the system root store.
- No checksum verification yet — accepted risk (HTTPS + cert pinning is the floor). Future enhancement: ship `SHA256SUMS.txt` and verify in `install::download`.
- The `cmd /c` command string is composed only from `current_exe()` and `stage_path()`; neither is user-controlled. No shell injection vector.
@@ -1,133 +0,0 @@
---
phase: 3
title: "Docs and release process"
status: in_progress
priority: P2
effort: "1h"
dependencies: [2]
---
# Phase 3: Docs and release process
## Overview
Now that v0.1.x ships out of CI, update the user-facing docs to point
people at the GitHub Release instead of "build from source", and
write a short maintainer checklist that future-me can follow to cut
a release without re-deriving it from this plan.
## Requirements
### Functional
- `README.md` "Install" section points to the Releases page and the SmartScreen warning.
- A new `docs/release-process.md` (one page) lists the cut-a-release steps: bump `Cargo.toml`, commit, tag, push.
- Keep the "build from source" path as a secondary option for developers.
### Non-functional
- `docs/release-process.md` under 60 lines.
- No `CHANGELOG.md` — the GitHub auto-generated release notes are the changelog.
## Architecture
The README has one "Install" section (`README.md:56-66`). Replace it
with a two-track structure:
```
Install
├── Download binary (recommended, one paragraph + SmartScreen note)
└── Build from source (existing block, kept verbatim)
```
## Related Code Files
- Modify: `README.md` (Install section)
- Create: `docs/release-process.md`
## Implementation Steps
1. **README.md** — replace the "Install" section. Sketch:
```markdown
## Install
### Download the latest release
Grab `claude-code-usage-bubble.exe` from the
[Releases page](https://github.com/tiennm99/claude-code-usage-bubble/releases/latest).
Put it anywhere on disk (e.g. `%LOCALAPPDATA%\ClaudeCodeUsageBubble\`)
and run it. The app self-updates from the same Releases feed.
First-run note: the binary is unsigned, so SmartScreen will show
"Windows protected your PC". Click "More info" → "Run anyway".
Code signing is on the roadmap.
### Build from source
<existing block: git clone + cargo build --release>
```
2. **docs/release-process.md** — new file. Sketch:
```markdown
# Cutting a release
The `release.yml` workflow builds and publishes on every pushed
tag matching `v*.*.*`. The workflow asserts that the pushed tag
matches `Cargo.toml` `version` and **fails fast on mismatch**, so
the order below matters: bump Cargo *before* you tag.
Steps for a new version:
1. Bump `Cargo.toml` `version` (`X.Y.Z`).
2. `cargo build --release` locally to refresh `Cargo.lock`.
3. Commit: `chore: bump version to X.Y.Z`.
4. Tag and push:
```bash
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin main
git push origin vX.Y.Z
```
5. Watch the "Release" workflow run; it creates the GitHub Release
with `claude-code-usage-bubble.exe` attached and auto-generated
notes.
## Testing without a real tag
Use the workflow's `workflow_dispatch` input with a throwaway tag
like `v0.0.0-test`. The release is created as a **draft**, so it
does not show up on the public Releases feed or trigger
self-updates for users.
## Versioning
Semver-ish: bump patch for fixes, minor for features, major for
breaking changes (e.g. settings.json schema change). The in-app
updater compares `Version { major, minor, patch }` lexicographically.
```
3. Verify the Releases page link in the README resolves (it will once Phase 2 has cut at least v0.1.0).
## Todo List
- [x] `README.md` Install section rewritten with two tracks
- [x] SmartScreen note added
- [x] `docs/release-process.md` created
- [ ] Links verified by clicking through (requires Phase 2 v0.1.0 release to exist)
## Success Criteria
- [ ] A new contributor reading only `README.md` knows how to install without building.
- [ ] A maintainer reading only `docs/release-process.md` can cut a release without re-reading this plan.
- [ ] No mention of "Until packaged binaries are published" remains anywhere.
## Risk Assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| README link to `/releases/latest` 404s before first release exists | Certain pre-Phase-2 | Land this phase **after** Phase 2 has cut v0.1.0 |
| Users skip the SmartScreen note and panic | Medium | Bold the "Click More info → Run anyway" line; mention it in the Releases body too if needed |
## Security Considerations
- The SmartScreen warning is the user's signal that the binary is unsigned. Be honest about it; do not obscure it.
- Recommending `%LOCALAPPDATA%` as the install location keeps the user inside their writable tree (no UAC needed for self-update).
@@ -1,103 +0,0 @@
---
title: "GitHub release CI + auto-update wiring"
description: "Publish Windows binaries to GitHub Releases via Actions so the existing in-app updater can self-update."
status: pending
priority: P2
created: 2026-05-16
---
# GitHub release CI + auto-update wiring
## Overview
The self-update subsystem already exists end-to-end in `src/update/`:
`release::fetch_latest` polls `releases/latest` on GitHub, parses the
`tag_name` into a `Version`, picks the asset whose name matches
`claude-code-usage-bubble.exe` (or the first `.exe` as fallback), and
`install::begin` downloads it + spawns an inline `cmd /c` handoff
that swaps the running exe and relaunches. `app.rs` wires this to a
24-hour timer (`UPDATE_CHECK_INTERVAL_SECS`) and the right-click menu
("Check for updates" / "Update available" / "Applying update…").
What is missing is the **producer side**: no `.github/workflows/`
directory exists, the repo has no tags, and the README explicitly
says "Until packaged binaries are published, build from source". The
updater therefore has nothing to pull from. Closing that loop is the
whole job.
This plan ships three things: (1) a tag-triggered GitHub Actions
workflow that builds release on `windows-latest` and attaches the
exe to a GitHub Release, (2) an end-to-end test that proves a running
v0.1.0 actually self-updates to v0.1.1 in the wild, and (3) the docs
and release-cutting checklist so future versions ship by pushing a
tag.
Out of scope: code signing, winget channel (`Channel::Winget` stays
stubbed), SHA256 sidecar verification (HTTPS + cert pinning by WinHTTP
is the security floor; checksum is a nice-to-have for later), and any
new updater code paths beyond what the existing code already supports.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Release CI workflow](./phase-01-release-ci-workflow.md) | Files written, awaiting commit + push |
| 2 | [End-to-end update test](./phase-02-end-to-end-update-test.md) | Pending (user-driven: requires tag pushes + Windows runs) |
| 3 | [Docs and release process](./phase-03-docs-and-release-process.md) | Files written, awaiting commit; link verification gated on Phase 2 |
## Key contracts (must not break)
The updater is already shipped logic — these constants are the
contract the CI workflow has to satisfy:
| Contract | Source | Value |
|---|---|---|
| Asset filename (primary match) | `src/update/release.rs:7` | `claude-code-usage-bubble.exe` |
| Asset filename (fallback) | `src/update/release.rs:67-69` | any `*.exe` |
| Endpoint | `src/update/release.rs:45` | `https://api.github.com/repos/tiennm99/claude-code-usage-bubble/releases/latest` |
| Tag → version parse | `src/update/release.rs:33-41` | strips leading `v`, splits on `-`, takes `major.minor.patch` |
| Current version source | `Cargo.toml` `version` | bumped per release |
A tag like `v0.1.1` → parses as `Version { 0, 1, 1 }`. The workflow
MUST upload an asset named exactly `claude-code-usage-bubble.exe`.
## Dependencies
No cross-plan dependencies. The prior plan
[`260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md`](../260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md)
delivered the consumer side and is complete in code (whether its own
phase row is checked is independent of this plan).
## Validation Log
### Session 1 — 2026-05-16
#### Verification Results
- **Tier:** Standard (3 phases → Fact Checker + Contract Verifier)
- **Claims checked:** 9
- **Verified:** 9 | **Failed:** 0 | **Unverified:** 0
- Claims verified: `ASSET_NAME` constant at `src/update/release.rs:7`, matcher at `src/update/release.rs:64`, fallback at `src/update/release.rs:67-69`, URL endpoint at `src/update/release.rs:45`, version parse at `src/update/release.rs:33-41`, `version_action` apply branch at `src/app.rs:1037-1066`, 24-hour interval at `src/app.rs:48`, Cargo `name = "claude-code-usage-bubble"` and `version = "0.1.0"` at `Cargo.toml:2-3`, README "Until packaged binaries are published" at `README.md:58`.
#### Decisions
1. **Version-tag match enforcement: YES, fail-fast in CI.**
The release workflow must parse `Cargo.toml` and abort if the tag (e.g. `v0.1.1`) does not equal the Cargo version. Prevents silent mismatch where a binary self-reports a different version than the release tag — which would in turn break the updater's `Version::current() vs Version::parse(tag_name)` comparison and either skip a real update or loop on the same one.
→ Propagated to `phase-01-release-ci-workflow.md` as a new step + extra success criterion.
2. **Initial release strategy: tag v0.1.0 first, then bump to v0.1.1 for the E2E test.**
Phase 2 stays as written. Cut v0.1.0 from current `main` (no Cargo bump needed since Cargo.toml is already `0.1.0`), download the asset, bump Cargo to `0.1.1`, tag `v0.1.1`, watch the v0.1.0 binary self-update to v0.1.1. Two real releases, clean test.
→ No changes needed in Phase 2 — already aligned.
3. **Asset matcher `*.exe` fallback: defer.**
Today only one asset ships so the fallback at `src/update/release.rs:67-69` is dead code. Once multi-arch lands (`x86_64`/`arm64`), the fallback could pick the wrong binary. Tracked as a future cleanup, NOT in scope for this plan.
→ Documented in Phase 1 success criteria as a non-action.
#### Whole-Plan Consistency Sweep
- Files reread: `plan.md`, `phase-01-release-ci-workflow.md`, `phase-02-end-to-end-update-test.md`, `phase-03-docs-and-release-process.md`
- Decision deltas checked: 3 (version-match enforcement, initial release version, asset fallback)
- Reconciled stale references: 4
- Phase 1 architecture flow + todo list + success criteria updated to include version-tag check
- Phase 1 non-functional line-count budget bumped 80 → ~100 lines to match the actual YAML after adding the check step
- Phase 1 flow diagram cleaned up (removed phantom `rustup default stable` step; added cache + dispatch-draft notation)
- Phase 3 `release-process.md` sketch now calls out that CI enforces tag-vs-Cargo match, explaining why step ordering matters
- Unresolved contradictions: 0
@@ -1,122 +0,0 @@
# Phase 01 — Implement Restart Action
## Context Links
- Reused pattern: `src/update/install.rs:1-120` (cmd-handoff swap-and-restart). Documented in `docs/release-process.md` if it exists.
- Menu wiring reference: `src/app.rs:870-1045` (`show_context_menu`) and `src/app.rs:363-392` (`on_menu_command`).
- Mutex acquisition: `src/app.rs:152-168` (`Global\ClaudeCodeUsageBubble`).
- i18n schema: `src/i18n/mod.rs` (`LocaleStrings` struct around line 22-80).
## Overview
- **Priority:** Low (UX enhancement).
- **Status:** Done. Code-reviewer DONE_WITH_CONCERNS — M1 (match-arm ordering) + L3 (lock-during-save) addressed in follow-up edits.
- **Size:** ~50 LOC across 3 files (+ 8 locale TOMLs, one line each).
## Key Insights
- The existing mutex check rejects a second instance immediately. A naive "spawn-then-exit" races. The `cmd.exe /c timeout` handoff (1 s sleep, then `start ""`) is the simplest decoupling — same trick `update::install::begin` already uses.
- `cmd.exe` expands `%var%` in argument strings. Current `current_exe()` path containing `%` is an injection vector; reject it (existing precedent: `update::install` rejects too).
- `std::process::Command` with `creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)` ensures the helper outlives the parent silently.
## Requirements
### Functional
- Right-click context menu shows a "Restart" item directly above "Exit".
- Clicking "Restart" closes the current process and a new instance starts within ~1–2 seconds, restoring tray icons and bubbles.
- No confirmation prompt.
- Item label is i18n-aware: all 8 locales get a translation.
### Non-functional
- No regression in mutex single-instance behavior — second instance must still be blocked if user accidentally launches manually mid-restart.
- No console window flashes during handoff.
## Architecture
```
User clicks "Restart"
→ WM_COMMAND with IDM_RESTART
→ app::on_menu_command → app::restart_app()
→ settings::save current snapshot (defensive flush)
→ spawn detached cmd.exe with delayed `start ""` for current_exe
→ PostQuitMessage(0)
→ message loop exits → mutex released
→ cmd.exe wakes up → new instance launches → acquires mutex → run()
```
## Related Code Files
**Modify:**
- `src/app.rs` — add `IDM_RESTART: u16 = 33` const (next free in the 30-39 band), match arm in `on_menu_command`, menu append in `show_context_menu` between `IDM_TOGGLE_WIDGET` row and the separator before `IDM_EXIT`, new `fn restart_app()`.
- `src/i18n/mod.rs` — add `pub restart: String,` field to `LocaleStrings` (place near `exit`).
- `src/i18n/locales/en.toml`, `de.toml`, `es.toml`, `fr.toml`, `ja.toml`, `ko.toml`, `nl.toml`, `zh-TW.toml` — add `restart = "<translation>"`.
**Create:** none.
**Delete:** none.
## Implementation Steps
1. **Add menu ID and i18n field.**
- `app.rs`: `const IDM_RESTART: u16 = 33;` (after `IDM_VERSION_ACTION`).
- `i18n/mod.rs`: add `pub restart: String,` to `LocaleStrings`. Place adjacent to `exit`.
- Add `restart = "Restart"` to `en.toml`. Translate for the other 7 locales (Vietnamese-quality is acceptable; native fluency not required for a single-word menu item).
2. **Wire the menu entry.**
- `app.rs::show_context_menu` — between the `show_widget` append and the `MF_SEPARATOR` before `IDM_EXIT`, add `append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));`.
- Add `IDM_RESTART => restart_app(),` arm in `on_menu_command` before the `_ => {}` catch-all.
3. **Implement `restart_app()`.**
- Persist a final settings snapshot (defensive flush). Read current state, call `settings::save(&snap)`.
- Resolve `std::env::current_exe()`. If `Err`, log error and `PostQuitMessage(0)` (degrade to plain Exit).
- Convert path to string. If it contains `%`, log error and return (refuse — matches `update::install` precedent).
- Build the cmd line: `timeout /t 1 >nul & start "" "<exe>"`.
- Spawn via `std::process::Command::new("cmd.exe")` with `.creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)` and a `raw_arg` payload `/c "<cmd>"` (mirrors `install.rs:104-114`).
- On spawn success: `PostQuitMessage(0)`. On failure: log error and return (app stays running).
4. **Verify.**
- `cargo check` — no warnings.
- Manual smoke test: build release, right-click tray, Restart, observe close + relaunch within ~2 s, mutex acquired by new instance, bubbles + tray icons rendered.
## Todo List
- [x] Add `IDM_RESTART` const in `app.rs`.
- [x] Add `restart: String` to `LocaleStrings` in `i18n/mod.rs`.
- [x] Update all 8 locale `.toml` files.
- [x] Append menu item in `show_context_menu`.
- [x] Add match arm in `on_menu_command` (placed before `IDM_LANG_BASE` guard per reviewer M1).
- [x] Implement `restart_app()` in `app.rs` (clone-then-save per reviewer L3).
- [x] `cargo check` clean.
- [ ] Manual smoke test on Windows (deferred to user; needs release build).
## Success Criteria
- Right-click tray → menu shows "Restart" between "Show widget" group and "Exit".
- Clicking it closes the process and a new one starts within 2 s with identical state (settings honored, bubble positions persisted, tray icons restored).
- No console window flashes.
- `cargo check` passes with no new warnings.
- All 8 locales include the new key (no fallback to English).
## Risk Assessment
| Risk | Severity | Mitigation |
|------|----------|------------|
| Mutex race — new instance starts before old releases | Medium | 1 s `timeout` in cmd handoff; matches update module precedent. |
| `current_exe()` path contains `%` (injection) | Low | Reject, log, abort (same as `install.rs:90-94`). |
| `cmd.exe` not on PATH (broken Windows install) | Very Low | Log error, app stays running. User can Exit manually. |
| Settings not flushed before quit | Low | Explicit `settings::save()` before `PostQuitMessage`. Bubble positions already persist on drag, so worst case is a no-op. |
| User restart-spams the menu | Low | Each click queues a new cmd handoff; the timeout dedupes via mutex. Worst case: one extra instance attempt that exits immediately on `ERROR_ALREADY_EXISTS`. |
## Security Considerations
- `%`-in-path rejection prevents `cmd.exe` variable expansion injection.
- No user-supplied input enters the cmd line — only `std::env::current_exe()` output.
- Detached process flags prevent inherited stdio from leaking.
## Next Steps
- After merge: bump version (semver patch — UX addition with no API change).
- Consider an analogous restart action for the bubble's context menu (currently the bubble also fires `on_menu_command` via `WM_COMMAND`, so the same menu id works there for free).
@@ -1,41 +0,0 @@
# Plan: Menu Restart Button
**Slug:** menu-restart-button
**Created:** 2026-05-18 09:45
**Branch:** main
**Status:** Implemented (awaiting commit)
## Goal
Add a "Restart" entry to the tray right-click context menu, positioned directly above "Exit". Clicking it relaunches the running binary in-place without prompting for confirmation.
## Why
User-requested. Current flow to apply a config/locale tweak that doesn't hot-reload (or to recover after a hang) is Exit → relaunch from Start menu. A one-click restart is symmetric with Exit and avoids hunting for the binary again.
## Phases
| # | Title | Status |
|---|-------|--------|
| 01 | Implement Restart action | Done — [phase-01-implement-restart-action.md](phase-01-implement-restart-action.md) |
## Key Decisions
- **Placement:** main menu, between `Show widget` separator and `Exit`. NOT inside Settings submenu — keeps top-level discoverability.
- **No confirmation dialog.** Settings auto-save on every change (settings.rs:138-152); restart is non-destructive.
- **Mechanism:** detached `cmd.exe /c timeout /t 1 >nul & start "" "<exe>"` handoff, then `PostQuitMessage(0)`. Same pattern as `update::install::begin` minus the swap step. The 1-second wait lets the current process release `Global\ClaudeCodeUsageBubble` mutex before the new instance's `CreateMutexW` runs.
- **Reject paths containing `%`** — cmd.exe expands `%var%`, same defense the update module already uses (install.rs:90-94).
## Dependencies
- None. Pure Rust + existing `windows` crate features.
## Out of Scope
- Restart after settings change auto-trigger (would be a separate feature).
- Restart-with-args (e.g., toggle `--diagnose`).
- Cross-platform — Windows-only by design.
## Unresolved Questions
None.
@@ -0,0 +1,56 @@
---
phase: 1
title: Research OpenCode Go contracts
status: blocked
priority: P1
effort: 2h
dependencies: []
---
# Phase 1: Research OpenCode Go Contracts
## Overview
Prove the OpenCode Go credential, provider ID, and usage-data contracts before implementation. This is the gate that prevents guessed percentages.
Current result: blocked. Provider/model IDs and console quota math are verified, but there is no verified stable CLI/public API for this app to fetch current weekly/monthly quota usage.
## Requirements
- Functional: Identify exact local credential path(s), JSON shape, auth key name, refresh/login command, weekly/monthly usage percent, and weekly/monthly reset time.
- Non-functional: Use primary sources first: official OpenCode docs, OpenCode source, locally installed `opencode` behavior if available.
## Architecture
OpenCode Go will only become a supported provider if it can return normalized weekly/monthly data from a stable source. Official docs confirm Go limits are 5h/weekly/monthly and dollar-value based, but not a public usage endpoint. For this app, OpenCode Go must render a custom four-bar layout: weekly usage percent + weekly remaining time in the upper section, monthly usage percent + monthly remaining time in the lower section.
## Related Code Files
- Read: `src/creds/mod.rs`
- Read: `src/creds/codex_auth.rs`
- Read: `src/usage/chatgpt.rs`
- Read: `src/usage/types.rs`
- Read: `src/usage/registry.rs`
- Created: `plans/260602-1115-opencode-go-provider-support/reports/research-opencode-go-contracts.md`
## Implementation Steps
1. Check official OpenCode Go docs for provider ID, limits, endpoints, and console usage semantics.
2. Check official OpenCode CLI/source for `auth.json` storage path and schema.
3. If OpenCode is installed locally, run safe read-only commands: `opencode stats --help`, `opencode providers --help`, `opencode models --help`.
4. Inspect local `~/.local/share/opencode/auth.json` only with user approval if privacy hook blocks or if file may contain API keys.
5. Search for an official usage endpoint or CLI command that returns current weekly/monthly usage percent or used/limit values plus reset/period end times.
6. Record exact request/response shape or command output needed by Phase 3, including how to compute remaining time for weekly and monthly sections.
7. If only console-authenticated usage exists, stop and ask user before planning implementation beyond auth detection.
## Success Criteria
- [x] Exact credential docs path documented.
- [x] Exact provider key documented: `opencode-go`.
- [x] Weekly/monthly usage percent and reset-time shape verified in console source.
- [x] Stable app-callable quota source confirmed unavailable from current CLI/docs.
- [ ] User decision recorded: console integration, limited detector, or defer.
## Risk Assessment
Main risk: OpenCode docs mention tracking usage in the console but do not document a public usage API. Mitigation: make Phase 3 blocked until a stable source exists; do not infer usage from price tables.
@@ -0,0 +1,61 @@
---
phase: 2
title: Refactor provider metadata and settings
status: completed
priority: P1
effort: 4h
dependencies:
- 1
---
# Phase 2: Refactor Provider Metadata And Settings
## Overview
Remove hard-coded two-provider assumptions before adding OpenCode Go. Keep behavior identical for Claude Code and Codex.
## Requirements
- Functional: Represent enabled providers and positions by `ProviderId`, not one field per provider.
- Non-functional: Preserve old `settings.json` compatibility for `show_claude_code`, `show_codex`, and `bubble_positions.{claude,codex}`.
## Architecture
Introduce a small provider metadata layer near `usage::types` or `usage::registry`: stable ID, slug, display label key, tray icon ID, default enabled, and display mode. Existing providers use the current two-window mode. OpenCode Go uses a custom weekly/monthly four-bar mode.
Keep `ProviderId::ChatGpt` internally unless touching the code anyway. The user-facing label remains Codex. Avoid a mechanical rename that adds risk without feature value.
## Related Code Files
- Modify: `src/usage/types.rs`
- Modify: `src/usage/registry.rs`
- Modify: `src/settings.rs`
- Modify: `src/app.rs`
- Modify: `src/tray/mod.rs`
- Modify: `src/tray/badge.rs`
- Modify: `src/usage_color.rs`
- Modify: `src/bubble.rs`
- Modify: `src/panel.rs`
## Implementation Steps
1. Add `ProviderId::OpenCodeGo` and a stable `slug()` value `opencode-go`.
2. Add `ProviderId::all()` or equivalent ordered provider list: Claude, Codex, OpenCode Go.
3. Replace `Settings { show_claude_code, show_codex }` runtime logic with a provider-enabled map or compact struct that can hold `opencode_go`.
4. Preserve serde compatibility: deserialize old fields and write new fields only after migration, or keep old fields plus add `show_opencode_go` if map migration is too broad.
5. Extend `BubblePositions` to store OpenCode Go position while keeping old `claude` and `codex` JSON keys.
6. Update tray icon IDs and badge colors for third provider.
7. Add provider-specific display metadata: Claude/Codex mode=`two-window`; OpenCode Go mode=`weekly-monthly-four-bar`.
8. Update app loops to iterate providers instead of hand-writing Claude/Codex branches where this reduces match duplication.
## Success Criteria
- [ ] Existing `settings.json` with only Claude/Codex still loads.
- [ ] If all providers disabled, Claude Code is re-enabled as today.
- [ ] Three providers can have separate positions and tray icon IDs.
- [ ] OpenCode Go can select a custom four-bar renderer without changing Claude/Codex labels.
- [ ] No behavior change for existing Claude Code/Codex users.
## Risk Assessment
Settings migration is the highest regression risk. Mitigation: add serde/default tests with old two-provider JSON and new three-provider JSON.
@@ -0,0 +1,56 @@
---
phase: 3
title: "Implement OpenCode Go provider"
status: pending
priority: P1
effort: "5h"
dependencies: [1, 2]
---
# Phase 3: Implement OpenCode Go Provider
## Overview
Add OpenCode Go credential discovery and polling once Phase 1 proves the source of truth.
## Requirements
- Functional: When enabled and authenticated, OpenCode Go returns weekly/monthly usage percent and reset time, enough for four bars: usage percent + remaining time for each section.
- Non-functional: Do not store or transmit API keys outside OpenCode/OpenCode Go endpoints. Do not mutate OpenCode auth files.
## Architecture
Create an OpenCode Go credential source that reads OpenCode's auth store and a provider implementation that maps verified weekly/monthly usage percent + reset time into a provider-specific snapshot. Do not force OpenCode Go into the existing `UsageWindows.primary/secondary` shape if that would lose the grouped weekly/monthly rendering semantics.
## Related Code Files
- Create: `src/creds/opencode_auth.rs`
- Create: `src/usage/opencode_go.rs`
- Modify: `src/creds/mod.rs`
- Modify: `src/usage/mod.rs`
- Modify: `src/usage/registry.rs`
- Modify: `src/usage/refresh.rs`
- Modify: `src/app.rs`
## Implementation Steps
1. Add `LocalOpenCodeGoCreds` with path detection from Phase 1. Expected primary path: `%LOCALAPPDATA%`/XDG-equivalent for `opencode/auth.json`; verify exact Windows path before coding.
2. Parse only the OpenCode Go auth entry. Support API-key style auth if Phase 1 confirms schema.
3. Add `RefreshHint::LocalOpenCodeCli` and spawn `opencode auth login` or equivalent only if Phase 1 confirms the command.
4. Add `OpenCodeGoProvider::poll`, using the verified usage endpoint/command.
5. Add a provider-specific snapshot type if needed, for example `ProviderUsage::TwoWindow(UsageWindows)` and `ProviderUsage::WeeklyMonthly { weekly: Window, monthly: Window }`.
6. Map returned weekly/monthly source data to usage percent and reset-time windows. Existing renderer can then draw usage and remaining-time bars for each window.
7. Return `AuthRequired` on 401/403 or missing provider auth.
8. Add unit tests for credential parsing and response-to-snapshot mapping.
## Success Criteria
- [ ] Provider compiles behind the existing registry.
- [ ] Missing OpenCode auth produces `NoCredentials`, not panic.
- [ ] Expired/invalid auth produces `AuthRequired`.
- [ ] Weekly/monthly usage percent clamps to 0-100 and remaining-time bars derive from verified reset times.
- [ ] If Phase 1 cannot verify usage, this phase is not implemented.
## Risk Assessment
OpenCode Go limits are dollar-value based. If the usage source returns dollars instead of percentages, compute percent only as `used / limit * 100` from server-provided weekly/monthly values. Never estimate usage from model prices. If reset times are unavailable, stop and ask before shipping remaining-time bars with guessed periods.
@@ -0,0 +1,60 @@
---
phase: 4
title: Rename Models UI to Providers
status: in-progress
priority: P2
effort: 2h
dependencies:
- 2
---
# Phase 4: Rename Models UI To Providers
## Overview
Rename the menu and documentation wording from "Models" to "Providers" where the app is selecting quota sources.
Current result: partial. Provider wording and OpenCode Go listing are implemented. The OpenCode Go four-bar renderer is still blocked by Phase 1 because no stable quota source is available.
## Requirements
- Functional: Context menu uses "Providers" and lists Claude Code, Codex, OpenCode Go.
- Functional: OpenCode Go panel/bubble layout renders two grouped parts: Weekly on top and Monthly below, each with usage percent and remaining-time bars. Claude/Codex keep their current 5h/7d labels.
- Non-functional: Keep real model wording where it means actual LLM models, not provider selection.
## Architecture
This is mostly i18n and README copy. Keep internal `model` variable renames scoped to files touched by provider iteration. Do not churn every `model` local in renderer code just for style.
## Related Code Files
- Modify: `src/i18n/mod.rs`
- Modify: `src/i18n/locales/en.toml`
- Modify: `src/i18n/locales/ja.toml`
- Modify: `src/i18n/locales/ko.toml`
- Modify: `src/i18n/locales/vi.toml`
- Modify: `src/i18n/locales/zh-TW.toml`
- Modify: `src/app.rs`
- Modify: `README.md`
## Implementation Steps
1. Rename `LocaleStrings.models` to `providers`, or keep field name and change values if minimizing code churn is preferred.
2. Update English label to `Providers`; update other locales with best available translation.
3. Add `opencode_go_label = "OpenCode Go"` to all locale files.
4. Add localized generic labels for Weekly and Monthly group headers. Reuse existing usage-percent and remaining-time visual conventions.
5. Add or branch rendering for OpenCode Go's four-bar layout in `bubble.rs` and `panel.rs`; do not distort Claude/Codex two-bar layout.
6. Update README sections: "displayed models" -> "displayed providers"; "### Models" -> "### Providers".
7. Add locale schema tests for the new label.
## Success Criteria
- [x] Right-click menu says Providers.
- [x] README consistently describes selectable services as providers.
- [ ] OpenCode Go UI has Weekly and Monthly grouped sections, each with usage percent and remaining-time bars.
- [x] No accidental rename of OpenCode's documented `/models` command.
- [x] All embedded locale tests pass.
## Risk Assessment
Translations may be imperfect. Mitigation: keep provider product names untranslated and only translate the generic "Providers" label.
@@ -0,0 +1,56 @@
---
phase: 5
title: "Verify and document"
status: pending
priority: P1
effort: "3h"
dependencies: [3, 4]
---
# Phase 5: Verify And Document
## Overview
Prove the third-provider workflow works and document setup/privacy accurately.
## Requirements
- Functional: Claude Code, Codex, and OpenCode Go can be toggled independently; polling and tray refresh do not regress.
- Non-functional: Privacy docs must list every local file read and network endpoint called.
## Architecture
Verification must cover pure tests, build, and Windows manual smoke tests. OpenCode Go cannot be fully claimed without a real authenticated account or a captured fixture from Phase 1.
## Related Code Files
- Modify: `README.md`
- Optional Modify: `docs/release-process.md` only if release steps change
- Read: `src/app.rs`
- Read: `src/settings.rs`
- Read: `src/usage/*`
- Read: `src/creds/*`
## Implementation Steps
1. Run `cargo test`.
2. Run `cargo build --release`.
3. Run app with old settings file and confirm migration/defaults.
4. Run app with all three providers enabled; verify separate bubbles, positions, tray icons, panel labels, and menu toggles.
5. Verify missing OpenCode Go auth shows auth/no-credentials state without breaking other providers.
6. With real OpenCode Go auth, verify weekly/monthly percent and remaining-time bars match the official console/source found in Phase 1.
7. Update README privacy section with OpenCode auth file and endpoints.
## Success Criteria
- [ ] Tests pass.
- [ ] Release build passes.
- [ ] Existing two-provider settings migrate.
- [ ] Three-provider UI works on Windows.
- [ ] OpenCode Go renders four bars: weekly usage percent, weekly remaining time, monthly usage percent, monthly remaining time.
- [ ] README setup/privacy docs are accurate.
- [ ] No unresolved OpenCode Go usage-source uncertainty remains.
## Risk Assessment
Manual verification needs real OpenCode Go auth. If unavailable, ship code only behind disabled-by-default toggle and explicitly mark e2e verification deferred.
@@ -0,0 +1,72 @@
---
title: OpenCode Go provider support and providers terminology
description: >-
Add OpenCode Go as a usage provider and rename user-facing Models wording to
Providers.
status: in-progress
priority: P2
branch: main
tags:
- providers
- opencode-go
- usage
- ui-copy
blockedBy: []
blocks: []
created: '2026-06-02T04:20:10.983Z'
createdBy: 'ck:plan'
source: skill
---
# OpenCode Go Provider Support And Providers Terminology
## Overview
Add OpenCode Go as a third selectable usage provider beside Claude Code and Codex. Rename the app's user-facing "Models" menu/copy to "Providers" because the app chooses quota sources/services, not individual model IDs.
I agree with the rename. OpenCode itself still has a `/models` command, but this app's menu toggles Claude Code, Codex, and OpenCode Go providers. Internals already use `ProviderId`, so the naming is conceptually aligned.
## Codebase Findings
- Rust Win32 app; usage abstraction lives under `src/usage/*`.
- Current extension points: `UsageProvider`, `ProviderId`, `Registry`, `CredentialSource`, `RefreshHint`.
- Two-provider assumptions remain in `src/settings.rs`, `src/app.rs`, `src/tray/mod.rs`, `src/tray/badge.rs`, `src/panel.rs`, `src/usage_color.rs`, i18n TOMLs, and README.
- OpenCode official docs say Go is configured as an OpenCode provider, uses `/connect`, stores credentials in `~/.local/share/opencode/auth.json`, has 5h/weekly/monthly dollar-value limits, and current usage is visible in the console.
- User decision: OpenCode Go should display four bars in two parts: upper part is weekly usage, lower part is monthly usage. Each part has the same two concepts as current Claude/Codex: usage percent and remaining time. Keep Claude/Codex on their existing two-bar presentation unless a separate UI redesign decides otherwise.
- OpenCode docs expose model endpoints and `https://opencode.ai/zen/go/v1/models`, but do not document a stable usage endpoint. Do not fake usage percentages.
- Phase 1 found the OpenCode console computes `weeklyUsage` and `monthlyUsage` with `usagePercent` and `resetInSec`, but only through an authenticated console server query. `opencode stats` is local cost/token history and cannot drive the requested Go bars.
## Related Plans
- `plans/260516-0707-cleanroom-rewrite/plan.md`: introduced current provider abstraction; related, no blocking dependency.
- `plans/260523-ui-ux-improvement-plan/plan.md`: overlaps labels/tooltips; related, no blocking dependency.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Research OpenCode Go contracts](./phase-01-research-opencode-go-contracts.md) | Blocked |
| 2 | [Refactor provider metadata and settings](./phase-02-refactor-provider-metadata-and-settings.md) | Completed |
| 3 | [Implement OpenCode Go provider](./phase-03-implement-opencode-go-provider.md) | Pending |
| 4 | [Rename Models UI to Providers](./phase-04-rename-models-ui-to-providers.md) | In Progress |
| 5 | [Verify and document](./phase-05-verify-and-document.md) | Pending |
## Dependencies
Phase 1 gates Phase 3. If no stable OpenCode Go usage source is found, pause before Phase 3 and ask user whether to defer usage support or ship a limited connectivity/auth detector.
Phase 2 is implemented. Phase 4 provider wording is implemented; OpenCode Go remains listed but disabled until Phase 1's quota-source decision is resolved.
## Success Criteria
- OpenCode Go can be enabled/disabled independently without breaking Claude Code or Codex.
- Existing settings migrate safely; at least one provider remains enabled.
- UI and README say "Providers" where the app means selectable services.
- No guessed OpenCode Go usage. Weekly/monthly percent and reset-time data comes from verified source, or feature pauses.
- `cargo test` and `cargo build --release` pass.
## Sources
- OpenCode Go docs: https://dev.opencode.ai/docs/go/
- OpenCode CLI docs: https://opencode.ai/docs/cli/
- Current repo README: `README.md`
@@ -0,0 +1,42 @@
# Research: OpenCode Go Contracts
Date: 2026-06-02
Status: blocked
## Summary
OpenCode Go provider/model identity is verified, and the official console computes the exact quota fields we want. Implementation should not proceed yet because the verified quota source is an authenticated console server query, not a documented CLI command or public endpoint this desktop app can call safely.
## Verified Facts
- Provider/model ID format: OpenCode Go models use `opencode-go/<model-id>`, for example `opencode-go/kimi-k2.6`.
- Official limits: Go has a rolling 5-hour window, a weekly window, and a monthly window. The requested UI only needs weekly and monthly sections.
- Limits are cost based, not request-count based: weekly `$30`, monthly `$60`, and rolling 5-hour `$12`.
- CLI auth docs say credentials are stored at `~/.local/share/opencode/auth.json`; locally, `opencode providers` is an alias for auth management.
- Local `opencode stats` only aggregates local sessions from the OpenCode database and reports cost/token totals. It does not report Go quota percentages or reset times.
- Console source has the desired shape:
- `weeklyUsage: { status, resetInSec, usagePercent }`
- `monthlyUsage: { status, resetInSec, usagePercent }`
- Console source derives those from `LiteTable.weeklyUsage`, `LiteTable.monthlyUsage`, `LiteTable.timeWeeklyUpdated`, `LiteTable.timeMonthlyUpdated`, `LiteTable.timeCreated`, and `LiteData.getLimits()`.
## Sources
- OpenCode Go docs: https://dev.opencode.ai/docs/go/
- OpenCode CLI docs: https://opencode.ai/docs/cli/
- OpenCode source: `packages/opencode/src/cli/cmd/stats.ts`
- OpenCode source: `packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx`
- OpenCode source: `packages/console/core/src/subscription.ts`
- Local command: `opencode stats --help`
## Decision
Do not implement OpenCode Go four-bar usage yet. The app cannot honestly render weekly/monthly usage percent and remaining time until one of these is chosen:
1. Use an official/detected console endpoint with user-approved authentication.
2. Ship only provider/auth detection now and hide/disable Go usage bars until quota data is available.
3. Ask OpenCode upstream for a documented quota API or CLI output.
## Unresolved Questions
- Should this app integrate with the authenticated OpenCode console, or should Go support be limited to detection until OpenCode exposes a stable quota API?
- Do we still want to continue Phase 2/4 now for the provider terminology rename, while deferring Phase 3?
@@ -0,0 +1,166 @@
---
phase: 1
title: "Scaffold Tauri crate, JS toolchain and CI"
status: in-progress
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 Linux row also runs `npm run tauri build -- --bundles deb` (no
patchelf needed), so a broken `bundle.icon` list fails before release.
- 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.
## Todo
- [x] npm toolchain: `package.json`, `package-lock.json`, `eslint.config.js`, `tests/js/.gitkeep`
- [x] `tauri.conf.json`, `capabilities/default.json`, `.taurignore`, `.gitignore`
- [x] `src/lib.rs` (`diag`, `run()`, test window, D18 exit handling) and the `src/main.rs` shim; Win32 modules out of the module tree
- [x] Icons generated, `icons/icon.ico` replaced by `src/icons/icon.ico`; `res/icon.rc` deleted
- [x] `tauri-build` embeds the icon, VERSIONINFO from the Cargo version and a PerMonitorV2 manifest
- [x] `npx tauri info` resolves the root-level `tauri.conf.json`
- [x] `ci.yml` passes `actionlint` and `yamllint`; all three rows green with a `bubble-exe-<sha>` artifact
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`
- [ ] Windows manual check: the CI exe starts and shows the test window (needs a Windows machine)
## Implementation notes
- `windows` is `0.62` and `webview2-com` is `0.39`, the majors tauri 2.12 pulls
(`cargo tree -i windows` shows a single copy). `webview2-com` is a direct
dependency because tauri does not re-export it.
- `x11rb` is `0.14` and `libloading` is `0.9`, the current releases.
- Actions use their current majors: `actions/checkout@v7`,
`actions/setup-node@v7`, `actions/upload-artifact@v7`.
- `tauri-build` writes VERSIONINFO only from the config `version`, which D16
leaves out. `build.rs` merges the Cargo version into `TAURI_CONFIG` before
`try_build`, keeping any CLI `--config` override. The Windows CI row asserts
that the exe `ProductVersion` equals the `Cargo.toml` version.
- The default tauri manifest has no DPI awareness, so
`res/windows-app-manifest.xml` keeps its Common-Controls v6 dependency and
adds `PerMonitorV2`.
- Only desktop icons are kept: the mobile (`android/`, `ios/`) and Windows
Store (`Square*Logo.png`, `StoreLogo.png`) outputs of `tauri icon` are unused.
- `bundle.icon` lists every desktop icon. The bundler has no fallback (an
empty list panics the AppImage step and ships a deb without hicolor icons).
`icons/icon.png` comes first because tauri-codegen takes the first `.png` as
the Linux and macOS default window icon.
- The test window (`bubble-test`) exits the app on close, since D18 keeps the
process alive after its last window is destroyed.
## 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. `npm run tauri build -- --bundles deb` builds a deb with 32, 128, 256 and 512 px hicolor icons. The AppImage needs patchelf, so it 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,118 @@
---
phase: 2
title: "Extract portable core with tests"
status: completed
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.
## Todo
- [x] `core/geom.rs`: `Rect`, `Point`, `scale`, bubble sizing, `default_position`, `clamp_into_work_area`, `snap` over `SnapInput`, `point_in_rounded_rect`, `place_near`, `position_on_any_monitor`, `rect_covers_monitor`, `window_style_bits_allow_fullscreen`
- [x] `core/settings.rs`: `parse_and_normalise`, `drop_offscreen_positions`, same path, serde attributes, atomic save and toggle invariant
- [x] `core/i18n.rs`: `start_at_login` selected by `cfg(windows)`, `format_pct`, `format_window`, `format_countdown`, `time_until_display_change`
- [x] `core/color.rs` and `core/usage_color.rs`: `Rgb::to_hex`, `accent_color_for`, `bar_fill_color`, `brighten`, `pulse_triangle`
- [x] `core/state.rs`: `apply_results`, 30-minute cooldown, `tray_tooltip`, `UpdateStatus`, `version_action_label`, update-check due/remaining
- [x] `core/payload.rs`: `BubblePayload` and `PanelPayload` builders
- [x] `core/menu_model.rs`: menu tree with string ids, check/enabled flags and hint rows
- [x] `src/lib.rs` declares `core` and `usage`; `usage/mod.rs` reduced to `types`; `ProviderId` derives `Serialize`
- [x] Deleted `src/settings.rs`, `src/i18n/mod.rs`, `src/usage_color.rs`, `src/os/color.rs`, `src/os/dpi.rs`
- [x] Host: `cargo test core::` (65 tests), `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`
## Implementation notes
- `apply_results` takes `core::state::PollOutcome` (`Ok`, `AuthFailed`,
`Failed`) instead of `usage::Error`, because the error type needs `net` and
`creds`, which return in Phase 03. The caller maps `AuthRequired` and
`TokenExpired` to `AuthFailed`, everything else to `Failed`, and logs the
transient error as before.
- The cooldown lives in `UsageState::admit_alert`. Threshold alerts pass it
inside `apply_results`; `session_expired_alert` uses the same gate; the
update-applied alert does not (parity).
- Time-dependent helpers have `_at(…, now)` variants so the countdown and
payload tests are deterministic. `bubble_payload` takes `now` as well as
the `Canvas { w, h, dpi }`.
- `version_action_label` drops the old WinGet suffix: no WinGet package
exists and Phase 09 replaces the channel enum.
- `drop_offscreen_positions` keeps every position when the monitor list is
empty, so a failed `available_monitors()` cannot wipe them. The probe
needs real overlap with a monitor rect, like `MonitorFromRect` with
`MONITOR_DEFAULTTONULL`.
- `Settings::save` now returns `io::Result` so the caller can log a failure;
the tmp-and-rename write and its direct-write fallback are unchanged.
- `Settings::toggle_live_provider` holds the toggle invariant from
`toggle_model`: providers without live usage never change, and the last
enabled live provider is re-enabled.
- `MenuCommand::parse` accepts only the poll and update intervals the menu
offers, so a malformed id cannot set an arbitrary interval.
- The old `pulse_triangle` doc comment had the peaks wrong; the code is
unchanged and the comment now says 0 at phase 0 and 1 at phase 12.
- `src/os/mod.rs` still names the deleted `color` and `dpi` files. It is
outside the module tree, and the phase that deletes `src/os/` removes it.
## 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,119 @@
---
phase: 3
title: "Data layer on ureq"
status: completed
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: `net::env_proxy()` walks the variables ureq reads (`ALL_PROXY`, `HTTPS_PROXY`, `HTTP_PROXY`, either case) and keeps the first valid one, in ureq's order. ureq's `socks-proxy` feature is on, so a SOCKS entry such as Clash's `ALL_PROXY=socks5h://…` is used, not skipped. `NO_PROXY` applies to the chosen proxy.
- `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.
## Todo
- [x] `src/net/mod.rs` on ureq: same `Client`/`RequestBuilder`/`Response` surface, `Transport(String)` replaces `Win`, `http_status_as_error(false)`, connect 60 s / receive 30 s / global 120 s, gzip, rustls with the platform verifier, `claude-code-usage-bubble/<version>` User-Agent with per-request override
- [x] Proxy: env proxies from ureq; `cfg(windows)` `platform::windows::system_proxy()` reads `ProxyEnable`/`ProxyServer`/`ProxyOverride` only when no env proxy is set; `core::proxy` parses per-scheme and bare servers, `;`-split override globs and `<local>`; `Client` holds a proxied and a direct agent and picks per request
- [x] `refresh.rs`: Windows candidates and `CREATE_NO_WINDOW` behind `cfg(windows)`; Unix `claude -p .` / `codex exec .` searched in `PATH`, `~/.local/bin`, `~/.claude/local`, `/opt/homebrew/bin`, `/usr/local/bin`; child reaped on a detached thread
- [x] `usage/mod.rs` restored (providers, registry, headers, refresh, `Error`, `UsageProvider`); `src/lib.rs` declares `creds`, `net` and `platform`
- [x] Test asserting `LIVE_USAGE` excludes OpenCode Go
- [x] Deleted `src/net/winhttp.rs`
- [x] Fixture tests in `tests/fixtures/*.json`: OAuth usage (full, partial, null buckets, null utilization), rate-limit headers (fractions, missing, negative, a 429 over loopback), ChatGPT envelope (null `rate_limit`, null windows, negative `reset_at`), ISO 8601 (`+0000`, lowercase `t`, before 1970), credential JSON (missing fields, float `expiresAt`, null `tokens`, empty `CODEX_HOME`), proxy strings
- [x] `#[ignore]` `live_poll` smoke test, run by hand on the host: both providers returned data
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (112 passed, 1 ignored), `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`
- [x] CI Windows, Linux and macOS rows green, including the Windows proxy reader
## Implementation notes
- The registry reader uses `windows-registry` 0.6, the crate and major that
`auto-launch` already pulls, so no new crate is compiled and no `unsafe`
registry code is needed.
- ureq always tunnels through an HTTP proxy with `CONNECT`, also for `http://`
targets. The loopback routing test asserts the `CONNECT` line instead of a
proxied reply.
- `net::Error::Status` is kept so the providers compile unchanged; the client
never builds it, because every status comes back as a `Response`.
- `net::Client::new` consults `net::system_proxy()` only when
`net::env_proxy()` finds no usable proxy. SOCKS env proxies go through
ureq's `socks-proxy` connector; without that feature ureq fails every
request for `socks5h` and silently connects directly for `socks5`. The
system proxy stays HTTP/HTTPS only. `Client::system_proxy()`
exposes the system proxy in use for the updater
builder in Phase 09. Proxy URLs are never logged (they may hold
credentials).
- An empty `CODEX_HOME` now counts as unset, as in the Codex CLI. Before, it
resolved to `auth.json` in the working directory.
- A float `expiresAt` is truncated to whole milliseconds instead of being
treated as "no expiry".
- `anthropic::parse_usage_body` and `codex_auth::{auth_path, parse_codex_json}`
were split out of the I/O paths so the fixtures test them directly; the
behaviour is unchanged.
- The README note that PAC/WPAD is no longer evaluated belongs to the README
rewrite in Phase 11; this phase does not own the README.
## 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,135 @@
---
phase: 4
title: "App runtime: state, scheduler, CLI, single instance"
status: in-progress
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.
## Todo
- [x] `src/cli.rs`: pure parser for `--diagnose`, `--wait-pid <pid>` and `--updated-to <v>` (alone and combined); unknown flags, `--apply-update`, pid 0 and non-version values are ignored; arguments read with `args_os`
- [x] Pre-Builder order in `runtime::run()`: diag, `cfg(windows)` WebView2 guard with `message_box_error` and exit 0, `--wait-pid` wait capped at 15 s and logged, Linux `GDK_BACKEND=x11` rule, single-instance plugin first with a no-op callback, no other plugin
- [x] `platform::wait_pid`: `kill(pid, 0)` every 100 ms on Unix, `OpenProcess`/`WaitForSingleObject` on Windows; `platform::windows::{webview2_available, message_box_error, is_dark}`
- [x] `setup()`: settings load, `drop_offscreen_positions` against `available_monitors()`, theme read, `AppState` behind one mutex with `with_state`, `UiBridge` holding the `AppHandle`, startup cleanup, scheduler start, first poll
- [x] `src/handoff.rs`: `relaunch(extra_args) -> io::Result<()>` (`$APPIMAGE` or `current_exe`, `--wait-pid <pid>`, Windows creation flags, own process group on Unix) and `cleanup_stale_files` with a temp-dir test
- [x] `src/scheduler.rs`: `PollNow`, `SetPollInterval`, `RecomputeCountdown`, `SetUpdateInterval`, `UpdateCheckDone`, `Shutdown`; pure `Deadlines` tested with a fake host, including "Weekly to Daily with the last check 2 days ago checks at once"
- [x] Poll worker: `AtomicBool` gate, registry poll, `apply_results`, CLI refresh (8 s each, session-expired alert for the first provider not `Refreshed`, no re-poll), then `propagate()`
- [x] `propagate()`: snapshot under the lock, payloads built and emitted to `bubble-<slug>` from the main thread, countdown re-armed
- [x] Commands `get_bubble_state`, `get_strings`, `log_js`, `bubble_ready`, with argument validation
- [x] `RunEvent::ExitRequested { code: None }` prevents exit; `RunEvent::Exit` stops the scheduler; bubbles `prevent_close`
- [x] Restart (save, `relaunch(&[])`, exit only on `Ok`) and Exit on `Runtime`
- [x] `--updated-to` stored as a pending `UpdateApplied` alert
- [x] Deleted `src/app.rs` and `src/update/handoff.rs`
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (155 passed, 1 ignored), `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`
- [x] CI Windows, Linux and macOS rows green
- [ ] Linux desktop (X11) and Windows manual checks; they need a display and the Restart/Exit menu, which arrives with the menu work
## Implementation notes
- `GDK_BACKEND=x11` is set only when `DISPLAY` is also set. With
`WAYLAND_DISPLAY` but no X server, forcing x11 would stop GTK from starting
at all, so the native Wayland backend stays and positioning features become
no-ops later (D12).
- On Windows the parent wait is one `WaitForSingleObject` with the 15 s
timeout instead of 100 ms polls: it returns as soon as the process exits and
holds the handle, so a reused pid cannot be mistaken for the parent.
- `--wait-pid 0` is rejected: on Unix `kill(0, 0)` addresses the process group.
`--updated-to` accepts only version-like text (digits first, then
`[0-9A-Za-z.+-]`, at most 64 chars), because it ends up in a notification.
- The scheduler floors the poll interval at 10 s and the update interval at
60 s, so a hand-edited `0` cannot become a busy loop. The update deadline is
cleared when a check fires and only `UpdateCheckDone` re-arms it.
- The scheduler channel is created before the thread starts, so the runtime
holds its sender before the first action runs.
- The update check action and the alert delivery are hooks: no updater or
notification plugin is registered yet (D8, D9), so a due check only logs and
alerts reach the diagnostic log. The pending update-applied alert waits in
`AppState::pending_update_alert`. `tray::sync`, `menu::refresh` and
`panel::refresh_if_visible` join `propagate()` with their modules.
- `bubble_ready` shows the window with `show()` until the no-activation show
path exists (D5).
- The scaffold window is now labelled `bubble-<first enabled provider>`, is a
decorated 460x420 window, renders the payload JSON from `get_bubble_state`
and `bubble:update`, and forwards errors through `log_js`. Like every bubble
it ignores close requests.
- `LocaleStrings` derives `Serialize` (camelCase) for `get_strings`.
- `src/update/mod.rs` and `install.rs` still name the deleted `handoff`
module. They are outside the module tree; the updater rewrite replaces them.
## 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,123 @@
---
phase: 5
title: "Menu, tray, alerts, autostart"
status: in-progress
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 the names the tray backend loads, in its order: `libayatana-appindicator3.so.1`, `libappindicator3.so.1`, then the unversioned `libayatana-appindicator3.so` and `libappindicator3.so` (its default `backcompat` feature; sometimes the only names in an AppImage). The loaded library stays loaded. If none 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.
## Todo
- [x] `src/badge.rs`: moved from `src/tray/badge.rs` without the HICON code; `demultiplied_rgba` for `Image::new_owned`; pixel tests for the disk centre, the track at radius 12.15, the arc at 12 o'clock for every percent above 0, a quarter sweep, transparent corners, and demultiply
- [x] `src/menu.rs`: `core::menu_model` mapped onto `Menu`, `Submenu`, `CheckMenuItem`, `MenuItem` (hints disabled) and separators; autostart check from `autolaunch().is_enabled()`; one `on_menu_event` that runs the command and then rebuilds every tray menu (also for no-op clicks); `show_context_menu(label)` with `SetForegroundWindow` first on Windows
- [x] Headless menu test: the full tree (order, labels, ids, check and grey states) walked in `core::menu_model`
- [x] `src/tray.rs`: one `TrayIconBuilder::with_id(slug)` per enabled live provider in `LIVE_USAGE` order, `show_menu_on_left_click(false)`, left `Click`/`Up` toggles the widget, badge always dark, tooltip from `tray_tooltip` cut to 127 UTF-16 units, `set_icon`/`set_tooltip` only on a changed `(pct, colour)` or text
- [x] Linux: `platform::linux::appindicator_available()` probes `libayatana-appindicator3.so.1`, `libappindicator3.so.1`, `libayatana-appindicator3.so`, then `libappindicator3.so` (the backend's order); without any the tray is skipped and logged; other tray errors are logged
- [x] Autostart plugin with `app_name("ClaudeCodeUsageBubble")`, no args, `MacosLauncher::LaunchAgent` on macOS (Linux and macOS); Windows pinned to a current-user entry with the quoted exe path through `auto-launch` directly (notes below); no migration code needed
- [x] `platform::windows::register_aumid()` (HKCU `AppUserModelId` key with `DisplayName` and `IconUri`, PNG under `data_local_dir()/ClaudeCodeUsageBubble/icon.png`, `SetCurrentProcessExplicitAppUserModelID`) run in `setup()` before any toast
- [x] `tauri-plugin-notification` shows the `apply_results` and session-expired alerts and the pending "Update applied" alert (no cooldown), after the first tray sync
- [x] Provider toggle: at-least-one invariant, bubble window created or destroyed (stand-in window until the real bubble), tray sync, menu rebuild, poll
- [x] `propagate()` now syncs the trays and rebuilds the menus when the tree changed
- [x] Deleted `src/tray/mod.rs`, `src/tray/callback.rs`, `src/os/registry.rs` (and `src/os/theme.rs`, its only user)
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (168 passed, 1 ignored), `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`
- [ ] Windows toast spike on Windows 10 and 11 from the CI artifact (manual; no Windows machine here). This is the D9 gate and the phase stays in progress until it passes; the alert wiring already exists, so a failed spike goes to the user before Phase 06 builds on it
- [ ] Linux desktop (X11) and Windows manual checks below
## Implementation notes
- **Autostart plugin findings** (`tauri-plugin-autostart` 2.7.0 on `auto-launch` 0.6.0):
- Windows `enable()` writes the `Run` value data as `"<app_path> <args joined>"`, so with no args it is the **unquoted** exe path plus a trailing space. The legacy data was the quoted path.
- Windows `is_enabled()` only checks that the `Run` value exists (in HKLM or HKCU) and is not disabled in `StartupApproved\Run`; it never compares the data. A legacy entry therefore shows checked and no data rewrite is written.
- Windows `enable()` also writes the "enabled" bytes to `StartupApproved\Run` when that key exists. That only happens when the user ticks the item, which is the user re-enabling; our own code never touches `StartupApproved`, and `is_enabled()` honours a Task Manager disable.
- The plugin Builder does not expose the Windows enable mode, so the plugin would use `auto-launch`'s default `Dynamic`: `enable()` tries HKLM first, so an elevated tick writes an all-users entry that a normal untick cannot remove (`disable()` ignores the HKLM access-denied error and `is_enabled()` still finds it), and that entry holds the unquoted path. Windows therefore does not register the plugin: `platform::windows::autostart_entry` builds an `auto_launch::AutoLaunch` with `WindowsEnableMode::CurrentUser` and the quoted exe path. Its data is `"<exe>" ` (the legacy quoted path plus the trailing space `auto-launch` always appends); `is_enabled()` is unchanged (value present in HKLM or HKCU, not disabled in `StartupApproved\Run`), so a legacy entry still shows checked. Linux and macOS keep the plugin (D10).
- Linux `enable()` writes `~/.config/autostart/ClaudeCodeUsageBubble.desktop`, and the plugin uses `$APPIMAGE` (Tauri's `env().appimage`) as the path when it is set, so no entry of our own is needed. The `Exec` path is not quoted.
- `menu::refresh` is `Runtime::refresh_shell(force)`: on the main thread it snapshots state (lock released before any Tauri call), then syncs the trays and, when the menu tree differs from the last one applied (or after any menu click), gives every tray a freshly built menu. Countdown ticks and polls therefore rebuild no menu unless something in it changed. The snapshot is taken inside the main-thread closure because a closure queued from a worker runs after one a menu click runs inline; a snapshot taken on the calling thread could otherwise re-apply a stale tree and recreate a removed tray icon.
- The left-click handler is registered once on the Tauri builder (`Builder::on_tray_icon_event`), not per icon: Tauri calls a per-icon handler while holding the per-icon listener table, and the inline main-thread refresh may build a missing icon, whose registration locks that same table (a same-thread deadlock).
- Each tray gets its own `Menu` instance; the bubble right-click builds a fresh one and pops it from the main thread after the async command returns.
- The bubble windows are still the decorated JSON stand-ins; one is created per enabled provider. A window destroyed by a provider toggle is recreated from its `Destroyed` event if the provider is enabled again, except while the process is exiting. Outside `setup()`, bubbles are built on a short-lived thread (Tauri documents that building a webview window inside a menu or window event handler deadlocks on Windows); a process-wide lock, never taken by the main thread, serialises the toggle and the recreate path so only one builds the window. Widget show/hide uses Tauri `show`/`hide` until the no-activation path exists. Reset position and size changes update and save the settings and push new payloads; moving and resizing windows arrives with the bubble window.
- The version item calls the update-check hook, which still only logs until the updater exists.
- The stand-in page forwards `contextmenu` to `show_context_menu` so the menu can be checked before the real bubble UI.
- The Windows toast spike could not run here. The AUMID registration and the notification wiring are implemented; the toast check stays a manual Windows check on the CI artifact, and if no toast appears the alerts need the user's decision before more work.
## 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.
- Toggle a provider off and on from the tray menu, once slowly and once quickly: the app does not hang, and exactly one bubble and one tray icon come back.
- Tick "Start with Windows" from an elevated run: only `HKCU\…\Run` gets the value, with the quoted path; a normal run can untick it.
- 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,148 @@
---
phase: 6
title: "Bubble window and UI"
status: in-progress
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 defaults to dark and is rechecked on bubble creation and `ThemeChanged`. macOS uses `window.theme()`. Linux reads the portal `org.freedesktop.appearance color-scheme` itself (1 dark, 2 light) and keeps the current theme on "no preference" or an unreachable portal, because tao maps both to light.
- 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.
## Todo
- [x] `ui/layout.js`: `headMetrics`, `tailLayout`, `ringDash`, `remainingFraction`, `pulseT`, `brighten`, plus `usageFillWidth`, `timeFillWidth`, `headLabelText` and `cssPx`; `tests/js/layout.test.js` checks them against values from the Rust formulas at 200 and 360 logical, dpi 96 and 144 (300 px at 144 dpi included) and 140 at 120 dpi
- [x] `ui/bubble.html`, `ui/bubble.css` (palette per `[data-theme]`, dead corners), `ui/bubble.js` (layout, texts from the payload only, pulse, 60 s time progress, `visibilitychange`, pointer drag/click, `contextmenu`, Ctrl+wheel), `ui/tauri-bridge.js`
- [x] `src/bubble.rs` rewritten: builder options as listed, exact physical size and saved/default position clamped into the work area, refit on `ScaleFactorChanged`, `destroy()` only with recreate after `Destroyed`, show on `bubble_ready` or the 1.5 s fallback, `widget_visible` honoured at creation
- [x] `src/platform/windows.rs`: `apply_no_activate`, `show_no_activate`, `hide`, `install_bubble_hook` (`WM_EXITSIZEMOVE`, `WM_SETTINGCHANGE`, `WM_DISPLAYCHANGE`, plus `MA_NOACTIVATE`), `taskbar`
- [x] Linux/macOS move end: 250 ms debounce armed by `bubble_drag_start`, re-armed while X11 Button1 is held (`platform::linux::button1_down`); theme from `window.theme()` (macOS) or the portal color-scheme (Linux, only a stated preference applies) on creation and `ThemeChanged`; native Wayland skips snap, clamp and saving
- [x] Move end snaps with peers in `LIVE_USAGE` order, moves only on a change, saves the position
- [x] Commands `bubble_click` (logs until the panel exists), `bubble_drag_start`, `bubble_resize_step`, `bubble_ready`; resize around each centre without snap or save
- [x] Reset position moves the existing bubbles to their defaults
- [x] `src/core/visibility.rs` with tests for every row of the transition table; user show/hide goes through it
- [x] Deleted `src/os/mod.rs` and `src/os/string.rs` (`src/os/theme.rs` was already gone)
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (183 passed, 1 ignored), `npm run lint`, `npm test` (32 passed), `npm run tauri build -- --no-bundle`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest`
- [x] Headless Chromium render of the page with a stubbed `__TAURI__` at 100%, 150% and 200% (layout, both themes, dead corners) and synthetic pointer checks (click, drag past 3 px, no drag on a later hover, Ctrl+wheel, right-click on the pill only)
- [ ] Visual comparison against a v0.5.0 screenshot at 100% and 150% (none in the repo; Windows manual check)
- [ ] Linux desktop (X11) and Windows manual checks below
## Implementation notes
- Rust sends a `theme` event (`{ isDark }`) to every webview when the system theme changes, then fresh payloads, because the fill colours also depend on the theme. Windows reads `SystemUsesLightTheme` on `WM_SETTINGCHANGE` and ignores tao's `ThemeChanged`, which follows the apps setting.
- The visibility state (`user_hidden`, `hidden_by_fullscreen`) lives in `AppState`; `Runtime::apply_visibility(event)` applies `core::visibility::next` and is the entry point for the fullscreen thread. A user show or hide also saves `widget_visible`.
- Window event handlers and the Windows hook hand their Tauri work to a short-lived thread, so a refit runs after tao's own `WM_DPICHANGED` resize and nothing runs inside a window procedure.
- A new bubble picks the monitor its saved position overlaps most (else the nearest, else the primary) for its DPI and work area.
- The head label is measured at its drawn weight (600) for the "5H" fallback; the reserved tail widths ("999d", "100%") are measured at regular weight, as the GDI bubble did.
- The details panel does not exist yet, so a bubble click is only logged.
- On Linux there is no display-change hook, so clamping after a resolution change happens only on Windows. The README note on transparency without a compositor belongs to the docs phase.
## 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,117 @@
---
phase: 7
title: "Details panel window"
status: in-progress
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.
## Todo
- [x] `src/panel.rs` rewritten: the `panel` webview with the listed builder options, created hidden at startup, hidden and shown but never destroyed; Alt+F4 hides it
- [x] `panel::toggle`: same provider hides, otherwise `place_near` with the bubble's outer rect, the panel sized for the bubble's DPI and the union of all monitors, then `panel:update`, `show()` and `set_focus()`
- [x] Focus loss hides the panel; a toggle within 150 ms of that hide for the same provider is ignored (pure `toggle_state`/`blur_state`, unit-tested)
- [x] `refresh_if_visible` from `propagate`, outside the state lock, checked on the main thread against the provider on screen
- [x] `src/platform/windows.rs`: `apply_toolwindow`, `round_corners` on each show, `disable_browser_accelerator_keys`; `apply_no_activate` shares the style helper
- [x] `get_panel_state` command; `bubble_click` toggles the panel
- [x] `ui/panel.html`, `ui/panel.css`, `ui/panel.js`: GDI coordinates, square bars, palette per `[data-theme]`, texts and colours from the payload only, row labels from `get_strings`, `contextmenu`, Ctrl+wheel and browser accelerator keys suppressed
- [x] `ui/panel-view.js` (`panelFillWidth`, `isBrowserAccelerator`) with `tests/js/panel-view.test.js`
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (191 passed, 1 ignored), `npm run lint`, `npm test` (37 passed), `npm run tauri build -- --no-bundle`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest`
- [x] Headless Chromium render of the page with a stubbed `__TAURI__` in both themes (coordinates, fill widths, clipping)
- [ ] Visual comparison against a v0.5.0 screenshot and the DWM shadow check (none in the repo; Windows manual check)
- [ ] Linux desktop (X11) and Windows manual checks below
## Implementation notes
- The row labels ("5h", "7d") are not in `PanelPayload`; the page reads them once from the existing `get_strings` command, so the payload contract stays as planned.
- The pure page helpers live in `ui/panel-view.js` instead of `ui/panel.js`, because `panel.js` reads `window.__TAURI__` on import and cannot run under `node --test`.
- Tauri does not expose wry's `with_browser_accelerator_keys`, so `platform::windows::disable_browser_accelerator_keys` sets `AreBrowserAcceleratorKeysEnabled` through `with_webview` and reloads the page once, because the setting applies from the next navigation. A page-load hook on the panel runs this after the first `panel.html` load finishes, so the reload cannot replace the initial navigation; still to check on Windows hardware that the panel draws its payload after startup. The `keydown` handler covers the same keys on every platform.
- `bubble_click` is an async command, so the toggle is queued to the main thread instead of running inside the webview's IPC callback. Toggles and refreshes both run on the main thread, which keeps a refresh from drawing a provider the panel no longer shows.
- The panel background colour is set from the theme before every show, and the payload is emitted before `show()`, against a white or stale flash.
- `shadow(false)` is kept until the Windows screenshot comparison says otherwise.
- Under native Wayland the panel is only resized, not moved (D12).
## 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,87 @@
---
phase: 8
title: "Fullscreen auto-hide"
status: in-progress
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.
## Todo
- [x] `platform::foreground_is_fullscreen()` dispatcher in `src/platform/mod.rs`; native Wayland (no X11 positioning) reports false
- [x] Windows: the v0.5.0 evaluation moved into `src/platform/windows.rs`, with the bubble-class check replaced by the own-PID check (`GetWindowThreadProcessId`); `Progman`, `WorkerW` and `Shell_TrayWnd` still ignored; `rect_covers_monitor` and the style bits come from `core::geom`, whose two tests stay there
- [x] Linux: x11rb reader on the shared cached connection (`_NET_ACTIVE_WINDOW`, `_NET_WM_STATE`, `_NET_WM_PID`), pure `evaluate_active_window` unit-tested
- [x] macOS stub returning false, pointing at `CGWindowListCopyWindowInfo`
- [x] `src/fullscreen.rs`: 1500 ms thread, snapshots the visibility state, then `Runtime::apply_visibility` outside the lock; each hide/show decision logged with reason and detail under `--diagnose`
- [x] `core::visibility::next` tests cover every row of the Phase 06 table (user show during fullscreen, fullscreen off while user-hidden); `fullscreen::` tests cover check sequences through it
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (197 passed, 1 ignored), `cargo test -- fullscreen:: visibility::` (9 passed), `npm run lint`, `npm test` (37 passed), `npm run tauri build -- --no-bundle`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest`
- [ ] Linux desktop (X11) and Windows manual checks below
## Implementation notes
- `foreground_is_fullscreen()` returns a `FullscreenCheck { fullscreen, reason, detail }` instead of a `(bool, &'static str)` pair, so the diagnostic log keeps the window handle, class, bounds and monitor that v0.5.0 logged.
- Linux also treats a `_NET_WM_STATE_HIDDEN` (minimized) active window as not fullscreen, the X11 counterpart of the Windows minimized check. A window without `_NET_WM_PID` is not ours. A failed query (the window vanished between reads) counts as not fullscreen.
- Decisions are logged only when they change the bubbles, as v0.5.0 did; logging every 1.5 s tick would flood the log.
- A user show during fullscreen keeps `hidden_by_fullscreen`, so the next tick does not hide the bubbles again; leaving fullscreen then clears the flag.
- The thread starts before the bubbles are created; a bubble created while fullscreen is active stays hidden through `Visibility::shows`.
- The listed `cargo test fullscreen:: visibility::` is not valid cargo syntax (one filter before `--`); the equivalent is `cargo test -- fullscreen:: visibility::`.
## 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,146 @@
---
phase: 9
title: "Updater"
status: in-progress
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.
## Todo
- [ ] Step 1, user action: generate the key pair (`npx tauri signer generate`), put the public key in `tauri.conf.json` `plugins.updater.pubkey` (empty until then), store `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` as GitHub secrets and keep an offline backup
- [x] `tauri-plugin-updater = "2"` registered in `src/runtime.rs` in the same commit as `plugins.updater` (`pubkey`, `endpoints`) in `tauri.conf.json`; `createUpdaterArtifacts` stays `false`; no capability added
- [x] `src/core/version.rs`: suffix-ignoring `Version` and `newer`, used as the plugin's `version_comparator`
- [x] Check: 30 s timeout, Windows system proxy unless an env proxy is set (skipped when an update host is in the bypass list), `TargetNotFound`/`TargetsNotFound` map to Up to date, `last_update_check_unix` saved on every check, then `UpdateCheckDone`
- [x] Apply: `Portable` stages `updates/update.exe`, runs `ensure_writable`, `reject_unsafe_path` and `self_replace`, deletes the stage; `AppImage` runs the plugin `install`; both relaunch with `--updated-to X.Y.Z` and exit only on `Ok`; a relaunch failure sets Failed and writes `update-error.log`
- [x] `PackageManager` and `Unsupported` channels: the check runs, "Update available" is greyed (`MenuInput::update_can_apply`)
- [x] Plugin errors mapped onto `update::Error`; apply failures write `update-error.log` in the existing format
- [x] Removed `src/update/release.rs`, the WinHTTP download, the `--apply-update` helper, `parse_sha256_digest` and the `sha2` dependency
- [x] Tests: version (`v1.2.3-rc1` = 1.2.3, `v1` = 1.0.0, `abc` = 0.0.0, no downgrade), channel detection, staging and log paths, stage-and-swap, error mapping and `TargetNotFound` to Up to date, update proxy bypass, plugin config parse
- [x] Step 3: signature check verified in the pinned plugin source and by running its tamper tests (see notes)
- [x] Step 4: builder timeout vs download verified in the pinned plugin source (see notes)
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (216 passed, 1 ignored), `cargo test -- version:: update::` (18 passed), `npm run lint`, `npm test` (37 passed), `npm run tauri build -- --no-bundle`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest`
- [ ] Linux desktop (X11) manual check below
## Implementation notes
- **Step 3 (signature):** in `tauri-plugin-updater` 2.14.0, `Update::download` ends with `self.verify(&buffer)?`, which runs `minisign_verify::PublicKey::verify` against the configured `pubkey` before any bytes are returned, and `Update::install` verifies again. The plugin's own offline tests (`verifies_cached_bytes_and_rejects_tampering`, `install_rejects_bytes_that_do_not_match_the_signature`, plus the signed-version ones) were run on this host against the pinned source and pass: tampered bytes, a tampered signature, an invalid signature and a different key are all rejected. No separate `minisign-verify` call is needed.
- **Step 4 (timeout):** the builder timeout does **not** reach the download. `Updater::update_from_release` builds every `Update` with `timeout: None`, so `download()` would run without any limit. Following the phase, apply drops the second builder and the second `check()`: it downloads the `Update` the last check found, after setting its public `timeout` field to 10 min (`update::DOWNLOAD_TIMEOUT`). The found update is kept in `Runtime::pending_update`, outside the state lock, as v0.5.0 kept its release.
- **Public key:** the agent could not generate the signing key (key generation is the user action in step 1, and it was refused here as credential handling). `plugins.updater.pubkey` is therefore an empty string, which the plugin accepts at registration: checks work, and a download fails signature verification, so nothing unverified is ever installed. `build.rs` refuses to build when `TAURI_SIGNING_PRIVATE_KEY` is set and the effective `pubkey` (file or `--config`) is empty, so a signed release cannot ship without it. `release.yml` also refuses to build, before any build step, while `pubkey` is empty, so an unsigned tag build cannot publish an exe that could never verify an update.
- **Proxy:** the plugin's reqwest reads env proxies itself, so the parsed system proxy is passed only when no env proxy is set. `Proxy::all` cannot bypass per host, so it is skipped when `github.com` or a GitHub release-asset host is in `ProxyOverride`; reqwest's own system-proxy support then routes those requests.
- **Error log:** only apply failures write `update-error.log`, as in v0.5.0; check failures are logged under `--diagnose` only, so a transient network error never overwrites the record of a failed swap.
- **Concurrency:** check and apply claim their status under the state lock (`Idle/UpToDate/Available/Failed` to `Checking`, `Available` to `Applying`), so a scheduled check cannot run alongside an apply. A scheduled check that fires during an apply re-arms the schedule instead of being lost.
- Not enabled: `requireSignedVersion` would also bind the announced version to the signature. It needs every published signature to carry `version:` in its trusted comment, which Phase 10's signing step would have to guarantee.
## 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,192 @@
---
phase: 10
title: "Release pipeline and legacy-client compatibility"
status: in-progress
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. `TAURI_SIGNING_PRIVATE_KEY` and `_PASSWORD` are step-level env on
the steps that check or use them only, never job-level, so `npm ci`
install scripts, setup actions and crate build scripts never see the key.
A signing step fails hard when the key variable is empty.
- Keep the step that fails before any build when `plugins.updater.pubkey` in
`tauri.conf.json` is empty. `build.rs` only checks the key when
`TAURI_SIGNING_PRIVATE_KEY` is in the build environment, so it does not
cover a build step that runs without it.
- **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 -- --no-bundle -- --locked` without the key, then
`npm run tauri bundle -- --bundles appimage,deb --config …` with it.
- 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 `npm ci --prefix current` on the default
branch's lockfile, 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`. The build job runs with
`contents: read` and `persist-credentials: false` and uploads an artifact; a
separate publish job holds `contents: write`, as in `release.yml`. 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 | pass (code): re-read at all three tags (`release.rs:8-9`, `:49`); a tag run of `release.yml` publishes without `--draft`/`--prerelease`, with `--latest`, then fails if `gh api …/releases/latest` returns another tag. Draft check: pending |
| 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 | pass (code): `release.rs:104-119` at all three tags; `gh release create` makes a standard release object |
| 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) | pass (code): `release.rs:60-63` at all three tags; the `prepare` job requires `vX.Y.Z[-suffix]` equal to `Cargo.toml`. The first Tauri tag must be above v0.5.0 (a version bump, not a workflow 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) | pass (code): `release.rs:7`, `:65-74` at all three tags; `make-latest-json.js` rejects any other `.exe` (any case) and any asset outside the exact set, and runs before `gh release create`. Draft check: pending |
| 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 | pass (code): `release.rs:78`, `:86-98`; `install.rs:95` (v0.4.0) and `:104` (v0.4.1, v0.5.0) compare the body hash. `tauri signer sign` was run on this host with a throwaway key: it writes `<file>.sig` beside the binary and leaves the binary as built |
| 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 | pass (code): `install.rs:79-108` (v0.4.0) and `:88-117` (v0.4.1, v0.5.0) read the whole body after a 2xx; the URL is GitHub's `browser_download_url` as today |
| 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 | pass (code): v0.4.0 `install.rs:44-45`, `:128-143` (helper args), `:173` (`--updated-to` only), `main.rs:33` (`run_cli` before `app::run`; `main.rs` is identical at all three tags); v0.4.1 and v0.5.0 `install.rs:155-172`. The Tauri `src/cli.rs` `parse` accepts a bare start, `--updated-to X` and `--wait-pid <pid> --updated-to X` and ignores `--apply-update` (`unknown_flags_are_ignored`) |
| 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 | pass (code): v0.4.0 `install.rs:214` (`.old.<pid>`), `:247` (`update.exe`), `:258` (`updater-helper-<pid>.exe`); v0.4.1 and v0.5.0 `install.rs:276` (`update.exe`). `src/handoff.rs` `cleanup_stale_files` (called at startup from `src/runtime.rs`) deletes all three patterns |
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`, `docs/release-process.md` (assets, name contract, residual risk), this phase file (the filled-in table and the residual risk)
- Added during implementation: `.yamllint.yaml` (see notes)
## 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.
## Todo
- [x] `scripts/make-latest-json.js` with `tests/js/make-latest-json.test.js` (two-platform shape, signature content, URLs equal to the renamed names, `--windows-only`, a name with a space, a second `.exe` in any case, a missing `.sig`, an AppImage of another version, an empty signature, bad arguments)
- [x] `release.yml` rewritten: `prepare` (tag form, tag equals `Cargo.toml`, non-empty `pubkey`) gates `win-x64` and `linux-x64`; both refuse an empty `TAURI_SIGNING_PRIVATE_KEY`; `publish` runs the script and `gh release create` (draft on dispatch, `--latest` on tags, then checks `/releases/latest`)
- [x] `release-legacy.yml` added (`ref`, `version`, `tag`, `draft` default `true`)
- [x] `actionlint` and `yamllint` clean on all three workflows (with the new `.yamllint.yaml`)
- [x] Compatibility table re-read from `git show <tag>:src/update/{release,install}.rs` and `src/main.rs` at v0.4.0, v0.4.1 and v0.5.0; every row passes against the code
- [x] Residual risk recorded here and in `docs/release-process.md`
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`, `cargo test --locked` (216 passed, 1 ignored), `npm run lint`, `npm test` (52 passed), `npm run tauri build -- --no-bundle -- --locked`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest` for the workflow commits
- [ ] Step 3, needs the user: the `release.yml` dispatch dry run and the `release-legacy.yml` draft run, then the draft asset-list check of every table row. Blocked until the signing key exists (Phase 09 step 1), and it creates draft releases, which this run was not allowed to do
## Implementation notes
- **Dry runs not run.** Both workflows refuse to build without the signing secret, and `release.yml` also without `plugins.updater.pubkey`, which is still empty (Phase 09 step 1 is a user action). Creating draft releases was also outside what this run may touch. The table therefore says "pass (code)"; the "Draft check" items stay open until the user runs step 3.
- **Signing flow checked locally.** With a throwaway key (deleted afterwards) and the matching `pubkey` passed through `--config`, `npm run tauri build -- --bundles deb --config '{"bundle":{"createUpdaterArtifacts":true},…}' -- --locked` produced `Claude Code Usage Bubble_<ver>_<arch>.deb` plus a `.deb.sig`, and `tauri signer sign --app-version` wrote `<binary>.sig` beside an untouched binary. The bundler also signs the deb, so the rename step copies only the AppImage, its `.sig` and the deb; the deb signature is not published (no updater entry uses it). Bundle names carry spaces, as the phase expected.
- **Runner args.** `npm run tauri build -- <opts> -- --locked` hands `--locked` to cargo (checked with an invalid flag, which cargo rejected), so release builds stay locked like the old `cargo build --release --locked`.
- **`--app-version`.** Both signing steps bind the version into the signature's trusted comment, as `tauri build` does for the AppImage, so `requireSignedVersion` can be turned on later without re-signing anything.
- **Legacy version patch.** `release-legacy.yml` patches the first `version =` line of `Cargo.toml`, then sets the same version on the package's own `Cargo.lock` entry with `sed`, so no dependency is re-resolved. Checked on this host against an archive of v0.5.0: `cargo metadata --locked` accepts the patched lockfile without rewriting it, and rejects it when the two versions differ. The release is created with `--target` set to the built commit, so the tag lands on the legacy sources, not the default branch.
- **`make-latest-json.js` scope.** Besides the listed guards it requires the exact asset set (any unexpected file fails), so a stray upload cannot slip into a release. It writes `<dir>/latest.json` and ignores an existing one, so a re-run is safe.
- **yamllint.** The repo had no yamllint config, and the default rules reject every GitHub workflow (`on:` as a truthy key, no `---`, 80-column lines), `ci.yml` included. `.yamllint.yaml` relaxes exactly those three rules (line length 140), so the check is reproducible.
- **Draft dry-run tag.** The tag check also runs for dispatch, so `v0.0.0-test` must point at a commit whose `Cargo.toml` says `0.0.0-test`; `docs/release-process.md` says so.
- **Suffixed tags never publish.** The push trigger matches only plain `vX.Y.Z`, and `prepare` also fails a non-dispatch run on a suffixed tag. Before this, pushing `v0.0.0-test` (or an rc tag) started a run that published it with `--latest`, and both client comparators ignore the suffix.
## Residual risk
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. The draft dry runs (step 3) are also still
outstanding, so the asset list has been checked against the workflow and the
script, not against a real draft.
## 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; the dry-run tag carries a suffix, which the push trigger and `prepare` refuse to publish; both drafts are deleted after inspection. |
## Rollback
Revert the workflow commits. Already published releases are unaffected.
@@ -0,0 +1,93 @@
---
phase: 11
title: "Cleanup, docs, parity sign-off"
status: in-progress
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)
## Todo
- [x] Leftover sweep: the `windows` crate is used only in `src/platform/windows.rs`; the other `windows::` matches are calls into the `platform::windows` module (`src/bubble.rs`, `src/menu.rs`, `src/panel.rs`, `src/platform/mod.rs`). `src/os`, `src/tray` and `res/icon.rc` are gone; the empty untracked `src/i18n/` directory was removed. `res/windows-app-manifest.xml` stays (embedded by `build.rs`), and so do `src/icons/*` (the icon sources `icons/` was generated from)
- [x] Stale Windows-only header comment in `src/creds/local_fs.rs` corrected (the credentials path is per OS)
- [x] `README.md`: Windows and Linux x86_64 badges, Linux ARM64 and macOS unsupported (links `ROADMAP.md`), WebView2 requirement, Linux runtime deps, compositor, Wayland and tray limits, log, settings, update and webview profile paths per OS, the AUMID registration, privacy list, visible intentional changes (dead corners, toasts, PAC/WPAD, per-session lock), tray icons not optional, Linux install (AppImage self-updates, `.deb` check only), rewritten "Build from source"
- [x] `docs/release-process.md`: `Cargo.toml` as the only version source and `cargo build` for `Cargo.lock`, signing-key custody and secrets with an offline backup, the asset table and `latest.json` shape, the rename rule, the legacy-client asset contract, dry-run steps, the `release-legacy.yml` rollback procedure, residual risk
- [x] Links in both docs resolve; claims checked against the code and workflows
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`, `cargo test --locked` (216 passed, 1 ignored), `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle -- --locked`
- [ ] Windows parity checklist above, on Windows 10 and 11 at 100% and 150% (manual, needs a Windows machine with the CI artifact; this host has no Windows or display)
- [ ] Linux x86_64 X11 smoke run (manual, needs a desktop)
## Implementation notes
- **`.deb` dependencies checked.** A host-built `.deb` declares `Depends: libayatana-appindicator3-1, libwebkit2gtk-4.1-0, libgtk-3-0`, so the README's "the `.deb` pulls these in" holds.
- **Webview profile paths.** Tauri 2.12 sets the webview data directory to `app_local_data_dir()` on Windows and Linux (`tauri` `src/manager/webview.rs`), which is `<LocalAppData>/<identifier>`; the README lists it per OS.
- **Linux tray.** AppIndicator icons deliver no click events and no tooltip, so the README tells Linux users to use the menu; the badge still renders.
- **Privacy list.** The README never named WinHTTP, so the list is unchanged. The AUMID registry key and icon PNG are listed with the other written files instead.
- **Manual checklist open.** Every row needs a Windows 10/11 machine; the earlier phases' Windows manual checks are also still open. Nothing in this phase changes runtime behaviour, so no parity row can have regressed here.
## 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.
+263
View File
@@ -0,0 +1,263 @@
---
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: in-progress
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 and Wayland-native positioning are later work in the root `ROADMAP.md`.
The rename to `usage-monitor` follows in its own plan,
[261021-1226-rename-to-usage-monitor](../261021-1226-rename-to-usage-monitor/plan.md).
None of these is 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).
12. Windows autostart does not register `tauri-plugin-autostart`. It uses `auto-launch` directly in current-user mode with the quoted exe path, because the plugin's default mode writes an all-users entry when elevated that a normal untick cannot remove (Phase 05, refines D10).
13. Applying an update downloads the `Update` the last check found, with its timeout set to 600 s, instead of building a second updater and checking again: the builder timeout never reaches `download()` (Phase 09, refines D8).
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 | in-progress: code done; awaits the Windows manual check |
| 02 | [Extract portable core with tests](phase-02-portable-core.md) | 6h | 01 | completed |
| 03 | [Data layer on ureq](phase-03-data-layer-ureq.md) | 4h | 02 | completed |
| 04 | [App runtime: state, scheduler, CLI, single instance](phase-04-app-runtime.md) | 5h | 03 | in-progress: code done; awaits the Linux desktop and Windows manual checks |
| 05 | [Menu, tray, alerts, autostart](phase-05-menu-tray-notify-autostart.md) | 6h | 04 | in-progress: code done; awaits the Windows toast spike (D9 gate) and the Linux desktop and Windows manual checks |
| 06 | [Bubble window and UI](phase-06-bubble-window-ui.md) | 9h | 05 | in-progress: code done; awaits the v0.5.0 visual comparison and the Linux desktop and Windows manual checks |
| 07 | [Details panel window](phase-07-panel-window.md) | 3h | 06 | in-progress: code done; awaits the v0.5.0 visual and DWM shadow comparison and the Linux desktop and Windows manual checks |
| 08 | [Fullscreen auto-hide](phase-08-fullscreen-auto-hide.md) | 2h | 06 | in-progress: code done; awaits the Linux desktop and Windows manual checks |
| 09 | [Updater](phase-09-updater-migration.md) | 4h | 05 | in-progress: code done; awaits the user generating the signing key and setting `pubkey`, and the Linux desktop manual check |
| 10 | [Release pipeline and legacy-client compatibility](phase-10-ci-release.md) | 4h | 09 | in-progress: code done; awaits the user-approved `release.yml` and `release-legacy.yml` draft dry runs, which need the signing key |
| 11 | [Cleanup, docs, parity sign-off](phase-11-cleanup-docs-parity.md) | 3h | 07, 08, 10 | in-progress: docs done; awaits the Windows parity checklist on Windows 10 and 11 and the Linux x86_64 X11 smoke run |
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.
@@ -0,0 +1,43 @@
---
title: Rename to usage-monitor
description: >-
Rename the repository and product from claude-code-usage-bubble to
usage-monitor once the Tauri port lands.
status: pending
priority: P3
branch: main
tags:
- rename
blockedBy:
- 261021-1028-tauri-v2-port
blocks: []
created: '2026-10-21T05:26:00.000Z'
---
# Rename To usage-monitor
## Decision
The user chose `usage-monitor` (display name "Usage Monitor") on 2026-10-21.
Both Claude Code and Codex call their quota "usage", and the name does not tie
the app to the bubble shape or to one provider. This is a personal tool, so the
user's preference decides the name.
## Constraints
- Installed legacy copies update only from an asset named
`claude-code-usage-bubble.exe` in `tiennm99/claude-code-usage-bubble`
releases. The first releases after the rename must keep publishing that
asset name, and the old repository URL must keep redirecting.
- Whether the legacy WinHTTP updater follows GitHub's API redirect after a
repository rename is unverified. Verify it before relying on the redirect.
## Steps
1. Rename the GitHub repository to `tiennm99/usage-monitor` with
`gh repo rename`, then update `origin` and move the local checkout to
`tiennm99/usage-monitor`.
2. Update the updater's repository constant, `productName`, README, release
workflow and docs to the new name. Keep publishing the legacy `.exe` asset
name alongside the new one.
3. Confirm a legacy copy still finds the update through the redirect.
@@ -0,0 +1,124 @@
# Silent in-app update + restart (no cmd.exe)
**Date:** 2026-05-21
**Author:** brainstormer
**Status:** approved (ready for `/ck:plan`)
## Problem
User sees an occasional flash terminal window. Two paths today spawn `cmd.exe /c "timeout ... & start ..."` for update install and app restart, with `CREATE_NO_WINDOW | DETACHED_PROCESS`. Combination of those flags + the inner `start ""` invocation can still emit a brief console flash on some Windows configs (Defender hooks, conhost init, AV inspection). Goal: zero-flash, fully silent auto-update + restart, with in-app notification.
## Scope
**In scope**
- `src/update/install.rs::begin` — kill cmd.exe handoff
- `src/app.rs::restart_app` — kill cmd.exe handoff
- New CLI flag `--wait-pid <pid>` on the main binary (cooperates with itself across update)
- Cleanup of stale `bubble.exe.old.*` siblings at startup
- Tray balloon "Updated to vX.Y.Z" on first run after auto-update
**Out of scope (split as follow-up)**
- `src/usage/refresh.rs` CLI spawns (`claude.cmd`, `codex.cmd`, `powershell.exe`, `wsl.exe`) — already use `CREATE_NO_WINDOW`; address separately if flash persists after this change
- `src/creds/wsl_bridge.rs` `wsl.exe` calls — same reasoning
## Approaches evaluated
| Approach | Decision | Rationale |
|---|---|---|
| A: Native rename + direct `CreateProcessW` + `--wait-pid` | **CHOSEN** | Zero cmd.exe ⇒ zero flash possible; single-binary preserved; matches existing Win32 style; recoverable on interrupt |
| B: Helper-exe pattern (`bubble-updater.exe`) | rejected | Reverses deliberate "no helper exe" decision (`src/update/install.rs:3-5`); release-pipeline change; helper bootstrap problem |
| C: NTFS POSIX atomic replace (`FileRenameInfoEx`) | rejected | Obscure API; harder failure modes with AV / image-protection; not worth the elegance trade-off |
## Final design — Approach A
### Update install flow
```
1. fetch_latest() (unchanged — pure HTTP via WinHTTP)
2. download(release_url, staging_path) (unchanged — sha256 verify)
3. rename current.exe -> current.exe.old.<pid> (MoveFileExW, allowed while running)
4. move staging.exe -> current.exe (MoveFileExW REPLACE_EXISTING)
5. settings::save(snap) (defensive flush, unchanged)
6. release singleton mutex (explicit ReleaseMutex + CloseHandle)
7. CreateProcessW(current.exe, "--wait-pid <our_pid> --updated-to vX.Y.Z",
CREATE_NO_WINDOW | DETACHED_PROCESS)
8. PostQuitMessage(0)
```
### Restart flow (settings-change path)
```
1. settings::save(snap)
2. CreateProcessW(current.exe, "--wait-pid <our_pid>",
CREATE_NO_WINDOW | DETACHED_PROCESS)
3. release singleton mutex
4. PostQuitMessage(0)
```
### New instance startup additions
```rust
// Before acquiring Global\ClaudeCodeUsageBubble mutex:
if let Some(parent_pid) = parse_wait_pid_arg() {
let h = OpenProcess(SYNCHRONIZE, FALSE, parent_pid)?;
WaitForSingleObject(h, 5000); // 5s cap; proceed regardless
CloseHandle(h);
}
// After main window is up:
cleanup_old_exes(current_dir, "bubble.exe.old.*");
if let Some(version) = parse_updated_to_arg() {
tray::show_balloon(t!("update.toast.updated_to", v = version));
}
```
### Why `--wait-pid` instead of cmd's `timeout /t 2`
- `timeout` is a cmd.exe builtin; using it requires cmd.exe.
- `WaitForSingleObject` on the parent process handle is the canonical Win32 idiom: zero delay if parent already exited, exact timing when it actually exits, no console involvement.
- Bonus: removes the magic "2 seconds is enough" guess.
### Path safety
The current `reject_unsafe_path` (`%` rejection) becomes unnecessary — no cmd.exe to expand `%var%`. Keep the function for defense-in-depth; revisit in code review.
## Files touched
| File | Change |
|---|---|
| `src/update/install.rs` | Replace `spawn_handoff` with `swap_and_spawn` using `MoveFileExW` + `CreateProcessW`; drop `cmd` arg-quoting code |
| `src/update/mod.rs` | Add `Error::SwapFailed` variant if needed |
| `src/app.rs::restart_app` | Replace cmd.exe spawn with `CreateProcessW` + mutex release ordering |
| `src/app.rs` | Add CLI flag parsing for `--wait-pid` and `--updated-to`; cleanup pass for `.old.*` siblings; balloon tray call on successful update boot |
| `src/main.rs` | Wire `--wait-pid` into the early-startup mutex-acquisition path (BEFORE `update::run_cli` check) |
| `src/tray/mod.rs` (or `tray/badge.rs`) | Confirm `Shell_NotifyIconW` with `NIF_INFO` balloon is supported by current tray code; add helper if missing |
| `src/i18n/*` | New string keys: `update.toast.updated_to` |
## Risks + mitigations
| Risk | Mitigation |
|---|---|
| `MoveFileExW` rename fails (NTFS permission denied, AV scanner holding handle) | Surface `Error::NotWritable` to user; do NOT proceed to step 4 (still recoverable — original exe untouched at this point) |
| New instance crashes before clearing old exe ⇒ `.old.*` accumulates | Cleanup glob `bubble.exe.old.*` on every startup is idempotent and cheap |
| Mutex race: new instance acquires before old releases | `--wait-pid` + `WaitForSingleObject(5000ms)` covers it; if it times out the new instance retries `CreateMutexW` in a 200ms loop for ~3s before giving up |
| User on FAT32 / non-NTFS volume | `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` still works on FAT32; renaming-while-running is the NTFS-specific concern but the .exe is rarely on FAT32. Document the edge case |
| `--wait-pid` arg parsed in legacy build that doesn't recognize it | Old builds will ignore unknown args (cargo CLI parser behavior — verify). If they crash, the user can manually launch. Acceptable: this is a one-way migration; once the new flag is in a release, future updates are smooth |
## Success criteria
1. Manual update from v0.1.9 → test-tagged v0.1.99 produces ZERO visible console window across 20 consecutive runs on Win11 + Win10
2. Restart triggered from menu (e.g. language change) produces ZERO visible console
3. Auto-update at scheduled interval (Hourly) produces a tray balloon "Updated to v0.1.99" on next launch
4. `.old.*` files do not accumulate after 5 update cycles
5. App still launches cleanly when no parent PID was passed (i.e. fresh user start)
6. SHA-256 verification path unchanged and still rejects tampered binaries
## Out-of-scope follow-ups
1. **Audit `usage::refresh::spawn_local` / `spawn_wsl`**: the `wsl.exe` invocation in particular has known console-flash quirks even with `CREATE_NO_WINDOW`. If the user still sees occasional flashes after this change ships, that is the next investigation target.
2. **`creds::wsl_bridge::wsl_run`**: same family of `wsl.exe` invocations.
## Unresolved questions
- None blocking implementation. (Open follow-up: whether to also pipe `--wait-pid` into the legacy `--apply-update` compatibility branch in `update::run_cli`, in case a very old build is doing the spawning.)
@@ -0,0 +1,111 @@
# Final Audit: Tauri v2 Port (`feat/tauri-port` vs `main`)
Date: 2026-10-21. Scope: 55 commits `main..HEAD`, 124 files, about +22.9k / -6.7k
lines. Plan: `plans/261021-1028-tauri-v2-port/plan.md`.
## Verdict
The automatable side is done. Every row of the feature-to-phase map has code
behind it, the latest CI run is green on Windows, Linux and macOS, nothing
Win32-only is left over, and the commits are clean. What remains is user-only
work: the signing key, the release dry runs, and the manual Windows parity and
Linux X11 checks.
## 1. Feature parity (plan.md feature-to-phase map)
| Feature | Evidence |
|---------|----------|
| Bubble styles: topmost, no taskbar, no activation, move cursor | `src/bubble.rs:323-329`, `src/platform/windows.rs:193` (`apply_no_activate`), `:292` (`SW_SHOWNOACTIVATE`), `ui/bubble.css:40` |
| Stadium, size 140..360 step 20 | `src/core/geom.rs:77-80` |
| Rings, time ring, bars, text, '5H' fallback, placeholders | `ui/bubble.js:198` (dasharray), `ui/layout.js:10`, `:334`; strings in `src/core/payload.rs`, `src/core/i18n.rs` |
| Colours, theme, live re-theme (intentional change 5) | `src/platform/windows.rs:57-62`, `WM_SETTINGCHANGE` hook `:309` and `src/bubble.rs:414`; Linux portal `src/platform/linux.rs:217-220` |
| Pulse at 95%+, 60 s time-progress refresh | `ui/layout.js:19`, `:275`; `ui/bubble.js:53-54` |
| Drag, click vs drag, snap, clamp | `ui/bubble.js:52`, `:389-414`; `src/core/geom.rs:179` (clamp), `:220` (snap); move-end hook `src/platform/windows.rs:307`; X11 debounce `src/bubble.rs:40`, `:517` |
| Offscreen check on load, reclamp on setting/display change, Codex stagger | `src/runtime.rs:939` to `src/core/settings.rs:242`; `src/bubble.rs:420`; `src/core/geom.rs:89` |
| Default position, DPI change, Ctrl+wheel resize | `src/core/geom.rs:157`; `src/bubble.rs:512`; `ui/bubble.js:453-462` to `src/commands.rs:84` |
| Hit area: dead corners (intentional change 1) | `ui/bubble.css:30-47` |
| Fullscreen auto-hide | `src/fullscreen.rs:15`; `src/platform/windows.rs:449-471`; `src/platform/linux.rs:163` |
| `user_hidden` / `hidden_by_fullscreen` | `src/core/visibility.rs:10-25`; `src/runtime.rs:471-481` |
| Panel: 280x120, blur dismissal, no Alt-Tab, Win11 corners, live refresh | `src/panel.rs:35-36`, `:352`, `:187`, `:318`; `src/platform/windows.rs:246-252`; `src/core/geom.rs:366` (`place_near`) |
| Tray per provider, badge, 4-line tooltip, left-click toggle | `src/tray.rs:2`, `:202`, `:249-265`; `src/badge.rs:20` |
| Context menu, check and grey states, OpenCode Go greyed | `src/core/menu_model.rs:173`, `:374`; `src/menu.rs:190`, `:208` |
| Threshold, session-expired and update-applied alerts, 30 min cooldown, AUMID | `src/core/state.rs:16`, `:48-50`; `src/runtime.rs:351`, `:1026`; `src/platform/windows.rs:124` |
| Poll scheduling, in-flight gate, credential refresh | `src/scheduler.rs:1-31`; `src/runtime.rs:170`, `:282-294`, `:305-345`; `src/usage/refresh.rs:65-104` |
| Provider toggle invariant | `src/core/settings.rs:165-173`; `src/runtime.rs:441-446` |
| Reset position, Restart, Exit, CLI flags, `--diagnose`, WebView2 guard | `src/runtime.rs:499`, `:404-405`, `:708`; `src/cli.rs:44`; `src/diag/mod.rs:19`; `src/runtime.rs:844-848`, `src/platform/windows.rs:90-110` |
| Single instance (per session, intentional change 2) | `src/runtime.rs:873` |
| Settings path, atomic save, normalise | `src/core/settings.rs:191`, `:250-257` |
| Start with Windows (same Run value name) | `src/runtime.rs:56`; `src/platform/windows.rs:169`; `src/menu.rs:78-108` |
| Updater: check, comparator, `TargetNotFound` mapped to up to date, self-replace, relaunch, cleanup | `src/update/mod.rs:109-141`, `:145-163`; `src/update/install.rs:43`; `src/handoff.rs:17`, `:79`; `src/runtime.rs:981` |
| Providers, creds, HTTP, proxy, TLS | `src/net/mod.rs:19`, `:55`, `:118`; `src/core/proxy.rs`; `Cargo.toml` (`ureq` with `platform-verifier`) |
| Icons, version info, manifest | `build.rs` (version merged in, manifest embedded), `res/windows-app-manifest.xml`, `icons/`; CI checks `ProductVersion` against `Cargo.toml` |
| CI, release, legacy compatibility, docs | `.github/workflows/ci.yml`, `release.yml`, `release-legacy.yml`, `scripts/make-latest-json.js`, `docs/release-process.md`, `README.md` |
Deviations from the plan text are documented in the phase notes, not in the
plan.md "Intentionally changed behaviour" list:
- **Windows autostart** goes through `auto-launch` directly (`WindowsEnableMode::CurrentUser`) instead of `tauri-plugin-autostart`. The plugin's builder cannot pin the current-user mode. The Run value name and `is_enabled` behaviour stay the same, and this is recorded in the phase 05 notes.
- **Update apply** sets `Update.timeout = 600 s` instead of building a second updater and checking again. The pinned plugin builds each `Update` with no timeout, so a second check was not needed. This is recorded in the phase 09 notes. Neither change alters behaviour users can see.
## 2. CI
Latest run on `feat/tauri-port` (HEAD `6407e49`, after this audit's doc fix):
<https://github.com/tiennm99/claude-code-usage-bubble/actions/runs/39590034376>
is **success**. `windows-latest` ran fmt, clippy, tests, lint, `npm test`, the
no-bundle build, the version-resource check and the exe artifact.
`ubuntu-22.04` ran the same plus the deb bundle. `macos-latest` ran
`cargo check`. The previous HEAD `fa889c1` was also green (run 39588741335).
Host re-run: `cargo fmt --check` OK. `cargo clippy --all-targets --locked -D warnings`
OK. `cargo test --locked` passed 216, with 1 ignored. `npm run lint` OK.
`npm test` passed 52. `actionlint` and `yamllint` OK on every workflow.
## 3. Hygiene
- **Leftover Win32 code:** none. `src/os/`, `src/tray/`, `src/app.rs`, `src/settings.rs`, `src/net/winhttp.rs`, `src/update/{release,handoff}.rs` and `res/icon.rc` are deleted. The `windows` crate is used only in `src/platform/windows.rs`; `std::os::windows` in `src/handoff.rs:52` and `src/usage/refresh.rs:103` is std, behind `cfg(windows)`. `res/windows-app-manifest.xml` is embedded by `build.rs`. `src/icons/*` are the icon sources and stay.
- **Commits:** all 55 are authored by `tiennm99 <tiennm99@outlook.com>`, carry no trailers and contain no AI attribution. Messages follow conventional commits and hold no phase numbers, decision IDs or finding codes.
- **Code comments:** no phase, decision or finding IDs. The only hits are `F5`/`F12` key names, `Alt+F4` and the pulse wave "phase", all of which are legitimate.
- **Docs:**
- `README.md` matches the code: badges, WebView2, Linux dependencies, Wayland and tray limits, the `--diagnose` and file paths, the autostart label, the visible intentional changes, and build from source.
- `ROADMAP.md` matches the plan's out-of-scope list.
- `docs/release-process.md` said "Both release workflows refuse to build while [pubkey] is empty". `release-legacy.yml` never reads the pubkey; it checks only the private key secret. **Fixed** in `6407e49` (`docs: say only the Tauri release workflow checks the updater public key`), and CI is green on it.
## Larger gaps (not fixed here; user action or plan bookkeeping)
1. **Release blocker:** `tauri.conf.json` `plugins.updater.pubkey` is `""`. `release.yml` refuses to run until it is set, and `build.rs` refuses a signed build without it. The user must generate the key pair, set the pubkey, store both secrets and keep an offline backup.
2. **`release.yml` dispatch dry run not run.** This blocks the success criterion on the six-asset draft. Its dry run needs the key (item 1). `release-legacy.yml` has a separate, later prerequisite: GitHub dispatches it only from the default branch, so it can only be dry-run after the merge (`docs/release-process.md`).
3. **Manual checks open:** the Windows parity checklist (below), the D9 toast spike (phase 05 gate), and the Linux X11 smoke run.
4. **Plan bookkeeping is stale:** the plan.md frontmatter says `status: pending`, and the phase table shows 05 in progress and 06-11 pending. The phase files say 01-04 are completed and 05-11 are in progress, waiting only on manual checks. Leave the fix to the plan owner.
5. **Low:** `Registry::credential_signatures` (`src/usage/registry.rs:69`) has no callers. It is portable code and was already dead on `main`, so it is not Win32 debris. Remove it opportunistically.
## Manual Windows parity checklist (CI exe artifact; Windows 10 and 11; 100% and 150%)
1. **First launch:** the bubble appears bottom right, with Codex stacked above Claude. It has no taskbar or Alt-Tab entry, stays topmost and never steals focus.
2. **Saved position on an unplugged monitor:** at startup the bubble resets to the default position.
3. **Visuals** match a v0.5.0 screenshot: rings, bars, text, colours, the 95% pulse. A light/dark switch applies live.
4. **Click and drag:** a click toggles the panel and a drag moves the bubble. Corner, edge, taskbar and peer snaps work, and the position survives a restart. Hovering right after a drag does not start a new drag. The transparent corners ignore clicks.
5. **Panel:**
- Its layout and palette match v0.5.0.
- It opens below the bubble and flips above it near the bottom edge.
- It hides on blur and on a second click of the same bubble. Clicking the other bubble swaps its content.
- It has no Alt-Tab or taskbar entry, and its corners are round on Windows 11.
- Values refresh live while it is open.
- Right-click, F5 and Ctrl+wheel do nothing.
6. **Resize:** Ctrl+wheel resizes from 140 to 360 around the centre, and every bubble resizes. The menu size items grey out at the limits.
7. **Right-click menu:** the full structure, check marks and every action work. Re-checking a checked frequency or interval, and unchecking the last provider, leave the marks correct. A click elsewhere dismisses the menu.
8. **Tray:** one icon per provider, the badge, the 4-line tooltip, left-click toggle and right-click menu.
9. **Alerts:** the threshold, session-expired and update-applied toasts appear from a fresh portable exe with no Start-menu shortcut. The 30-minute cooldown holds.
10. **Fullscreen and displays:** the bubbles hide while an app is fullscreen and come back after it. A DPI change or monitor unplug reclamps them.
11. **Autostart:**
- A legacy v0.5.0 Run entry shows checked.
- Two launches leave one entry.
- The toggle works.
- A Task Manager disable survives two launches.
12. **Instances:** a second launch exits silently. Restart relaunches and the old process is gone. Exit ends the process.
13. **No WebView2:** in Windows Sandbox or an LTSC VM, the exe shows the download-link message box and exits.
14. **Diagnostics:** `--diagnose` writes `%TEMP%\claude-code-usage-bubble.log`.
15. **Legacy update (after the first real release):** v0.4.0, v0.4.1 and v0.5.0 each self-update into the Tauri exe. Settings and autostart are kept, and the `.old.*` and `updater-helper-*` leftovers are cleaned up.
## Unresolved questions
None beyond the user actions above.
@@ -0,0 +1,74 @@
# Cross-Platform Framework Options
Researched 2026-10-21. Scope: run the bubble on Windows, macOS, and Linux from one codebase.
## Answer
Use **Tauri v2**. It keeps the project in Rust, so the provider, credential, settings, and usage-model code (`src/usage/`, `src/creds/`, `src/settings.rs`, about 1,500 lines) moves over with small changes. Tauri covers the parts that are Win32-only today: a transparent, frameless, always-on-top window, the tray, and an updater plugin. The bubble and panel drawing in `src/bubble.rs` and `src/panel.rs`, about 2,600 lines of tiny-skia and GDI code, would be rewritten as HTML, CSS, and SVG. A circle with a progress ring is simple to draw that way.
No framework removes all per-OS work. The OS limits below apply to every option.
## Current state
The app is 7,500 lines of Rust written directly against Win32 through the `windows` crate. The Win32-bound modules are `bubble.rs`, `app.rs`, `panel.rs`, `tray/`, `net/winhttp.rs`, `os/`, and `update/install.rs`. The portable modules are the provider clients, the credential parsing, the settings, and the usage types.
## Options compared
| Option | Language | What carries over | Fit for a small floating widget | Maturity |
| --- | --- | --- | --- | --- |
| **Tauri v2** | Rust + web UI | All non-UI Rust | Good. It has transparent, `alwaysOnTop`, `skipTaskbar`, tray, and updater support. Uses the system webview, so the binary stays small. | Stable, with releases through mid-2026 |
| winit + tiny-skia + `tray-icon` + `muda` | Rust | Non-UI Rust plus the tiny-skia renderer | Good in theory, but you build drag, DPI, menus, and updates yourself. Transparent presentation through `softbuffer` is uneven across platforms (unverified). | These are the crates Tauri uses internally |
| egui / eframe | Rust | Non-UI Rust | Fair. There are open reports of transparent windows rendering black, and tray support is not built in. | Stable, but the API changes often |
| Wails v3 | Go | Nothing. This is a full rewrite. | Good. It has frameless and transparent windows plus a tray with an attached window. | **Beta** (v3.0.0-beta.x), and the docs still say alpha |
| Electron | JS | Nothing. This is a full rewrite. | Works, but it ships about 100 MB of Chromium for a 100 px circle. | Stable |
Wails v3 is the best match for your language ranking (Go first). It is still beta, though, and it means throwing away working Rust. Tauri gives you cross-platform support without a rewrite.
## Limits that apply to every framework
- **Linux Wayland:** Wayland does not let an app place its own window or keep it always on top. There is no core protocol for either, and wlr-layer-shell exists only on some compositors. On GNOME and KDE Wayland, the bubble's drag-to-position, edge snapping, and "always on top" will fail or do nothing. The workarounds are to run the app under XWayland (for example `GDK_BACKEND=x11`) or to treat Wayland as best-effort.
- **macOS credentials:** Claude Code stores its token in the macOS Keychain, not in a file. Third-party code reports the Keychain service name as `Claude Code-credentials`; check that on a real Mac. Linux uses `~/.claude/.credentials.json`, the same as Windows. `src/creds/` needs a Keychain backend, for example the `keyring` or `security-framework` crate.
- **macOS transparency:** Tauri needs `macOSPrivateApi` plus the `macos-private-api` Cargo feature. That rules out the Mac App Store, which a GitHub-release app doesn't need. One open issue reports transparency working in `tauri dev` but rendering white in a bundled build.
- **Fullscreen auto-hide:** Detecting a fullscreen foreground app is OS-specific under any framework. Expect a small `#[cfg(target_os)]` module for each OS.
- **Self-update:** Each OS needs its own approach. Windows replaces the .exe, macOS replaces the .app bundle, and Linux replaces an AppImage (Tauri's updater supports all three). The updater requires signed update artifacts. Unsigned macOS builds also hit Gatekeeper, which is stricter than SmartScreen, so you need an Apple Developer ID ($99 a year) to ship macOS properly.
- **Startup entry:** The Windows registry Run key becomes a LaunchAgent on macOS and an XDG autostart file on Linux. The Tauri `autostart` plugin handles all three.
## Suggested migration path
```text
1. Split crate: core (usage, creds, settings; swap WinHTTP -> ureq/reqwest) + platform shell
2. Add Tauri v2 shell: transparent always-on-top window, tray, autostart, updater plugins
3. Port bubble/panel rendering to HTML/CSS/SVG; drag via data-tauri-drag-region or startDragging()
4. Per-OS shims: macOS Keychain creds, fullscreen detection
5. CI matrix: windows / macos / ubuntu builds + signed updater manifests
```
Step 1 is worth doing whichever framework you choose, because it isolates the provider logic from the UI.
## Next steps
1. Spike Tauri v2 for a day. Build only a transparent, round, draggable, always-on-top window with a tray icon, and test it on Windows, macOS, and Linux (both X11 and Wayland). Decide based on that result, not on the docs.
2. If the spike passes, plan the core/shell split before porting any UI.
## Unresolved questions
- Is macOS in scope, and will you pay for an Apple Developer ID? Without one, macOS distribution is painful.
- Which Linux targets matter? Is Wayland-first GNOME/KDE in scope, or is X11/XWayland acceptable?
- Would you accept a Go rewrite (Wails v3, still beta) to follow your language preference, or is keeping Rust the priority?
## Follow-up decisions (2026-10-21)
The user set the platform priority to Windows first, then Linux, then macOS. macOS and Wayland support are optional, but the stack must allow porting to them later. The user will not buy an Apple Developer ID and is open to either Rust or Go.
- **Unsigned macOS can still ship.** On macOS 15 Sequoia and later, Control-click → Open no longer bypasses Gatekeeper. Users open the app once, then click **Privacy & Security → Open Anyway**, or run `xattr -d com.apple.quarantine <app>`. Tauri's updater signs updates with its own free key pair, so it does not need an Apple certificate.
- **Wails v3 is still in beta.** The latest release found is v3.0.0-beta.12, whose notes say the API is stable. On macOS and Linux it needs cgo (Cocoa and WebKitGTK bindings), which breaks the pure-Go, `CGO_ENABLED=0` rule. On Windows it does not need cgo.
## Sources
- [Tauri release history](https://tauri.app/release/tauri/all-versions/)
- [Tauri v2 window configuration notes](https://dev.to/rain9/tauri-part-3-get-the-window-configuration-right-first-3gf7)
- [Tauri issue: transparency white in bundled macOS build](https://github.com/tauri-apps/tauri/issues/13415)
- [Wails v3 what's new](https://v3.wails.io/ru/whats-new), [Wails v3 frameless windows](https://v3.wails.io/features/windows/frameless/), [Wails v3.0.0-beta.2](https://newreleases.io/project/github/wailsapp/wails/release/v3.0.0-beta.2)
- [egui transparency discussion](https://de.github.com/emilk/egui/discussions/4446)
- [Wayland: always-on-top protocol request](https://gitlab.freedesktop.org/wayland/wayland/-/issues/305), [Wayland: can't set top-level position](https://gitlab.freedesktop.org/wayland/wayland/-/issues/183)
- [Claude Code authentication docs](https://code.claude.com/docs/en/authentication.md), [ai-usagebar keychain source](https://docs.rs/crate/ai-usagebar/0.21.0/source/src/anthropic/keychain.rs)
+221
View File
@@ -0,0 +1,221 @@
# UI Design Review — Floating Bubble
Date: 2026-05-23
Scope: Visual-only redesign of the stadium bubble (head ring + tail bars).
Constraints: tiny-skia primitives only (AA fills, AA stroked arcs, AA pills) + GDI text. No new deps. No animations beyond existing pulse. Logical px values; `scale_to_dpi` handles HiDPI.
Files reviewed:
- `D:\tiennm99\claude-code-usage-bubble\src\bubble.rs` (lines 1032–1703)
- `D:\tiennm99\claude-code-usage-bubble\src\usage_color.rs`
- `D:\tiennm99\claude-code-usage-bubble\src\os\color.rs`
---
## 1. Issues Found
### A. Information hierarchy is flat
The big head "31%" and the tail "64%" are typographically equal in weight against their backgrounds — but they answer different questions (now vs. weekly). The eye has no anchor. Add weight contrast.
### B. Tail text is cramped
`tail_usage_pct_rect` and `tail_time_text_rect` share the same right edge (`content_right`) with no rule for the gap between the bar end and the percent label. With `pad = 6 logical` and `pct_reserve_w` literally just `measure("100%") + 2 logical`, the "64% / 3d" pair reads as one glyph blob. The two right-aligned tokens stack with only ~2 logical px of internal breathing.
### C. The inner time ring is nearly invisible
At ring_stroke_w 3 logical and time_ring_stroke_w 2 logical with only a 3-logical gap between them (line 1074), and using `#303030` track on a `#1F1F1F` background, the ratio is ~1.13:1. Below the visibility threshold; users won't read it as a ring.
### D. Two bubbles read as one merged blob
Claude and Codex stagger vertically by `height_px + gap=24` (line 1750) but visually the dark-on-dark stadia float without anchor — there's no provider identifier inside the bubble itself. The accent color is the *only* differentiator, and it disappears below 60% (Codex teal vs. orange both reduce to white at that range for tail text — see line 1646).
### E. Track contrast vs. fill is loud
`track = #3A3A3A` on `#1F1F1F` bg (4.0:1) is louder than the fill at low percentages. At 5% usage the dim track screams more than the bright fill — backwards visual priority.
### F. Time-bar reads as a second-quota
The grey time bar fills *left-to-right* same direction as the usage bar, and shares the same shape, position, and visual weight class. A user glancing sees "two progress bars" and assumes both are quotas. The grey hue helps but the *gestalt* fights it.
### G. Head "5h" label is buried
`small_font_px ≈ 55% of big_font_px` and uses `#888888`. At 200 logical width the label is ~10px and dim. It's the only thing telling the user the ring is the *5-hour* window.
### H. Ring uses round caps but track does not — visual mismatch
Active arc has `LineCap::Round` (line 1249) but the track is a full circle. At low percentages the rounded start cap juts out above the track — looks unfinished. The track should be `LineCap::Butt` (default closed circle is fine) but the *visual idiom* would benefit from the track being a hint subtler.
### I. Corner radius of pill = `canvas_h / 2` is fine, but the head circle inscribed in the same height feels visually small
`ring_radius = head_diameter/2 - 4 - stroke/2` makes the ring fill ~92% of the head square — but the head_square equals the canvas height, so the head looks slightly under-sized vs. the visual weight of the tail bars. Slight padding nudge.
### J. No separator/cue between the two stacked bubbles
Not a per-bubble issue, but worth noting: when both providers run, a faint provider mark inside each bubble would let users disambiguate without remembering "the upper one is Codex."
---
## 2. Proposed Changes
All values are **logical px**. Hex colors are dark-theme; the light-theme entry shown after `/`.
### Change 1 — Demote the head "5h" label, promote into a chip
**What:** Keep the small label, but render it as an uppercase, letter-spaced micro-cap inside a 1-px-stroke pill (no fill).
**Why:** Reads as a "window selector" tag rather than disambiguated noise.
**Values:**
- text: `"5H"` (uppercase, was `"5h"`)
- font weight: `FW_SEMIBOLD` (was normal)
- letter-spacing: simulate via `+1 logical px` between glyphs — actually, just keep tracking from font; the uppercase alone reads stronger.
- color: `#A8A8A8` / `#5E5E5E` (was `#888888` / `#6E6E6E`)
- no chip border for v1 — KISS. Just style the text. If chip is wanted later, AA stroke a pill rect.
- Keep current vertical position; the `label_pct_gap` is fine.
### Change 2 — Bump big-percent weight + tighten size
**What:** Big number gets heavier and slightly smaller; tightens visual mass.
**Why:** Heavier weight = stronger anchor without taking more space.
**Values:**
- weight: `FW_BOLD` (was `FW_SEMIBOLD`)
- size factor: `big_font_px = head_diameter * 24/100` (was `26/100`)
- color unchanged: `#EAEAEA` / `#1F1F1F`
### Change 3 — Lift the inner time ring above noise
**What:** Increase contrast of the time-ring track and fill; thicken slightly.
**Why:** Current ratio of 1.13:1 against bg is invisible. Per WCAG 1.4.11 non-text 3:1 minimum.
**Values:**
- `time_ring_stroke_w`: `scale_to_dpi(2, dpi).clamp(2, 3)` (was `1..3`, effective 1px floor → too thin)
- gap between outer ring inner edge and inner ring outer edge: `4 logical` (was `3`)
- time_track: `#2F2F2F` → **`#404040`** (3.5:1) / light unchanged
- time_fill (used for inner-ring active arc *and* tail time-bar fill): `#9A9A9A` → **`#B0B0B0`** / `#777777` → `#666666`
- Keep `LineCap::Round` on the active arc.
### Change 4 — Reserve a real gap between tail bar and tail text
**What:** Add a `bar_text_gap = 8` between bar end and text left edge (currently `pad = 6`).
**Why:** Eight is the eyeballed minimum where the eye registers "two columns" instead of "one wall of glyphs."
**Values:**
- new constant: `bar_text_gap = scale_to_dpi(8, dpi)` (was effectively `pad = 6`)
- `bar_right = (text_left - bar_text_gap).max(bar_left + bar_min);` (line 1126)
- `pad` stays `6` for the head→tail content_left inset.
### Change 5 — Right-edge inset
**What:** Increase inner right margin of the tail.
**Why:** The current `scale_to_dpi(12, dpi)` insetinto the pill's rounded right cap leaves text near the curvature. Bump to clear the cap visually.
**Values:**
- `tail_right = width_px - scale_to_dpi(14, dpi)` (was `12`)
### Change 6 — Reweight tail percent vs. tail countdown
**What:** Make the tail percent a touch heavier than the countdown so the *number* anchors the lane.
**Why:** Today both are FW_NORMAL same size, both `text_color` — flat. Bigger number with smaller dimmer suffix establishes hierarchy.
**Values:**
- weekly percent: `FW_SEMIBOLD`, color `text_color` (`#EAEAEA` / `#1F1F1F`)
- weekly countdown: `FW_NORMAL`, color **`muted_color`** (`#A8A8A8` / `#5E5E5E`) — was `text_color`
- Font sizes unchanged: both `small_font_px` / `main_font_px`.
### Change 7 — Tone down the usage-bar track
**What:** Drop track contrast so the *fill* dominates, not the track.
**Why:** At low percent (5–10%) the bright track outscreams the fill. Track should be a hint.
**Values:**
- `track`: `#3A3A3A` → **`#2C2C2C`** (was 4.0:1 vs. bg; now 1.6:1 — the *fill* hits 4.5:1+ from accent colors and carries the signal)
- light theme: `#D6D6D6` → `#E2E2E2`
### Change 8 — Differentiate the time-bar shape from the usage-bar shape
**What:** Make the time bar visibly *thinner and lower-contrast* so it doesn't read as a second quota.
**Why:** Current ratio: usage_bar 9% of height, time_bar 5% — close. Push the spread.
**Values:**
- `usage_bar_h = (height_px * 10 / 100).clamp(6, 12)` (was 9% / clamp 5–12)
- `time_bar_h = (height_px * 4 / 100).clamp(3, 6)` (was 5% / clamp 3–7)
- `lane_gap = scale_to_dpi(6, dpi)` (was 5)
- This gives the usage bar ~2.5× the visual mass of the time bar — clear "primary" vs. "context."
### Change 9 — Pull the head ring in by 1 px so the head circle feels deliberate
**What:** Slightly more head padding; ring sits 1 logical px farther in.
**Why:** Ring currently kisses the visual edge of the head square; a touch of breathing room makes the head feel composed and balances vs. the heavier tail.
**Values:**
- `head_pad = scale_to_dpi(5, dpi)` (was 4)
- `ring_stroke_w`: keep `scale_to_dpi(3, dpi).clamp(2, 4)` — already good.
### Change 10 — Provider mark dot (subtle disambiguator)
**What:** A 4×4 logical solid circle in the accent color, positioned at the *outer* edge of the ring at 12 o'clock — between the ring and the head's left edge.
**Why:** Today the only provider tell is the accent of the active arc; below 60% the arc *is* the accent so it works, but at >60% the arc shifts to amber/red and the provider identity vanishes. A constant dot fixes that. Also helps when two bubbles stack.
**Values:**
- center: `(ring_cx, ring_cy - ring_radius - ring_stroke_w/2 - 4)` — i.e. 4 logical px above the ring's outer edge
- radius: `scale_to_dpi(2, dpi)` (logical 2 → diameter 4)
- color: `accent_color_for(model, is_dark)` (existing function — `#D97757` Claude / `#10A37F` Codex)
- Implemented as one extra `pb.push_circle(...)` fill before the ring strokes — zero new dependencies.
### Change 11 — Round-cap the active tail bars; flat-cap the tracks
**What:** Keep the existing `paint_pill` for the *track*, but reduce its end-cap radius. For the *fill*, keep full cap. Actually, simpler: leave both as full-cap pills (current behavior) — just ensure the fill never paints below `2 * cap` width.
**Why:** Already correct in code (`paint_pill` does both end-caps). No change needed visually, but lock in a min-fill so the bar at 1% doesn't render as a dot.
**Values:**
- in the weekly-pct render block (line 1300+): if `fill_w > 0.0 && fill_w < bar_h`, set `fill_w = bar_h` (a one-cap-diameter minimum). Cosmetic only — preserves "I see some progress" cue when usage is 0.1–2%.
### Change 12 — Text-color for tail percent when bar is in alarm range
**What:** When `weekly_pct >= 95`, tint the percent text toward the alarm color instead of bumping its luminance via `brighten` only.
**Why:** Today, at 98%, the text just gets *brighter* via the pulse — but in dark mode the bar is already pulsing deep red. Tinting the number red ties it to the bar.
**Values:**
- if `pct >= 95.0`: `text_color_for_pct = #E08070` (dark) / `#B02810` (light), then apply pulse `brighten` on top.
- Keep the FW_SEMIBOLD from Change 6.
- 80 ≤ pct < 95: leave at default text color (the bar carries the warning).
---
## 3. ASCII Mockup (one bubble, dark, ~270 logical px wide)
```
canvas_w = 270 (logical)
<─────────────────────────────────────────────────────────────────>
┌─────────────────────────────────────────────────────────────────┐
│ • <─ accent dot (2-logical r, 4 above ring outer) │
│ ╭───╮ │
│ / \ ┌─────────────────────────────────────┐ │ ^
│ │ ┌───┐ │ │▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░│ 64% │ ←lane │ |
│ │ │5H │ │ └─────────────────────────────────┴─────┘ │ |
│ │ │31%│ │ ┌─────────────────────────────┐ 3d ←FW_NORMAL│ | height
│ │ └───┘ │ │██░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ muted│ |
│ \ / └─────────────────────────────┘ │ |
│ ╰───╯ │ v
└─────────────────────────────────────────────────────────────────┘
<──head_diameter──><pad=6><────────bar_w────────><gap=8><text_w>
= canvas_h <r-inset=14>
```
Key:
- `•` accent dot (4-logical px diameter, provider color)
- Outer thick ring = 5h usage (active arc in accent / amber / red)
- Inner thinner ring = 5h remaining time (now `#B0B0B0` on `#404040`, visible)
- `5H` = uppercase semibold label, `muted` color, sits above the big number
- `31%` = big bold pct, `text_color`
- `▓▓▓` top tail bar = weekly usage fill (accent/amber/red), `usage_bar_h ≈ 10% of canvas_h`
- `░░░` track = `#2C2C2C` (toned down)
- `64%` = right-aligned, FW_SEMIBOLD, text_color
- `██░░` bottom tail bar = remaining time, `time_bar_h ≈ 4% of canvas_h` (visibly thinner)
- `3d` = right-aligned, FW_NORMAL, muted_color
- 8-logical gap between bar end and right-aligned text column (Change 4)
---
## 4. Do Not Change
- **Overall stadium shape with `corner_radius = canvas_h/2`** — clean and iconic, photographs well in screenshots.
- **Head-on-left, tail-on-right layout** — well-established mental model; sweeping arc + horizontal bar = "circular thing for now-ish, linear thing for week-ish."
- **Accent ramp by percent** (60/80/95 thresholds in `usage_color.rs`) — solid color logic; don't touch.
- **Pulse animation at ≥95%** — subtle, draws the right amount of attention. Keep.
- **`paint_pill` two-circle + middle-rect construction** — exactly right for tiny-skia. Don't refactor to a rounded-rect path; the current is faster and AA-clean.
- **`LineCap::Round` on the active arc** — feels alive; the "unfinished" look at low % I mentioned in issue (H) is acceptable trade-off.
- **DPI scaling via `scale_to_dpi`** — keep all proposed values in logical px and let the function do its job.
- **Locale-aware countdown width via `COUNTDOWN_TEMPLATE = "999시간"`** — clever and right; preserve.
- **`DT_END_ELLIPSIS` on tail text** — graceful degradation at narrow widths.
- **Existing fallback from countdown to `"5H"` when label rect is too narrow** — keep, just change the static fallback to uppercase per Change 1.
- **Aspect ratio taper (`aspect_at_width`)** — tail breathes better at wider sizes; preserve.
---
## Implementation Notes
- All proposed color tokens belong inline in `paint_bubble_pixmap` and `paint_bubble_text` — no new modules needed.
- The accent dot (Change 10) is ~3 new lines in the ring block.
- Bar height and gap changes (Change 8) are 3 single-line edits in `compute_bubble_layout`.
- Min-fill (Change 11) is a one-line guard before `paint_pill` for the weekly fill.
- No new geometry struct fields needed. No new fonts. No new dependencies.
---
## Unresolved Questions
1. **Light-theme accent dot**: Claude `#D97757` and Codex `#10A37F` on `#F3F3F3` — Codex teal contrast is ~3.0:1, borderline. Accept (it's a 4-px decorative dot, not text) or use a darkened variant for light theme?
2. **Change 12 alarm-tint**: should it apply to the head big-pct too, or only to the tail pct? Current proposal is tail-only. Confirm whether the head should also tint red at ≥95%.
3. **Change 10 dot position**: 12 o'clock is canonical but could be at 10–11 o'clock so the start of the active arc (which begins at 12 sweeping clockwise) doesn't visually merge with the dot. Open to either.
---
**Status:** DONE
**Summary:** Twelve concrete, primitive-implementable changes that hierarchically organize percentages, raise the invisible inner time ring above WCAG 1.4.11, breathe the cramped tail text by 8 logical px, and add a 4-px provider dot for identity — all without new deps or animation engines.
+187
View File
@@ -0,0 +1,187 @@
# UI Rendering Code Review
Scope: `src/bubble.rs` (renderer), `src/usage_color.rs`, `src/os/color.rs`,
drawing portions of `src/panel.rs`, `src/tray/badge.rs`.
## Findings
1. **`src/bubble.rs:1178-1197` — severity: high.** Eight neutral surface
colours (`#1F1F1F`, `#F3F3F3`, `#3A3A3A`, `#D6D6D6`, `#303030`, `#E0E0E0`,
`#9A9A9A`, `#777777`) are hex literals inside `paint_bubble_pixmap` and
duplicated near-verbatim in `panel.rs:279-293` (`#1F1F1F`, `#FAFAFA`,
`#EAEAEA`, `#3A3A3A`, `#D6D6D6`) plus `bubble.rs:1590-1597` text colours.
Three surfaces silently disagree (`#F3F3F3` bubble bg vs `#FAFAFA` panel
bg) and a designer cannot retune the palette without grepping. Fix:
centralise into a `palette` module alongside `usage_color.rs` exposing
`bg(is_dark)`, `track(is_dark)`, `time_track(is_dark)`, `time_fill(is_dark)`,
`text(is_dark)`, `muted(is_dark)`; have `panel.rs` consume the same helpers.
2. **`src/tray/badge.rs:53, 112, 114` — severity: high.** Badge colours go
through `paint.set_color_rgba8(0x3a, 0x3a, 0x3a, 255)` and raw
`[u8; 3]` arrays instead of `Rgb` / `os::color`. `#3A3A3A` already exists
as the shared "track" colour in `bubble.rs:1184`, but the tray hard-codes
it. The two Claude/Codex base tints (`#2A1F1C`, `#1A1F26`) also live only
here. Fix: route through the same palette module, even if the badge keeps
its own dark inner-disk variants (named constants beat magic byte arrays).
3. **`src/bubble.rs:1064-1117` — severity: high.** Padding/gap literals
`scale_to_dpi(2|4|5|6|8|12, dpi)` appear 15+ times inside
`compute_bubble_layout` with no naming. Same logical "edge padding"
(`scale_to_dpi(4, dpi)`) is used for `head_pad`, head-label left/right,
head-pct left/right (lines 1064, 1086, 1088, 1092, 1094); same "small
nudge" (`scale_to_dpi(2, dpi)`) is the ring stroke clamp, label/pct
row vertical breathing room, pct-reserve gap, and time-text padding. A
designer tweaking head-text padding will touch four lines and miss the
fifth. Fix: hoist named DPI-scaled constants at the top of the function
(`HEAD_PAD`, `TEXT_VPAD`, `LANE_GAP`, `TAIL_PAD`, `RIGHT_INSET`,
`BAR_MIN_W`) and reuse — same pattern `panel.rs` uses with its
`*_LOGICAL` constants (line 24-30).
4. **`src/panel.rs:333-340` — severity: med.** Bar-x / bar-w / row-y math
mixes `scaled(PADDING_LOGICAL)`, `scaled(LABEL_W_LOGICAL)`,
`scaled(RIGHT_TEXT_W_LOGICAL)` with bare `scaled(4)`, `scaled(8)`,
`scaled(24)`, `scaled(18)` — four un-named "small" values doing
semantically distinct jobs (label-bar gap, bar-text gap, header height,
row-1 offset). Fix: name them (`LABEL_BAR_GAP_LOGICAL`,
`BAR_TEXT_GAP_LOGICAL`, `HEADER_H_LOGICAL`, `HEADER_OFFSET_LOGICAL`) so
the row geometry is auditable in one place.
5. **`src/panel.rs:340` — severity: med.** `row2_y = row1_y +
scale_to_dpi(BAR_HEIGHT_LOGICAL, dpi) + scale_to_dpi(ROW_GAP_LOGICAL, dpi)
+ scaled(8)`. The trailing `+ scaled(8)` is an unexplained extra gap on
top of `ROW_GAP_LOGICAL`; this is exactly the inconsistency `ROW_GAP_LOGICAL`
was created to prevent. Fix: fold into `ROW_GAP_LOGICAL` (16) or rename
the extra into a labelled `ROW_TEXT_GAP_LOGICAL`.
6. **`src/bubble.rs:1099, 1126` — severity: med.** `tail_right = width_px -
scale_to_dpi(12, dpi)` and `bar_right = (text_left - pad).max(bar_left +
bar_min)`. The `12` is the right-edge inset to clear the stadium's right
end-cap; this is conceptually `corner_radius / 2`-ish but encoded as a
constant that won't track if aspect ratio changes. Fix: derive from
`layout.corner_radius` or hoist a `TAIL_RIGHT_INSET` constant with a
comment tying it to the end-cap curvature.
7. **`src/bubble.rs:1360-1377` and `src/tray/badge.rs:86-106` — severity:
med.** `build_arc` is duplicated verbatim between the bubble renderer
and the tray badge — same 64-segment sampling, same `FRAC_PI_2` start,
same edge-case `.max(1)` segment count. Fix: lift into a small
`geometry` / `tiny_skia_helpers` module shared by both call sites;
change neither call site to keep behaviour identical.
8. **`src/bubble.rs:1339-1352` — severity: med.** `paint_pill` is a perfect
helper candidate for `panel.rs`'s bar drawing — `panel.rs` uses
`FillRect` rectangles with hard corners (`draw_row` lines 406-428),
visually inconsistent with the bubble's rounded pill caps. Fix: extract
`paint_pill` to a shared rendering helper module and have panel use it
so the two surfaces have matching bar geometry. (Cross-surface
consistency was the stated reason for `usage_color.rs` existing — same
logic applies to bar shape.)
9. **`src/bubble.rs:1080-1083, 1111-1112` — severity: med.** `head_label_h`,
`head_pct_h`, `time_text_h`, `usage_pct_h` all add `scale_to_dpi(2, dpi)`
of "breathing room" to a font height, but never call it that — and the
computed rect height is then used by `DrawTextW` with `DT_VCENTER` so a
too-tight value would clip ascenders/descenders. Currently safe because
2 px (logical) ≈ font leading, but the magic `2` is load-bearing. Fix:
`const FONT_VPAD_LOGICAL: i32 = 2;` with a one-line comment "ascender/
descender slack for DT_VCENTER".
10. **`src/bubble.rs:1141-1146, 1153-1158` — severity: low.** The vertical
centring expression `usage_bar_top + (usage_bar_h - usage_pct_h) / 2`
is computed twice for `tail_usage_pct_rect` (top + bottom). Tiny but
if a designer asks "where does the % text sit relative to the bar?"
they have to mentally simplify. Fix: compute `pct_text_top` /
`time_text_top` as named locals before the struct literal.
11. **`src/bubble.rs:1076, 1077` — severity: low.** `big_font_px =
head_diameter * 26 / 100`; `small_font_px = big_font_px * 55 / 100`.
The 26 % and 55 % ratios are the core typographic scale of the head
text — promote to `BIG_FONT_RATIO_PCT`, `SMALL_TO_BIG_FONT_PCT`
constants with a "tweak these to retune head proportions" comment.
12. **`src/bubble.rs:1078` — severity: low (dead-code adjacent).**
`main_font_px = small_font_px;` — `main_font_px` is identical to
`small_font_px` but kept as a separate field on `BubbleLayout`
(line 1056) and used for the countdown (line 1604, 1658). If the
intent is "may diverge in future", document it; otherwise drop the
duplicate field and use `small_font_px` directly.
13. **`src/bubble.rs:1085-1096` — severity: low.** `head_label_rect` and
`head_pct_rect` both use `left: scale_to_dpi(4, dpi)` and `right:
head_diameter - scale_to_dpi(4, dpi)` — identical horizontal extents.
Could share a single `head_text_left`/`head_text_right` pair to make
"head text is centered in the head circle" structurally visible.
14. **`src/bubble.rs:1216 vs 1339-1352` — severity: low.** Stadium
background uses inline two-circle-plus-rect path; the pill helper
does the same shape. The stadium could call `paint_pill(pixmap, 0.0,
0.0, w, h, h/2.0, bg)` and shed ~15 lines. Worth doing once
`paint_pill` moves to a shared module (finding 8).
15. **`src/bubble.rs:1653` — severity: low (text layout).**
`draw_tail_text_in_rect` is called with `DT_RIGHT | DT_VCENTER |
DT_SINGLELINE | DT_END_ELLIPSIS`. The `DT_END_ELLIPSIS` on a
right-aligned 3-char string ("100%") inside a tight rect will produce
`1…` if the rect collapses by even a pixel — fine, but worth
confirming the `pct_reserve_w` (line 1103) leaves a 1-px AA safety
margin. Current `+ scale_to_dpi(2, dpi)` looks adequate. No fix
needed; flag for future locale changes.
16. **`src/bubble.rs:1674-1685` — severity: low.** `draw_text_in_rect`
always uses `DT_NOCLIP`; `draw_tail_text_in_rect` uses
`DT_END_ELLIPSIS` (no `DT_NOCLIP`). The two helpers diverge silently
on whether text may escape its rect. Document the contract on each
helper ("head text trusts layout, tail text fits-or-ellipsises").
17. **`src/panel.rs:447-501` — severity: low.** `draw_text` creates and
destroys a font on every call (4× per `paint`). Same anti-pattern in
`bubble.rs:paint_bubble_text` is amortised by caching `big_font`,
`small_font`, `main_font` for the whole paint. Not a correctness
issue but means the panel allocates 4 GDI fonts on every
InvalidateRect. Cache by `(size, bold)` keyed on the HDC.
## Quick wins
1. **Centralise the neutral palette** (findings 1, 2). One new module
`palette.rs` exposing `bg / track / time_track / time_fill / text /
muted` plus tray-specific tints. Replace all `Color::from_hex(...)`
calls in `bubble.rs:1178-1197`, `bubble.rs:1589-1598`,
`panel.rs:279-293`, and the byte arrays in `tray/badge.rs`. ~20-line
diff, kills cross-surface drift.
2. **Name the padding constants in `compute_bubble_layout`** (finding 3,
9). Add ~6 `const` declarations at the top of the function — keeps
them locally scoped, matches `panel.rs` style. Designer can retune
metrics from one block.
3. **Lift `build_arc` to a shared `tiny_skia_helpers` module** (finding
7). Two-file delete-and-import. Identical behaviour.
4. **Promote `paint_pill` and reuse in `panel.rs` rows + stadium bg**
(findings 8, 14). Makes bar visual style consistent between bubble
and panel; bonus simplification of the stadium fill.
5. **Drop / rename `main_font_px`** (finding 12). Either delete the field
and use `small_font_px` directly, or split the constants (`MAIN_FONT_RATIO`)
so future divergence is intentional.
## Defer
- Finding 4-6 (panel/bubble padding naming, derived `TAIL_RIGHT_INSET`):
worth doing alongside the palette refactor but not blocking.
- Finding 10, 13 (local temporaries for centring math, shared
`head_text_*` extents): pure readability, low payoff.
- Finding 15 (ellipsis safety margin on locale changes): keep an eye on
this when adding a locale wider than `999시간`.
- Finding 17 (panel GDI font caching): only matters if the panel starts
refreshing more often than once per poll cycle.
Out-of-scope sighting (one-line flag as instructed): `compute_bubble_layout`
takes an `HDC` purely to measure text — couples geometry calc to a live GDI
device. Not a render bug, but it makes the function untestable without a
window and is worth refactoring when convenient.
**Status:** DONE
**Summary:** Renderer is functionally solid; the dominant problem is
duplicated/un-named geometry and colour literals scattered across bubble /
panel / badge that drift independently and resist designer-led tuning.
-32
View File
@@ -1,32 +0,0 @@
#include <winver.h>
1 ICON "..\\src\\icons\\icon.ico"
1 VERSIONINFO
FILEVERSION 0,1,0,0
PRODUCTVERSION 0,1,0,0
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS 0x40004L
FILETYPE 0x1L
FILESUBTYPE 0x0L
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904E4"
BEGIN
VALUE "CompanyName", "tiennm99\0"
VALUE "FileDescription", "Claude Code Usage Bubble\0"
VALUE "FileVersion", "0.1.0\0"
VALUE "InternalName", "ClaudeCodeUsageBubble\0"
VALUE "LegalCopyright", "Copyright (C) 2026 tiennm99\0"
VALUE "OriginalFilename", "claude-code-usage-bubble.exe\0"
VALUE "ProductName", "Claude Code Usage Bubble\0"
VALUE "ProductVersion", "0.1.0\0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1252
END
END
+20
View File
@@ -0,0 +1,20 @@
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</windowsSettings>
</application>
</assembly>
+192
View File
@@ -0,0 +1,192 @@
// Write the Tauri updater manifest (`latest.json`) for a release.
//
// Usage: node scripts/make-latest-json.js [--windows-only] <tag> <dir>
//
// `<dir>` holds exactly the release assets, already renamed to space-free
// names. The script checks that set before it writes `<dir>/latest.json`,
// because the same files are uploaded next to the manifest:
//
// - Legacy clients (v0.5.0 and earlier) read `/releases/latest` through the
// GitHub API and download the asset named `claude-code-usage-bubble.exe`,
// else the first asset ending in `.exe`. So exactly one `.exe` asset may
// exist, under that exact name.
// - GitHub rewrites spaces in asset names, so a name with whitespace would no
// longer match the URL written into the manifest.
// - Every platform entry the app looks up must be present; a missing one
// would leave those installs without updates.
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
export const REPO = 'tiennm99/claude-code-usage-bubble';
export const WINDOWS_EXE = 'claude-code-usage-bubble.exe';
export const MANIFEST_NAME = 'latest.json';
const TAG_PATTERN = /^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/;
/**
* @typedef {object} PlatformEntry
* @property {string} url Release-download URL of the update asset.
* @property {string} signature Content of the asset's `.sig` file.
*/
/**
* @typedef {object} Manifest
* @property {string} version Version without the leading `v`.
* @property {string} notes Short release note.
* @property {string} pub_date RFC 3339 publication time.
* @property {Record<string, PlatformEntry>} platforms Entries by updater target.
*/
/**
* The version a release tag announces.
* @param {string} tag Tag such as `v1.2.3` or `v0.0.0-test`.
* @returns {string} The version without the leading `v`.
* @throws {Error} When the tag is not `v<major>.<minor>.<patch>[-suffix]`.
*/
export function versionFromTag(tag) {
const match = TAG_PATTERN.exec(tag);
if (!match) {
throw new Error(`tag "${tag}" is not of the form vX.Y.Z or vX.Y.Z-suffix`);
}
return match[1];
}
/**
* The updater platforms a release carries, with the asset each one installs.
* @param {string} version Release version.
* @param {boolean} windowsOnly True for a Windows-only (legacy rollback) release.
* @returns {Record<string, string>} Asset name by updater target.
*/
export function platformAssets(version, windowsOnly) {
/** @type {Record<string, string>} */
const platforms = { 'windows-x86_64': WINDOWS_EXE };
if (!windowsOnly) {
platforms['linux-x86_64'] = `claude-code-usage-bubble_${version}_amd64.AppImage`;
}
return platforms;
}
/**
* Every asset a release must upload besides `latest.json`.
* @param {string} version Release version.
* @param {boolean} windowsOnly True for a Windows-only release.
* @returns {string[]} Asset names, sorted.
*/
export function expectedAssets(version, windowsOnly) {
const names = Object.values(platformAssets(version, windowsOnly)).flatMap((n) => [n, `${n}.sig`]);
if (!windowsOnly) {
names.push(`claude-code-usage-bubble_${version}_amd64.deb`);
}
return names.sort();
}
/**
* Check a release's asset names against what every client needs.
* @param {string[]} names Asset names, `latest.json` excluded.
* @param {string} version Release version.
* @param {boolean} windowsOnly True for a Windows-only release.
* @throws {Error} On a name with whitespace, a second `.exe`, a missing asset or an unexpected one.
*/
export function checkAssetNames(names, version, windowsOnly) {
const spaced = names.filter((n) => /\s/.test(n));
if (spaced.length > 0) {
throw new Error(`asset names must not contain whitespace: ${spaced.join(', ')}`);
}
const strayExe = names.filter((n) => n.toLowerCase().endsWith('.exe') && n !== WINDOWS_EXE);
if (strayExe.length > 0) {
throw new Error(
`the only .exe asset allowed is ${WINDOWS_EXE}; legacy clients could pick: ${strayExe.join(', ')}`,
);
}
const expected = expectedAssets(version, windowsOnly);
const missing = expected.filter((n) => !names.includes(n));
if (missing.length > 0) {
throw new Error(`missing release assets: ${missing.join(', ')}`);
}
const unexpected = names.filter((n) => !expected.includes(n));
if (unexpected.length > 0) {
throw new Error(`unexpected release assets: ${unexpected.join(', ')}`);
}
}
/**
* The download URL GitHub serves a release asset from.
* @param {string} tag Release tag.
* @param {string} name Asset name.
* @returns {string} The URL.
*/
export function assetUrl(tag, name) {
return `https://github.com/${REPO}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`;
}
/**
* Build the manifest for a release whose asset names were already checked.
* @param {object} input Release facts.
* @param {string} input.tag Release tag.
* @param {boolean} input.windowsOnly True for a Windows-only release.
* @param {(name: string) => string} input.readSignature Content of the named `.sig` asset.
* @param {Date} input.now Publication time.
* @returns {Manifest} The manifest.
* @throws {Error} When a signature is empty.
*/
export function buildManifest({ tag, windowsOnly, readSignature, now }) {
const version = versionFromTag(tag);
/** @type {Record<string, PlatformEntry>} */
const platforms = {};
for (const [target, name] of Object.entries(platformAssets(version, windowsOnly))) {
const signature = readSignature(`${name}.sig`).trim();
if (signature === '') {
throw new Error(`${name}.sig is empty`);
}
platforms[target] = { url: assetUrl(tag, name), signature };
}
return {
version,
notes: `See https://github.com/${REPO}/releases/tag/${tag}`,
pub_date: now.toISOString(),
platforms,
};
}
/**
* Check the assets in `dir` and write `dir/latest.json`.
* @param {string[]} argv Arguments after the script path.
* @param {Date} [now] Publication time.
* @returns {Manifest} The manifest written.
* @throws {Error} On bad arguments or any failed check.
*/
export function run(argv, now = new Date()) {
const windowsOnly = argv.includes('--windows-only');
const positional = argv.filter((a) => a !== '--windows-only');
if (positional.length !== 2 || positional.some((a) => a.startsWith('--'))) {
throw new Error('usage: make-latest-json.js [--windows-only] <tag> <dir>');
}
const [tag, dir] = positional;
const version = versionFromTag(tag);
const names = readdirSync(dir).filter((n) => n !== MANIFEST_NAME);
const notFiles = names.filter((n) => !statSync(join(dir, n)).isFile());
if (notFiles.length > 0) {
throw new Error(`not a file: ${notFiles.join(', ')}`);
}
checkAssetNames(names, version, windowsOnly);
const manifest = buildManifest({
tag,
windowsOnly,
readSignature: (name) => readFileSync(join(dir, name), 'utf8'),
now,
});
writeFileSync(join(dir, MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`);
return manifest;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const manifest = run(process.argv.slice(2));
console.log(`latest.json ${manifest.version}: ${Object.keys(manifest.platforms).join(', ')}`);
} catch (err) {
console.error(`make-latest-json: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
}
-1447
View File
File diff suppressed because it is too large. Load diff
+228
View File
@@ -0,0 +1,228 @@
//! Anti-aliased tray badge rendering.
//!
//! A filled disk plus a percentage-sweep ring, drawn with tiny-skia. The
//! badge is intentionally text-free: the floating bubble already shows the
//! exact percentage, the tray badge is a coarse colour-and-fill indicator.
use tiny_skia::{FillRule, LineCap, Paint, Path, PathBuilder, Pixmap, Stroke, Transform};
use crate::core::usage_color;
use crate::usage::ProviderId;
/// Width and height of the badge in pixels.
pub const BADGE_PX: u32 = 32;
/// Colour of the unused part of the ring.
const TRACK_RGB: [u8; 3] = [0x3a, 0x3a, 0x3a];
/// Render the badge for `kind` with `percent` of the ring filled, or only
/// the track when `percent` is `None`. Premultiplied RGBA.
pub fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
let mut pixmap = Pixmap::new(BADGE_PX, BADGE_PX).expect("32x32 pixmap");
pixmap.fill(tiny_skia::Color::TRANSPARENT);
let c = BADGE_PX as f32 / 2.0;
let outer = c - 1.0;
let inner = outer * 0.62;
let ring_radius = (inner + outer) / 2.0;
let ring_width = outer - inner - 1.0;
// Filled inner disk in the provider's base tint.
let mut pb = PathBuilder::new();
pb.push_circle(c, c, inner);
if let Some(path) = pb.finish() {
pixmap.fill_path(
&path,
&paint(base_color(kind)),
FillRule::Winding,
Transform::identity(),
None,
);
}
// Track ring (the unused portion of the quota).
let mut pb = PathBuilder::new();
pb.push_circle(c, c, ring_radius);
if let Some(path) = pb.finish() {
let stroke = Stroke {
width: ring_width,
..Stroke::default()
};
pixmap.stroke_path(
&path,
&paint(TRACK_RGB),
&stroke,
Transform::identity(),
None,
);
}
// Active sweep: the used share of the ring in the usage colour.
if let Some(p) = percent {
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
if sweep > 0.0 {
if let Some(path) = build_arc(c, c, ring_radius, sweep) {
let stroke = Stroke {
width: ring_width,
line_cap: LineCap::Round,
..Stroke::default()
};
pixmap.stroke_path(
&path,
&paint(usage_rgb(kind, p)),
&stroke,
Transform::identity(),
None,
);
}
}
}
pixmap
}
/// The pixmap as straight (non-premultiplied) RGBA bytes, the format
/// `tauri::image::Image::new_owned` expects.
pub fn demultiplied_rgba(pixmap: &Pixmap) -> Vec<u8> {
pixmap
.pixels()
.iter()
.flat_map(|p| {
let c = p.demultiply();
[c.red(), c.green(), c.blue(), c.alpha()]
})
.collect()
}
fn paint(rgb: [u8; 3]) -> Paint<'static> {
let mut paint = Paint::default();
paint.set_color_rgba8(rgb[0], rgb[1], rgb[2], 255);
paint.anti_alias = true;
paint
}
/// An open polyline arc from 12 o'clock, clockwise. tiny-skia 0.11 has no
/// arc primitive, so the circumference is sampled.
fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option<Path> {
let segments = ((sweep_fraction * 64.0).ceil() as usize).max(1);
let start = -std::f32::consts::FRAC_PI_2;
let total = sweep_fraction * std::f32::consts::TAU;
let mut pb = PathBuilder::new();
for i in 0..=segments {
let a = start + (i as f32 / segments as f32) * total;
let (x, y) = (cx + a.cos() * radius, cy + a.sin() * radius);
if i == 0 {
pb.move_to(x, y);
} else {
pb.line_to(x, y);
}
}
pb.finish()
}
fn base_color(kind: ProviderId) -> [u8; 3] {
match kind {
// A dark warm tint reads as Claude without copying its exact accent.
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
// Cool dark slate for Codex.
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
// Deep green for OpenCode Go.
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
}
}
/// Sweep colour. The badge disk is always dark whatever the system theme,
/// so the dark palette keeps the ring readable (Codex stays white).
fn usage_rgb(kind: ProviderId, percent: f64) -> [u8; 3] {
let c = usage_color::bar_fill_color(kind, true, percent);
[c.r, c.g, c.b]
}
#[cfg(test)]
mod tests {
use super::*;
/// Straight RGB at a pixel, asserting it is fully opaque.
fn opaque_rgb(pixmap: &Pixmap, x: u32, y: u32) -> [u8; 3] {
let p = pixmap.pixel(x, y).expect("pixel in range").demultiply();
assert_eq!(p.alpha(), 255, "pixel ({x},{y}) is not opaque");
[p.red(), p.green(), p.blue()]
}
/// The pixel whose centre lies on the ring radius (12.15) to the right
/// of the centre: x = 16 + 12.15 rounds into pixel 28.
const TRACK_SAMPLE: (u32, u32) = (28, 16);
/// The pixel at 12 o'clock on the ring radius: y = 16 - 12.15 = 3.85.
const TOP_SAMPLE: (u32, u32) = (16, 3);
#[test]
fn centre_is_the_provider_disk() {
for kind in ProviderId::ALL {
let pixmap = render_pixmap(kind, Some(50.0));
assert_eq!(opaque_rgb(&pixmap, 16, 16), base_color(kind), "{kind:?}");
}
}
#[test]
fn ring_is_track_coloured_without_a_percent() {
let pixmap = render_pixmap(ProviderId::Claude, None);
assert_eq!(
opaque_rgb(&pixmap, TRACK_SAMPLE.0, TRACK_SAMPLE.1),
TRACK_RGB
);
assert_eq!(opaque_rgb(&pixmap, TOP_SAMPLE.0, TOP_SAMPLE.1), TRACK_RGB);
// Zero percent draws no arc either.
let zero = render_pixmap(ProviderId::Claude, Some(0.0));
assert_eq!(opaque_rgb(&zero, TOP_SAMPLE.0, TOP_SAMPLE.1), TRACK_RGB);
}
#[test]
fn arc_starts_at_twelve_o_clock_for_any_percent() {
for kind in ProviderId::LIVE_USAGE {
for pct in [0.5, 1.0, 25.0, 59.9, 60.0, 80.0, 95.0, 100.0, 150.0] {
let pixmap = render_pixmap(kind, Some(pct));
assert_eq!(
opaque_rgb(&pixmap, TOP_SAMPLE.0, TOP_SAMPLE.1),
usage_rgb(kind, pct),
"{kind:?} at {pct}%"
);
}
}
}
#[test]
fn a_quarter_sweep_leaves_the_left_side_as_track() {
let pixmap = render_pixmap(ProviderId::Claude, Some(25.0));
// 3 o'clock is the end of the sweep (round cap), 9 o'clock is track.
assert_eq!(
opaque_rgb(&pixmap, TRACK_SAMPLE.0, TRACK_SAMPLE.1),
usage_rgb(ProviderId::Claude, 25.0)
);
assert_eq!(opaque_rgb(&pixmap, 3, 16), TRACK_RGB);
}
#[test]
fn corners_stay_transparent() {
let pixmap = render_pixmap(ProviderId::ChatGpt, Some(100.0));
assert_eq!(pixmap.pixel(0, 0).expect("pixel").alpha(), 0);
assert_eq!(pixmap.pixel(31, 31).expect("pixel").alpha(), 0);
}
#[test]
fn demultiply_restores_straight_alpha() {
let mut pixmap = Pixmap::new(2, 1).expect("pixmap");
// 50% alpha red: premultiplied (128, 0, 0, 128).
pixmap.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 128));
assert_eq!(pixmap.data()[..4], [128, 0, 0, 128]);
assert_eq!(demultiplied_rgba(&pixmap), [255, 0, 0, 128, 255, 0, 0, 128]);
let badge = render_pixmap(ProviderId::Claude, Some(42.0));
let rgba = demultiplied_rgba(&badge);
assert_eq!(rgba.len(), (BADGE_PX * BADGE_PX * 4) as usize);
// Opaque pixels are unchanged; transparent ones stay zero.
assert_eq!(rgba[..4], [0, 0, 0, 0]);
let centre = ((16 * BADGE_PX + 16) * 4) as usize;
assert_eq!(rgba[centre..centre + 3], base_color(ProviderId::Claude));
assert_eq!(rgba[centre + 3], 255);
}
}
+853 -1449
View File
File diff suppressed because it is too large. Load diff
+164
View File
@@ -0,0 +1,164 @@
//! Command-line flags. Parsing is pure so every accepted form is unit-tested.
//!
//! Accepted flags:
//! - `--diagnose` turns on the diagnostic log.
//! - `--wait-pid <pid>` waits for an exiting instance before starting.
//! - `--updated-to <version>` announces a finished update.
//!
//! Anything else, including a flag whose value is missing or malformed, is
//! ignored so an old or newer caller can never stop the app from starting.
use std::ffi::OsString;
/// Longest version string accepted for `--updated-to`.
const MAX_VERSION_LEN: usize = 64;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CliArgs {
pub diagnose: bool,
pub wait_pid: Option<u32>,
pub updated_to: Option<String>,
}
/// The process arguments, parsed. Non-UTF-8 arguments are read lossily
/// instead of panicking, as `std::env::args` would.
pub fn from_env() -> CliArgs {
parse(std::env::args_os().skip(1))
}
/// Parse the arguments after the program name.
pub fn parse<I, S>(args: I) -> CliArgs
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let args: Vec<String> = args
.into_iter()
.map(|a| a.into().to_string_lossy().into_owned())
.collect();
let mut out = CliArgs::default();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--diagnose" => out.diagnose = true,
"--wait-pid" => {
if let Some(pid) = args.get(i + 1).and_then(|v| parse_pid(v)) {
out.wait_pid = Some(pid);
i += 1;
}
}
"--updated-to" => {
if let Some(version) = args.get(i + 1).filter(|v| is_version_like(v)) {
out.updated_to = Some(version.clone());
i += 1;
}
}
_ => {}
}
i += 1;
}
out
}
/// A positive process id. Zero is rejected: on Unix it names the whole
/// process group, never a single parent.
fn parse_pid(value: &str) -> Option<u32> {
value.parse::<u32>().ok().filter(|pid| *pid != 0)
}
/// A version as the updater passes it (`0.6.0`, `1.2.3-rc.1`). The text ends
/// up in a notification, so anything else is dropped.
fn is_version_like(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_VERSION_LEN
&& value.starts_with(|c: char| c.is_ascii_digit())
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
}
#[cfg(test)]
mod tests {
use super::*;
fn p(args: &[&str]) -> CliArgs {
parse(args.iter().copied())
}
#[test]
fn no_flags_is_the_default() {
assert_eq!(p(&[]), CliArgs::default());
}
#[test]
fn diagnose_flag() {
assert!(p(&["--diagnose"]).diagnose);
}
#[test]
fn wait_pid_takes_the_next_value() {
assert_eq!(p(&["--wait-pid", "4242"]).wait_pid, Some(4242));
}
#[test]
fn wait_pid_rejects_zero_garbage_and_a_missing_value() {
assert_eq!(p(&["--wait-pid", "0"]).wait_pid, None);
assert_eq!(p(&["--wait-pid", "-5"]).wait_pid, None);
assert_eq!(p(&["--wait-pid"]).wait_pid, None);
// A following flag is not swallowed as the value.
let args = p(&["--wait-pid", "--diagnose"]);
assert_eq!(args.wait_pid, None);
assert!(args.diagnose);
}
#[test]
fn updated_to_alone() {
// v0.3.13 to v0.4.0 relaunch with only this flag.
assert_eq!(
p(&["--updated-to", "0.6.0"]),
CliArgs {
updated_to: Some("0.6.0".into()),
..CliArgs::default()
}
);
}
#[test]
fn updated_to_combined_with_wait_pid() {
assert_eq!(
p(&[
"--wait-pid",
"17",
"--updated-to",
"1.2.3-rc.1",
"--diagnose"
]),
CliArgs {
diagnose: true,
wait_pid: Some(17),
updated_to: Some("1.2.3-rc.1".into()),
}
);
}
#[test]
fn updated_to_rejects_text_that_is_not_a_version() {
assert_eq!(p(&["--updated-to", "--diagnose"]).updated_to, None);
assert_eq!(p(&["--updated-to", "v0.6.0"]).updated_to, None);
assert_eq!(p(&["--updated-to", "0.6.0 <b>"]).updated_to, None);
let long = "1".repeat(MAX_VERSION_LEN + 1);
assert_eq!(p(&["--updated-to", &long]).updated_to, None);
assert_eq!(p(&["--updated-to"]).updated_to, None);
}
#[test]
fn unknown_flags_are_ignored() {
assert_eq!(
p(&["--apply-update", "x", "--wait-pid", "9", "--frobnicate"]),
CliArgs {
wait_pid: Some(9),
..CliArgs::default()
}
);
}
}
+146
View File
@@ -0,0 +1,146 @@
//! Commands the webviews invoke. Every argument comes from a webview, so
//! each one is validated here before it reaches the runtime.
use std::sync::Arc;
use tauri::{AppHandle, State, WebviewWindow};
use crate::bubble;
use crate::core::geom::RESIZE_STEP_LOGICAL;
use crate::core::i18n::LocaleStrings;
use crate::core::payload::{BubblePayload, PanelPayload};
use crate::panel;
use crate::runtime::Runtime;
use crate::usage::ProviderId;
/// Longest JS log message kept, in characters.
const MAX_JS_LOG_CHARS: usize = 2_000;
/// The current payload for `provider`'s bubble, sized for the calling
/// window's scale factor.
#[tauri::command]
pub fn get_bubble_state(
window: WebviewWindow,
runtime: State<'_, Arc<Runtime>>,
provider: String,
) -> Result<BubblePayload, String> {
let provider = live_provider(&provider)?;
let scale = window.scale_factor().map_err(|err| err.to_string())?;
Ok(runtime.bubble_payload(provider, scale))
}
/// The details-panel payload for the provider the panel last showed;
/// `None` before the first bubble click.
#[tauri::command]
pub fn get_panel_state(runtime: State<'_, Arc<Runtime>>) -> Option<PanelPayload> {
panel::current_provider().map(|provider| runtime.panel_payload(provider))
}
/// Every UI string, camelCase keys.
#[tauri::command]
pub fn get_strings(runtime: State<'_, Arc<Runtime>>) -> LocaleStrings {
runtime.with_state(|s| s.strings.clone())
}
/// Forward a webview error or message to the diagnostic log.
#[tauri::command]
pub fn log_js(window: WebviewWindow, level: String, message: String) {
let message = sanitize_log_message(&message);
let label = window.label();
match level.as_str() {
"error" => log::error!(target: "js", "[{label}] {message}"),
"warn" => log::warn!(target: "js", "[{label}] {message}"),
"debug" => log::debug!(target: "js", "[{label}] {message}"),
_ => log::info!(target: "js", "[{label}] {message}"),
}
}
/// The bubble rendered its first state and can be shown (unless hidden).
#[tauri::command]
pub fn bubble_ready(app: AppHandle, label: String) -> Result<(), String> {
bubble::mark_ready(&app, &label)
}
/// A left click (a press released before the drag threshold) on a bubble.
/// Async, so the panel toggle is queued to the main thread instead of
/// running inside the webview's IPC callback.
#[tauri::command]
pub async fn bubble_click(runtime: State<'_, Arc<Runtime>>, slug: String) -> Result<(), String> {
let provider = live_provider(&slug)?;
runtime.bubble_clicked(provider);
Ok(())
}
/// The page is about to start an OS drag of a bubble.
#[tauri::command]
pub fn bubble_drag_start(app: AppHandle, slug: String) -> Result<(), String> {
let provider = live_provider(&slug)?;
bubble::drag_start(&app, provider);
Ok(())
}
/// Ctrl+wheel on a bubble: `dir` 1 grows every bubble one step, -1 shrinks.
#[tauri::command]
pub fn bubble_resize_step(runtime: State<'_, Arc<Runtime>>, dir: i32) -> Result<(), String> {
let delta = resize_delta(dir)?;
runtime.resize_bubbles(delta);
Ok(())
}
/// The size change for a resize direction; only 1 and -1 are accepted.
fn resize_delta(dir: i32) -> Result<i32, String> {
match dir {
1 => Ok(RESIZE_STEP_LOGICAL),
-1 => Ok(-RESIZE_STEP_LOGICAL),
_ => Err("resize direction must be 1 or -1".into()),
}
}
fn live_provider(slug: &str) -> Result<ProviderId, String> {
ProviderId::from_slug(slug)
.filter(|p| p.metadata().live_usage)
.ok_or_else(|| "unknown provider".to_string())
}
/// One log line: control characters become spaces, so a message cannot
/// forge extra log lines, and the length is capped.
fn sanitize_log_message(message: &str) -> String {
message
.chars()
.take(MAX_JS_LOG_CHARS)
.map(|c| if c.is_control() { ' ' } else { c })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_live_providers_are_accepted() {
assert_eq!(live_provider("claude"), Ok(ProviderId::Claude));
assert_eq!(live_provider("chatgpt"), Ok(ProviderId::ChatGpt));
assert!(live_provider("opencode-go").is_err());
assert!(live_provider("").is_err());
assert!(live_provider("CLAUDE").is_err());
}
#[test]
fn resize_steps_are_one_step_either_way() {
assert_eq!(resize_delta(1), Ok(20));
assert_eq!(resize_delta(-1), Ok(-20));
assert!(resize_delta(0).is_err());
assert!(resize_delta(2).is_err());
assert!(resize_delta(i32::MIN).is_err());
}
#[test]
fn log_messages_stay_on_one_capped_line() {
assert_eq!(sanitize_log_message("a\nb\r\tc"), "a b c");
let long = "x".repeat(MAX_JS_LOG_CHARS + 50);
assert_eq!(
sanitize_log_message(&long).chars().count(),
MAX_JS_LOG_CHARS
);
}
}
+113
View File
@@ -0,0 +1,113 @@
//! Plain RGB colour helpers shared by the bubble, the panel and the tray badge.
//!
//! The webview takes colours as `#RRGGBB` strings, so [`Rgb::to_hex`] is the
//! boundary conversion.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
/// Parse `#RRGGBB` or `RRGGBB`. Panics on malformed input — meant for
/// hardcoded design-system literals at compile-known call sites.
pub fn from_hex(hex: &str) -> Self {
Self::parse_hex(hex).unwrap_or_else(|| panic!("invalid hex color literal: {hex:?}"))
}
/// Parse `#RRGGBB` or `RRGGBB`. Returns `None` on malformed input.
pub fn parse_hex(hex: &str) -> Option<Self> {
let s = hex.trim_start_matches('#');
if s.len() != 6 || !s.is_ascii() {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some(Self { r, g, b })
}
/// Format as an uppercase `#RRGGBB` CSS colour.
pub fn to_hex(self) -> String {
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
}
/// Linear interpolation between two colours. `t` is clamped to `[0, 1]`.
pub fn lerp(self, other: Rgb, t: f64) -> Rgb {
let t = t.clamp(0.0, 1.0);
let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;
Rgb {
r: mix(self.r, other.r),
g: mix(self.g, other.g),
b: mix(self.b, other.b),
}
}
}
/// Triangle wave in [0, 1] with a period of 24 ticks: 0 at phase 0, 0.5 at
/// phase 6 and 18, and 1 at phase 12. The bubble UI mirrors this exactly.
pub fn pulse_triangle(phase: u32) -> f64 {
let p = (phase % 24) as i32;
let dist = (p - 12).abs(); // 0..12
1.0 - (dist as f64 / 12.0)
}
/// Linearly brighten `c` toward white by `t` in [0, 1].
pub fn brighten(c: Rgb, t: f64) -> Rgb {
// Map t to a smaller brightness delta — the pulse should be a subtle nudge.
let t = t.clamp(0.0, 1.0) * 0.30;
Rgb::new(
((c.r as f64) + (255.0 - c.r as f64) * t).round() as u8,
((c.g as f64) + (255.0 - c.g as f64) * t).round() as u8,
((c.b as f64) + (255.0 - c.b as f64) * t).round() as u8,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_round_trips_and_rejects_malformed_input() {
let c = Rgb::parse_hex("#d97757").expect("valid hex");
assert_eq!(c, Rgb::new(0xD9, 0x77, 0x57));
assert_eq!(c.to_hex(), "#D97757");
assert_eq!(Rgb::parse_hex("1A1A1A"), Some(Rgb::new(0x1A, 0x1A, 0x1A)));
assert_eq!(Rgb::parse_hex("#12345"), None);
assert_eq!(Rgb::parse_hex("#GG0000"), None);
assert_eq!(Rgb::parse_hex("#ééé"), None);
}
#[test]
fn pulse_triangle_peaks_mid_period() {
assert_eq!(pulse_triangle(0), 0.0);
assert_eq!(pulse_triangle(6), 0.5);
assert_eq!(pulse_triangle(12), 1.0);
assert_eq!(pulse_triangle(18), 0.5);
assert_eq!(pulse_triangle(24), 0.0);
}
#[test]
fn brighten_caps_at_thirty_percent_toward_white() {
let c = Rgb::new(0xA0, 0x18, 0x18);
assert_eq!(brighten(c, 0.0), c);
// 160 + 95 * 0.3 = 188.5 -> 189; 24 + 231 * 0.3 = 93.3 -> 93
assert_eq!(brighten(c, 1.0), Rgb::new(189, 93, 93));
assert_eq!(brighten(c, 5.0), brighten(c, 1.0));
}
#[test]
fn lerp_clamps_t() {
let a = Rgb::new(0, 0, 0);
let b = Rgb::new(255, 255, 255);
assert_eq!(a.lerp(b, -1.0), a);
assert_eq!(a.lerp(b, 2.0), b);
assert_eq!(a.lerp(b, 0.5), Rgb::new(128, 128, 128));
}
}
+776
View File
@@ -0,0 +1,776 @@
//! Window geometry in physical pixels: bubble sizing, default placement,
//! snapping, clamping, panel placement and fullscreen checks.
//!
//! Everything here is pure. The window layer collects monitors, work areas,
//! the taskbar and peer bubbles, and passes them in as plain [`Rect`]s.
use crate::usage::ProviderId;
/// Screen rectangle with exclusive right/bottom edges, like Win32 `RECT`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Rect {
pub l: i32,
pub t: i32,
pub r: i32,
pub b: i32,
}
impl Rect {
pub const fn new(l: i32, t: i32, r: i32, b: i32) -> Self {
Self { l, t, r, b }
}
/// Rect from a top-left corner and a size.
pub const fn from_xywh(x: i32, y: i32, w: i32, h: i32) -> Self {
Self {
l: x,
t: y,
r: x + w,
b: y + h,
}
}
pub const fn w(&self) -> i32 {
self.r - self.l
}
pub const fn h(&self) -> i32 {
self.b - self.t
}
pub const fn is_empty(&self) -> bool {
self.r <= self.l || self.b <= self.t
}
/// True when the two rects share a non-empty area.
pub const fn intersects(&self, other: &Rect) -> bool {
self.l < other.r && other.l < self.r && self.t < other.b && other.t < self.b
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Point {
pub x: i32,
pub y: i32,
}
impl Point {
pub const fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
}
/// The DPI the OS reports for 100% scaling.
pub const BASE_DPI: u32 = 96;
/// Scale a logical (96-DPI) pixel measurement to the given DPI.
pub fn scale(logical_px: i32, dpi: u32) -> i32 {
((logical_px as i64) * (dpi as i64) / (BASE_DPI as i64)) as i32
}
// ---------- Bubble size ----------
// Width clamps in logical pixels. Height is derived per width (see
// `bubble_height_logical`) — aspect tapers from 3:1 at the small end toward
// 2.6:1 at the large end so the bars look proportionally chunkier as the
// bubble grows.
pub const MIN_BUBBLE_SIZE: i32 = 140;
pub const MAX_BUBBLE_SIZE: i32 = 360;
pub const DEFAULT_BUBBLE_SIZE: i32 = 200;
pub const RESIZE_STEP_LOGICAL: i32 = 20;
const SNAP_ZONE_LOGICAL: i32 = 12;
const CORNER_SNAP_ZONE_LOGICAL: i32 = 32;
const CORNER_INSET_LOGICAL: i32 = 12;
const TASKBAR_GAP_LOGICAL: i32 = 4;
const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8;
/// Raw (unscaled) gap used by the default position and the Codex stagger.
const STAGGER_GAP: i32 = 24;
/// Side of the square probed when validating a saved position. 140 px
/// matches `MIN_BUBBLE_SIZE`: a saved top-left a few px past the work-area
/// edge still passes the validator, but a position fully on a disconnected
/// monitor fails.
const POSITION_PROBE_PX: i32 = 140;
/// Gap between a bubble and its details panel, in physical px.
const PANEL_GAP_PX: i32 = 8;
const FULLSCREEN_EDGE_TOLERANCE_PX: i32 = 2;
/// (num, den) such that bubble_height = (width * den) / num. 3:1 below 200,
/// 2.8:1 below 280, 2.6:1 above — the bubble gets a touch taller as it
/// grows so the wider bars don't look anaemic.
pub fn aspect_at_width(w_logical: i32) -> (i32, i32) {
if w_logical <= 200 {
(3, 1)
} else if w_logical <= 280 {
(14, 5) // 2.8 : 1
} else {
(13, 5) // 2.6 : 1
}
}
pub fn bubble_height_logical(width_logical: i32) -> i32 {
let (num, den) = aspect_at_width(width_logical);
((width_logical * den) / num).max(20)
}
/// Physical `(width, height)` of a bubble at `size_logical` and `dpi`.
pub fn bubble_size_px(size_logical: i32, dpi: u32) -> (i32, i32) {
(
scale(size_logical, dpi),
scale(bubble_height_logical(size_logical), dpi),
)
}
pub fn corner_radius_px(height_px: i32) -> i32 {
height_px / 2
}
/// Whether the local point `(x, y)` lies inside a `w`×`h` rounded rect with
/// corner radius `r`.
pub fn point_in_rounded_rect(x: i32, y: i32, w: i32, h: i32, r: i32) -> bool {
if x < 0 || y < 0 || x >= w || y >= h {
return false;
}
// The straight horizontal and vertical strips are always inside; only the
// four corner squares need the circular falloff check.
let in_x_strip = x >= r && x < w - r;
let in_y_strip = y >= r && y < h - r;
if in_x_strip || in_y_strip {
return true;
}
let cx = if x < r { r } else { w - 1 - r };
let cy = if y < r { r } else { h - 1 - r };
let dx = x - cx;
let dy = y - cy;
dx * dx + dy * dy <= r * r
}
// ---------- Placement ----------
/// Bottom-right of the primary work area, with a 24-pixel gap from the
/// edges. The Codex bubble is staggered above the Claude one so both fit
/// when enabled.
pub fn default_position(
work_area: Rect,
width_px: i32,
height_px: i32,
model: ProviderId,
) -> Point {
let stagger = match model {
ProviderId::Claude => 0,
ProviderId::ChatGpt => height_px + STAGGER_GAP,
ProviderId::OpenCodeGo => 2 * (height_px + STAGGER_GAP),
};
let x = work_area.r - width_px - STAGGER_GAP;
let y = work_area.b - height_px - STAGGER_GAP - stagger;
Point::new(x, y)
}
/// Keep `rect` inside `work_area` and return the clamped top-left.
///
/// When both bubbles get clamped to the same bottom-right corner (e.g.
/// saved positions were on a disconnected monitor and the validator missed
/// them), the Codex bubble keeps the stagger `default_position` uses so they
/// don't visually stack.
pub fn clamp_into_work_area(rect: Rect, work_area: Rect, model: ProviderId) -> Point {
let (w, h) = (rect.w(), rect.h());
let nx = rect
.l
.clamp(work_area.l, (work_area.r - w).max(work_area.l));
let mut ny = rect
.t
.clamp(work_area.t, (work_area.b - h).max(work_area.t));
if model == ProviderId::ChatGpt && nx == work_area.r - w && ny == work_area.b - h {
ny = (ny - h - STAGGER_GAP).max(work_area.t);
}
Point::new(nx, ny)
}
/// Monitor edge the taskbar is docked to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Edge {
Left,
Top,
Right,
Bottom,
}
/// Everything the drag-end snap needs, in physical px.
#[derive(Clone, Debug)]
pub struct SnapInput {
/// The dragged bubble's current outer rect.
pub rect: Rect,
/// Work area of the monitor nearest the bubble.
pub work_area: Rect,
/// DPI of the bubble's monitor.
pub dpi: u32,
/// Docked taskbar edge and rect, when the platform reports one.
pub taskbar: Option<(Edge, Rect)>,
/// Outer rects of the other bubbles, in `ProviderId::LIVE_USAGE` order.
pub peers: Vec<Rect>,
}
/// Snap a dropped bubble: corner first; otherwise edge, taskbar and peer
/// alignment; then always clamp into the work area. Returns the new
/// top-left, which equals `input.rect`'s when nothing moves.
pub fn snap(input: &SnapInput) -> Point {
let dpi = input.dpi;
let edge_zone = scale(SNAP_ZONE_LOGICAL, dpi);
let corner_zone = scale(CORNER_SNAP_ZONE_LOGICAL, dpi);
let corner_inset = scale(CORNER_INSET_LOGICAL, dpi);
let taskbar_gap = scale(TASKBAR_GAP_LOGICAL, dpi);
let peer_tolerance = scale(PEER_ALIGN_TOLERANCE_LOGICAL, dpi);
let wa = &input.work_area;
let w = input.rect.w();
let h = input.rect.h();
let mut nx = input.rect.l;
let mut ny = input.rect.t;
// 1. Corner snap — if the bubble's nearest-corner distance is under the
// 32-px corner zone, slam it into the corner with the 12-px inset.
let snapped_to_corner = try_corner_snap(&mut nx, &mut ny, wa, w, h, corner_zone, corner_inset);
if !snapped_to_corner {
// 2. Edge snap — also handles taskbar adjacency when the taskbar
// steals from the work area on the same edge.
snap_to_work_area_edges(&mut nx, &mut ny, wa, w, h, edge_zone);
if let Some((edge, tb)) = &input.taskbar {
let fit = Fit {
wa,
w,
h,
zone: edge_zone,
};
snap_alongside_taskbar(&mut nx, &mut ny, *edge, tb, &fit, taskbar_gap);
}
// 3. Peer vertical alignment — when another bubble is within ±8 px
// on Y, snap the dragged bubble to share its baseline.
align_with_peer(&input.peers, &mut ny, peer_tolerance);
}
// Clamp into the work area in any case (so the bubble can't be lost off-screen).
nx = nx.clamp(wa.l, (wa.r - w).max(wa.l));
ny = ny.clamp(wa.t, (wa.b - h).max(wa.t));
Point::new(nx, ny)
}
fn try_corner_snap(
nx: &mut i32,
ny: &mut i32,
wa: &Rect,
w: i32,
h: i32,
zone: i32,
inset: i32,
) -> bool {
// Distance from each work-area corner to the bubble's nearest corner.
let tl = (*nx - wa.l).abs() + (*ny - wa.t).abs();
let tr = (wa.r - (*nx + w)).abs() + (*ny - wa.t).abs();
let bl = (*nx - wa.l).abs() + (wa.b - (*ny + h)).abs();
let br = (wa.r - (*nx + w)).abs() + (wa.b - (*ny + h)).abs();
let min = tl.min(tr).min(bl).min(br);
if min > zone * 2 {
return false;
}
if min == tl {
*nx = wa.l + inset;
*ny = wa.t + inset;
} else if min == tr {
*nx = wa.r - inset - w;
*ny = wa.t + inset;
} else if min == bl {
*nx = wa.l + inset;
*ny = wa.b - inset - h;
} else {
*nx = wa.r - inset - w;
*ny = wa.b - inset - h;
}
true
}
fn snap_to_work_area_edges(nx: &mut i32, ny: &mut i32, wa: &Rect, w: i32, h: i32, zone: i32) {
if (*nx - wa.l).abs() < zone {
*nx = wa.l;
} else if (wa.r - (*nx + w)).abs() < zone {
*nx = wa.r - w;
}
if (*ny - wa.t).abs() < zone {
*ny = wa.t;
} else if (wa.b - (*ny + h)).abs() < zone {
*ny = wa.b - h;
}
}
/// Work area, bubble size and snap zone shared by the taskbar snap.
struct Fit<'a> {
wa: &'a Rect,
w: i32,
h: i32,
zone: i32,
}
fn snap_alongside_taskbar(
nx: &mut i32,
ny: &mut i32,
edge: Edge,
tb: &Rect,
fit: &Fit<'_>,
gap: i32,
) {
let Fit { wa, w, h, zone } = *fit;
// Only snap on the taskbar's docked edge. The bubble docks against the
// inner face of the taskbar with a 4-px gap so it visually leans on it.
match edge {
Edge::Bottom => {
let target = tb.t - gap - h;
if (*ny - target).abs() < zone {
*ny = target.max(wa.t);
}
}
Edge::Top => {
let target = tb.b + gap;
if (*ny - target).abs() < zone {
*ny = target.min(wa.b - h);
}
}
Edge::Left => {
let target = tb.r + gap;
if (*nx - target).abs() < zone {
*nx = target.min(wa.r - w);
}
}
Edge::Right => {
let target = tb.l - gap - w;
if (*nx - target).abs() < zone {
*nx = target.max(wa.l);
}
}
}
}
fn align_with_peer(peers: &[Rect], ny: &mut i32, tolerance: i32) {
if let Some(peer) = peers.iter().find(|p| (*ny - p.t).abs() <= tolerance) {
*ny = peer.t;
}
}
/// Place a `panel_w`×`panel_h` panel below `anchor` (the bubble's outer
/// rect), flipping above when it would clip, and keep it inside `bounds`
/// (the virtual-screen union of all monitors, whose origin can be negative).
pub fn place_near(anchor: Rect, panel_size: (i32, i32), bounds: Rect) -> Point {
let (panel_w, panel_h) = panel_size;
let mut x = anchor.l;
let mut y = anchor.b + PANEL_GAP_PX;
if y + panel_h > bounds.b {
y = anchor.t - panel_h - PANEL_GAP_PX;
}
if y < bounds.t {
y = anchor.t.max(bounds.t);
}
if x + panel_w > bounds.r {
x = bounds.r - panel_w - PANEL_GAP_PX;
}
if x < bounds.l {
x = bounds.l + PANEL_GAP_PX;
}
Point::new(x, y)
}
/// Whether a saved top-left `(x, y)` still lands on a connected monitor:
/// a `140×140` probe there must overlap at least one of `monitors`.
pub fn position_on_any_monitor(x: i32, y: i32, monitors: &[Rect]) -> bool {
let probe = Rect::from_xywh(x, y, POSITION_PROBE_PX, POSITION_PROBE_PX);
monitors.iter().any(|m| probe.intersects(m))
}
// ---------- Fullscreen ----------
/// Whether `rect` covers the monitor `bounds`, allowing 2 px of slack per edge.
pub fn rect_covers_monitor(rect: &Rect, bounds: &Rect) -> bool {
rect.l <= bounds.l + FULLSCREEN_EDGE_TOLERANCE_PX
&& rect.t <= bounds.t + FULLSCREEN_EDGE_TOLERANCE_PX
&& rect.r >= bounds.r - FULLSCREEN_EDGE_TOLERANCE_PX
&& rect.b >= bounds.b - FULLSCREEN_EDGE_TOLERANCE_PX
}
// Win32 window style bits (`WS_*`), as the plain values `GWL_STYLE` returns.
pub const WS_CHILD: u32 = 0x4000_0000;
pub const WS_POPUP: u32 = 0x8000_0000;
pub const WS_CAPTION: u32 = 0x00C0_0000;
pub const WS_THICKFRAME: u32 = 0x0004_0000;
/// A fullscreen candidate is a top-level popup, or a top-level window with
/// neither a caption nor a resize frame.
pub fn window_style_bits_allow_fullscreen(style: u32) -> bool {
let has_child = style & WS_CHILD != 0;
let has_popup = style & WS_POPUP != 0;
let has_caption = style & WS_CAPTION != 0;
let has_resize_frame = style & WS_THICKFRAME != 0;
!has_child && (has_popup || (!has_caption && !has_resize_frame))
}
#[cfg(test)]
mod tests {
use super::*;
const WA: Rect = Rect::new(0, 0, 1920, 1040);
fn input(rect: Rect) -> SnapInput {
SnapInput {
rect,
work_area: WA,
dpi: 96,
taskbar: None,
peers: Vec::new(),
}
}
#[test]
fn scale_follows_dpi() {
assert_eq!(scale(200, 96), 200);
assert_eq!(scale(200, 144), 300);
assert_eq!(scale(12, 120), 15);
assert_eq!(scale(66, 168), 115);
}
#[test]
fn aspect_and_height_per_width() {
for (w, aspect, h) in [
(140, (3, 1), 46),
(200, (3, 1), 66),
(220, (14, 5), 78),
(280, (14, 5), 100),
(300, (13, 5), 115),
(360, (13, 5), 138),
] {
assert_eq!(aspect_at_width(w), aspect, "aspect at {w}");
assert_eq!(bubble_height_logical(w), h, "height at {w}");
}
assert_eq!(bubble_size_px(200, 144), (300, 99));
}
#[test]
fn rounded_rect_excludes_corners_only() {
let (w, h) = (200, 66);
let r = corner_radius_px(h);
assert!(point_in_rounded_rect(100, 0, w, h, r));
assert!(point_in_rounded_rect(33, 33, w, h, r));
assert!(!point_in_rounded_rect(0, 0, w, h, r));
assert!(!point_in_rounded_rect(w - 1, h - 1, w, h, r));
assert!(point_in_rounded_rect(1, 33, w, h, r));
assert!(!point_in_rounded_rect(0, 33, w, h, r));
assert!(!point_in_rounded_rect(-1, 33, w, h, r));
assert!(!point_in_rounded_rect(w, 33, w, h, r));
}
#[test]
fn default_position_staggers_by_provider() {
let (w, h) = (200, 66);
assert_eq!(
default_position(WA, w, h, ProviderId::Claude),
Point::new(1920 - 200 - 24, 1040 - 66 - 24)
);
assert_eq!(
default_position(WA, w, h, ProviderId::ChatGpt),
Point::new(1696, 1040 - 66 - 24 - 90)
);
assert_eq!(
default_position(WA, w, h, ProviderId::OpenCodeGo),
Point::new(1696, 1040 - 66 - 24 - 180)
);
}
#[test]
fn clamp_pulls_offscreen_rect_into_work_area() {
let r = Rect::from_xywh(-500, -30, 200, 66);
assert_eq!(
clamp_into_work_area(r, WA, ProviderId::Claude),
Point::new(0, 0)
);
let inside = Rect::from_xywh(100, 100, 200, 66);
assert_eq!(
clamp_into_work_area(inside, WA, ProviderId::ChatGpt),
Point::new(100, 100)
);
}
#[test]
fn clamp_lifts_codex_out_of_bottom_right_corner() {
let r = Rect::from_xywh(5000, 5000, 200, 66);
assert_eq!(
clamp_into_work_area(r, WA, ProviderId::Claude),
Point::new(1720, 974)
);
assert_eq!(
clamp_into_work_area(r, WA, ProviderId::ChatGpt),
Point::new(1720, 974 - 66 - 24)
);
// The lift never goes above the work area.
let tiny = Rect::new(0, 0, 400, 100);
assert_eq!(
clamp_into_work_area(
Rect::from_xywh(900, 900, 200, 66),
tiny,
ProviderId::ChatGpt
),
Point::new(200, 0)
);
}
#[test]
fn corner_snap_insets_into_each_corner() {
// Within 64 px (2 x 32) Manhattan distance of a corner.
let tl = snap(&input(Rect::from_xywh(20, 30, 200, 66)));
assert_eq!(tl, Point::new(12, 12));
let br = snap(&input(Rect::from_xywh(1690, 940, 200, 66)));
assert_eq!(br, Point::new(1920 - 12 - 200, 1040 - 12 - 66));
let tr = snap(&input(Rect::from_xywh(1700, 10, 200, 66)));
assert_eq!(tr, Point::new(1708, 12));
let bl = snap(&input(Rect::from_xywh(10, 950, 200, 66)));
assert_eq!(bl, Point::new(12, 962));
}
#[test]
fn corner_snap_tie_order_is_tl_tr_bl_br() {
// The work area leaves 40 px of slack on both axes, so the four
// corner distances can be made to tie.
let wa = Rect::new(0, 0, 240, 106);
let at = |x, y| SnapInput {
work_area: wa,
..input(Rect::from_xywh(x, y, 200, 66))
};
// tl = tr = 20 (bl = br = 60): top-left wins.
assert_eq!(snap(&at(20, 0)), Point::new(12, 12));
// tl = 50, tr = br = 30: top-right wins over bottom-right.
assert_eq!(snap(&at(30, 20)), Point::new(28, 12));
// tl = tr = 50, bl = br = 30: bottom-left wins over bottom-right.
assert_eq!(snap(&at(20, 30)), Point::new(12, 28));
// Only bottom-right is nearest.
assert_eq!(snap(&at(30, 30)), Point::new(28, 28));
}
#[test]
fn corner_zone_boundary_is_inclusive_at_twice_the_zone() {
// tl distance exactly 64 snaps; 65 does not.
assert_eq!(
snap(&input(Rect::from_xywh(32, 32, 200, 66))),
Point::new(12, 12)
);
let out = snap(&input(Rect::from_xywh(33, 32, 200, 66)));
assert_ne!(out, Point::new(12, 12));
// Not a corner: falls through to edge snap on the top edge (32 >= 12
// so x stays; y=32 is outside the 12 px edge zone too).
assert_eq!(out, Point::new(33, 32));
}
#[test]
fn edge_zone_is_exclusive_at_twelve_px() {
// 11 px from the left edge snaps flush; 12 px does not.
assert_eq!(
snap(&input(Rect::from_xywh(11, 500, 200, 66))),
Point::new(0, 500)
);
assert_eq!(
snap(&input(Rect::from_xywh(12, 500, 200, 66))),
Point::new(12, 500)
);
// Right and bottom edges.
assert_eq!(
snap(&input(Rect::from_xywh(1709, 500, 200, 66))),
Point::new(1720, 500)
);
assert_eq!(
snap(&input(Rect::from_xywh(800, 965, 200, 66))),
Point::new(800, 974)
);
assert_eq!(
snap(&input(Rect::from_xywh(800, 5, 200, 66))),
Point::new(800, 0)
);
}
#[test]
fn edge_zone_scales_with_dpi() {
let mut i = input(Rect::from_xywh(17, 500, 300, 99));
i.dpi = 144; // zone 18 px
assert_eq!(snap(&i), Point::new(0, 500));
i.rect = Rect::from_xywh(18, 500, 300, 99);
assert_eq!(snap(&i), Point::new(18, 500));
}
#[test]
fn taskbar_snap_on_each_edge() {
// Monitor 1920x1080; the work area excludes a 40 px taskbar.
let cases = [
(
Edge::Bottom,
Rect::new(0, 1040, 1920, 1080),
Rect::new(0, 0, 1920, 1040),
Rect::from_xywh(800, 960, 200, 66),
Point::new(800, 1040 - 4 - 66),
),
(
Edge::Top,
Rect::new(0, 0, 1920, 40),
Rect::new(0, 40, 1920, 1080),
Rect::from_xywh(800, 52, 200, 66),
Point::new(800, 44),
),
(
Edge::Left,
Rect::new(0, 0, 40, 1080),
Rect::new(40, 0, 1920, 1080),
Rect::from_xywh(52, 500, 200, 66),
Point::new(44, 500),
),
(
Edge::Right,
Rect::new(1880, 0, 1920, 1080),
Rect::new(0, 0, 1880, 1080),
Rect::from_xywh(1666, 500, 200, 66),
Point::new(1880 - 4 - 200, 500),
),
];
for (edge, tb, wa, rect, expected) in cases {
let i = SnapInput {
rect,
work_area: wa,
dpi: 96,
taskbar: Some((edge, tb)),
peers: Vec::new(),
};
assert_eq!(snap(&i), expected, "taskbar {edge:?}");
}
}
#[test]
fn taskbar_snap_ignores_far_positions() {
let i = SnapInput {
rect: Rect::from_xywh(800, 900, 200, 66),
work_area: WA,
dpi: 96,
taskbar: Some((Edge::Bottom, Rect::new(0, 1040, 1920, 1080))),
peers: Vec::new(),
};
assert_eq!(snap(&i), Point::new(800, 900));
}
#[test]
fn peer_alignment_takes_first_peer_within_tolerance() {
let mut i = input(Rect::from_xywh(800, 500, 200, 66));
i.peers = vec![
Rect::from_xywh(100, 509, 200, 66),
Rect::from_xywh(400, 506, 200, 66),
];
// 9 px is outside the 8 px tolerance; 6 px is inside.
assert_eq!(snap(&i), Point::new(800, 506));
i.peers = vec![
Rect::from_xywh(100, 508, 200, 66),
Rect::from_xywh(400, 506, 200, 66),
];
assert_eq!(snap(&i), Point::new(800, 508));
i.peers.clear();
assert_eq!(snap(&i), Point::new(800, 500));
}
#[test]
fn peer_alignment_is_skipped_after_a_corner_snap() {
let mut i = input(Rect::from_xywh(20, 20, 200, 66));
i.peers = vec![Rect::from_xywh(900, 18, 200, 66)];
assert_eq!(snap(&i), Point::new(12, 12));
}
#[test]
fn final_clamp_keeps_peer_aligned_bubble_on_screen() {
// The bottom edge snap puts y at 974; aligning to the peer at 980
// would overflow the 1040 bottom, so the clamp wins.
let mut i = input(Rect::from_xywh(800, 970, 200, 66));
i.peers = vec![Rect::from_xywh(100, 980, 200, 30)];
assert_eq!(snap(&i), Point::new(800, 974));
// A rect larger than the work area pins to its top-left.
let mut big = input(Rect::from_xywh(500, 500, 3000, 2000));
big.work_area = Rect::new(100, 50, 1920, 1040);
assert_eq!(snap(&big), Point::new(100, 50));
}
#[test]
fn place_near_below_then_above() {
let bounds = Rect::new(0, 0, 1920, 1080);
let bubble = Rect::from_xywh(100, 100, 200, 66);
assert_eq!(place_near(bubble, (280, 120), bounds), Point::new(100, 174));
let low = Rect::from_xywh(100, 950, 200, 66);
assert_eq!(place_near(low, (280, 120), bounds), Point::new(100, 822));
}
#[test]
fn place_near_clamps_left_and_right() {
let bounds = Rect::new(0, 0, 1920, 1080);
let right = Rect::from_xywh(1800, 100, 200, 66);
assert_eq!(
place_near(right, (280, 120), bounds),
Point::new(1920 - 280 - 8, 174)
);
let left = Rect::from_xywh(-50, 100, 200, 66);
assert_eq!(place_near(left, (280, 120), bounds), Point::new(8, 174));
}
#[test]
fn place_near_handles_negative_virtual_origin() {
// A secondary monitor left of and above the primary.
let bounds = Rect::new(-1920, -200, 1920, 1080);
let bubble = Rect::from_xywh(-1900, -190, 200, 66);
assert_eq!(
place_near(bubble, (280, 120), bounds),
Point::new(-1900, -116)
);
let edge = Rect::from_xywh(-2000, 1000, 200, 66);
assert_eq!(
place_near(edge, (280, 120), bounds),
Point::new(-1912, 1000 - 120 - 8)
);
// Flipping above would leave the top too; it pins to the anchor top.
let tall_bounds = Rect::new(-1920, -200, 1920, 100);
let b = Rect::from_xywh(0, -190, 200, 66);
assert_eq!(place_near(b, (280, 400), tall_bounds), Point::new(0, -190));
}
#[test]
fn position_probe_against_monitors() {
let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)];
assert!(position_on_any_monitor(100, 100, &monitors));
assert!(position_on_any_monitor(2000, 500, &monitors));
// Partially off the left edge still overlaps.
assert!(position_on_any_monitor(-100, 100, &monitors));
// Fully past the left edge does not.
assert!(!position_on_any_monitor(-140, 100, &monitors));
assert!(!position_on_any_monitor(4000, 100, &monitors));
assert!(!position_on_any_monitor(100, 100, &[]));
}
#[test]
fn style_bits_allow_popup_or_borderless_windows_only() {
assert!(window_style_bits_allow_fullscreen(WS_POPUP));
assert!(window_style_bits_allow_fullscreen(0));
assert!(!window_style_bits_allow_fullscreen(
WS_CAPTION | WS_THICKFRAME
));
assert!(!window_style_bits_allow_fullscreen(WS_CHILD | WS_POPUP));
}
#[test]
fn rect_cover_check_allows_small_edge_differences() {
let monitor = Rect::new(0, 0, 1920, 1080);
let almost_exact = Rect::new(1, 2, 1919, 1078);
let inset = Rect::new(8, 0, 1920, 1080);
assert!(rect_covers_monitor(&almost_exact, &monitor));
assert!(!rect_covers_monitor(&inset, &monitor));
}
}
+375
View File
@@ -0,0 +1,375 @@
// English UI strings and formatting helpers.
//
// Every string the UI displays is formatted here; the webview never formats
// a number for display.
use std::time::{Duration, SystemTime};
use serde::Serialize;
use crate::usage::{ProviderId, Window};
/// The strings every UI module needs. Serialised camelCase for the
/// webviews.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocaleStrings {
pub window_title: String,
pub refresh: String,
pub update_frequency: String,
pub one_minute: String,
pub five_minutes: String,
pub fifteen_minutes: String,
pub one_hour: String,
pub providers: String,
pub claude_label: String,
pub chatgpt_label: String,
pub opencode_go_label: String,
pub settings: String,
pub start_with_windows: String,
pub start_at_login: String,
pub reset_position: String,
pub size_smaller: String,
pub size_larger: String,
pub reset_size: String,
pub controls: String,
pub control_left_click: String,
pub control_right_click: String,
pub control_drag: String,
pub control_ctrl_wheel: String,
pub control_tray_click: String,
pub tray_left_click: String,
pub check_for_updates: String,
pub checking_for_updates: String,
pub up_to_date: String,
pub update_failed: String,
pub applying_update: String,
pub update_available: String,
pub update_via_winget: String,
pub auto_update_check: String,
pub auto_check_disabled: String,
pub auto_check_hourly: String,
pub auto_check_daily: String,
pub auto_check_weekly: String,
pub exit: String,
pub restart: String,
pub show_widget: String,
pub session_window: String,
pub weekly_window: String,
pub now: String,
pub day_suffix: String,
pub hour_suffix: String,
pub minute_suffix: String,
pub second_suffix: String,
pub token_expired_title: String,
pub token_expired_body: String,
pub chatgpt_token_expired_title: String,
pub chatgpt_token_expired_body: String,
/// Body text for "your usage just crossed 80% of the 5h limit". The
/// title is composed from the provider label + percent.
pub threshold_80_body: String,
/// Body text for the 95% threshold balloon.
pub threshold_95_body: String,
/// Title for the tray balloon shown on first launch after an auto-update.
pub update_applied_title: String,
/// Prefix for the tray balloon body. Call site appends the version.
pub update_applied_body: String,
/// Prefix for the rollback-failed MessageBox body. Call site appends
/// the backup path and a separator with the expected target filename.
pub update_rollback_failed_body: String,
}
pub struct I18n {
strings: LocaleStrings,
}
impl I18n {
pub fn load() -> Self {
Self {
strings: english_strings(),
}
}
pub fn strings(&self) -> &LocaleStrings {
&self.strings
}
}
impl LocaleStrings {
/// Label of the autostart menu item for the platform this build targets.
pub fn autostart_label(&self) -> &str {
if cfg!(windows) {
&self.start_with_windows
} else {
&self.start_at_login
}
}
/// Display name of a provider.
pub fn provider_label(&self, provider: ProviderId) -> &str {
match provider {
ProviderId::Claude => &self.claude_label,
ProviderId::ChatGpt => &self.chatgpt_label,
ProviderId::OpenCodeGo => &self.opencode_go_label,
}
}
}
fn english_strings() -> LocaleStrings {
LocaleStrings {
window_title: "Claude Code Usage Bubble".into(),
refresh: "Refresh".into(),
update_frequency: "Update frequency".into(),
one_minute: "1 minute".into(),
five_minutes: "5 minutes".into(),
fifteen_minutes: "15 minutes".into(),
one_hour: "1 hour".into(),
providers: "Providers".into(),
claude_label: "Claude Code".into(),
chatgpt_label: "Codex".into(),
opencode_go_label: "OpenCode Go".into(),
settings: "Settings".into(),
start_with_windows: "Start with Windows".into(),
start_at_login: "Start at login".into(),
reset_position: "Reset position".into(),
size_smaller: "Make smaller".into(),
size_larger: "Make larger".into(),
reset_size: "Reset size".into(),
controls: "Controls".into(),
control_left_click: "Left-click: details".into(),
control_right_click: "Right-click: menu".into(),
control_drag: "Drag: move/snap".into(),
control_ctrl_wheel: "Ctrl+Wheel: resize".into(),
control_tray_click: "Tray click: show/hide".into(),
tray_left_click: "Left-click: show/hide".into(),
check_for_updates: "Check for updates".into(),
checking_for_updates: "Checking for updates...".into(),
up_to_date: "Up to date".into(),
update_failed: "Update failed".into(),
applying_update: "Applying update...".into(),
update_available: "Update available".into(),
update_via_winget: "via WinGet".into(),
auto_update_check: "Auto-update check".into(),
auto_check_disabled: "Disabled".into(),
auto_check_hourly: "Hourly".into(),
auto_check_daily: "Daily".into(),
auto_check_weekly: "Weekly".into(),
exit: "Exit".into(),
restart: "Restart".into(),
show_widget: "Show widget".into(),
session_window: "5h".into(),
weekly_window: "7d".into(),
now: "now".into(),
day_suffix: "d".into(),
hour_suffix: "h".into(),
minute_suffix: "m".into(),
second_suffix: "s".into(),
token_expired_title: "Claude Code session expired".into(),
token_expired_body: "Sign in again to keep tracking your usage.".into(),
chatgpt_token_expired_title: "Codex session expired".into(),
chatgpt_token_expired_body: "Sign in again to keep tracking your usage.".into(),
threshold_80_body: "Approaching the 5-hour limit.".into(),
threshold_95_body: "Limit is close - consider easing up.".into(),
update_applied_title: "Update applied".into(),
update_applied_body: "Updated to v".into(),
update_rollback_failed_body: "Update failed. Your original binary is saved at: ".into(),
}
}
// ---------- Free-function helpers ----------
/// A percentage rounded half-to-even (`"72%"`), or `"—"` when there is none.
pub fn format_pct(pct: Option<f64>) -> String {
match pct {
Some(p) => format!("{p:.0}%"),
None => String::from("\u{2014}"),
}
}
/// Format a `usage::Window` percentage + countdown as `"73% · 2h"`-style text.
/// Returns just the percentage when no reset time is available.
pub fn format_window(window: &Window, strings: &LocaleStrings) -> String {
format_window_at(window, strings, SystemTime::now())
}
/// [`format_window`] evaluated at `now`.
pub fn format_window_at(window: &Window, strings: &LocaleStrings, now: SystemTime) -> String {
let pct = format!("{:.0}%", window.utilization);
let cd = format_countdown_at(window.resets_at, strings, now);
if cd.is_empty() {
pct
} else {
format!("{pct} \u{00b7} {cd}")
}
}
/// Countdown only — used by the bubble, which renders the percent inside the
/// bar fill and only needs the time-to-reset on the right.
pub fn format_countdown(resets_at: Option<SystemTime>, strings: &LocaleStrings) -> String {
format_countdown_at(resets_at, strings, SystemTime::now())
}
/// [`format_countdown`] evaluated at `now`.
pub fn format_countdown_at(
resets_at: Option<SystemTime>,
strings: &LocaleStrings,
now: SystemTime,
) -> String {
let Some(reset) = resets_at else {
return String::new();
};
let remaining = match reset.duration_since(now) {
Ok(d) => d,
Err(_) => return strings.now.clone(),
};
format_countdown_secs(remaining.as_secs(), strings)
}
fn format_countdown_secs(total_secs: u64, strings: &LocaleStrings) -> String {
let days = total_secs / 86_400;
let hours = total_secs / 3_600;
let mins = total_secs / 60;
if days >= 1 {
format!("{days}{}", strings.day_suffix)
} else if hours >= 1 {
format!("{hours}{}", strings.hour_suffix)
} else if mins >= 1 {
format!("{mins}{}", strings.minute_suffix)
} else {
format!("{total_secs}{}", strings.second_suffix)
}
}
/// How long before `format_window`'s string would change.
/// Used by the countdown timer to refresh exactly when needed.
pub fn time_until_display_change(resets_at: Option<SystemTime>) -> Option<Duration> {
time_until_display_change_at(resets_at, SystemTime::now())
}
/// [`time_until_display_change`] evaluated at `now`.
pub fn time_until_display_change_at(
resets_at: Option<SystemTime>,
now: SystemTime,
) -> Option<Duration> {
let reset = resets_at?;
let remaining = reset.duration_since(now).ok()?;
let secs = remaining.as_secs();
let bucket_start = if secs / 86_400 >= 1 {
(secs / 86_400) * 86_400
} else if secs / 3_600 >= 1 {
(secs / 3_600) * 3_600
} else if secs / 60 >= 1 {
(secs / 60) * 60
} else {
secs
};
Some(Duration::from_secs(secs.saturating_sub(bucket_start) + 1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn english_strings_include_required_menu_labels() {
let strings = english_strings();
for (name, value) in [
("refresh", strings.refresh.as_str()),
("providers", strings.providers.as_str()),
("settings", strings.settings.as_str()),
("size_smaller", strings.size_smaller.as_str()),
("size_larger", strings.size_larger.as_str()),
("reset_size", strings.reset_size.as_str()),
("controls", strings.controls.as_str()),
("control_left_click", strings.control_left_click.as_str()),
("control_right_click", strings.control_right_click.as_str()),
("control_drag", strings.control_drag.as_str()),
("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()),
("control_tray_click", strings.control_tray_click.as_str()),
("tray_left_click", strings.tray_left_click.as_str()),
] {
assert!(!value.trim().is_empty(), "empty string: {name}");
}
}
fn at(now: SystemTime, secs: u64) -> Option<SystemTime> {
Some(now + Duration::from_secs(secs))
}
#[test]
fn countdown_buckets() {
let s = english_strings();
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let cd = |secs| format_countdown_at(at(now, secs), &s, now);
assert_eq!(format_countdown_at(None, &s, now), "");
assert_eq!(
format_countdown_at(Some(now - Duration::from_secs(1)), &s, now),
"now"
);
assert_eq!(cd(0), "0s");
assert_eq!(cd(59), "59s");
assert_eq!(cd(60), "1m");
assert_eq!(cd(3_599), "59m");
assert_eq!(cd(3_600), "1h");
assert_eq!(cd(86_399), "23h");
assert_eq!(cd(86_400), "1d");
assert_eq!(cd(86_400 + 23 * 3_600), "1d");
assert_eq!(cd(7 * 86_400), "7d");
}
#[test]
fn window_text_joins_percent_and_countdown() {
let s = english_strings();
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let w = Window {
utilization: 73.2,
resets_at: at(now, 2 * 3_600 + 5),
};
assert_eq!(format_window_at(&w, &s, now), "73% \u{00b7} 2h");
let bare = Window {
utilization: 73.2,
resets_at: None,
};
assert_eq!(format_window_at(&bare, &s, now), "73%");
}
#[test]
fn display_change_fires_just_past_the_bucket_edge() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let ttl = |secs| time_until_display_change_at(at(now, secs), now);
assert_eq!(time_until_display_change_at(None, now), None);
assert_eq!(
time_until_display_change_at(Some(now - Duration::from_secs(5)), now),
None
);
// 1d 23h: "1d" stays until 23h + 1s from now.
assert_eq!(
ttl(86_400 + 23 * 3_600),
Some(Duration::from_secs(23 * 3_600 + 1))
);
assert_eq!(ttl(2 * 3_600 + 30), Some(Duration::from_secs(31)));
assert_eq!(ttl(5 * 60 + 7), Some(Duration::from_secs(8)));
// Under a minute the text changes every second.
assert_eq!(ttl(42), Some(Duration::from_secs(1)));
}
#[test]
fn percent_rounds_half_to_even() {
assert_eq!(format_pct(Some(72.5)), "72%");
assert_eq!(format_pct(Some(73.5)), "74%");
assert_eq!(format_pct(Some(0.0)), "0%");
assert_eq!(format_pct(None), "\u{2014}");
}
#[test]
fn autostart_label_matches_the_platform() {
let s = english_strings();
let expected = if cfg!(windows) {
"Start with Windows"
} else {
"Start at login"
};
assert_eq!(s.autostart_label(), expected);
assert_eq!(s.provider_label(ProviderId::ChatGpt), "Codex");
}
}
+583
View File
@@ -0,0 +1,583 @@
//! The context menu as a plain tree with string ids, independent of any
//! menu toolkit. The tray and bubble menus are built from it, and menu
//! events come back as [`MenuCommand::parse`] of the clicked id.
use crate::core::geom::{DEFAULT_BUBBLE_SIZE, MAX_BUBBLE_SIZE, MIN_BUBBLE_SIZE};
use crate::core::i18n::LocaleStrings;
use crate::core::settings::{Settings, POLL_INTERVALS, UPDATE_CHECK_INTERVALS};
use crate::core::state::{version_action_label, UpdateStatus};
use crate::usage::ProviderId;
/// Every action a menu item can trigger.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuCommand {
Refresh,
PollInterval(u32),
ToggleProvider(ProviderId),
Autostart,
ResetPosition,
VersionAction,
/// `None` disables the automatic check.
UpdateInterval(Option<u64>),
SizeSmaller,
SizeLarger,
SizeReset,
ToggleWidget,
Restart,
Exit,
}
impl MenuCommand {
/// The item id: `refresh`, `freq:<ms>`, `provider:<slug>`, `autostart`,
/// `reset-position`, `version`, `update-interval:<secs|off>`,
/// `size-smaller`, `size-larger`, `size-reset`, `toggle-widget`,
/// `restart` or `exit`.
pub fn id(self) -> String {
match self {
MenuCommand::Refresh => "refresh".into(),
MenuCommand::PollInterval(ms) => format!("freq:{ms}"),
MenuCommand::ToggleProvider(p) => format!("provider:{}", p.slug()),
MenuCommand::Autostart => "autostart".into(),
MenuCommand::ResetPosition => "reset-position".into(),
MenuCommand::VersionAction => "version".into(),
MenuCommand::UpdateInterval(None) => "update-interval:off".into(),
MenuCommand::UpdateInterval(Some(secs)) => format!("update-interval:{secs}"),
MenuCommand::SizeSmaller => "size-smaller".into(),
MenuCommand::SizeLarger => "size-larger".into(),
MenuCommand::SizeReset => "size-reset".into(),
MenuCommand::ToggleWidget => "toggle-widget".into(),
MenuCommand::Restart => "restart".into(),
MenuCommand::Exit => "exit".into(),
}
}
/// Parse an item id. Only values the menu offers are accepted, so an
/// unknown or hand-made id maps to `None` instead of an arbitrary
/// interval.
pub fn parse(id: &str) -> Option<Self> {
let simple = match id {
"refresh" => Some(MenuCommand::Refresh),
"autostart" => Some(MenuCommand::Autostart),
"reset-position" => Some(MenuCommand::ResetPosition),
"version" => Some(MenuCommand::VersionAction),
"size-smaller" => Some(MenuCommand::SizeSmaller),
"size-larger" => Some(MenuCommand::SizeLarger),
"size-reset" => Some(MenuCommand::SizeReset),
"toggle-widget" => Some(MenuCommand::ToggleWidget),
"restart" => Some(MenuCommand::Restart),
"exit" => Some(MenuCommand::Exit),
_ => None,
};
if simple.is_some() {
return simple;
}
let (kind, value) = id.split_once(':')?;
match kind {
"freq" => {
let ms = value.parse::<u32>().ok()?;
POLL_INTERVALS
.contains(&ms)
.then_some(MenuCommand::PollInterval(ms))
}
"provider" => ProviderId::from_slug(value).map(MenuCommand::ToggleProvider),
"update-interval" => {
let secs = if value == "off" {
None
} else {
Some(value.parse::<u64>().ok()?)
};
UPDATE_CHECK_INTERVALS
.contains(&secs)
.then_some(MenuCommand::UpdateInterval(secs))
}
_ => None,
}
}
}
/// One node of the menu tree.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MenuEntry {
Action {
command: MenuCommand,
label: String,
enabled: bool,
},
Check {
command: MenuCommand,
label: String,
checked: bool,
enabled: bool,
},
/// A greyed, id-less hint row.
Hint {
label: String,
},
Separator,
Submenu {
label: String,
entries: Vec<MenuEntry>,
},
}
/// Everything the menu reflects, snapshotted outside the state lock.
#[derive(Clone, Copy, Debug)]
pub struct MenuInput<'a> {
pub settings: &'a Settings,
pub strings: &'a LocaleStrings,
pub autostart_enabled: bool,
pub update_status: UpdateStatus,
/// Whether this install can apply an update in place. When it cannot
/// (a package-manager install), "Update available" stays greyed.
pub update_can_apply: bool,
}
fn action(command: MenuCommand, label: &str, enabled: bool) -> MenuEntry {
MenuEntry::Action {
command,
label: label.to_string(),
enabled,
}
}
fn check(command: MenuCommand, label: &str, checked: bool, enabled: bool) -> MenuEntry {
MenuEntry::Check {
command,
label: label.to_string(),
checked,
enabled,
}
}
fn hint(label: &str) -> MenuEntry {
MenuEntry::Hint {
label: label.to_string(),
}
}
fn submenu(label: &str, entries: Vec<MenuEntry>) -> MenuEntry {
MenuEntry::Submenu {
label: label.to_string(),
entries,
}
}
/// The version item checks for updates, or applies one once it is
/// available. It is greyed while either runs, and an available update stays
/// greyed when this install cannot apply it.
fn version_action_enabled(status: UpdateStatus, can_apply: bool) -> bool {
!status.is_busy() && (can_apply || status != UpdateStatus::Available)
}
/// Build the context menu in display order.
pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
let MenuInput {
settings,
strings: s,
autostart_enabled,
update_status,
update_can_apply,
} = input;
let freq_labels = [
&s.one_minute,
&s.five_minutes,
&s.fifteen_minutes,
&s.one_hour,
];
let frequency = POLL_INTERVALS
.iter()
.zip(freq_labels)
.map(|(&ms, label)| {
check(
MenuCommand::PollInterval(ms),
label,
settings.poll_interval_ms == ms,
true,
)
})
.collect();
let providers = ProviderId::ALL
.iter()
.map(|&p| {
check(
MenuCommand::ToggleProvider(p),
s.provider_label(p),
settings.is_provider_enabled(p),
p.metadata().live_usage,
)
})
.collect();
let auto_labels = [
&s.auto_check_disabled,
&s.auto_check_hourly,
&s.auto_check_daily,
&s.auto_check_weekly,
];
let auto_update = UPDATE_CHECK_INTERVALS
.iter()
.zip(auto_labels)
.map(|(&secs, label)| {
check(
MenuCommand::UpdateInterval(secs),
label,
settings.update_check_interval_secs == secs,
true,
)
})
.collect();
let size = settings.bubble_size_logical;
let settings_menu = vec![
check(
MenuCommand::Autostart,
s.autostart_label(),
autostart_enabled,
true,
),
action(MenuCommand::ResetPosition, &s.reset_position, true),
action(
MenuCommand::VersionAction,
&version_action_label(update_status, s),
version_action_enabled(update_status, update_can_apply),
),
submenu(&s.auto_update_check, auto_update),
];
let controls = vec![
action(
MenuCommand::SizeSmaller,
&s.size_smaller,
size > MIN_BUBBLE_SIZE,
),
action(
MenuCommand::SizeLarger,
&s.size_larger,
size < MAX_BUBBLE_SIZE,
),
action(
MenuCommand::SizeReset,
&s.reset_size,
size != DEFAULT_BUBBLE_SIZE,
),
MenuEntry::Separator,
hint(&s.control_left_click),
hint(&s.control_right_click),
hint(&s.control_drag),
hint(&s.control_ctrl_wheel),
hint(&s.control_tray_click),
];
vec![
action(MenuCommand::Refresh, &s.refresh, true),
submenu(&s.update_frequency, frequency),
submenu(&s.providers, providers),
submenu(&s.settings, settings_menu),
submenu(&s.controls, controls),
check(
MenuCommand::ToggleWidget,
&s.show_widget,
settings.widget_visible,
true,
),
MenuEntry::Separator,
action(MenuCommand::Restart, &s.restart, true),
action(MenuCommand::Exit, &s.exit, true),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::i18n::I18n;
use crate::core::settings::{POLL_15_MIN, UPDATE_CHECK_DAILY_SECS};
fn menu(settings: &Settings, status: UpdateStatus) -> Vec<MenuEntry> {
let strings = I18n::load().strings().clone();
build(MenuInput {
settings,
strings: &strings,
autostart_enabled: true,
update_status: status,
update_can_apply: true,
})
}
fn find<'a>(entries: &'a [MenuEntry], id: &str) -> Option<&'a MenuEntry> {
entries.iter().find_map(|e| match e {
MenuEntry::Action { command, .. } | MenuEntry::Check { command, .. }
if command.id() == id =>
{
Some(e)
}
MenuEntry::Submenu { entries, .. } => find(entries, id),
_ => None,
})
}
fn enabled(entries: &[MenuEntry], id: &str) -> bool {
match find(entries, id) {
Some(MenuEntry::Action { enabled, .. } | MenuEntry::Check { enabled, .. }) => *enabled,
other => panic!("no item {id}: {other:?}"),
}
}
fn checked(entries: &[MenuEntry], id: &str) -> bool {
match find(entries, id) {
Some(MenuEntry::Check { checked, .. }) => *checked,
other => panic!("no check item {id}: {other:?}"),
}
}
fn with_size(size: i32) -> Settings {
Settings {
bubble_size_logical: size,
..Settings::default()
}
}
#[test]
fn size_items_grey_out_at_the_limits() {
let m = menu(&with_size(140), UpdateStatus::Idle);
assert!(!enabled(&m, "size-smaller"));
assert!(enabled(&m, "size-larger"));
assert!(enabled(&m, "size-reset"));
let m = menu(&with_size(360), UpdateStatus::Idle);
assert!(enabled(&m, "size-smaller"));
assert!(!enabled(&m, "size-larger"));
assert!(enabled(&m, "size-reset"));
let m = menu(&with_size(200), UpdateStatus::Idle);
assert!(enabled(&m, "size-smaller"));
assert!(enabled(&m, "size-larger"));
assert!(!enabled(&m, "size-reset"));
}
#[test]
fn checks_follow_settings() {
let settings = Settings {
poll_interval_ms: POLL_15_MIN,
update_check_interval_secs: Some(UPDATE_CHECK_DAILY_SECS),
show_codex: true,
show_opencode_go: true,
widget_visible: false,
..Settings::default()
};
let m = menu(&settings, UpdateStatus::Idle);
assert!(checked(&m, "freq:900000"));
assert!(!checked(&m, "freq:300000"));
assert!(checked(&m, "update-interval:86400"));
assert!(!checked(&m, "update-interval:off"));
assert!(checked(&m, "provider:claude"));
assert!(checked(&m, "provider:chatgpt"));
// OpenCode Go mirrors its flag but stays greyed.
assert!(checked(&m, "provider:opencode-go"));
assert!(!enabled(&m, "provider:opencode-go"));
assert!(checked(&m, "autostart"));
assert!(!checked(&m, "toggle-widget"));
}
#[test]
fn version_item_is_disabled_while_busy() {
assert!(!enabled(
&menu(&Settings::default(), UpdateStatus::Checking),
"version"
));
assert!(!enabled(
&menu(&Settings::default(), UpdateStatus::Applying),
"version"
));
assert!(enabled(
&menu(&Settings::default(), UpdateStatus::Available),
"version"
));
}
#[test]
fn available_update_is_greyed_when_it_cannot_be_applied() {
let strings = I18n::load().strings().clone();
let settings = Settings::default();
let menu_for = |status| {
build(MenuInput {
settings: &settings,
strings: &strings,
autostart_enabled: true,
update_status: status,
update_can_apply: false,
})
};
assert!(!enabled(&menu_for(UpdateStatus::Available), "version"));
// Checking still works on such an install.
for status in [
UpdateStatus::Idle,
UpdateStatus::UpToDate,
UpdateStatus::Failed,
] {
assert!(enabled(&menu_for(status), "version"));
}
}
#[test]
fn top_level_order_and_hint_rows() {
let m = menu(&Settings::default(), UpdateStatus::Idle);
let labels: Vec<String> = m
.iter()
.map(|e| match e {
MenuEntry::Action { label, .. }
| MenuEntry::Check { label, .. }
| MenuEntry::Hint { label }
| MenuEntry::Submenu { label, .. } => label.clone(),
MenuEntry::Separator => "-".into(),
})
.collect();
assert_eq!(
labels,
[
"Refresh",
"Update frequency",
"Providers",
"Settings",
"Controls",
"Show widget",
"-",
"Restart",
"Exit"
]
);
let Some(MenuEntry::Submenu { entries, .. }) = m.get(4) else {
panic!("controls submenu missing");
};
assert_eq!(entries.len(), 9);
assert_eq!(entries[3], MenuEntry::Separator);
assert!(entries[4..]
.iter()
.all(|e| matches!(e, MenuEntry::Hint { .. })));
}
/// One line per node, indented by depth: `label [id] kind`.
fn outline(entries: &[MenuEntry], depth: usize, out: &mut Vec<String>) {
let pad = " ".repeat(depth);
for e in entries {
match e {
MenuEntry::Action {
command,
label,
enabled,
} => out.push(format!(
"{pad}{label} [{}]{}",
command.id(),
if *enabled { "" } else { " grey" }
)),
MenuEntry::Check {
command,
label,
checked,
enabled,
} => out.push(format!(
"{pad}{label} [{}] {}{}",
command.id(),
if *checked { "on" } else { "off" },
if *enabled { "" } else { " grey" }
)),
MenuEntry::Hint { label } => out.push(format!("{pad}{label} hint")),
MenuEntry::Separator => out.push(format!("{pad}-")),
MenuEntry::Submenu { label, entries } => {
out.push(format!("{pad}{label} >"));
outline(entries, depth + 1, out);
}
}
}
}
#[test]
fn full_tree_keeps_order_labels_ids_and_states() {
let strings = I18n::load().strings().clone();
let m = build(MenuInput {
settings: &Settings::default(),
strings: &strings,
autostart_enabled: false,
update_status: UpdateStatus::Idle,
update_can_apply: true,
});
let mut lines = Vec::new();
outline(&m, 0, &mut lines);
let autostart = format!(" {} [autostart] off", strings.autostart_label());
let version = format!(
" Check for updates · v{} [version]",
env!("CARGO_PKG_VERSION")
);
let expected = [
"Refresh [refresh]",
"Update frequency >",
" 1 minute [freq:60000] off",
" 5 minutes [freq:300000] on",
" 15 minutes [freq:900000] off",
" 1 hour [freq:3600000] off",
"Providers >",
" Claude Code [provider:claude] on",
" Codex [provider:chatgpt] off",
" OpenCode Go [provider:opencode-go] off grey",
"Settings >",
&autostart,
" Reset position [reset-position]",
&version,
" Auto-update check >",
" Disabled [update-interval:off] off",
" Hourly [update-interval:3600] on",
" Daily [update-interval:86400] off",
" Weekly [update-interval:604800] off",
"Controls >",
" Make smaller [size-smaller]",
" Make larger [size-larger]",
" Reset size [size-reset] grey",
" -",
];
let got: Vec<&str> = lines.iter().map(String::as_str).collect();
assert_eq!(got[..expected.len()], expected);
let tail: Vec<&str> = got[expected.len()..].to_vec();
assert_eq!(tail.len(), 9);
assert!(tail[..5].iter().all(|l| l.ends_with(" hint")), "{tail:?}");
assert_eq!(
tail[5..],
[
"Show widget [toggle-widget] on",
"-",
"Restart [restart]",
"Exit [exit]"
]
);
}
#[test]
fn ids_round_trip_and_unknown_ids_are_rejected() {
let m = menu(&Settings::default(), UpdateStatus::Idle);
fn walk(entries: &[MenuEntry], out: &mut Vec<MenuCommand>) {
for e in entries {
match e {
MenuEntry::Action { command, .. } | MenuEntry::Check { command, .. } => {
out.push(*command)
}
MenuEntry::Submenu { entries, .. } => walk(entries, out),
_ => {}
}
}
}
let mut commands = Vec::new();
walk(&m, &mut commands);
assert_eq!(commands.len(), 21);
for c in commands {
assert_eq!(MenuCommand::parse(&c.id()), Some(c), "{}", c.id());
}
for bad in [
"",
"freq:1",
"freq:abc",
"provider:codex",
"update-interval:5",
"nope:1",
] {
assert_eq!(MenuCommand::parse(bad), None, "{bad}");
}
}
}
+13
View File
@@ -0,0 +1,13 @@
//! Portable logic with no OS or Tauri dependency, unit-tested on every host.
pub mod color;
pub mod geom;
pub mod i18n;
pub mod menu_model;
pub mod payload;
pub mod proxy;
pub mod settings;
pub mod state;
pub mod usage_color;
pub mod version;
pub mod visibility;
+347
View File
@@ -0,0 +1,347 @@
//! Pure builders for the JSON payloads the bubble and panel webviews render.
//!
//! Rust owns every displayed string and colour; the webview only lays out
//! pixels. Field names are camelCase on the wire.
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;
use crate::core::i18n::{self, LocaleStrings};
use crate::core::state::ProviderUiState;
use crate::core::usage_color::{accent_color_for, bar_fill_color};
use crate::usage::ProviderId;
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
const THIRTY_DAYS_SECS: u64 = 30 * 24 * 60 * 60;
/// Placeholder for the head label and weekly countdown before the first poll.
const PENDING: &str = "…";
/// Length of a provider's `(primary, secondary)` usage windows in seconds.
/// Claude exposes 5h/7d directly; Codex's primary/secondary map to the same
/// short/long windows in the compact UI.
pub fn window_durations_secs(provider: ProviderId) -> (u64, u64) {
match provider {
ProviderId::Claude | ProviderId::ChatGpt => (FIVE_HOURS_SECS, SEVEN_DAYS_SECS),
ProviderId::OpenCodeGo => (SEVEN_DAYS_SECS, THIRTY_DAYS_SECS),
}
}
/// The bubble window's canvas, in physical px.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Canvas {
pub w: u32,
pub h: u32,
pub dpi: u32,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BubblePayload {
pub provider: ProviderId,
pub session_pct: Option<f64>,
pub weekly_pct: Option<f64>,
/// `"73%"`, or `"—"` with no value.
pub session_pct_text: String,
/// `None` hides the weekly percent.
pub weekly_pct_text: Option<String>,
/// Session countdown; `"…"` before the first poll. The UI swaps an empty
/// or overflowing label for `"5H"`.
pub head_label: String,
/// Weekly countdown; `"…"` before the first poll, `""` hides it.
pub weekly_countdown: String,
pub session_resets_at_ms: Option<u64>,
pub weekly_resets_at_ms: Option<u64>,
pub primary_dur_secs: u64,
pub secondary_dur_secs: u64,
pub session_fill: String,
pub weekly_fill: String,
pub accent: String,
pub is_dark: bool,
pub canvas_w: u32,
pub canvas_h: u32,
pub dpi: u32,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PanelPayload {
pub provider: ProviderId,
pub label: String,
pub is_dark: bool,
pub accent: String,
pub session_pct: f64,
pub weekly_pct: f64,
/// `"73% · 2h"`, `"!"` after an auth failure, `"…"` after a transient
/// error, `""` before the first poll.
pub session_text: String,
pub weekly_text: String,
pub session_fill: String,
pub weekly_fill: String,
}
fn unix_ms(t: Option<SystemTime>) -> Option<u64> {
let ms = t?.duration_since(UNIX_EPOCH).ok()?.as_millis();
u64::try_from(ms).ok()
}
/// Build the bubble payload. `snapshot` is `None` before the provider's
/// first poll. After an error the snapshot still holds the last good
/// windows, so the bubble keeps those values and countdowns; it never shows
/// the panel's `"!"` or `"…"` markers.
pub fn bubble_payload(
provider: ProviderId,
snapshot: Option<&ProviderUiState>,
strings: &LocaleStrings,
is_dark: bool,
canvas: Canvas,
now: SystemTime,
) -> BubblePayload {
let windows = snapshot.map(|s| s.windows);
let session_pct = windows.map(|w| w.primary.utilization);
let weekly_pct = windows.map(|w| w.secondary.utilization);
let (head_label, weekly_countdown) = match windows {
Some(w) => (
i18n::format_countdown_at(w.primary.resets_at, strings, now),
i18n::format_countdown_at(w.secondary.resets_at, strings, now),
),
None => (PENDING.to_string(), PENDING.to_string()),
};
let (primary_dur_secs, secondary_dur_secs) = window_durations_secs(provider);
BubblePayload {
provider,
session_pct,
weekly_pct,
session_pct_text: i18n::format_pct(session_pct),
weekly_pct_text: weekly_pct.map(|p| i18n::format_pct(Some(p))),
head_label,
weekly_countdown,
session_resets_at_ms: unix_ms(windows.and_then(|w| w.primary.resets_at)),
weekly_resets_at_ms: unix_ms(windows.and_then(|w| w.secondary.resets_at)),
primary_dur_secs,
secondary_dur_secs,
session_fill: bar_fill_color(provider, is_dark, session_pct.unwrap_or(0.0)).to_hex(),
weekly_fill: bar_fill_color(provider, is_dark, weekly_pct.unwrap_or(0.0)).to_hex(),
accent: accent_color_for(provider, is_dark).to_hex(),
is_dark,
canvas_w: canvas.w,
canvas_h: canvas.h,
dpi: canvas.dpi,
}
}
/// Build the details-panel payload. Before the first poll the bars are
/// empty and the texts blank.
pub fn panel_payload(
provider: ProviderId,
snapshot: Option<&ProviderUiState>,
strings: &LocaleStrings,
is_dark: bool,
) -> PanelPayload {
let state = snapshot.cloned().unwrap_or_default();
let session_pct = state.windows.primary.utilization;
let weekly_pct = state.windows.secondary.utilization;
PanelPayload {
provider,
label: strings.provider_label(provider).to_string(),
is_dark,
accent: accent_color_for(provider, is_dark).to_hex(),
session_pct,
weekly_pct,
session_text: state.primary_text,
weekly_text: state.secondary_text,
session_fill: bar_fill_color(provider, is_dark, session_pct).to_hex(),
weekly_fill: bar_fill_color(provider, is_dark, weekly_pct).to_hex(),
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use super::*;
use crate::core::i18n::I18n;
use crate::core::state::{PollOutcome, UsageState};
use crate::usage::{UsageWindows, Window};
const CANVAS: Canvas = Canvas {
w: 300,
h: 99,
dpi: 144,
};
fn now() -> SystemTime {
UNIX_EPOCH + Duration::from_secs(1_800_000_000)
}
fn strings() -> LocaleStrings {
I18n::load().strings().clone()
}
fn polled(session: f64, weekly: f64) -> UsageState {
let mut state = UsageState::default();
let windows = UsageWindows {
primary: Window {
utilization: session,
resets_at: Some(now() + Duration::from_secs(2 * 3_600 + 10)),
},
secondary: Window {
utilization: weekly,
resets_at: Some(now() + Duration::from_secs(3 * 86_400)),
},
};
state.apply_results(
vec![(ProviderId::Claude, PollOutcome::Ok(windows))],
&strings(),
Instant::now(),
);
state
}
#[test]
fn before_first_poll_shows_placeholders() {
let p = bubble_payload(ProviderId::ChatGpt, None, &strings(), true, CANVAS, now());
assert_eq!(p.session_pct, None);
assert_eq!(p.session_pct_text, "—");
assert_eq!(p.weekly_pct_text, None);
assert_eq!(p.head_label, "…");
assert_eq!(p.weekly_countdown, "…");
assert_eq!(p.session_resets_at_ms, None);
assert_eq!(p.session_fill, "#E5E5E5");
assert_eq!(p.accent, "#E5E5E5");
assert_eq!((p.canvas_w, p.canvas_h, p.dpi), (300, 99, 144));
let panel = panel_payload(ProviderId::ChatGpt, None, &strings(), false);
assert_eq!(panel.session_text, "");
assert_eq!(panel.weekly_text, "");
assert_eq!(panel.session_pct, 0.0);
assert_eq!(panel.label, "Codex");
assert_eq!(panel.accent, "#1A1A1A");
}
#[test]
fn polled_values_format_in_rust() {
let state = polled(72.5, 96.0);
let snap = state.snapshots.get(&ProviderId::Claude);
let p = bubble_payload(ProviderId::Claude, snap, &strings(), true, CANVAS, now());
assert_eq!(p.session_pct, Some(72.5));
assert_eq!(p.session_pct_text, "72%");
assert_eq!(p.weekly_pct_text.as_deref(), Some("96%"));
assert_eq!(p.head_label, "2h");
assert_eq!(p.weekly_countdown, "3d");
assert_eq!(
p.session_resets_at_ms,
Some((1_800_000_000 + 2 * 3_600 + 10) * 1_000)
);
assert_eq!(
(p.primary_dur_secs, p.secondary_dur_secs),
(18_000, 604_800)
);
assert_eq!(p.session_fill, "#E0A040");
assert_eq!(p.weekly_fill, "#A01818");
assert_eq!(p.accent, "#D97757");
}
#[test]
fn errors_keep_stale_bubble_values_but_mark_the_panel() {
let mut state = polled(40.0, 20.0);
state.apply_results(
vec![(ProviderId::Claude, PollOutcome::AuthFailed)],
&strings(),
Instant::now(),
);
let snap = state.snapshots.get(&ProviderId::Claude);
let p = bubble_payload(ProviderId::Claude, snap, &strings(), false, CANVAS, now());
assert_eq!(p.session_pct_text, "40%");
assert_eq!(p.head_label, "2h");
assert_eq!(p.weekly_countdown, "3d");
let json = serde_json::to_string(&p).expect("serialise");
assert!(!json.contains('!'), "{json}");
let panel = panel_payload(ProviderId::Claude, snap, &strings(), false);
assert_eq!(panel.session_text, "!");
assert_eq!(panel.weekly_text, "!");
assert_eq!(panel.session_pct, 40.0);
state.apply_results(
vec![(ProviderId::Claude, PollOutcome::Failed)],
&strings(),
Instant::now(),
);
let snap = state.snapshots.get(&ProviderId::Claude);
let panel = panel_payload(ProviderId::Claude, snap, &strings(), false);
assert_eq!(panel.session_text, "…");
assert_eq!(panel.weekly_text, "…");
let p = bubble_payload(ProviderId::Claude, snap, &strings(), false, CANVAS, now());
assert_eq!(p.head_label, "2h");
}
#[test]
fn wire_format_is_camel_case_with_nulls() {
let p = bubble_payload(ProviderId::Claude, None, &strings(), true, CANVAS, now());
let v = serde_json::to_value(&p).expect("serialise");
assert_eq!(v["provider"], "claude");
assert!(v["sessionPct"].is_null());
assert!(v["weeklyPctText"].is_null());
assert_eq!(v["sessionPctText"], "—");
assert_eq!(v["canvasW"], 300);
assert_eq!(v["isDark"], true);
let mut keys: Vec<&str> = v
.as_object()
.expect("object")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"accent",
"canvasH",
"canvasW",
"dpi",
"headLabel",
"isDark",
"primaryDurSecs",
"provider",
"secondaryDurSecs",
"sessionFill",
"sessionPct",
"sessionPctText",
"sessionResetsAtMs",
"weeklyCountdown",
"weeklyFill",
"weeklyPct",
"weeklyPctText",
"weeklyResetsAtMs",
]
);
let panel = serde_json::to_value(panel_payload(ProviderId::Claude, None, &strings(), true))
.expect("serialise");
for key in [
"provider",
"label",
"isDark",
"accent",
"sessionPct",
"weeklyPct",
"sessionText",
"weeklyText",
"sessionFill",
"weeklyFill",
] {
assert!(panel.get(key).is_some(), "missing {key}");
}
}
#[test]
fn opencode_go_uses_weekly_and_monthly_windows() {
assert_eq!(
window_durations_secs(ProviderId::OpenCodeGo),
(604_800, 2_592_000)
);
}
}
+242
View File
@@ -0,0 +1,242 @@
//! Parsing of the Windows "Internet Settings" static proxy.
//!
//! The registry reader lives in `platform::windows`; everything here is pure
//! so it can be tested on any host. PAC/WPAD scripts are not evaluated.
/// A static system proxy and the hosts that bypass it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SystemProxy {
/// Proxy URI such as `http://proxy.corp:8080`, ready for `ureq::Proxy::new`.
pub url: String,
/// `ProxyOverride` entries, trimmed and non-empty.
pub bypass: Vec<String>,
}
impl SystemProxy {
/// Whether a request to `host` must skip the proxy.
pub fn bypasses(&self, host: &str) -> bool {
bypass_matches(&self.bypass, host)
}
}
/// Build the proxy from the raw `ProxyEnable`, `ProxyServer` and
/// `ProxyOverride` registry values. Returns `None` unless the proxy is
/// enabled and the server string names a usable entry.
pub fn from_registry_values(
enable: Option<u32>,
server: Option<&str>,
proxy_override: Option<&str>,
) -> Option<SystemProxy> {
if enable != Some(1) {
return None;
}
let url = parse_proxy_server(server?)?;
Some(SystemProxy {
url,
bypass: parse_override(proxy_override.unwrap_or_default()),
})
}
/// Pick the proxy for HTTPS traffic out of a `ProxyServer` value.
///
/// A per-scheme value (`http=h:p;https=h:p;…`) uses the `https=` entry, else
/// the `http=` entry. A bare `h:p` applies to every scheme. The result always
/// carries a scheme, defaulting to `http://` (a CONNECT proxy). Only `http`
/// and `https` proxies are accepted; any other scheme yields `None`.
pub fn parse_proxy_server(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
let target = if value.contains('=') {
let entries: Vec<(String, &str)> = value
.split(';')
.filter_map(|entry| entry.split_once('='))
.map(|(scheme, addr)| (scheme.trim().to_ascii_lowercase(), addr.trim()))
.filter(|(_, addr)| !addr.is_empty())
.collect();
let pick = |scheme: &str| {
entries
.iter()
.find(|(s, _)| s == scheme)
.map(|(_, addr)| *addr)
};
pick("https").or_else(|| pick("http"))?
} else {
value
};
match target.split_once("://") {
None => Some(format!("http://{target}")),
Some((scheme, _))
if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") =>
{
Some(target.to_string())
}
// SOCKS and any other scheme are not supported as a system proxy.
Some(_) => None,
}
}
/// Split a `ProxyOverride` value on `;` into trimmed, non-empty entries.
pub fn parse_override(value: &str) -> Vec<String> {
value
.split(';')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_string)
.collect()
}
/// Whether `host` matches any bypass entry. Entries are case-insensitive
/// globs where `*` matches any run of characters; `<local>` matches every
/// host without a dot.
pub fn bypass_matches(entries: &[String], host: &str) -> bool {
let host = host.to_ascii_lowercase();
entries.iter().any(|entry| {
if entry.eq_ignore_ascii_case("<local>") {
!host.contains('.')
} else {
glob_match(&entry.to_ascii_lowercase(), &host)
}
})
}
/// Iterative `*`-only glob match with single-star backtracking.
fn glob_match(pattern: &str, text: &str) -> bool {
let p = pattern.as_bytes();
let t = text.as_bytes();
let (mut pi, mut ti) = (0, 0);
let mut star: Option<(usize, usize)> = None;
while ti < t.len() {
if pi < p.len() && p[pi] == b'*' {
star = Some((pi, ti));
pi += 1;
} else if pi < p.len() && p[pi] == t[ti] {
pi += 1;
ti += 1;
} else if let Some((sp, st)) = star {
pi = sp + 1;
ti = st + 1;
star = Some((sp, st + 1));
} else {
return false;
}
}
p[pi..].iter().all(|&c| c == b'*')
}
#[cfg(test)]
mod tests {
use super::*;
fn entries(value: &str) -> Vec<String> {
parse_override(value)
}
#[test]
fn per_scheme_server_prefers_https_entry() {
assert_eq!(
parse_proxy_server("http=web:80;https=secure:8443;ftp=f:21").as_deref(),
Some("http://secure:8443")
);
}
#[test]
fn per_scheme_server_without_https_uses_http_entry() {
assert_eq!(
parse_proxy_server("ftp=f:21; HTTP=web:3128").as_deref(),
Some("http://web:3128")
);
}
#[test]
fn per_scheme_server_without_http_entries_is_none() {
assert_eq!(parse_proxy_server("socks=s:1080"), None);
assert_eq!(parse_proxy_server("https=;http="), None);
}
#[test]
fn bare_server_applies_to_all_schemes() {
assert_eq!(
parse_proxy_server(" proxy.corp:8080 ").as_deref(),
Some("http://proxy.corp:8080")
);
assert_eq!(
parse_proxy_server("http://proxy.corp:8080").as_deref(),
Some("http://proxy.corp:8080")
);
assert_eq!(parse_proxy_server(" "), None);
}
#[test]
fn non_http_proxy_schemes_are_rejected() {
assert_eq!(parse_proxy_server("socks5://h:1"), None);
assert_eq!(parse_proxy_server("https=socks5://h:1"), None);
assert_eq!(parse_proxy_server("SOCKS4://h:1"), None);
assert_eq!(
parse_proxy_server("HTTPS://h:1").as_deref(),
Some("HTTPS://h:1")
);
assert_eq!(
from_registry_values(Some(1), Some("socks5://h:1"), None),
None
);
}
#[test]
fn registry_values_need_enable_flag_and_server() {
assert_eq!(
from_registry_values(Some(0), Some("p:1"), Some("<local>")),
None
);
assert_eq!(from_registry_values(None, Some("p:1"), None), None);
assert_eq!(from_registry_values(Some(1), None, None), None);
assert_eq!(
from_registry_values(Some(1), Some("p:1"), Some("*.corp; <local> ;")),
Some(SystemProxy {
url: "http://p:1".into(),
bypass: vec!["*.corp".into(), "<local>".into()],
})
);
assert_eq!(
from_registry_values(Some(1), Some("p:1"), None).map(|p| p.bypass),
Some(Vec::new())
);
}
#[test]
fn override_wildcard_matches_subdomains_case_insensitively() {
let list = entries("*.anthropic.com");
assert!(bypass_matches(&list, "api.anthropic.com"));
assert!(bypass_matches(&list, "API.Anthropic.COM"));
assert!(!bypass_matches(&list, "anthropic.com"));
assert!(!bypass_matches(&list, "chatgpt.com"));
}
#[test]
fn override_exact_host_matches_only_that_host() {
let list = entries("chatgpt.com");
assert!(bypass_matches(&list, "chatgpt.com"));
assert!(!bypass_matches(&list, "api.chatgpt.com"));
assert!(!bypass_matches(&list, "chatgpt.com.evil"));
}
#[test]
fn override_local_matches_dotless_hosts() {
let list = entries("<LOCAL>");
assert!(bypass_matches(&list, "intranet"));
assert!(bypass_matches(&list, "localhost"));
assert!(!bypass_matches(&list, "api.anthropic.com"));
assert!(!bypass_matches(&list, "127.0.0.1"));
}
#[test]
fn override_globs_cover_prefixes_and_inner_stars() {
let list = entries("10.*;api.*.com");
assert!(bypass_matches(&list, "10.1.2.3"));
assert!(bypass_matches(&list, "api.anthropic.com"));
assert!(!bypass_matches(&list, "chatgpt.com"));
assert!(bypass_matches(&entries("*"), "anything.example"));
assert!(!bypass_matches(&[], "chatgpt.com"));
}
}
+466
View File
@@ -0,0 +1,466 @@
// Persisted settings. The path, schema, key names and physical-px positions
// stay byte-compatible with the Win32 builds, so no migration step exists.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::core::geom::{
position_on_any_monitor, Rect, DEFAULT_BUBBLE_SIZE, MAX_BUBBLE_SIZE, MIN_BUBBLE_SIZE,
};
use crate::usage::ProviderId;
pub const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble";
const SETTINGS_FILE: &str = "settings.json";
pub const POLL_1_MIN: u32 = 60_000;
pub const POLL_5_MIN: u32 = 5 * 60_000;
pub const POLL_15_MIN: u32 = 15 * 60_000;
pub const POLL_1_HOUR: u32 = 60 * 60_000;
/// The poll intervals the menu offers, in menu order.
pub const POLL_INTERVALS: [u32; 4] = [POLL_1_MIN, POLL_5_MIN, POLL_15_MIN, POLL_1_HOUR];
// Update-check intervals (seconds). `None` means auto-check is disabled.
pub const UPDATE_CHECK_HOURLY_SECS: u64 = 60 * 60;
pub const UPDATE_CHECK_DAILY_SECS: u64 = 24 * 60 * 60;
pub const UPDATE_CHECK_WEEKLY_SECS: u64 = 7 * 24 * 60 * 60;
/// The auto-update check choices the menu offers, in menu order.
pub const UPDATE_CHECK_INTERVALS: [Option<u64>; 4] = [
None,
Some(UPDATE_CHECK_HOURLY_SECS),
Some(UPDATE_CHECK_DAILY_SECS),
Some(UPDATE_CHECK_WEEKLY_SECS),
];
fn default_show_claude() -> bool {
true
}
fn default_show_codex() -> bool {
false
}
fn default_show_opencode_go() -> bool {
false
}
fn default_widget_visible() -> bool {
true
}
fn default_bubble_size() -> i32 {
DEFAULT_BUBBLE_SIZE
}
fn default_poll_interval_ms() -> u32 {
POLL_5_MIN
}
fn default_update_check_interval_secs() -> Option<u64> {
Some(UPDATE_CHECK_HOURLY_SECS)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BubblePositions {
pub claude: Option<(i32, i32)>,
pub codex: Option<(i32, i32)>,
pub opencode_go: Option<(i32, i32)>,
}
impl BubblePositions {
pub fn get(&self, provider: ProviderId) -> Option<(i32, i32)> {
match provider {
ProviderId::Claude => self.claude,
ProviderId::ChatGpt => self.codex,
ProviderId::OpenCodeGo => self.opencode_go,
}
}
pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) {
match provider {
ProviderId::Claude => self.claude = Some(pos),
ProviderId::ChatGpt => self.codex = Some(pos),
ProviderId::OpenCodeGo => self.opencode_go = Some(pos),
}
}
pub fn reset(&mut self, provider: ProviderId) {
match provider {
ProviderId::Claude => self.claude = None,
ProviderId::ChatGpt => self.codex = None,
ProviderId::OpenCodeGo => self.opencode_go = None,
}
}
pub fn reset_all(&mut self) {
self.claude = None;
self.codex = None;
self.opencode_go = None;
}
/// Drop any saved position whose top-left no longer falls on one of
/// `monitors`. Guards against creating a bubble on a disconnected
/// secondary monitor, where the user can't see or recover it.
pub fn validate(&mut self, monitors: &[Rect]) {
for provider in ProviderId::ALL {
if let Some((x, y)) = self.get(provider) {
if !position_on_any_monitor(x, y, monitors) {
log::warn!(
"bubble position {} ({x},{y}) outside all monitors; resetting to default",
provider.slug()
);
self.reset(provider);
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "default_show_claude")]
pub show_claude_code: bool,
#[serde(default = "default_show_codex")]
pub show_codex: bool,
#[serde(default = "default_show_opencode_go")]
pub show_opencode_go: bool,
#[serde(default)]
pub bubble_positions: BubblePositions,
#[serde(default = "default_bubble_size")]
pub bubble_size_logical: i32,
#[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u32,
#[serde(default)]
pub last_update_check_unix: Option<u64>,
#[serde(default = "default_update_check_interval_secs")]
pub update_check_interval_secs: Option<u64>,
#[serde(default = "default_widget_visible")]
pub widget_visible: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
show_claude_code: default_show_claude(),
show_codex: default_show_codex(),
show_opencode_go: default_show_opencode_go(),
bubble_positions: BubblePositions::default(),
bubble_size_logical: default_bubble_size(),
poll_interval_ms: default_poll_interval_ms(),
last_update_check_unix: None,
update_check_interval_secs: default_update_check_interval_secs(),
widget_visible: default_widget_visible(),
}
}
}
impl Settings {
pub fn is_provider_enabled(&self, provider: ProviderId) -> bool {
match provider {
ProviderId::Claude => self.show_claude_code,
ProviderId::ChatGpt => self.show_codex,
ProviderId::OpenCodeGo => self.show_opencode_go,
}
}
pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) {
match provider {
ProviderId::Claude => self.show_claude_code = enabled,
ProviderId::ChatGpt => self.show_codex = enabled,
ProviderId::OpenCodeGo => self.show_opencode_go = enabled,
}
}
pub fn toggle_provider(&mut self, provider: ProviderId) {
let enabled = !self.is_provider_enabled(provider);
self.set_provider_enabled(provider, enabled);
}
/// Menu toggle for a provider. Providers without live usage cannot be
/// toggled, and unchecking the last enabled live provider is undone so
/// the app always shows something. Returns the provider's new state.
pub fn toggle_live_provider(&mut self, provider: ProviderId) -> bool {
if provider.metadata().live_usage {
self.toggle_provider(provider);
if !self.has_enabled_live_provider() {
self.set_provider_enabled(provider, true);
}
}
self.is_provider_enabled(provider)
}
pub fn has_enabled_live_provider(&self) -> bool {
ProviderId::LIVE_USAGE
.iter()
.any(|provider| self.is_provider_enabled(*provider))
}
}
pub fn settings_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join(APP_DIR_NAME))
}
pub fn settings_path() -> PathBuf {
settings_dir()
.unwrap_or_else(|| std::env::temp_dir().join(APP_DIR_NAME))
.join(SETTINGS_FILE)
}
/// Read and normalise the settings file. A missing, unreadable or corrupt
/// file yields the defaults. Saved positions are not checked against the
/// monitors here; call [`drop_offscreen_positions`] once they are known.
pub fn load() -> Settings {
load_from(&settings_path())
}
/// [`load`] from an explicit path.
pub fn load_from(path: &Path) -> Settings {
match std::fs::read_to_string(path) {
Ok(content) => parse_and_normalise(&content),
Err(err) => {
if err.kind() != std::io::ErrorKind::NotFound {
log::warn!("settings read failed ({}): {err}", path.display());
}
Settings::default()
}
}
}
/// Parse settings JSON, falling back to the defaults when it is corrupt,
/// then enforce the invariants a hand-edited file may break.
pub fn parse_and_normalise(content: &str) -> Settings {
let mut settings: Settings = serde_json::from_str(content).unwrap_or_else(|err| {
log::warn!("settings file is not valid; using defaults: {err}");
Settings::default()
});
// At least one live provider must be visible. Otherwise the app has nothing to show.
if !settings.has_enabled_live_provider() {
settings.show_claude_code = true;
}
// Clamp bubble size to safe range in case settings.json was hand-edited.
settings.bubble_size_logical = settings
.bubble_size_logical
.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
settings
}
/// Drop positions on monitors that have since been disconnected.
/// `monitors` are the full monitor rects in physical px. An empty list means
/// the monitors could not be enumerated, so every position is kept rather
/// than all of them being thrown away.
pub fn drop_offscreen_positions(settings: &mut Settings, monitors: &[Rect]) {
if monitors.is_empty() {
log::warn!("no monitors reported; keeping saved bubble positions");
return;
}
settings.bubble_positions.validate(monitors);
}
/// Persist `settings` with an atomic write (tmp file, then rename). Falls
/// back to a direct write when the rename fails.
pub fn save(settings: &Settings) -> std::io::Result<()> {
save_to(&settings_path(), settings)
}
/// [`save`] to an explicit path.
pub fn save_to(path: &Path, settings: &Settings) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(settings).map_err(std::io::Error::other)?;
let tmp_path = path.with_extension("json.tmp");
let atomic = std::fs::write(&tmp_path, &json).and_then(|()| std::fs::rename(&tmp_path, path));
match atomic {
Ok(()) => Ok(()),
Err(err) => {
log::warn!("atomic settings save failed, writing directly: {err}");
let _ = std::fs::remove_file(&tmp_path);
std::fs::write(path, json)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn old_two_provider_settings_json_still_loads() {
let json = r#"{
"show_claude_code": false,
"show_codex": true,
"bubble_positions": {
"claude": [10, 20],
"codex": [30, 40]
}
}"#;
let settings: Settings =
serde_json::from_str(json).expect("old settings json should parse");
assert!(!settings.show_claude_code);
assert!(settings.show_codex);
assert!(!settings.show_opencode_go);
assert_eq!(
settings.bubble_positions.get(ProviderId::Claude),
Some((10, 20))
);
assert_eq!(
settings.bubble_positions.get(ProviderId::ChatGpt),
Some((30, 40))
);
assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None);
}
#[test]
fn three_provider_settings_json_loads() {
let json = r#"{
"show_claude_code": true,
"show_codex": false,
"show_opencode_go": true,
"bubble_positions": {
"opencode_go": [50, 60]
}
}"#;
let settings: Settings =
serde_json::from_str(json).expect("new settings json should parse");
assert!(settings.show_claude_code);
assert!(!settings.show_codex);
assert!(settings.show_opencode_go);
assert_eq!(
settings.bubble_positions.get(ProviderId::OpenCodeGo),
Some((50, 60))
);
}
#[test]
fn corrupt_file_falls_back_to_defaults() {
let settings = parse_and_normalise("{ not json");
assert!(settings.show_claude_code);
assert!(!settings.show_codex);
assert_eq!(settings.bubble_size_logical, DEFAULT_BUBBLE_SIZE);
assert_eq!(settings.poll_interval_ms, POLL_5_MIN);
assert_eq!(
settings.update_check_interval_secs,
Some(UPDATE_CHECK_HOURLY_SECS)
);
assert!(settings.widget_visible);
}
#[test]
fn normalise_restores_a_live_provider_and_clamps_size() {
let settings = parse_and_normalise(
r#"{"show_claude_code": false, "show_codex": false, "show_opencode_go": true,
"bubble_size_logical": 9000}"#,
);
assert!(settings.show_claude_code);
assert_eq!(settings.bubble_size_logical, MAX_BUBBLE_SIZE);
let small = parse_and_normalise(r#"{"bubble_size_logical": 10}"#);
assert_eq!(small.bubble_size_logical, MIN_BUBBLE_SIZE);
}
#[test]
fn serialised_keys_stay_compatible() {
let value = serde_json::to_value(Settings::default()).expect("serialise");
let mut keys: Vec<_> = value.as_object().expect("object").keys().cloned().collect();
keys.sort();
assert_eq!(
keys,
[
"bubble_positions",
"bubble_size_logical",
"last_update_check_unix",
"poll_interval_ms",
"show_claude_code",
"show_codex",
"show_opencode_go",
"update_check_interval_secs",
"widget_visible",
]
);
let mut s = Settings::default();
s.bubble_positions.set(ProviderId::ChatGpt, (-1900, 40));
let json = serde_json::to_string(&s).expect("serialise");
assert!(json.contains(r#""codex":[-1900,40]"#), "{json}");
}
#[test]
fn offscreen_positions_are_dropped_against_monitors() {
let mut settings = parse_and_normalise(
r#"{"bubble_positions": {"claude": [100, 100], "codex": [-3000, 100],
"opencode_go": [2000, 500]}}"#,
);
let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)];
drop_offscreen_positions(&mut settings, &monitors);
let p = &settings.bubble_positions;
assert_eq!(p.get(ProviderId::Claude), Some((100, 100)));
assert_eq!(p.get(ProviderId::ChatGpt), None);
assert_eq!(p.get(ProviderId::OpenCodeGo), Some((2000, 500)));
}
#[test]
fn no_monitors_keeps_saved_positions() {
let mut settings = Settings::default();
settings
.bubble_positions
.set(ProviderId::Claude, (-3000, 100));
drop_offscreen_positions(&mut settings, &[]);
assert_eq!(
settings.bubble_positions.get(ProviderId::Claude),
Some((-3000, 100))
);
}
#[test]
fn save_then_load_round_trips() {
let dir = std::env::temp_dir().join(format!(
"ccub-settings-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default()
));
let path = dir.join(SETTINGS_FILE);
let mut settings = Settings {
show_codex: true,
poll_interval_ms: POLL_15_MIN,
update_check_interval_secs: None,
last_update_check_unix: Some(1_700_000_000),
..Settings::default()
};
settings.bubble_positions.set(ProviderId::Claude, (12, 34));
save_to(&path, &settings).expect("save");
assert!(!path.with_extension("json.tmp").exists());
let loaded = load_from(&path);
assert!(loaded.show_codex);
assert_eq!(loaded.poll_interval_ms, POLL_15_MIN);
assert_eq!(loaded.update_check_interval_secs, None);
assert_eq!(loaded.last_update_check_unix, Some(1_700_000_000));
assert_eq!(
loaded.bubble_positions.get(ProviderId::Claude),
Some((12, 34))
);
std::fs::remove_dir_all(&dir).expect("cleanup");
// A missing file loads the defaults.
assert!(load_from(&path).show_claude_code);
}
#[test]
fn toggle_keeps_provider_flags_independent() {
let mut settings = Settings::default();
settings.toggle_provider(ProviderId::ChatGpt);
assert!(settings.is_provider_enabled(ProviderId::ChatGpt));
settings.toggle_provider(ProviderId::Claude);
assert!(!settings.is_provider_enabled(ProviderId::Claude));
assert!(settings.has_enabled_live_provider());
settings.toggle_provider(ProviderId::ChatGpt);
assert!(!settings.has_enabled_live_provider());
}
#[test]
fn live_toggle_never_disables_the_last_live_provider() {
let mut settings = Settings::default();
assert!(settings.toggle_live_provider(ProviderId::Claude));
assert!(settings.show_claude_code);
assert!(settings.toggle_live_provider(ProviderId::ChatGpt));
assert!(!settings.toggle_live_provider(ProviderId::Claude));
assert!(settings.toggle_live_provider(ProviderId::ChatGpt));
// OpenCode Go has no live usage, so its flag never changes.
assert!(!settings.toggle_live_provider(ProviderId::OpenCodeGo));
assert!(!settings.show_opencode_go);
}
}
+522
View File
@@ -0,0 +1,522 @@
//! Portable app state rules: applying poll results, alert crossings and
//! cooldown, the tray tooltip, the update status label and the update-check
//! schedule.
//!
//! None of this touches a window, tray or menu. Callers apply the returned
//! values after dropping their state lock.
use std::collections::HashMap;
use std::time::{Duration, Instant, SystemTime};
use crate::core::i18n::{self, LocaleStrings};
use crate::usage::{ProviderId, UsageWindows};
/// Minimum gap between two cooldown-gated alerts (threshold and session
/// expired), so notifications stay calm.
pub const ALERT_COOLDOWN: Duration = Duration::from_secs(30 * 60);
/// Usage thresholds that raise an alert the poll they are crossed.
pub const ALERT_THRESHOLDS: [u8; 2] = [80, 95];
/// Shortest countdown re-render delay.
const MIN_COUNTDOWN_DELAY: Duration = Duration::from_secs(1);
/// One provider's last known usage plus its panel/tooltip texts.
#[derive(Clone, Debug, Default)]
pub struct ProviderUiState {
pub windows: UsageWindows,
/// `format_window` text for the session window, `"!"` after an auth
/// failure, `"…"` after a transient error.
pub primary_text: String,
/// Same as `primary_text` for the weekly window.
pub secondary_text: String,
}
/// How one provider's poll ended, independent of the HTTP error type.
#[derive(Clone, Debug)]
pub enum PollOutcome {
Ok(UsageWindows),
/// The credentials were rejected (auth required or token expired).
AuthFailed,
/// Any other failure: network, bad response, missing credentials.
Failed,
}
/// A notification to show.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Alert {
Threshold { provider: ProviderId, threshold: u8 },
SessionExpired { provider: ProviderId },
UpdateApplied { version: String },
}
impl Alert {
/// `(title, body)` for the notification.
pub fn text(&self, strings: &LocaleStrings) -> (String, String) {
match self {
Alert::Threshold {
provider,
threshold,
} => {
let title = format!("{} · {threshold}%", strings.provider_label(*provider));
let body = if *threshold >= 95 {
strings.threshold_95_body.clone()
} else {
strings.threshold_80_body.clone()
};
(title, body)
}
Alert::SessionExpired { provider } => match provider {
ProviderId::Claude => (
strings.token_expired_title.clone(),
strings.token_expired_body.clone(),
),
ProviderId::ChatGpt => (
strings.chatgpt_token_expired_title.clone(),
strings.chatgpt_token_expired_body.clone(),
),
ProviderId::OpenCodeGo => (
strings.opencode_go_label.clone(),
strings.update_failed.clone(),
),
},
Alert::UpdateApplied { version } => (
strings.update_applied_title.clone(),
format!("{}{version}", strings.update_applied_body),
),
}
}
}
/// What a batch of poll results changed beyond the state itself.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Applied {
/// Threshold alerts that passed the cooldown, in firing order.
pub alerts: Vec<Alert>,
/// Providers whose credentials were rejected; the caller runs the CLI
/// refresh for them.
pub auth_failures: Vec<ProviderId>,
}
/// Usage snapshots and the alert cooldown.
#[derive(Clone, Debug, Default)]
pub struct UsageState {
pub snapshots: HashMap<ProviderId, ProviderUiState>,
/// True when at least one provider succeeded on the last non-empty poll.
/// Shared by all providers, as before.
pub last_poll_ok: bool,
pub last_alert_at: Option<Instant>,
}
impl UsageState {
/// Apply one poll's results. A provider fires an alert only the poll its
/// session usage crosses 80 or 95, and each alert must pass the
/// cooldown. An empty batch changes nothing.
pub fn apply_results(
&mut self,
results: Vec<(ProviderId, PollOutcome)>,
strings: &LocaleStrings,
now: Instant,
) -> Applied {
let mut applied = Applied::default();
if results.is_empty() {
return applied;
}
let mut crossings: Vec<(ProviderId, u8)> = Vec::new();
let mut any_ok = false;
for (id, outcome) in results {
let entry = self.snapshots.entry(id).or_default();
match outcome {
PollOutcome::Ok(windows) => {
let old_pct = entry.windows.primary.utilization;
let new_pct = windows.primary.utilization;
for threshold in ALERT_THRESHOLDS {
if old_pct < threshold as f64 && new_pct >= threshold as f64 {
crossings.push((id, threshold));
}
}
entry.windows = windows;
entry.primary_text = i18n::format_window(&windows.primary, strings);
entry.secondary_text = i18n::format_window(&windows.secondary, strings);
any_ok = true;
}
PollOutcome::AuthFailed => {
applied.auth_failures.push(id);
entry.primary_text = "!".into();
entry.secondary_text = "!".into();
}
PollOutcome::Failed => {
entry.primary_text = "…".into();
entry.secondary_text = "…".into();
}
}
}
self.last_poll_ok = any_ok;
for (provider, threshold) in crossings {
if self.admit_alert(now) {
applied.alerts.push(Alert::Threshold {
provider,
threshold,
});
}
}
applied
}
/// Claim the alert cooldown. Returns false while the previous gated
/// alert is younger than [`ALERT_COOLDOWN`].
pub fn admit_alert(&mut self, now: Instant) -> bool {
if let Some(last) = self.last_alert_at {
if now.saturating_duration_since(last) < ALERT_COOLDOWN {
return false;
}
}
self.last_alert_at = Some(now);
true
}
/// The session-expired alert for `provider`, if the cooldown allows one.
pub fn session_expired_alert(&mut self, provider: ProviderId, now: Instant) -> Option<Alert> {
self.admit_alert(now)
.then_some(Alert::SessionExpired { provider })
}
/// Re-format every countdown text after time passed. Error markers are
/// replaced too, matching the countdown timer of the Win32 build.
pub fn refresh_texts(&mut self, strings: &LocaleStrings) {
for entry in self.snapshots.values_mut() {
entry.primary_text = i18n::format_window(&entry.windows.primary, strings);
entry.secondary_text = i18n::format_window(&entry.windows.secondary, strings);
}
}
/// Delay until any displayed countdown changes, at least one second.
/// `None` when no window has a reset time ahead.
pub fn countdown_delay(&self, now: SystemTime) -> Option<Duration> {
self.snapshots
.values()
.flat_map(|e| [e.windows.primary.resets_at, e.windows.secondary.resets_at])
.filter_map(|reset| i18n::time_until_display_change_at(reset, now))
.min()
.map(|d| d.max(MIN_COUNTDOWN_DELAY))
}
}
/// Four-line tray tooltip: label, session, weekly and the click hint.
pub fn tray_tooltip(
label: &str,
entry: Option<&ProviderUiState>,
strings: &LocaleStrings,
) -> String {
let session = entry
.map(|e| e.primary_text.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("...");
let weekly = entry
.map(|e| e.secondary_text.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("...");
format!(
"{label}\n{}: {session}\n{}: {weekly}\n{}",
strings.session_window, strings.weekly_window, strings.tray_left_click
)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum UpdateStatus {
#[default]
Idle,
Checking,
UpToDate,
Available,
Applying,
Failed,
}
impl UpdateStatus {
/// Whether a check or apply is running, which disables the menu item.
pub fn is_busy(self) -> bool {
matches!(self, UpdateStatus::Checking | UpdateStatus::Applying)
}
}
/// The version menu item: `"<status> · v<version>"`.
pub fn version_action_label(status: UpdateStatus, strings: &LocaleStrings) -> String {
let base = match status {
UpdateStatus::Idle => &strings.check_for_updates,
UpdateStatus::Checking => &strings.checking_for_updates,
UpdateStatus::UpToDate => &strings.up_to_date,
UpdateStatus::Available => &strings.update_available,
UpdateStatus::Applying => &strings.applying_update,
UpdateStatus::Failed => &strings.update_failed,
};
// The running binary's version, so the user can see what they are on
// without an About dialog. The middle dot matches the countdown text.
format!("{base} \u{00b7} v{}", env!("CARGO_PKG_VERSION"))
}
/// When the next automatic update check runs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UpdateCheckDue {
Now,
After(Duration),
}
/// Schedule the automatic update check from the last check time (unix
/// seconds). `None` when auto-check is disabled. Due when never checked or
/// when `now - last >= interval`; otherwise after the remainder.
pub fn update_check_due(
last_check_unix: Option<u64>,
interval_secs: Option<u64>,
now_unix: u64,
) -> Option<UpdateCheckDue> {
let interval = interval_secs?;
let Some(last) = last_check_unix else {
return Some(UpdateCheckDue::Now);
};
let elapsed = now_unix.saturating_sub(last);
if elapsed >= interval {
Some(UpdateCheckDue::Now)
} else {
Some(UpdateCheckDue::After(Duration::from_secs(
interval - elapsed,
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::i18n::I18n;
use crate::usage::Window;
fn windows(primary: f64) -> UsageWindows {
UsageWindows {
primary: Window {
utilization: primary,
resets_at: None,
},
secondary: Window {
utilization: 10.0,
resets_at: None,
},
}
}
fn poll(state: &mut UsageState, pct: f64, now: Instant) -> Applied {
let strings = I18n::load().strings().clone();
state.apply_results(
vec![(ProviderId::Claude, PollOutcome::Ok(windows(pct)))],
&strings,
now,
)
}
fn threshold(t: u8) -> Alert {
Alert::Threshold {
provider: ProviderId::Claude,
threshold: t,
}
}
#[test]
fn crossing_eighty_fires_once() {
let now = Instant::now();
let mut state = UsageState::default();
assert!(poll(&mut state, 79.0, now).alerts.is_empty());
assert_eq!(poll(&mut state, 80.0, now).alerts, [threshold(80)]);
// Parked above the threshold: no new crossing, even after the cooldown.
let later = now + ALERT_COOLDOWN;
assert!(poll(&mut state, 85.0, later).alerts.is_empty());
}
#[test]
fn double_crossing_is_cut_to_one_by_the_cooldown() {
let now = Instant::now();
let mut state = UsageState::default();
poll(&mut state, 79.0, now);
assert_eq!(poll(&mut state, 96.0, now).alerts, [threshold(80)]);
}
#[test]
fn first_poll_above_threshold_alerts() {
let mut state = UsageState::default();
assert_eq!(
poll(&mut state, 90.0, Instant::now()).alerts,
[threshold(80)]
);
}
#[test]
fn cooldown_expires_after_thirty_minutes() {
let now = Instant::now();
let mut state = UsageState::default();
assert!(state.admit_alert(now));
assert!(!state.admit_alert(now + ALERT_COOLDOWN - Duration::from_secs(1)));
assert_eq!(state.session_expired_alert(ProviderId::ChatGpt, now), None);
assert_eq!(
state.session_expired_alert(ProviderId::ChatGpt, now + ALERT_COOLDOWN),
Some(Alert::SessionExpired {
provider: ProviderId::ChatGpt
})
);
}
#[test]
fn errors_set_marker_texts_and_auth_failures() {
let strings = I18n::load().strings().clone();
let mut state = UsageState::default();
poll(&mut state, 40.0, Instant::now());
let applied = state.apply_results(
vec![
(ProviderId::Claude, PollOutcome::AuthFailed),
(ProviderId::ChatGpt, PollOutcome::Failed),
],
&strings,
Instant::now(),
);
assert_eq!(applied.auth_failures, [ProviderId::Claude]);
assert!(!state.last_poll_ok);
let claude = &state.snapshots[&ProviderId::Claude];
assert_eq!(claude.primary_text, "!");
assert_eq!(claude.secondary_text, "!");
// The stored windows survive the error.
assert_eq!(claude.windows.primary.utilization, 40.0);
let codex = &state.snapshots[&ProviderId::ChatGpt];
assert_eq!(codex.primary_text, "…");
assert_eq!(codex.secondary_text, "…");
}
#[test]
fn last_poll_ok_is_global_and_empty_batches_change_nothing() {
let strings = I18n::load().strings().clone();
let mut state = UsageState::default();
let applied = state.apply_results(
vec![
(ProviderId::Claude, PollOutcome::Failed),
(ProviderId::ChatGpt, PollOutcome::Ok(windows(5.0))),
],
&strings,
Instant::now(),
);
assert!(state.last_poll_ok);
assert!(applied.auth_failures.is_empty());
assert_eq!(state.snapshots[&ProviderId::ChatGpt].primary_text, "5%");
assert_eq!(
state.apply_results(Vec::new(), &strings, Instant::now()),
Applied::default()
);
assert!(state.last_poll_ok);
}
#[test]
fn alert_texts() {
let strings = I18n::load().strings().clone();
assert_eq!(
threshold(95).text(&strings),
(
"Claude Code · 95%".to_string(),
"Limit is close - consider easing up.".to_string()
)
);
assert_eq!(
Alert::SessionExpired {
provider: ProviderId::ChatGpt
}
.text(&strings)
.0,
"Codex session expired"
);
assert_eq!(
Alert::UpdateApplied {
version: "0.6.0".into()
}
.text(&strings)
.1,
"Updated to v0.6.0"
);
}
#[test]
fn tooltip_uses_dots_for_empty_texts() {
let strings = I18n::load().strings().clone();
assert_eq!(
tray_tooltip("Claude Code", None, &strings),
"Claude Code\n5h: ...\n7d: ...\nLeft-click: show/hide"
);
let entry = ProviderUiState {
primary_text: "73% · 2h".into(),
secondary_text: String::new(),
..ProviderUiState::default()
};
assert_eq!(
tray_tooltip("Codex", Some(&entry), &strings),
"Codex\n5h: 73% · 2h\n7d: ...\nLeft-click: show/hide"
);
}
#[test]
fn version_label_carries_status_and_version() {
let strings = I18n::load().strings().clone();
let v = env!("CARGO_PKG_VERSION");
assert_eq!(
version_action_label(UpdateStatus::Idle, &strings),
format!("Check for updates · v{v}")
);
assert_eq!(
version_action_label(UpdateStatus::Applying, &strings),
format!("Applying update... · v{v}")
);
assert!(UpdateStatus::Checking.is_busy());
assert!(!UpdateStatus::Available.is_busy());
}
#[test]
fn update_schedule_due_and_remaining() {
let hour = 3_600;
assert_eq!(update_check_due(None, None, 10_000), None);
assert_eq!(update_check_due(Some(9_000), None, 10_000), None);
assert_eq!(
update_check_due(None, Some(hour), 10_000),
Some(UpdateCheckDue::Now)
);
assert_eq!(
update_check_due(Some(10_000 - hour), Some(hour), 10_000),
Some(UpdateCheckDue::Now)
);
assert_eq!(
update_check_due(Some(10_000 - 600), Some(hour), 10_000),
Some(UpdateCheckDue::After(Duration::from_secs(3_000)))
);
// A clock that moved backwards counts as zero elapsed.
assert_eq!(
update_check_due(Some(20_000), Some(hour), 10_000),
Some(UpdateCheckDue::After(Duration::from_secs(hour)))
);
}
#[test]
fn countdown_delay_takes_the_nearest_change_with_a_floor() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let mut state = UsageState::default();
assert_eq!(state.countdown_delay(now), None);
let mut w = windows(10.0);
w.primary.resets_at = Some(now + Duration::from_secs(2 * 3_600 + 30));
w.secondary.resets_at = Some(now + Duration::from_secs(3 * 86_400 + 90));
state.snapshots.insert(
ProviderId::Claude,
ProviderUiState {
windows: w,
..ProviderUiState::default()
},
);
assert_eq!(state.countdown_delay(now), Some(Duration::from_secs(31)));
w.primary.resets_at = Some(now + Duration::from_secs(20));
state
.snapshots
.get_mut(&ProviderId::Claude)
.expect("entry")
.windows = w;
assert_eq!(state.countdown_delay(now), Some(Duration::from_secs(1)));
}
}
+83
View File
@@ -0,0 +1,83 @@
// Shared usage→color ramp used by the floating bubble, the expanded panel,
// and the tray badge. Keeping the function in one place ensures the three
// surfaces never disagree about what "78% used" looks like.
use crate::core::color::Rgb as Color;
use crate::usage::ProviderId;
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex tracks
/// the OpenAI Codex monochrome palette — near-white `#E5E5E5` on dark themes,
/// near-black `#1A1A1A` on light — so the accent stays readable against both
/// the dark bubble surface and the `#F3F3F3` light background.
pub fn accent_color_for(model: ProviderId, is_dark: bool) -> Color {
match model {
ProviderId::Claude => Color::from_hex("#D97757"),
ProviderId::ChatGpt => {
if is_dark {
Color::from_hex("#E5E5E5")
} else {
Color::from_hex("#1A1A1A")
}
}
ProviderId::OpenCodeGo => Color::from_hex("#3BAE75"),
}
}
/// Discrete 4-band fill color. The "safe" band uses the provider's identity
/// color so Codex bars render monochrome (light-on-dark / dark-on-light) while
/// Claude bars stay orange; the warning bands are theme-aware so light-mode
/// amber stays readable against the `#F3F3F3` background.
///
/// - <60% → provider accent
/// - 60–80% → amber (dark `#E0A040`, light `#B47A20` for WCAG AA contrast)
/// - 80–95% → red `#C45020`
/// - ≥95% → deep red `#A01818` — paired with pulse animation
pub fn bar_fill_color(model: ProviderId, is_dark: bool, percent: f64) -> Color {
if percent < 60.0 {
accent_color_for(model, is_dark)
} else if percent < 80.0 {
if is_dark {
Color::from_hex("#E0A040")
} else {
Color::from_hex("#B47A20")
}
} else if percent < 95.0 {
Color::from_hex("#C45020")
} else {
Color::from_hex("#A01818")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hex(model: ProviderId, is_dark: bool, pct: f64) -> String {
bar_fill_color(model, is_dark, pct).to_hex()
}
#[test]
fn fill_band_edges() {
let c = ProviderId::Claude;
assert_eq!(hex(c, true, 59.9), "#D97757");
assert_eq!(hex(c, true, 60.0), "#E0A040");
assert_eq!(hex(c, false, 60.0), "#B47A20");
assert_eq!(hex(c, true, 79.9), "#E0A040");
assert_eq!(hex(c, false, 79.9), "#B47A20");
assert_eq!(hex(c, true, 80.0), "#C45020");
assert_eq!(hex(c, false, 94.9), "#C45020");
assert_eq!(hex(c, true, 95.0), "#A01818");
assert_eq!(hex(c, false, 100.0), "#A01818");
}
#[test]
fn safe_band_uses_theme_aware_accent() {
assert_eq!(hex(ProviderId::ChatGpt, true, 10.0), "#E5E5E5");
assert_eq!(hex(ProviderId::ChatGpt, false, 10.0), "#1A1A1A");
assert_eq!(hex(ProviderId::OpenCodeGo, false, 0.0), "#3BAE75");
assert_eq!(
accent_color_for(ProviderId::Claude, false),
accent_color_for(ProviderId::Claude, true)
);
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Release version parsing and comparison for the update check.
//!
//! Only `major.minor.patch` counts: a leading `v` and anything after the
//! first `-` are ignored, and a missing or non-numeric part reads as 0. So
//! `v1.2.3-rc1` equals `1.2.3`, and a pre-release of the running version is
//! never offered as an update.
use std::fmt;
/// A `major.minor.patch` triple, ordered numerically part by part.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl Version {
/// The running build's version, from `Cargo.toml`.
pub fn current() -> Self {
Self::parse(env!("CARGO_PKG_VERSION"))
}
/// Parse a release tag or version string. Never fails: anything that
/// is not a number becomes 0, so `abc` is `0.0.0`.
pub fn parse(s: &str) -> Self {
let core = s
.trim()
.trim_start_matches(['v', 'V'])
.split('-')
.next()
.unwrap_or_default();
let mut parts = core.split('.').map(|p| p.trim().parse::<u32>().ok());
let mut next = || parts.next().flatten().unwrap_or(0);
Version {
major: next(),
minor: next(),
patch: next(),
}
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
/// Whether `candidate` is strictly newer than `current`. Equal versions and
/// downgrades are not updates.
pub fn newer(candidate: &str, current: &str) -> bool {
Version::parse(candidate) > Version::parse(current)
}
#[cfg(test)]
mod tests {
use super::*;
fn v(major: u32, minor: u32, patch: u32) -> Version {
Version {
major,
minor,
patch,
}
}
#[test]
fn suffixes_and_prefix_are_ignored() {
assert_eq!(Version::parse("v1.2.3-rc1"), v(1, 2, 3));
assert_eq!(Version::parse("1.2.3"), v(1, 2, 3));
assert_eq!(Version::parse(" V0.5.0 "), v(0, 5, 0));
}
#[test]
fn missing_and_malformed_parts_read_as_zero() {
assert_eq!(Version::parse("v1"), v(1, 0, 0));
assert_eq!(Version::parse("1.2"), v(1, 2, 0));
assert_eq!(Version::parse("abc"), v(0, 0, 0));
assert_eq!(Version::parse(""), v(0, 0, 0));
assert_eq!(Version::parse("1.x.3"), v(1, 0, 3));
}
#[test]
fn only_a_strictly_newer_version_is_an_update() {
assert!(newer("0.6.0", "0.5.0"));
assert!(newer("v0.5.1", "0.5.0"));
assert!(newer("1.0.0", "0.99.99"));
assert!(newer("0.10.0", "0.9.0"));
// Same version, with or without a suffix: no update.
assert!(!newer("0.5.0", "0.5.0"));
assert!(!newer("v0.5.0-rc1", "0.5.0"));
assert!(!newer("0.5.0", "0.5.0-beta"));
// No downgrade.
assert!(!newer("0.4.9", "0.5.0"));
assert!(!newer("abc", "0.5.0"));
}
#[test]
fn displays_the_bare_triple() {
assert_eq!(Version::parse("v1.2.3-rc1").to_string(), "1.2.3");
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Bubble visibility: the user's show/hide choice and the fullscreen
//! auto-hide, combined into show and hide actions.
//!
//! A user show wins over fullscreen and shows at once; leaving fullscreen
//! shows the bubbles again only when the user has not hidden them.
/// The two reasons a bubble can be hidden.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Visibility {
pub user_hidden: bool,
pub hidden_by_fullscreen: bool,
}
impl Visibility {
/// The state at start-up, from the saved `widget_visible` setting.
pub fn from_widget_visible(widget_visible: bool) -> Self {
Self {
user_hidden: !widget_visible,
hidden_by_fullscreen: false,
}
}
/// Whether a bubble created now should be shown.
pub fn shows(&self) -> bool {
!self.user_hidden && !self.hidden_by_fullscreen
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Event {
UserShow,
UserHide,
FullscreenOn,
FullscreenOff,
}
/// What to do with every bubble after an event.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Show,
Hide,
}
/// Apply `event` to `state`. Returns the new state and the action for the
/// bubbles, `None` when they stay as they are.
pub fn next(state: Visibility, event: Event) -> (Visibility, Option<Action>) {
match event {
Event::UserShow => (
Visibility {
user_hidden: false,
..state
},
Some(Action::Show),
),
Event::UserHide => (
Visibility {
user_hidden: true,
..state
},
Some(Action::Hide),
),
Event::FullscreenOn if state.hidden_by_fullscreen => (state, None),
Event::FullscreenOn => (
Visibility {
hidden_by_fullscreen: true,
..state
},
Some(Action::Hide),
),
Event::FullscreenOff if !state.hidden_by_fullscreen => (state, None),
Event::FullscreenOff => (
Visibility {
hidden_by_fullscreen: false,
..state
},
(!state.user_hidden).then_some(Action::Show),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
const SHOWN: Visibility = Visibility {
user_hidden: false,
hidden_by_fullscreen: false,
};
fn state(user_hidden: bool, hidden_by_fullscreen: bool) -> Visibility {
Visibility {
user_hidden,
hidden_by_fullscreen,
}
}
#[test]
fn user_show_shows_at_once_even_during_fullscreen() {
assert_eq!(
next(state(true, false), Event::UserShow),
(SHOWN, Some(Action::Show))
);
assert_eq!(
next(state(true, true), Event::UserShow),
(state(false, true), Some(Action::Show))
);
}
#[test]
fn user_hide_hides() {
assert_eq!(
next(SHOWN, Event::UserHide),
(state(true, false), Some(Action::Hide))
);
assert_eq!(
next(state(false, true), Event::UserHide),
(state(true, true), Some(Action::Hide))
);
}
#[test]
fn fullscreen_on_hides_once() {
assert_eq!(
next(SHOWN, Event::FullscreenOn),
(state(false, true), Some(Action::Hide))
);
assert_eq!(
next(state(true, false), Event::FullscreenOn),
(state(true, true), Some(Action::Hide))
);
assert_eq!(
next(state(false, true), Event::FullscreenOn),
(state(false, true), None)
);
}
#[test]
fn fullscreen_off_shows_only_when_the_user_has_not_hidden() {
assert_eq!(
next(state(false, true), Event::FullscreenOff),
(SHOWN, Some(Action::Show))
);
assert_eq!(
next(state(true, true), Event::FullscreenOff),
(state(true, false), None)
);
assert_eq!(next(SHOWN, Event::FullscreenOff), (SHOWN, None));
}
#[test]
fn start_up_state_follows_the_saved_setting() {
assert!(Visibility::from_widget_visible(true).shows());
assert!(!Visibility::from_widget_visible(false).shows());
assert!(!state(false, true).shows());
}
}
+80 -17
View File
@@ -5,6 +5,7 @@
// timestamp in the file; we discover expiry only when the server returns
// 401 or 403 to a poll.
use std::ffi::OsString;
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
@@ -17,16 +18,36 @@ pub struct LocalCodexCreds {
impl LocalCodexCreds {
pub fn detect() -> Option<Self> {
let path = if let Some(home) = std::env::var_os("CODEX_HOME") {
PathBuf::from(home).join("auth.json")
} else {
dirs::home_dir()?.join(".codex").join("auth.json")
};
let path = auth_path(std::env::var_os("CODEX_HOME"), dirs::home_dir())?;
let id = format!("codex:{}", path.display());
Some(Self { path, id })
}
}
/// `$CODEX_HOME/auth.json`, else `~/.codex/auth.json`. An empty `CODEX_HOME`
/// counts as unset (as in the Codex CLI), so it never resolves to a path
/// relative to the working directory.
fn auth_path(codex_home: Option<OsString>, home: Option<PathBuf>) -> Option<PathBuf> {
match codex_home.filter(|h| !h.is_empty()) {
Some(dir) => Some(PathBuf::from(dir).join("auth.json")),
None => Some(home?.join(".codex").join("auth.json")),
}
}
/// Parse the `auth.json` shape written by the Codex CLI.
fn parse_codex_json(content: &str) -> Result<super::Token, super::Error> {
let parsed: Envelope = serde_json::from_str(content)?;
let tokens = parsed.tokens.ok_or(super::Error::MissingField("tokens"))?;
if tokens.access_token.is_empty() {
return Err(super::Error::MissingField("access_token"));
}
Ok(super::Token {
access_token: tokens.access_token,
expires_at_unix_ms: None,
account_id: tokens.account_id,
})
}
impl super::CredentialSource for LocalCodexCreds {
fn id(&self) -> &str {
&self.id
@@ -34,18 +55,7 @@ impl super::CredentialSource for LocalCodexCreds {
fn read(&self) -> Result<super::Token, super::Error> {
let content = std::fs::read_to_string(&self.path)?;
let parsed: Envelope = serde_json::from_str(&content)?;
let tokens = parsed
.tokens
.ok_or(super::Error::MissingField("tokens"))?;
if tokens.access_token.is_empty() {
return Err(super::Error::MissingField("access_token"));
}
Ok(super::Token {
access_token: tokens.access_token,
expires_at_unix_ms: None,
account_id: tokens.account_id,
})
parse_codex_json(&content)
}
fn signature(&self) -> Option<String> {
@@ -74,3 +84,56 @@ struct Tokens {
access_token: String,
account_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::Error;
fn fixture(name: &str) -> String {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}
#[test]
fn full_auth_reads_token_and_account() {
let token = parse_codex_json(&fixture("codex-auth-full.json")).expect("token");
assert_eq!(token.access_token, "fixture-access-token");
assert_eq!(token.account_id.as_deref(), Some("fixture-account"));
assert_eq!(token.expires_at_unix_ms, None);
}
#[test]
fn null_tokens_is_a_missing_field() {
let err = parse_codex_json(&fixture("codex-auth-null-tokens.json")).unwrap_err();
assert!(matches!(err, Error::MissingField("tokens")), "{err}");
let err = parse_codex_json("{}").unwrap_err();
assert!(matches!(err, Error::MissingField("tokens")), "{err}");
}
#[test]
fn empty_or_missing_access_token_is_rejected() {
let err = parse_codex_json(&fixture("codex-auth-empty-access-token.json")).unwrap_err();
assert!(matches!(err, Error::MissingField("access_token")), "{err}");
let err = parse_codex_json(&fixture("codex-auth-missing-access-token.json")).unwrap_err();
assert!(matches!(err, Error::Json(_)), "{err}");
}
#[test]
fn codex_home_wins_unless_empty() {
let home = Some(PathBuf::from("/home/u"));
assert_eq!(
auth_path(Some(OsString::from("/opt/codex")), home.clone()),
Some(PathBuf::from("/opt/codex/auth.json"))
);
assert_eq!(
auth_path(Some(OsString::new()), home.clone()),
Some(PathBuf::from("/home/u/.codex/auth.json"))
);
assert_eq!(
auth_path(None, home),
Some(PathBuf::from("/home/u/.codex/auth.json"))
);
assert_eq!(auth_path(Some(OsString::new()), None), None);
}
}
+53 -7
View File
@@ -1,8 +1,8 @@
// Read Claude credentials from the Windows user profile.
// Read Claude credentials from the user's home directory.
//
// Path: `%USERPROFILE%\.claude\.credentials.json` (matches what the
// official Claude CLI writes). Schema: `{ "claudeAiOauth": { "accessToken",
// "expiresAt": <ms-since-epoch> } }`.
// Path: `~/.claude/.credentials.json`, `%USERPROFILE%\.claude\.credentials.json`
// on Windows (matches what the official Claude CLI writes). Schema:
// `{ "claudeAiOauth": { "accessToken", "expiresAt": <ms-since-epoch> } }`.
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
@@ -51,8 +51,7 @@ impl super::CredentialSource for LocalClaudeCreds {
}
}
/// Shared between local-fs and wsl-bridge sources — both parse the same
/// JSON shape, the only difference is how they get to the file content.
/// Parse the Claude credential JSON shape written by the official CLI.
pub(crate) fn parse_claude_json(content: &str) -> Result<super::Token, super::Error> {
let value: serde_json::Value = serde_json::from_str(content)?;
let oauth = value
@@ -63,10 +62,57 @@ pub(crate) fn parse_claude_json(content: &str) -> Result<super::Token, super::Er
.and_then(|v| v.as_str())
.ok_or(super::Error::MissingField("accessToken"))?
.to_string();
let expires_at_unix_ms = oauth.get("expiresAt").and_then(|v| v.as_i64());
// The CLI writes integer milliseconds; a float is truncated rather than
// dropped, so a client-side expiry check still runs.
let expires_at_unix_ms = oauth
.get("expiresAt")
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)));
Ok(super::Token {
access_token,
expires_at_unix_ms,
account_id: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::Error;
fn fixture(name: &str) -> String {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}
#[test]
fn full_credentials_read_token_and_expiry() {
let token = parse_claude_json(&fixture("claude-credentials-full.json")).expect("token");
assert_eq!(token.access_token, "sk-ant-oat01-fixture");
assert_eq!(token.expires_at_unix_ms, Some(1_761_040_800_000));
assert_eq!(token.account_id, None);
}
#[test]
fn float_expiry_is_truncated_to_milliseconds() {
let token =
parse_claude_json(&fixture("claude-credentials-float-expiry.json")).expect("token");
assert_eq!(token.expires_at_unix_ms, Some(1_761_040_800_000));
}
#[test]
fn missing_expiry_is_none() {
let token = parse_claude_json(r#"{"claudeAiOauth":{"accessToken":"t","expiresAt":null}}"#)
.expect("token");
assert_eq!(token.expires_at_unix_ms, None);
}
#[test]
fn missing_fields_are_reported() {
let err = parse_claude_json(&fixture("claude-credentials-missing-token.json")).unwrap_err();
assert!(matches!(err, Error::MissingField("accessToken")), "{err}");
let err = parse_claude_json(&fixture("claude-credentials-missing-oauth.json")).unwrap_err();
assert!(matches!(err, Error::MissingField("claudeAiOauth")), "{err}");
let err = parse_claude_json("not json").unwrap_err();
assert!(matches!(err, Error::Json(_)), "{err}");
}
}
+3 -14
View File
@@ -1,13 +1,12 @@
// Pluggable credential discovery.
//
// Each `CredentialSource` knows how to read a single OAuth token from
// somewhere (a local JSON file, a WSL filesystem, …). The `Locator`
// somewhere, such as a local JSON file. The `Locator`
// holds a priority-ordered list and serves the first source that
// actually has a token. New sources drop in without touching the locator.
pub mod codex_auth;
pub mod local_fs;
pub mod wsl_bridge;
#[derive(Clone, Debug)]
pub struct Token {
@@ -23,8 +22,6 @@ pub struct Token {
pub enum RefreshHint {
/// `claude.cmd` / `claude.exe` on PATH.
LocalClaudeCli,
/// Run `claude -p .` inside a specific WSL distro.
WslClaudeCli { distro: String },
/// `codex` / `codex.cmd` / `codex.ps1` on PATH.
LocalCodexCli,
}
@@ -37,10 +34,6 @@ pub enum Error {
Json(#[from] serde_json::Error),
#[error("required field missing from credential JSON: {0}")]
MissingField(&'static str),
#[error("WSL command in {distro:?} failed: {detail}")]
WslCommand { distro: String, detail: String },
#[error("timeout while talking to WSL")]
WslTimeout,
#[error("credential source unavailable")]
Unavailable,
}
@@ -50,7 +43,7 @@ pub trait CredentialSource: Send + Sync {
/// signatures (e.g. `"local:C:\\Users\\me\\.claude\\.credentials.json"`).
fn id(&self) -> &str;
/// Read the current token. May spawn subprocesses (for WSL).
/// Read the current token.
fn read(&self) -> Result<Token, Error>;
/// Cheap change-detection fingerprint. `None` means "source is missing".
@@ -70,16 +63,12 @@ impl Locator {
Self { sources }
}
/// Build a Claude locator with the standard search order: Windows
/// home directory first, then every installed WSL distro.
/// Build a Claude locator with the standard Windows credential path.
pub fn for_claude() -> Self {
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
if let Some(s) = local_fs::LocalClaudeCreds::detect() {
sources.push(Box::new(s));
}
for distro in wsl_bridge::list_distros() {
sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro)));
}
Self { sources }
}
-155
View File
@@ -1,155 +0,0 @@
// Reach into installed WSL distros to read their Claude credentials.
//
// We never mount the WSL filesystem ourselves — instead we shell out to
// `wsl.exe -d <distro> -- sh -lc '...'` and read stdout. Every call has
// a hard timeout so a hung WSL doesn't freeze the poll thread.
use std::os::windows::process::CommandExt;
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
use super::local_fs::parse_claude_json;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
/// Enumerate installed WSL distributions. Returns an empty vec if WSL is
/// not installed or the probe fails.
pub fn list_distros() -> Vec<String> {
let Some(output) = run_with_timeout(
Command::new("wsl.exe").args(["-l", "-q"]),
COMMAND_TIMEOUT,
) else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
decode_wsl_text(&output.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(ToOwned::to_owned)
.collect()
}
pub struct WslClaudeCreds {
distro: String,
id: String,
}
impl WslClaudeCreds {
pub fn new(distro: String) -> Self {
let id = format!("wsl:{distro}");
Self { distro, id }
}
pub fn distro(&self) -> &str {
&self.distro
}
}
impl super::CredentialSource for WslClaudeCreds {
fn id(&self) -> &str {
&self.id
}
fn read(&self) -> Result<super::Token, super::Error> {
let output = wsl_run(&self.distro, "cat ~/.claude/.credentials.json")?;
if !output.status.success() {
return Err(super::Error::WslCommand {
distro: self.distro.clone(),
detail: format!("cat exited {}", output.status),
});
}
let content = String::from_utf8(output.stdout).map_err(|_| super::Error::WslCommand {
distro: self.distro.clone(),
detail: "non-UTF-8 stdout".into(),
})?;
parse_claude_json(&content)
}
fn signature(&self) -> Option<String> {
let output = wsl_run(
&self.distro,
"if [ -f ~/.claude/.credentials.json ]; then \
stat -c '%s|%Y' ~/.claude/.credentials.json; \
else echo MISSING; fi",
)
.ok()?;
if !output.status.success() {
return None;
}
let body = decode_wsl_text(&output.stdout).trim().to_string();
if body == "MISSING" {
return None;
}
Some(format!("{}|{}", self.id, body))
}
fn refresh_hint(&self) -> super::RefreshHint {
super::RefreshHint::WslClaudeCli {
distro: self.distro.clone(),
}
}
}
fn wsl_run(distro: &str, script: &str) -> Result<Output, super::Error> {
run_with_timeout(
Command::new("wsl.exe")
.arg("-d")
.arg(distro)
.arg("--")
.arg("sh")
.arg("-lc")
.arg(script),
COMMAND_TIMEOUT,
)
.ok_or(super::Error::WslTimeout)
}
fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option<Output> {
let mut child = cmd
.creation_flags(CREATE_NO_WINDOW)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return child.wait_with_output().ok(),
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(80));
}
Err(_) => return None,
}
}
}
/// `wsl.exe -l -q` historically emits UTF-16LE on stdout; other commands
/// emit UTF-8. Detect by sampling high bytes and decode appropriately.
pub(crate) fn decode_wsl_text(bytes: &[u8]) -> String {
if bytes.len() >= 2 && bytes.len() % 2 == 0 {
let sample_end = bytes.len().min(256);
let mut high_nul = 0usize;
for chunk in bytes[..sample_end].chunks_exact(2) {
if chunk[1] == 0 {
high_nul += 1;
}
}
if high_nul * 2 >= sample_end / 2 {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
return String::from_utf16_lossy(&units);
}
}
String::from_utf8_lossy(bytes).into_owned()
}
+16 -2
View File
@@ -7,10 +7,14 @@
use std::fs::File;
use std::path::PathBuf;
use simplelog::{Config, LevelFilter, WriteLogger};
use simplelog::{CombinedLogger, ConfigBuilder, LevelFilter, WriteLogger};
const LOG_FILE_NAME: &str = "claude-code-usage-bubble.log";
/// The updater plugin logs the proxy URL, credentials included, at debug
/// level, so its messages are kept to info and above.
const UPDATER_TARGET: &str = "tauri_plugin_updater";
/// Initialise file-based logging. Idempotent — second call is a no-op.
///
/// Returns the resolved log-file path on success, or `Ok(None)` when
@@ -22,8 +26,18 @@ pub fn init(enabled: bool) -> std::io::Result<Option<PathBuf>> {
}
let path = std::env::temp_dir().join(LOG_FILE_NAME);
let file = File::create(&path)?;
let updater_file = file.try_clone()?;
let app = ConfigBuilder::new()
.add_filter_ignore_str(UPDATER_TARGET)
.build();
let updater = ConfigBuilder::new()
.add_filter_allow_str(UPDATER_TARGET)
.build();
// simplelog will refuse a second init; convert that into a soft no-op.
let _ = WriteLogger::init(LevelFilter::Debug, Config::default(), file);
let _ = CombinedLogger::init(vec![
WriteLogger::new(LevelFilter::Debug, app, file),
WriteLogger::new(LevelFilter::Info, updater, updater_file),
]);
log::info!("diagnostic logging enabled at {}", path.display());
Ok(Some(path))
}
+122
View File
@@ -0,0 +1,122 @@
//! Fullscreen auto-hide: a background thread checks every 1.5 s whether a
//! fullscreen window of another process is in the foreground, and hides the
//! bubbles while one is. They come back without activation when it leaves,
//! unless the user hid them in the meantime.
use std::io;
use std::sync::Arc;
use std::time::Duration;
use crate::core::visibility::Event;
use crate::platform::{self, FullscreenCheck};
use crate::runtime::Runtime;
/// How often the foreground window is checked.
pub const CHECK_INTERVAL: Duration = Duration::from_millis(1500);
/// Start the check thread. It runs until the process starts exiting.
pub fn spawn(runtime: Arc<Runtime>) -> io::Result<()> {
std::thread::Builder::new()
.name("fullscreen".into())
.spawn(move || {
while !runtime.is_exiting() {
tick(&runtime);
std::thread::sleep(CHECK_INTERVAL);
}
})
.map(drop)
}
/// One check: read the foreground state and, when it differs from what the
/// bubbles reflect, apply the matching visibility event. The state is read
/// and released before any window call (`apply_visibility` does the same).
fn tick(runtime: &Runtime) {
let check = platform::foreground_is_fullscreen();
let visibility = runtime.with_state(|s| s.visibility);
let Some(event) = event_for(visibility.hidden_by_fullscreen, check.fullscreen) else {
return;
};
log_decision(event, &check, visibility.user_hidden);
runtime.apply_visibility(event);
}
/// The visibility event for a check result, or `None` when the bubbles
/// already reflect it.
pub fn event_for(hidden_by_fullscreen: bool, fullscreen: bool) -> Option<Event> {
match (hidden_by_fullscreen, fullscreen) {
(false, true) => Some(Event::FullscreenOn),
(true, false) => Some(Event::FullscreenOff),
_ => None,
}
}
fn log_decision(event: Event, check: &FullscreenCheck, user_hidden: bool) {
let action = if event == Event::FullscreenOn {
"hide"
} else {
"show"
};
log::info!(
"bubble fullscreen {action} reason={} {} user_hidden={user_hidden}",
check.reason,
check.detail
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::visibility::{self, Action, Visibility};
#[test]
fn only_a_change_produces_an_event() {
assert_eq!(event_for(false, true), Some(Event::FullscreenOn));
assert_eq!(event_for(true, false), Some(Event::FullscreenOff));
assert_eq!(event_for(false, false), None);
assert_eq!(event_for(true, true), None);
}
/// Feed a sequence of check results through the state machine and
/// collect the actions the bubbles receive.
fn run(start: Visibility, checks: &[bool]) -> (Visibility, Vec<Action>) {
let mut state = start;
let mut actions = Vec::new();
for &fullscreen in checks {
if let Some(event) = event_for(state.hidden_by_fullscreen, fullscreen) {
let (next, action) = visibility::next(state, event);
state = next;
actions.extend(action);
}
}
(state, actions)
}
#[test]
fn a_fullscreen_spell_hides_once_and_shows_once() {
let (state, actions) = run(Visibility::default(), &[false, true, true, true, false]);
assert_eq!(actions, [Action::Hide, Action::Show]);
assert_eq!(state, Visibility::default());
}
#[test]
fn user_hidden_bubbles_stay_hidden_after_fullscreen() {
let start = Visibility::from_widget_visible(false);
let (state, actions) = run(start, &[true, false]);
assert_eq!(actions, [Action::Hide]);
assert_eq!(state, start);
}
#[test]
fn a_user_show_during_fullscreen_is_not_undone_by_the_next_check() {
let (hidden, _) = run(Visibility::default(), &[true]);
let (shown, action) = visibility::next(hidden, Event::UserShow);
assert_eq!(action, Some(Action::Show));
// Fullscreen is still on: no event, so the bubbles stay shown.
let (state, actions) = run(shown, &[true, true]);
assert!(actions.is_empty());
// Leaving fullscreen clears the flag; the bubbles are already shown.
let (state, actions) = run(state, &[false]);
assert_eq!(actions, [Action::Show]);
assert!(state.shows());
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Process handoff: relaunching the app and cleaning up after earlier
//! updates.
//!
//! A relaunched instance gets `--wait-pid <our pid>` and waits for this
//! process to exit before it takes the single-instance lock.
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::core::settings::APP_DIR_NAME;
/// Spawn a detached copy of the app with `--wait-pid <pid>` followed by
/// `extra_args`. The caller exits only when this returns `Ok`, so a failed
/// spawn never leaves the user without a running app.
pub fn relaunch(extra_args: &[OsString]) -> io::Result<()> {
let exe = relaunch_target()?;
let mut command = Command::new(&exe);
command
.arg("--wait-pid")
.arg(std::process::id().to_string())
.args(extra_args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
detach(&mut command);
let child = command.spawn()?;
log::info!(
"relaunched {} as pid {} (parent pid {})",
exe.display(),
child.id(),
std::process::id()
);
Ok(())
}
/// The file to start: the AppImage itself when running from one (the
/// executable path points into its temporary mount), else this executable.
fn relaunch_target() -> io::Result<PathBuf> {
#[cfg(target_os = "linux")]
{
if let Some(appimage) = std::env::var_os("APPIMAGE").filter(|v| !v.is_empty()) {
return Ok(PathBuf::from(appimage));
}
}
std::env::current_exe()
}
#[cfg(windows)]
fn detach(command: &mut Command) {
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
#[cfg(unix)]
fn detach(command: &mut Command) {
use std::os::unix::process::CommandExt;
// A new process group, so a Ctrl+C aimed at the old instance's
// terminal does not also stop the new one.
command.process_group(0);
}
/// `data_local_dir()/ClaudeCodeUsageBubble/updates`, where updates are
/// staged and `update-error.log` lives.
pub fn updates_dir() -> Option<PathBuf> {
dirs::data_local_dir().map(|d| d.join(APP_DIR_NAME).join("updates"))
}
/// Delete what earlier updates left behind: `<exe file name>.old.*` next to
/// the executable and, in `updates_dir`, the staged `update.exe` and
/// `updater-helper-*.exe` copies. `update-error.log` is kept. Every failure
/// is logged and skipped.
pub fn cleanup_stale_files(exe: &Path, updates_dir: Option<&Path>) {
if let (Some(dir), Some(name)) = (exe.parent(), exe.file_name()) {
let prefix = format!("{}.old.", name.to_string_lossy());
remove_matching(dir, |file| file.starts_with(&prefix));
}
if let Some(dir) = updates_dir {
remove_matching(dir, |file| {
file == "update.exe" || (file.starts_with("updater-helper-") && file.ends_with(".exe"))
});
}
}
fn remove_matching(dir: &Path, matches: impl Fn(&str) -> bool) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) => {
if err.kind() != io::ErrorKind::NotFound {
log::debug!("cleanup: read_dir {} failed: {err}", dir.display());
}
return;
}
};
for entry in entries.flatten() {
let name = entry.file_name();
if !matches(&name.to_string_lossy()) {
continue;
}
let path = entry.path();
if !path.is_file() {
continue;
}
match std::fs::remove_file(&path) {
Ok(()) => log::info!("cleanup: removed {}", path.display()),
Err(err) => log::debug!("cleanup: remove {} failed: {err}", path.display()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
/// A fresh, empty directory under the system temp dir.
fn scratch_dir(tag: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let dir =
std::env::temp_dir().join(format!("ccub-handoff-{tag}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
dir
}
fn touch(path: &Path) {
std::fs::write(path, b"x").expect("write file");
}
fn names(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.expect("read dir")
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
#[test]
fn cleanup_removes_only_leftovers_from_updates() {
let root = scratch_dir("cleanup");
let install = root.join("install");
let updates = root.join("updates");
std::fs::create_dir_all(&install).expect("install dir");
std::fs::create_dir_all(&updates).expect("updates dir");
let exe = install.join("claude-code-usage-bubble.exe");
touch(&exe);
touch(&install.join("claude-code-usage-bubble.exe.old.1234"));
touch(&install.join("claude-code-usage-bubble.exe.old.99"));
touch(&install.join("other.exe.old.1"));
touch(&install.join("settings.json"));
// A directory with a matching name is left alone.
std::fs::create_dir_all(install.join("claude-code-usage-bubble.exe.old.dir")).expect("dir");
touch(&updates.join("update.exe"));
touch(&updates.join("updater-helper-4242.exe"));
touch(&updates.join("updater-helper-notes.txt"));
touch(&updates.join("update-error.log"));
cleanup_stale_files(&exe, Some(&updates));
assert_eq!(
names(&install),
[
"claude-code-usage-bubble.exe",
"claude-code-usage-bubble.exe.old.dir",
"other.exe.old.1",
"settings.json",
]
);
assert_eq!(
names(&updates),
["update-error.log", "updater-helper-notes.txt"]
);
std::fs::remove_dir_all(&root).expect("remove scratch dir");
}
#[test]
fn cleanup_tolerates_missing_directories() {
let root = scratch_dir("missing");
let exe = root.join("absent").join("app");
cleanup_stale_files(&exe, Some(&root.join("no-updates")));
cleanup_stale_files(Path::new("app"), None);
std::fs::remove_dir_all(&root).expect("remove scratch dir");
}
#[test]
fn updates_dir_sits_under_the_app_data_dir() {
if let Some(dir) = updates_dir() {
assert!(dir.ends_with(Path::new(APP_DIR_NAME).join("updates")));
}
}
}
-73
View File
@@ -1,73 +0,0 @@
// Discover the user's preferred Windows UI language.
//
// We try three sources in priority order and return the first non-empty
// result. Callers normalise the returned BCP-47-ish code against the
// list of locales we actually ship.
use windows::core::PWSTR;
use windows::Win32::Globalization::{
GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages,
LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME,
};
/// First non-empty locale code from the user's preferences. May be
/// `Some("en-US")` style; callers do prefix normalisation.
pub fn detect_system_locale() -> Option<String> {
preferred_ui()
.into_iter()
.next()
.or_else(default_ui_language)
.or_else(default_locale_name)
}
fn preferred_ui() -> Vec<String> {
unsafe {
let mut count: u32 = 0;
let mut buf_len: u32 = 0;
if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &mut count, PWSTR::null(), &mut buf_len)
.is_err()
|| buf_len == 0
{
return Vec::new();
}
let mut buffer = vec![0u16; buf_len as usize];
if GetUserPreferredUILanguages(
MUI_LANGUAGE_NAME,
&mut count,
PWSTR(buffer.as_mut_ptr()),
&mut buf_len,
)
.is_err()
{
return Vec::new();
}
buffer
.split(|u| *u == 0)
.filter(|s| !s.is_empty())
.map(String::from_utf16_lossy)
.collect()
}
}
fn default_ui_language() -> Option<String> {
unsafe {
let lcid = GetUserDefaultUILanguage();
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
let len = LCIDToLocaleName(lcid as u32, Some(&mut buf), LOCALE_ALLOW_NEUTRAL_NAMES);
if len <= 1 {
return None;
}
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
}
}
fn default_locale_name() -> Option<String> {
unsafe {
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
let len = GetUserDefaultLocaleName(&mut buf);
if len <= 1 {
return None;
}
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
}
}
-46
View File
@@ -1,46 +0,0 @@
code = "de"
native_name = "Deutsch"
window_title = "Claude Code Usage Bubble"
refresh = "Aktualisieren"
update_frequency = "Aktualisierungsintervall"
one_minute = "1 Minute"
five_minutes = "5 Minuten"
fifteen_minutes = "15 Minuten"
one_hour = "1 Stunde"
models = "Modelle"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Einstellungen"
start_with_windows = "Mit Windows starten"
reset_position = "Position zurücksetzen"
language = "Sprache"
system_default = "Systemstandard"
check_for_updates = "Nach Updates suchen"
checking_for_updates = "Suche läuft…"
up_to_date = "Aktuell"
update_failed = "Update fehlgeschlagen"
applying_update = "Update wird angewendet…"
update_available = "Update verfügbar"
update_via_winget = "über WinGet"
auto_update_check = "Automatische Updateprüfung"
auto_check_disabled = "Deaktiviert"
auto_check_hourly = "Stündlich"
auto_check_daily = "Täglich"
auto_check_weekly = "Wöchentlich"
exit = "Beenden"
restart = "Neu starten"
show_widget = "Widget anzeigen"
session_window = "5h"
weekly_window = "7d"
now = "jetzt"
day_suffix = "T"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Claude Code-Sitzung abgelaufen"
token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
threshold_80_body = "5-Stunden-Limit naht."
threshold_95_body = "Limit fast erreicht — gönn dir eine Pause."
-46
View File
@@ -1,46 +0,0 @@
code = "en"
native_name = "English"
window_title = "Claude Code Usage Bubble"
refresh = "Refresh"
update_frequency = "Update frequency"
one_minute = "1 minute"
five_minutes = "5 minutes"
fifteen_minutes = "15 minutes"
one_hour = "1 hour"
models = "Models"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Settings"
start_with_windows = "Start with Windows"
reset_position = "Reset position"
language = "Language"
system_default = "System default"
check_for_updates = "Check for updates"
checking_for_updates = "Checking for updates…"
up_to_date = "Up to date"
update_failed = "Update failed"
applying_update = "Applying update…"
update_available = "Update available"
update_via_winget = "via WinGet"
auto_update_check = "Auto-update check"
auto_check_disabled = "Disabled"
auto_check_hourly = "Hourly"
auto_check_daily = "Daily"
auto_check_weekly = "Weekly"
exit = "Exit"
restart = "Restart"
show_widget = "Show widget"
session_window = "5h"
weekly_window = "7d"
now = "now"
day_suffix = "d"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Claude Code session expired"
token_expired_body = "Sign in again to keep tracking your usage."
chatgpt_token_expired_title = "Codex session expired"
chatgpt_token_expired_body = "Sign in again to keep tracking your usage."
threshold_80_body = "Approaching the 5-hour limit."
threshold_95_body = "Limit is close — consider easing up."
-46
View File
@@ -1,46 +0,0 @@
code = "es"
native_name = "Español"
window_title = "Claude Code Usage Bubble"
refresh = "Actualizar"
update_frequency = "Frecuencia de actualización"
one_minute = "1 minuto"
five_minutes = "5 minutos"
fifteen_minutes = "15 minutos"
one_hour = "1 hora"
models = "Modelos"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Ajustes"
start_with_windows = "Iniciar con Windows"
reset_position = "Restablecer posición"
language = "Idioma"
system_default = "Predeterminado del sistema"
check_for_updates = "Buscar actualizaciones"
checking_for_updates = "Buscando actualizaciones…"
up_to_date = "Al día"
update_failed = "Actualización fallida"
applying_update = "Aplicando actualización…"
update_available = "Actualización disponible"
update_via_winget = "vía WinGet"
auto_update_check = "Búsqueda automática de actualizaciones"
auto_check_disabled = "Desactivada"
auto_check_hourly = "Cada hora"
auto_check_daily = "Cada día"
auto_check_weekly = "Cada semana"
exit = "Salir"
restart = "Reiniciar"
show_widget = "Mostrar widget"
session_window = "5h"
weekly_window = "7d"
now = "ahora"
day_suffix = "d"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Sesión de Claude Code caducada"
token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
chatgpt_token_expired_title = "Sesión de Codex caducada"
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
threshold_80_body = "Cerca del límite de 5 horas."
threshold_95_body = "Límite casi alcanzado — reduce el ritmo."
-46
View File
@@ -1,46 +0,0 @@
code = "fr"
native_name = "Français"
window_title = "Claude Code Usage Bubble"
refresh = "Actualiser"
update_frequency = "Fréquence de mise à jour"
one_minute = "1 minute"
five_minutes = "5 minutes"
fifteen_minutes = "15 minutes"
one_hour = "1 heure"
models = "Modèles"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Paramètres"
start_with_windows = "Lancer avec Windows"
reset_position = "Réinitialiser la position"
language = "Langue"
system_default = "Paramètre système"
check_for_updates = "Rechercher des mises à jour"
checking_for_updates = "Recherche en cours…"
up_to_date = "À jour"
update_failed = "Mise à jour échouée"
applying_update = "Mise à jour en cours…"
update_available = "Mise à jour disponible"
update_via_winget = "via WinGet"
auto_update_check = "Vérification automatique des mises à jour"
auto_check_disabled = "Désactivée"
auto_check_hourly = "Toutes les heures"
auto_check_daily = "Quotidienne"
auto_check_weekly = "Hebdomadaire"
exit = "Quitter"
restart = "Redémarrer"
show_widget = "Afficher le widget"
session_window = "5h"
weekly_window = "7j"
now = "maintenant"
day_suffix = "j"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Session Claude Code expirée"
token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
chatgpt_token_expired_title = "Session Codex expirée"
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
threshold_80_body = "Approche de la limite de 5 heures."
threshold_95_body = "Limite proche — pensez à lever le pied."
-46
View File
@@ -1,46 +0,0 @@
code = "ja"
native_name = "日本語"
window_title = "Claude Code Usage Bubble"
refresh = "更新"
update_frequency = "更新間隔"
one_minute = "1分"
five_minutes = "5分"
fifteen_minutes = "15分"
one_hour = "1時間"
models = "モデル"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "設定"
start_with_windows = "Windows起動時に開始"
reset_position = "位置をリセット"
language = "言語"
system_default = "システム既定"
check_for_updates = "更新を確認"
checking_for_updates = "確認中…"
up_to_date = "最新です"
update_failed = "更新に失敗しました"
applying_update = "更新を適用中…"
update_available = "更新あり"
update_via_winget = "WinGet経由"
auto_update_check = "更新の自動確認"
auto_check_disabled = "無効"
auto_check_hourly = "1時間ごと"
auto_check_daily = "毎日"
auto_check_weekly = "毎週"
exit = "終了"
restart = "再起動"
show_widget = "ウィジェットを表示"
session_window = "5時間"
weekly_window = "7日"
now = "今"
day_suffix = "日"
hour_suffix = "時"
minute_suffix = "分"
second_suffix = "秒"
token_expired_title = "Claude Codeのセッションが切れました"
token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
chatgpt_token_expired_title = "Codexのセッションが切れました"
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
threshold_80_body = "5時間の上限に近づいています。"
threshold_95_body = "上限に近づきました — ペースを落としましょう。"
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more