Compare commits

...
65 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
168 changed files with 23482 additions and 12147 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 -39
View File
@@ -1,62 +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:
runs-on: windows-latest
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
+4841 -306
View File
File diff suppressed because it is too large. Load diff
+57 -20
View File
@@ -1,45 +1,82 @@
[package]
name = "claude-code-usage-bubble"
version = "0.4.1"
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_Dwm",
"Win32_Graphics_Gdi",
"Win32_System_LibraryLoader",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_Storage_FileSystem",
"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"
+134 -37
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).
@@ -33,31 +36,56 @@ self-updater are all written from scratch against the same public APIs
(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 providers, update frequency, language,
startup, updates, exit
- Optional system tray icons (one per enabled provider)
- 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 **Providers** 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).
@@ -68,31 +96,72 @@ 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, providers, refresh frequency, language, "Start
with Windows", controls, 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, or **Controls** in the right-click
menu, to resize it
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
right-click opens the same menu
- **Tray icon:** left-click toggles the bubble visibility, right-click opens
the same menu (on Linux, any click opens the menu)
### Providers
@@ -107,30 +176,59 @@ Use the right-click **Providers** menu to choose what is shown:
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`
@@ -145,7 +243,6 @@ What the app stores locally:
- Bubble position(s) per model
- Bubble size
- Polling frequency
- Language preference
- Last update check time
- Displayed provider preferences
+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.
@@ -1,104 +0,0 @@
---
phase: 1
title: "Foundation: CLI flags + native spawn helper + mutex retry"
status: complete
priority: P1
effort: "3h"
dependencies: []
---
# Phase 1: Foundation: CLI flags + native spawn helper + mutex retry
## Overview
Build the primitives that phases 2-4 reuse: a CLI argument parser for `--wait-pid <pid>` and `--updated-to <version>`, a `spawn_detached_self` helper that calls `CreateProcessW` directly (no cmd.exe), and mutex-acquisition retry logic that activates only when `--wait-pid` was passed.
## Requirements
**Functional**
- Parse `--wait-pid <u32>` and `--updated-to <version-string>` from `std::env::args` without breaking existing flags (`--diagnose`, `--apply-update`).
- Expose `spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()>` that uses `CreateProcessW` with `CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`.
- Expose `wait_for_parent_exit(pid: u32, timeout_ms: u32)` that uses `OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject`. Returns silently on timeout (best-effort).
- Mutex acquisition in `app::run` retries `CreateMutexW` for ~3 seconds (200ms backoff) ONLY when `--wait-pid` was present; preserves today's immediate-fail behavior for normal startup.
**Non-functional**
- No new external dependencies. All Win32 calls via the existing `windows = "0.58"` crate features.
- Helper module ≤ ~120 lines total.
## Architecture
```
src/
├── update/
│ └── handoff.rs ← NEW: spawn_detached, wait_for_parent_exit, cleanup_stale_old_exes
├── main.rs ← parse --wait-pid early, call wait_for_parent_exit BEFORE app::run
└── app.rs ← run() reads a static "wait_pid_was_passed" flag, retries mutex if set
```
Rationale for `src/update/handoff.rs`: keeps low-level Win32 process/file ops alongside the update module that uses them most. `app.rs` and `main.rs` import it for restart + post-update bootstrap.
## Related Code Files
- **Create**: `src/update/handoff.rs`
- **Modify**: `src/main.rs` (early arg parse + wait + flag handoff to app)
- **Modify**: `src/update/mod.rs` (declare `pub mod handoff`)
- **Modify**: `src/app.rs` (mutex retry loop, gated on flag from main)
- **Modify**: `Cargo.toml` if a new `windows` feature is needed (likely `Win32_System_Threading` already covers `OpenProcess`/`WaitForSingleObject`/`CreateProcessW`)
## Implementation Steps
1. **Audit `windows` crate features.** Confirm `Win32_System_Threading` is in `Cargo.toml` (it is — line 31). Verify `CreateProcessW`, `STARTUPINFOW`, `PROCESS_INFORMATION` are accessible. Add `Win32_Storage_FileSystem` if not already present (needed for phase 3's `MoveFileExW`).
2. **Create `src/update/handoff.rs`** with three pub fns:
- `pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()>`
- Build a wide-char command line: quoted exe path + space-joined args, NUL-terminated.
- `STARTUPINFOW` zero-initialized, `cb` set.
- `CreateProcessW(NULL, cmdline_wide.as_mut_ptr(), NULL, NULL, FALSE, CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, NULL, NULL, &si, &pi)`.
- Close `pi.hProcess` and `pi.hThread` immediately (fire-and-forget).
- `pub fn wait_for_parent_exit(pid: u32, timeout_ms: u32)`
- `OpenProcess(SYNCHRONIZE, FALSE, pid)`. If it fails (parent already gone), return immediately.
- `WaitForSingleObject(h, timeout_ms)`. Ignore return value.
- `CloseHandle(h)`.
- `pub fn cleanup_stale_old_exes(current_exe: &Path)` (used by phase 4; stub here, fill in phase 4)
- Stub: returns `Ok(())`.
3. **Modify `src/update/mod.rs`** to add `pub mod handoff;`.
4. **Modify `src/main.rs`** — insert BEFORE `app::run()`:
```rust
let wait_pid = args.iter()
.position(|a| a == "--wait-pid")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse::<u32>().ok());
if let Some(pid) = wait_pid {
update::handoff::wait_for_parent_exit(pid, 5_000);
}
```
Note: keep this AFTER `update::run_cli` so the legacy `--apply-update` path still short-circuits cleanly.
5. **Modify `src/app.rs::run`** — gate mutex retry on whether `--wait-pid` appeared. Cheapest implementation: re-parse `std::env::args` once at the top of `run()`. Then around line 156:
```rust
let retry_mutex = std::env::args().any(|a| a == "--wait-pid");
let _mutex = acquire_singleton_mutex(retry_mutex)?; // new helper
```
New helper `acquire_singleton_mutex(retry: bool)`:
- If `!retry`: today's behavior (fail immediately on `ERROR_ALREADY_EXISTS`).
- If `retry`: loop CreateMutexW → on `ALREADY_EXISTS`, `Sleep(200)` and retry. Budget 15 iterations = ~3 seconds. Log every retry. After budget exhausted, return error and exit cleanly.
6. **Compile check.** `cargo build --release`. Fix any feature gaps. No behavior should change yet — `--wait-pid` arg is parsed but no caller passes it yet.
## Success Criteria
- [ ] `cargo build --release` succeeds with zero warnings beyond the existing `dead_code` allow.
- [ ] Running the binary normally (no flags) behaves identically to today (mutex check is immediate, no retry).
- [ ] Running the binary with `--wait-pid <pid-of-running-instance>` against a live instance: the new process waits ≤5s for old one to exit, then acquires the mutex within ~200ms of its release. Verifiable by killing the original after 2s and watching the new one continue.
- [ ] `update::handoff::spawn_detached` smoke test: from a small one-off snippet in `main` (gated behind a never-used flag) verify CreateProcessW returns success and pid increments. Remove before commit.
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Wide-char cmdline construction has off-by-one / missing NUL | Hand-test with a path containing spaces; assert `OsStringExt::encode_wide` produces expected bytes |
| `CREATE_NO_WINDOW | DETACHED_PROCESS` combination misbehaves on GUI subsystem binaries | Documented Windows behavior: for a GUI subsystem child, both flags are effectively no-ops (no console requested), but combining them is harmless. Tested by phase 5 |
| Mutex retry loop hangs forever if budget logic wrong | Hard cap = 15 iterations × 200ms = 3.0s. After that, exit. Add log line per retry so a stuck loop is visible in `--diagnose` |
| `OpenProcess(SYNCHRONIZE, ...)` returns access-denied for cross-session | Fallback: `WaitForSingleObject` simply isn't called; mutex retry compensates |
@@ -1,113 +0,0 @@
---
phase: 2
title: "Restart path: replace restart_app with native CreateProcessW"
status: complete
priority: P1
effort: "1h"
dependencies: [1]
---
# Phase 2: Restart path: replace restart_app with native CreateProcessW
## Overview
Replace `src/app.rs::restart_app`'s `cmd.exe /c "timeout & start ..."` handoff with a direct `spawn_detached(current_exe, ["--wait-pid", our_pid])` call. The new instance handles the parent-exit wait itself using phase 1's helper; no timer needed.
## Requirements
**Functional**
- Restart triggered from the tray menu produces zero console flash.
- New instance acquires `Global\ClaudeCodeUsageBubble` mutex successfully every time.
- Settings still flushed to disk before exit (preserve current `snap + save` defensive write).
- Path-with-`%` defense becomes unnecessary (no cmd.exe); the check is removed.
**Non-functional**
- `restart_app` function shrinks from ~40 lines to ~25 lines.
## Architecture
```
restart_app():
1. settings::save(snap) (unchanged)
2. exe = current_exe() (unchanged)
3. our_pid = GetCurrentProcessId()
4. handoff::spawn_detached(exe, [--wait-pid, our_pid])
5. on success: PostQuitMessage(0)
6. on failure: log error, do NOT quit
```
Mutex release is implicit on process exit — no explicit `ReleaseMutex` needed because the `_mutex` handle in `app::run` is dropped when `run()` returns after `PostQuitMessage`. `Drop` closes the handle, which releases the mutex.
## Related Code Files
- **Modify**: `src/app.rs::restart_app` (lines ~1378-1432)
- **Remove**: the `RESTART_CREATE_NO_WINDOW` / `RESTART_DETACHED_PROCESS` constants (now in handoff.rs)
- **Remove**: the `%`-rejection defense (no cmd.exe to exploit)
- **Remove**: the `replace('"', "")` quote-stripping (handoff.rs handles quoting)
## Implementation Steps
1. **Read current `restart_app`** at `src/app.rs:1378-1432` to confirm exact bounds.
2. **Rewrite `restart_app`** to:
```rust
fn restart_app() {
// Defensive settings flush (unchanged)
let snap = lock_state().as_ref().map(|s| s.settings.clone());
if let Some(s) = snap {
settings::save(&s);
}
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
log::error!("restart: current_exe failed: {e}");
return;
}
};
let pid = unsafe { GetCurrentProcessId() };
let args = vec![
OsString::from("--wait-pid"),
OsString::from(pid.to_string()),
];
match update::handoff::spawn_detached(&exe, &args) {
Ok(()) => {
log::info!("restart: spawned detached child, posting quit");
unsafe { PostQuitMessage(0) };
}
Err(e) => log::error!("restart: spawn failed: {e}"),
}
}
```
3. **Drop the two `RESTART_*` const declarations** above the function — they were specific to the old cmd.exe path. Phase 1's helper has its own.
4. **Drop the `%`-rejection block** in the new `restart_app`. The brainstorm doc keeps the equivalent check in `install.rs` for defense-in-depth; here it's pure dead weight without cmd.exe.
5. **Imports**: add `use std::ffi::OsString;` and `use windows::Win32::System::Threading::GetCurrentProcessId;` if not already in scope.
6. **Compile check**: `cargo build --release`.
7. **Manual smoke test**: run the binary, click Restart in the tray menu, verify:
- No console window flashes
- New instance appears within ~1s
- Old instance log shows "spawned detached child, posting quit"
- New instance log shows mutex acquired (via Phase 1's retry path)
## Success Criteria
- [ ] `cargo build --release` clean.
- [ ] Manual restart from menu produces ZERO visible console window across 20 consecutive triggers.
- [ ] New instance window appears within 1500ms of menu click.
- [ ] Settings file (`settings.json`) shows updated mtime after restart, confirming defensive save still runs.
- [ ] `restart_app` function is ≤25 lines.
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Mutex handle not yet dropped when child tries to acquire | Phase 1's `--wait-pid` + 5s `WaitForSingleObject` + 3s mutex retry covers race comfortably (8s total budget vs <1s actual parent exit) |
| `PostQuitMessage` doesn't immediately exit; window-message pump may process more events | Phase 1 mutex retry tolerates up to 3s of overlap |
| `current_exe()` returns a path that the child can't load (rare: deleted exe, fileshare disconnect) | Existing behavior preserved: log error, do not quit. User can manually retry |
@@ -1,156 +0,0 @@
---
phase: 3
title: "Update install: rename + move + native spawn"
status: complete
priority: P1
effort: "2h"
dependencies: [1]
---
# Phase 3: Update install: rename + move + native spawn
## Overview
Replace `src/update/install.rs::begin`'s `cmd.exe /c "timeout & move & start ..."` handoff with native steps: `MoveFileExW` to rename the running exe sideways, `MoveFileExW` to move the staged exe into place, then `spawn_detached` of the new binary. Removes the only remaining cmd.exe invocation in the update flow.
## Requirements
**Functional**
- Update install produces zero console flash.
- New binary version starts after auto-update without user interaction.
- SHA-256 verification continues to gate the swap (no swap on checksum mismatch).
- On any failure step, the original exe must remain runnable (no half-state).
**Non-functional**
- `install.rs` net change ≈ -30 lines (cmd-quoting code is gone, replaced by short Win32 calls).
- New code paths use the windows crate; no new dependencies.
## Architecture
```
install::begin(http, release):
1. current = current_exe()
2. ensure_writable(current.parent()) (unchanged)
3. staging = stage_path()
4. reject_unsafe_path(current) (kept for defense-in-depth; harmless now)
5. reject_unsafe_path(staging)
6. create_dir_all(staging.parent())
7. download(http, asset_url, staging, sha256) (unchanged)
8. backup = current.with_file_name(format!("{}.old.{}", filename, pid))
9. MoveFileExW(current, backup, 0) ← NEW: rename running exe sideways
10. MoveFileExW(staging, current, MOVEFILE_REPLACE_EXISTING) ← NEW
11. our_pid = GetCurrentProcessId()
12. handoff::spawn_detached(current,
["--wait-pid", our_pid, "--updated-to", version_str]) ← NEW
13. return Ok(())
```
Caller (`app.rs` Apply action) is responsible for `PostQuitMessage` after `begin` returns Ok — same as today.
### Rollback semantics
| Step that failed | State | Recovery |
|---|---|---|
| 7 (download) | Original exe untouched | Existing behavior: error surfaced, user retries |
| 9 (rename current → backup) | Original exe untouched | Surface `Error::NotWritable`; do not proceed |
| 10 (move staging → current) | Original exe is at backup path, current path empty | Best-effort revert: rename backup back to current; surface error |
| 10 + revert (both fail) | Original at backup path; current path empty; user has no runnable binary at the install location | **Per Validation Session 1 decision:** show a Windows `MessageBoxW` (MB_OK \| MB_ICONERROR) telling the user where the backup is, then exit. Message: "Update failed. Your original binary is saved as `{backup_path}`. Please rename it back to `{exe_name}` manually." |
| 12 (spawn child) | New exe at correct path, but app didn't restart | Log + tray balloon "Update applied; restart manually". Rare — `CreateProcessW` on a fresh fully-written exe almost never fails |
<!-- Updated: Validation Session 1 - Rollback escalation MessageBox added -->
### Rollback escalation helper
Add a private `surface_rollback_failure(backup_path: &Path, target_name: &str)` helper that calls `MessageBoxW` with `MB_OK | MB_ICONERROR` and the localized message. Adds a new `LocaleStrings` field `update_rollback_failed_body` parameterized with `{backup_path}` and `{exe_name}` (Rust `format!` substitution at call site). The plain MessageBox uses the Win32 dialog, so no console can flash.
## Related Code Files
- **Modify**: `src/update/install.rs::begin`
- **Modify**: `src/update/install.rs::spawn_handoff` → REMOVED entirely
- **Modify**: `src/update/install.rs` imports (drop `os::windows::process::CommandExt`, `process::{Command, Stdio}`; add `MoveFileExW`, `MOVEFILE_REPLACE_EXISTING`, `GetCurrentProcessId`, `MessageBoxW`, `MB_OK`, `MB_ICONERROR`)
- **Modify**: `src/update/mod.rs::Error` — add `Error::SwapFailed(String)` variant if MoveFileExW failures don't fit existing variants cleanly
- **Modify**: `src/i18n/mod.rs::LocaleStrings` — add `update_rollback_failed_body: String` (also belongs to Phase 4 i18n group, but Phase 3 is the consumer)
## Implementation Steps
1. **Read current `install.rs::begin` + `spawn_handoff`** to confirm exact bounds (lines 19-36 + 99-121).
2. **Decide path-safety policy**: keep `reject_unsafe_path` (the `%`-check) as defense-in-depth even though no cmd.exe runs. Update the function-level comment to reflect new reality (kept for paranoia, not strict need).
3. **Add `swap_and_spawn` private helper** (replaces `spawn_handoff`):
```rust
fn swap_and_spawn(
source: &Path,
target: &Path,
version: &super::release::Version,
) -> Result<(), super::Error> {
let backup = backup_path(target);
move_file(target, &backup, 0)?;
if let Err(e) = move_file(source, target, MOVEFILE_REPLACE_EXISTING) {
// Best-effort revert
let _ = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING);
return Err(e);
}
let pid = unsafe { GetCurrentProcessId() };
let args = vec![
OsString::from("--wait-pid"),
OsString::from(pid.to_string()),
OsString::from("--updated-to"),
OsString::from(format!("{}.{}.{}", version.major, version.minor, version.patch)),
];
super::handoff::spawn_detached(target, &args)
.map_err(super::Error::Io)
}
fn move_file(src: &Path, dst: &Path, flags: MOVE_FILE_FLAGS) -> Result<(), super::Error> {
let src_w = to_utf16_nul(src);
let dst_w = to_utf16_nul(dst);
let r = unsafe {
MoveFileExW(
PCWSTR::from_raw(src_w.as_ptr()),
PCWSTR::from_raw(dst_w.as_ptr()),
flags,
)
};
r.ok().map_err(|e| super::Error::SwapFailed(e.to_string()))
}
fn backup_path(target: &Path) -> PathBuf {
let pid = unsafe { GetCurrentProcessId() };
let mut p = target.to_owned();
let fname = target.file_name().map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "exe".to_string());
p.set_file_name(format!("{fname}.old.{pid}"));
p
}
```
Use the existing `os::to_utf16_nul` helper for wide-char conversion (already used in `app.rs::run`).
4. **Rewrite `begin`** to call `swap_and_spawn(&staging, &current, &release.version)` instead of `spawn_handoff(&staging, &current)`. Pass the version from the `Release` struct already in hand.
5. **Add `Error::SwapFailed(String)` variant** to `src/update/mod.rs` if no existing variant fits the move-failure semantics. The `#[error(...)]` message should be `"file swap failed: {0}"`.
6. **Remove `spawn_handoff` function** entirely. Remove now-unused imports (`std::os::windows::process::CommandExt`, `Command`, `Stdio`, `CREATE_NO_WINDOW`, `DETACHED_PROCESS` constants).
7. **Compile check**: `cargo build --release`. Address any feature-flag gaps (likely need `Win32_Storage_FileSystem` added to Cargo `windows` features for `MoveFileExW`).
8. **Test rollback path manually**: write a temp .exe to staging that is read-only or has wrong permissions to force step 10 to fail; verify backup is restored and original is still runnable.
## Success Criteria
- [ ] `cargo build --release` clean.
- [ ] Manual auto-update from a test-tagged v0.1.99 produces ZERO visible console window across 5 consecutive runs.
- [ ] SHA-256 mismatch still rejects the swap (verify by tampering with a downloaded asset before swap).
- [ ] On forced step-10 failure (simulated): backup is restored, original binary still launches.
- [ ] `spawn_handoff` function no longer exists in the codebase (`grep -r spawn_handoff src/` returns empty).
- [ ] No `cmd.exe` string remains in `src/update/install.rs`.
## Risk Assessment
| Risk | Mitigation |
|---|---|
| `MoveFileExW` fails because AV holds a handle on the running exe | Surface `Error::SwapFailed`; user retries. Rare on Defender (which scans on read, not perpetually) |
| Two updates triggered in quick succession leave two `.old.<pid>` files | Phase 4 cleanup at startup handles this — glob removes ALL `.old.*` siblings |
| User's install dir is on a network share where renaming-while-open is forbidden | Existing `ensure_writable` probe catches read-only / no-write cases. Network FS oddities → surface as `NotWritable` |
| `MoveFileExW` with `REPLACE_EXISTING` on a non-NTFS volume | Works on FAT32 (per MS docs); the renaming-while-running concern is NTFS-specific but step 9 always renames an empty (just-emptied) path in step 10 |
| Release-build inlines + LTO breaks symbol-level rollback assumption | Functional rollback path is exercised by phase 5's manual test under release profile |
@@ -1,165 +0,0 @@
---
phase: 4
title: "Cleanup + tray notification"
status: complete
priority: P2
effort: "1.5h"
dependencies: [1, 3]
---
# Phase 4: Cleanup + tray notification
## Overview
Two additions that make the silent update visible to the user without being intrusive: (a) startup cleanup of stale `bubble.exe.old.<pid>` files from previous updates, and (b) a tray balloon "Updated to vX.Y.Z" on first launch after auto-update (driven by the `--updated-to` flag passed by phase 3).
<!-- Updated: Validation Session 1 - i18n approach corrected to match TOML struct-field architecture -->
## Requirements
**Functional**
- On every startup, scan `current_exe().parent()` for files matching `<exe-stem>.exe.old.*` and remove them silently. Errors logged at debug level, never surfaced to user.
- When `--updated-to vX.Y.Z` is passed AND the tray subsystem is initialized, show a balloon notification with localized title + body.
- Add **3 new fields** to `LocaleStrings` (`src/i18n/mod.rs:23-71`): `update_applied_title`, `update_applied_body`, `update_rollback_failed_body` (the last one is consumed by Phase 3 but the i18n change belongs to this phase's pattern). Body strings use Rust `format!` at call site — TOML strings hold raw text (e.g. body = `"Updated to v"`, then call site does `format!("{}{}", strings.update_applied_body, version)`). Choice of suffix vs prefix vs `{}` placeholder substitution is dialect-sensitive; use literal positional substitution via `format!` because TOML doesn't support template placeholders the i18n loader recognizes.
- Translate the 3 new strings in all **8 existing locale files**: `src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml`.
**Non-functional**
- Cleanup runs in the foreground startup path (it's a few file operations — no need for a thread).
- Balloon uses `NIIF_INFO` (blue info icon) not `NIIF_WARNING` (yellow triangle). The existing `tray::notify` (`src/tray/mod.rs:85`) hardcodes `NIIF_WARNING` and has **2 callers** (`src/app.rs:831` usage threshold, `src/app.rs:859` token expired) — both correctly semantically "warning". Rename existing `notify` → `notify_warning`; add new sibling `notify_info`.
## Architecture
### Cleanup
Triggered from `app::run` after tray icons register but before main message loop. Implementation lives in `update::handoff::cleanup_stale_old_exes` (stub was added in phase 1).
```rust
pub fn cleanup_stale_old_exes(current_exe: &Path) {
let Some(dir) = current_exe.parent() else { return };
let Some(stem) = current_exe.file_name() else { return };
let prefix = format!("{}.old.", stem.to_string_lossy());
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with(&prefix) {
if let Err(e) = std::fs::remove_file(entry.path()) {
log::debug!("cleanup_stale_old_exes: remove {:?} failed: {e}", entry.path());
}
}
}
}
```
### Balloon notification
Parse `--updated-to` argument early (alongside `--wait-pid` in main.rs), stash it on `AppState`. After the tray icons are registered for the first time, if the version string is present, call `tray::notify_info(hwnd, kind, title, body)`.
```rust
// main.rs (after --wait-pid parse):
let updated_to = args.iter()
.position(|a| a == "--updated-to")
.and_then(|i| args.get(i + 1))
.cloned();
// pass into app::run via existing arg-threading or a static OnceLock
```
```rust
// tray/mod.rs: split notify into two variants
pub fn notify_info(owner: HWND, kind: IconKind, title: &str, body: &str) {
notify_inner(owner, kind, title, body, NIIF_INFO);
}
pub fn notify_warning(owner: HWND, kind: IconKind, title: &str, body: &str) {
notify_inner(owner, kind, title, body, NIIF_WARNING);
}
fn notify_inner(owner: HWND, kind: IconKind, title: &str, body: &str, flags: NOTIFY_ICON_INFOTIP_FLAGS) {
// existing body, but use `flags` instead of hardcoded NIIF_WARNING
}
```
If `tray::notify` has only one caller today (which the brainstorm scout suggested), just rename it to `notify_warning` and add `notify_info`.
## Related Code Files
- **Modify**: `src/update/handoff.rs::cleanup_stale_old_exes` (fill in phase-1 stub)
- **Modify**: `src/app.rs::run` (call cleanup; call tray::notify_info after tray registration if updated_to is set)
- **Modify**: `src/app.rs:831,859` (rename `tray::notify` → `tray::notify_warning` at both existing call sites)
- **Modify**: `src/main.rs` (parse `--updated-to`, stash for app)
- **Modify**: `src/tray/mod.rs` — rename `notify` → `notify_warning`; add `notify_info`; extract shared `notify_inner(... flags: NOTIFY_ICON_INFOTIP_FLAGS)`
- **Modify**: `src/i18n/mod.rs::LocaleStrings` — add 3 new `String` fields: `update_applied_title`, `update_applied_body`, `update_rollback_failed_body`
- **Modify**: `src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml` — 8 files, add the 3 new keys to each. Use machine translation for non-English where idiomatic translation unavailable; flag with comment for native-speaker review later
## Implementation Steps
1. **i18n: add 3 new fields to `LocaleStrings`** in `src/i18n/mod.rs:23-71`. After the existing `threshold_95_body` field, add:
```rust
pub update_applied_title: String,
pub update_applied_body: String,
pub update_rollback_failed_body: String,
```
2. **Translate in 8 locale files** (`src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml`). Suggested English values (mirror style of existing `token_expired_title`/`token_expired_body`):
```toml
update_applied_title = "Update applied"
update_applied_body = "Updated to v" # call site appends version string
update_rollback_failed_body = "Update failed. Your original binary is saved as " # call site appends backup path + suffix
```
For non-English, machine-translate body+title, leave the trailing space/prefix structure intact. Comment in PR description: "Translations machine-generated; flagged for native-speaker review".
3. **Split `tray::notify`** (`src/tray/mod.rs:85-94`) into:
- rename existing fn → `notify_warning` (preserves current `NIIF_WARNING` semantics)
- extract shared `fn notify_inner(owner, kind, title, body, flags: NOTIFY_ICON_INFOTIP_FLAGS)`
- add new `pub fn notify_info(owner, kind, title, body)` that calls `notify_inner(..., NIIF_INFO)`
Update both existing call sites (`src/app.rs:831,859`) to call `notify_warning` instead of `notify`.
4. **Fill in `cleanup_stale_old_exes`** per architecture section.
5. **Parse `--updated-to` in main.rs**, thread it into `app::run`. Two options:
- Pass as a new arg to `pub fn run(updated_to: Option<String>)`.
- Store in a `OnceLock<Option<String>>` inside `update::handoff`, set in main, read in app.
Pick the simpler one — direct function arg is preferred unless `run`'s signature is already heavily used elsewhere.
6. **In `app::run`** after tray icons register and the main window message loop is about to enter:
```rust
if let Some(v) = updated_to.as_ref() {
let strings = i18n.strings();
let title = strings.update_applied_title.clone();
let body = format!("{}{}", strings.update_applied_body, v);
tray::notify_info(msg_hwnd, IconKind::ClaudeCode, &title, &body);
}
update::handoff::cleanup_stale_old_exes(&exe);
```
Use `ClaudeCode` IconKind because it's always present when Claude is enabled (default). If user disabled Claude and enabled only Codex, fall back to Codex kind. Cheapest: try ClaudeCode first; if `tray::notify_info` fails silently, no harm.
7. **Compile check**: `cargo build --release`.
8. **Manual test cleanup**:
- Create a file `claude-code-usage-bubble.exe.old.1234` next to the running binary.
- Launch the app.
- Verify the file is gone after launch.
9. **Manual test notification**:
- Launch with `--updated-to 9.9.9` flag.
- Verify Windows notification appears with the title + version body.
- Verify it uses the blue info icon, not yellow warning.
## Success Criteria
- [ ] `cargo build --release` clean.
- [ ] Stale `.old.<pid>` files in install dir are removed on every startup (idempotent).
- [ ] Launching with `--updated-to vX.Y.Z` shows a tray balloon with localized title and version body.
- [ ] Balloon icon is blue info (NIIF_INFO), not yellow warning.
- [ ] Launching WITHOUT `--updated-to` shows no balloon (existing behavior preserved).
- [ ] All 8 locale TOML files (`en, nl, es, fr, de, ja, ko, zh-TW`) contain the 3 new keys (`update_applied_title`, `update_applied_body`, `update_rollback_failed_body`); `cargo build --release` would fail to deserialize otherwise.
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Cleanup removes a `.old.<pid>` file that another running instance still depends on | Impossible by construction: only the spawning instance writes `.old.<pid>` and it has already exited by the time the new instance runs cleanup |
| Glob false-positive (e.g. a user-created file matching the pattern) | Pattern requires `.old.` literal AND a numeric pid-like suffix is implied; we don't pattern-match the suffix strictly. Risk is theoretical; user-created files matching `claude-code-usage-bubble.exe.old.*` is extremely unlikely |
| Tray balloon doesn't show because NIM_MODIFY runs before NIM_ADD completes | Defer the `notify_info` call by one tick (PostMessage to message loop) if testing shows races. Likely unnecessary because tray icons register synchronously |
| i18n loader is struct-field based, no template substitution at the loader level | Confirmed by Validation Session 1: append/prepend the version via Rust `format!` at the call site. TOML strings are static text fragments only |
| Translations diverge from idiomatic expression in non-English locales | Machine-translate for first cut, mark "FIXME: review by native speaker" in commit message. Subsequent crowd-sourced fixes are out of this plan's scope |
@@ -1,104 +0,0 @@
---
phase: 5
title: "Manual end-to-end verification"
status: pending
priority: P2
effort: "1h"
dependencies: [1, 2, 3, 4]
---
# Phase 5: Manual end-to-end verification
## Overview
Functional sign-off across all changed code paths. No unit tests added (the changed surface is Win32-heavy and effectively integration territory); instead, a documented manual checklist that the maintainer runs once before committing.
## Requirements
**Functional**
- All success criteria from phases 1-4 verified on a real Windows machine (Win11 preferred, Win10 as secondary if available).
- Build artifact runs without errors when launched plainly (no flags).
- No regression in existing update / restart / refresh paths.
**Non-functional**
- Verification log saved as a section in `phase-05` after run, with date + outcome per item.
## Test Matrix
### Group A: Restart path (phase 2)
| # | Step | Expected |
|---|---|---|
| A1 | Launch binary, open right-click menu, click "Restart" | No console flash; new instance appears within 1.5s |
| A2 | Repeat A1 twenty times back-to-back | Zero flashes observed; settings.json mtime updates each time |
| A3 | Launch with `--diagnose`, click Restart, inspect `%TEMP%\claude-code-usage-bubble.log` | Shows "restart: spawned detached child, posting quit" and new instance shows mutex acquired (within 200ms of the wait completing) |
### Group B: Update install path (phase 3)
Setup: tag a test release `v0.1.99` via the existing GitHub Actions workflow (per `plans/260516-1730-github-release-auto-update`). Bump local `Cargo.toml` back to `v0.1.0` before running. Build the local v0.1.0 with this plan's changes.
| # | Step | Expected |
|---|---|---|
| B1 | Launch v0.1.0 build, set update channel to "Hourly", manually trigger "Check for updates" | "Update available" shown; click "Apply" |
| B2 | During B1 apply | No console flash; new v0.1.99 instance appears |
| B3 | After B1/B2 | Tray balloon "Update applied — Updated to v0.1.99" appears (blue info icon) |
| B4 | Inspect install dir after B1/B2 | NO `.old.*` files remain (cleanup removed them) |
| B5 | Repeat B1 four more times (after re-tagging v0.1.100 etc.) | Zero flashes across 5 update cycles |
| B6 | Tamper test: edit downloaded asset on disk before swap (would require pausing between download and swap — gate via `--diagnose` log timestamps) | SHA-256 mismatch raises `Error::ChecksumMismatch`; swap is NOT performed; original binary still runs |
| B7 | Rollback test: make staging path read-only or simulate step-10 failure | Backup restored; original binary still runs after a manual restart |
### Group C: Cleanup + notification (phase 4)
| # | Step | Expected |
|---|---|---|
| C1 | Manually drop `claude-code-usage-bubble.exe.old.9999` next to the running binary; launch app | Stale file is removed within 1s of launch |
| C2 | Launch app with `--updated-to 9.9.9` arg from a terminal | Tray balloon shows title + "Updated to v9.9.9" in current UI language |
| C3 | Repeat C2 with each supported locale | Each locale shows correct translation |
| C4 | Launch normally (no `--updated-to`) | No balloon appears |
### Group D: Regression smoke
| # | Step | Expected |
|---|---|---|
| D1 | Launch binary fresh (cold start, no flags) | Bubble appears in <2s; usage refresh fires once; tray icon registers |
| D2 | Open settings (right-click → Language → switch), confirm restart happens | Restart works without flash (this is the same `restart_app` path) |
| D3 | Disable auto-update ("Disabled"), wait 30s, re-enable Hourly | No state corruption; check timer resets |
| D4 | Run with `--diagnose --apply-update <some-path> <pid>` (legacy compat) | Returns exit code 0 cleanly (per `update::install::run_cli`) — unchanged |
## Implementation Steps
1. Build `cargo build --release`.
2. Copy `target/release/claude-code-usage-bubble.exe` to `%LOCALAPPDATA%\ClaudeCodeUsageBubble\` (fresh test directory).
3. Run through Test Matrix A → B → C → D in order.
4. For each test row, record outcome (PASS/FAIL + notes) in the Verification Log section below.
5. If any FAIL: open an issue describing the failure, do NOT mark phase complete.
6. If all PASS: mark phase complete, commit changes following project commit conventions (no `chore:` or `docs:` per CLAUDE.md).
## Success Criteria
- [ ] All Group A tests PASS.
- [ ] All Group B tests PASS (B6 + B7 may be skipped if test scaffolding too costly; document as such).
- [ ] All Group C tests PASS.
- [ ] All Group D tests PASS.
- [ ] Verification Log filled in with date + outcomes.
- [ ] No console flash observed across the entire test session.
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Manual testing skips Group B because tagging real releases is annoying | Alternative: build two local copies (v0.1.0 + v0.1.99), set up a local HTTP server with a fake GitHub-Releases-shaped JSON, point the app at it via a debug flag. Out of scope for this plan but noted |
| "Flash" is subjective at 60Hz | Record screen with OBS at 60fps for one test run, scrub frame-by-frame to confirm zero console window appearance |
| Win10-only flash regression (only test on Win11) | Document Win10 testing as "best effort"; primary target is Win11. Note Win10 result in Verification Log |
## Verification Log
<!-- Fill in after running the test matrix -->
### Session 1 — TBD
- Group A: TBD
- Group B: TBD
- Group C: TBD
- Group D: TBD
- Flash observed: TBD
- Notes: TBD
@@ -1,96 +0,0 @@
---
title: "Silent in-app update + restart (no cmd.exe)"
description: "Replace cmd.exe handoff in update install + app restart paths with native Win32 (MoveFileExW + CreateProcessW) so no terminal window can ever flash. Add tray-balloon notification after auto-updates."
status: in_progress
priority: P2
branch: "main"
tags: ["update", "restart", "win32", "ux"]
blockedBy: []
blocks: []
created: "2026-05-21T07:43:16.332Z"
createdBy: "ck:plan"
source: skill
---
# Silent in-app update + restart (no cmd.exe)
## Overview
Two paths today spawn `cmd.exe /c "timeout ... & start ..."` for update install (`src/update/install.rs::begin`) and app restart (`src/app.rs::restart_app`). Combination of `CREATE_NO_WINDOW | DETACHED_PROCESS` + inner `start ""` can still flash a console window on some Windows configs. This plan replaces both with native `MoveFileExW` + `CreateProcessW` (the main exe is `windows_subsystem = "windows"`, so direct spawn never allocates a console). Also wires a tray balloon "Updated to vX.Y.Z" on first launch after an auto-update.
Brainstorm context: [`plans/reports/brainstormer-260521-1530-silent-update-no-cmd.md`](../reports/brainstormer-260521-1530-silent-update-no-cmd.md).
Supersedes the cmd.exe mechanism decision in [`260518-0945-menu-restart-button`](../260518-0945-menu-restart-button/plan.md) (that plan picked cmd.exe deliberately, modeled on `update::install`; this plan replaces both call sites with the native path).
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Foundation: CLI flags + native spawn helper + mutex retry](./phase-01-foundation-cli-flags-native-spawn-helper-mutex-retry.md) | Complete |
| 2 | [Restart path: replace restart_app with native CreateProcessW](./phase-02-restart-path-replace-restart-app-with-native-createprocessw.md) | Complete |
| 3 | [Update install: rename + move + native spawn](./phase-03-update-install-rename-move-native-spawn.md) | Complete |
| 4 | [Cleanup + tray notification](./phase-04-cleanup-tray-notification.md) | Complete |
| 5 | [Manual end-to-end verification](./phase-05-manual-end-to-end-verification.md) | Pending (user-driven) |
## Key contracts (must hold across plan)
| Contract | Source today | Invariant |
|---|---|---|
| Singleton mutex name | `app.rs` `APP_MUTEX_NAME` = `Global\ClaudeCodeUsageBubble` | New instance must wait for parent to release before acquiring |
| Main binary subsystem | `main.rs:1` `#![windows_subsystem = "windows"]` | Direct spawn allocates no console |
| Asset filename | `release.rs:7` `claude-code-usage-bubble.exe` | Unchanged |
| SHA-256 verification | `install.rs:64-73` | Unchanged — still verified before swap |
| Settings save on shutdown | `app.rs::restart_app` snap+save | Preserved in new restart helper |
## Dependencies
No cross-plan dependencies. Related (superseded mechanism): `260518-0945-menu-restart-button`.
## Out of Scope
- `src/usage/refresh.rs` CLI spawns (`claude.cmd`, `codex.cmd`, `powershell.exe`, `wsl.exe`) — already use `CREATE_NO_WINDOW`; tracked for follow-up.
- `src/creds/wsl_bridge.rs` `wsl.exe` calls — same.
- Code signing / SmartScreen suppression — separate roadmap item.
- Cross-platform restart/update — Windows-only by design.
## Unresolved Questions
None.
## Validation Log
### Session 1 — 2026-05-21
#### Verification Results
- **Tier**: Full (5 phases)
- **Claims checked**: 8
- **Verified**: 6 | **Failed**: 2 | **Unverified**: 0
- Verified: `app.rs:155` mutex creation; `app.rs:1378-1432` restart_app bounds; `install.rs:42-48` `--apply-update` exit-clean handler; `install.rs:99-121` `spawn_handoff`; `os::to_utf16_nul` exists and is in use; `windows = 0.58` features in Cargo.toml include `Win32_System_Threading` but NOT `Win32_Storage_FileSystem` (phase 3 must add it).
- Failed: (V1) phase 4 said `tray::notify` has "one current caller" — actually 2 (`app.rs:831`, `app.rs:859`); (V2) phase 4 said i18n uses key-based template lookup with `{v}` placeholder — actually uses struct-field-based `LocaleStrings` with TOML, no template engine.
#### Decisions
1. **i18n approach: add 3 fields to `LocaleStrings` + translate 8 locale TOML files; use Rust `format!` at call site for version substitution.**
Reason: existing pattern is struct-field-based via `include_str!` of `src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml`. No template substitution at the loader level.
→ Propagated to `phase-04-cleanup-tray-notification.md`: Requirements, Related Code Files, Implementation Steps, Success Criteria, Risk Assessment all updated.
2. **Rollback failure escalation: Windows `MessageBoxW` (MB_OK | MB_ICONERROR) when MoveFileExW step 10 AND best-effort revert both fail.**
Reason: silent failure here would orphan the user — they'd have no exe at install path. A clear modal tells them where the backup is (`bubble.exe.old.<pid>`).
→ Propagated to `phase-03-update-install-rename-move-native-spawn.md`: rollback table extended; imports list updated; new helper `surface_rollback_failure` added; new `LocaleStrings` field `update_rollback_failed_body` added to phase 4's i18n work.
3. **Stuck-parent fallback: accept 8s total budget (5s `WaitForSingleObject` + 3s mutex retry), then exit cleanly. No `TerminateProcess`.**
Reason: forcing process termination would defeat the `settings::save` defensive flush guarantee. The 8s ceiling is generous for normal Windows scheduling; if a real hang persists, user can manually kill old process via Task Manager — acceptable failure mode.
→ No phase file change needed. Phase 1 budget numbers (5s + 3s) already match.
4. **Auto-update timing: apply when check fires, no idle-window deferral.**
Reason: matches user's brainstorm-phase choice ("Fully silent auto-update"). Idle detection adds complexity (state tracking, defer-budget, defer-never-applies edge case) for marginal UX gain — bubble flicker during ~1s restart is acceptable.
→ No phase file change needed.
#### Whole-Plan Consistency Sweep
- Files reread: `plan.md`, `phase-01-…md`, `phase-02-…md`, `phase-03-…md`, `phase-04-…md`, `phase-05-…md`
- Decision deltas checked: 4
- Reconciled stale references: 2
- Phase 4 i18n section rewritten from key-based template to struct-field + Rust `format!`
- Phase 4 `tray::notify` caller count corrected from "1" to "2" with explicit file:line citations
- Cross-phase touchpoints verified: Phase 3 adds `update_rollback_failed_body` to `LocaleStrings`, Phase 4 owns the full i18n change (3 fields + 8 TOMLs) — both phases reference the same struct, consistent
- Unresolved contradictions: 0
@@ -1,57 +0,0 @@
---
phase: 1
title: "Lock geometry"
status: pending
priority: P1
effort: "45m"
dependencies: []
---
# Phase 1: Lock geometry
## Overview
Define the tail layout contract in `compute_bubble_layout` so both rows consume the same horizontal geometry and differ only in vertical placement and bar height.
## Requirements
- Functional: percent text and countdown text appear after the bar, not inside it.
- Functional: `tail_usage_bar_rect.left/right == tail_time_bar_rect.left/right`.
- Functional: `tail_usage_pct_rect.left/right == tail_time_text_rect.left/right`.
- Non-functional: preserve the 140-360 logical size behavior and the minimum bar-width guard.
- Non-functional: no new renderer inputs from `src/app.rs`.
## Architecture
- Data flow: `src/app.rs:639-655` -> `bubble::update_data` -> `PaintInputs` -> `compute_bubble_layout` -> `paint_bubble_pixmap` / `paint_bubble_text`.
- Geometry source of truth: shared `text_w`, `text_left`, `bar_left`, and `bar_right` in `src/bubble.rs:1114-1126`.
- Current rect assignment already fans those shared values into both tail rows at `src/bubble.rs:1141-1163`.
## Related Code Files
- Modify: `src/bubble.rs`
- Read-only check: `src/app.rs`
## Implementation Steps
1. Re-verify the live mismatch before changing code; the current source already shares bar and text columns.
2. Make the target contract explicit in `compute_bubble_layout`: one shared bar lane, one shared text lane, row-specific `top/bottom` only.
3. Preserve `bar_min` fallback so long countdowns shrink text first, not bar width below usability.
4. Keep bar-height asymmetry unless the user confirms that equal thickness is also required.
## Todo List
- [ ] Confirm whether the bug is still reproducible on `main`.
- [ ] Document the target geometry near `compute_bubble_layout`.
- [ ] Ensure no later row-specific width override remains.
## Success Criteria
- [ ] Both tail bars have identical `left/right` bounds.
- [ ] Both tail texts start at the same `left` and end at the same `right`.
- [ ] No upstream data-contract change is required.
## Risk Assessment
- High: the source may already satisfy the request; unnecessary edits would add churn. Mitigation: prove the runtime mismatch first.
- Medium: long localized countdown strings can starve bar width at minimum size. Mitigation: keep `bar_min` and shared fallback math.
- Rollback: revert only the `compute_bubble_layout` diff.
## Security Considerations
- None beyond normal memory-safety review; change is layout-only.
## Next Steps
- Hand off the shared-geometry contract to Phase 2 for text painting and stale-comment cleanup.
@@ -1,57 +0,0 @@
---
phase: 2
title: "Apply renderer change"
status: pending
priority: P2
effort: "45m"
dependencies: [1]
---
# Phase 2: Apply renderer change
## Overview
Apply the tail text-placement change in the renderer so the weekly percent lane and weekly remaining-time lane follow the same `bar -> text` behavior, without changing provider or state plumbing.
## Requirements
- Functional: weekly percent text renders from `tail_usage_pct_rect`; weekly countdown renders from `tail_time_text_rect`.
- Functional: both texts stay right-aligned after the bar using `DT_RIGHT`.
- Functional: no tail text is drawn inside the bar fill.
- Non-functional: preserve the existing pulse behavior for `weekly_pct >= 95`.
- Non-functional: avoid touching `PaintInputs`, polling, or panel code unless a stale comment must be corrected.
## Architecture
- Bar drawing is tiny-skia-only in `src/bubble.rs:1298-1331`.
- Tail text drawing is a later GDI overlay in `src/bubble.rs:1643-1661`.
- `src/app.rs:636-638` currently describes the bubble percent as inline in the bar fill; if that wording is now false, correct it in the same phase.
## Related Code Files
- Modify: `src/bubble.rs`
- Optional modify: `src/app.rs`
## Implementation Steps
1. Align `paint_bubble_text` with the Phase 1 geometry contract and keep the percent/countdown text outside the bars.
2. Remove any remaining inline-percent assumption in comments or naming if it conflicts with the final behavior.
3. Keep the weekly percent highlight behavior and empty-countdown handling intact.
4. Stop scope creep: no changes to provider snapshots, update timers, or panel layout.
## Todo List
- [ ] Confirm `paint_bubble_text` is the only text-placement site for the tail rows.
- [ ] Update or remove stale inline-bar comments if they become misleading.
- [ ] Re-check placeholder and `None` states after the layout change.
## Success Criteria
- [ ] Weekly percent text appears after the top tail bar.
- [ ] Weekly countdown text appears after the bottom tail bar.
- [ ] Tail bar widths are driven only by shared geometry from `compute_bubble_layout`.
## Risk Assessment
- Medium: if the reported mismatch is only visual perception from unequal bar heights, text-placement edits alone will not fix it. Mitigation: compare runtime screenshots before and after Phase 1.
- Low: optional comment cleanup in `src/app.rs` can drift from renderer reality. Mitigation: change comments only after the final behavior is locked.
- Rollback: revert only the text-placement and comment diffs.
## Security Considerations
- None; no auth, network, or filesystem behavior changes.
## Next Steps
- Hand off to Phase 3 for compile checks and Windows visual verification.
@@ -1,64 +0,0 @@
---
phase: 3
title: "Validate on Windows"
status: pending
priority: P2
effort: "30m"
dependencies: [2]
---
# Phase 3: Validate on Windows
## Overview
Verify that the scoped renderer change compiles and that the native layered-window bubble actually presents equal-width tail bars with text after each bar across common runtime conditions.
## Requirements
- Functional: both tail rows visually render as `bar -> text`.
- Functional: both tail bars have the same visible width.
- Non-functional: confirm no regression to head ring, head text, or tray/panel refresh behavior.
- Non-functional: validation stays command-light and uses the existing Windows runtime.
## Architecture
- Compile-time validation covers the Rust renderer path end to end.
- Runtime validation must observe the real layered window because there are no snapshot/golden tests for `tiny-skia + GDI` composition in this repo.
## Related Code Files
- Verify: `src/bubble.rs`
- Verify if touched: `src/app.rs`
## Implementation Steps
1. Run the compile/test commands below.
2. Launch the app and verify the bubble at 140, default, and max logical sizes.
3. Check light and dark theme, Claude and Codex bubbles, and a long countdown string if available.
4. Capture before/after notes so a no-op or perception-only result is explicit.
## Validation Commands
```powershell
cargo check
cargo test
cargo run
```
## Todo List
- [ ] `cargo check` passes.
- [ ] `cargo test` passes, or any pre-existing failures are called out separately.
- [ ] Manual runtime check confirms equal bar widths and text-after-bar alignment.
- [ ] No regression is seen in the head ring/text or bubble refresh path.
## Success Criteria
- [ ] Compile succeeds on the current branch.
- [ ] The top and bottom tail bars share the same left/right edges at runtime.
- [ ] The percent and countdown texts both sit to the right of their bars at runtime.
- [ ] Any remaining mismatch is explained with evidence, not assumption.
## Risk Assessment
- High: native renderer issues are hard to prove without manual observation. Mitigation: test at minimum/default/maximum sizes and common DPI settings.
- Medium: reproducing the original complaint may require a specific locale, DPI, or stale binary. Mitigation: record the runtime conditions used during verification.
- Rollback: revert the renderer change if compile or visual regression appears.
## Security Considerations
- None.
## Next Steps
- If validation passes, implementation can be approved as a scoped `src/bubble.rs` change. If not, reopen Phase 1 with the observed runtime evidence.
@@ -1,50 +0,0 @@
---
title: "Bubble tail bar layout alignment"
description: "Scoped renderer-only plan to align weekly percent and remaining-time tail bar geometry."
status: pending
priority: P2
effort: 2h
branch: "main"
tags: [rust, renderer, bubble, layout]
blockedBy: []
blocks: []
created: 2026-05-23
createdBy: "ck:plan"
source: skill
---
# Bubble tail bar layout alignment
## Scope
- User-facing goal: weekly percent lane and weekly remaining-time lane both render as `bar -> text`, and both bars share identical left/right bounds.
- Expected code scope: `src/bubble.rs`; touch `src/app.rs` only if comment cleanup is needed.
- Backwards compatibility: no settings, storage, IPC, or provider-data changes.
## Verified Codebase Facts
- Bubble data already provides `weekly_pct`, `weekly_text`, and `weekly_resets_at` through `bubble::update_data`; no new inputs are needed (`src/app.rs:639-655`).
- `compute_bubble_layout` already derives one shared text column and one shared bar lane for the two tail rows (`src/bubble.rs:1114-1163`).
- `paint_bubble_text` already renders weekly percent and weekly countdown as separate right-aligned texts (`src/bubble.rs:1644-1661`).
- `paint_bubble_pixmap` paints both tail bars from rects only; text is a GDI overlay, so geometry must stay the single source of truth (`src/bubble.rs:1298-1331`).
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Lock geometry](./phase-01-lock-geometry.md) | Pending |
| 2 | [Apply renderer change](./phase-02-apply-renderer-change.md) | Pending |
| 3 | [Validate on Windows](./phase-03-validate-on-windows.md) | Pending |
## Dependencies
- Sequence: Phase 1 -> Phase 2 -> Phase 3.
- File ownership: `src/bubble.rs` stays single-owner across phases; optional `src/app.rs` comment cleanup happens only in Phase 2.
- Related existing plan: `plans/260523-ui-ux-improvement-plan/plan.md` Phase 3 overlaps in theme but does not block this scoped renderer-only change.
## Rollback
- Revert layout math and text-placement changes in `src/bubble.rs`.
- Revert optional comment cleanup in `src/app.rs`.
- No data migration or persisted-state rollback is needed.
## Unresolved Questions
- Does "same length" mean equal width only, or should the two tail bars also share the same height? Current code intentionally uses different heights (`src/bubble.rs:1105-1110`).
- The current source already looks close to the requested behavior. If runtime still differs, is the issue in this branch, a stale binary, or perception caused by different bar heights?
@@ -1,91 +0,0 @@
# UI/UX Improvement Plan
## Context
- Product: native Windows floating usage bubble for Claude Code/Codex.
- Current UI stack: Win32 popup/layered windows, tiny-skia drawing, GDI text, Shell tray icons.
- Current baseline: `cargo check` passes on 2026-05-23.
- Primary files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, `src/tray/*`, `src/usage_color.rs`, `src/i18n/locales/*.toml`.
## Phase 1 - Accessibility And Status Clarity
- Status: Partially Complete
- Priority: High
- Files: `src/usage_color.rs`, `src/bubble.rs`, `src/panel.rs`, `src/tray/mod.rs`, locale TOMLs.
- Improve non-color status cues for normal/warning/critical/auth/error states.
- Add richer tray tooltips: model, 5h percent/countdown, 7d percent/countdown, current state.
- Add localized strings for warning/critical labels and unavailable/auth states.
- Keep usage colors centralized in `usage_color.rs`; avoid per-surface color drift.
- Validation: contrast check for light/dark colors, manual tray tooltip check, `cargo check`.
- Completed 2026-05-23: richer tray tooltip now includes model, 5h, 7d, and left-click hint.
- Completed 2026-05-23: tray tooltip uses shorter localized tray hint text to reduce truncation risk.
## Phase 2 - Discoverability And Native Controls
- Status: Partially Complete
- Priority: High
- Files: `src/app.rs`, `src/bubble.rs`, locale TOMLs.
- Add menu items for common hidden actions: resize smaller/larger, reset size, show details.
- Add a short localized "Help" or "Controls" submenu listing drag, click, right-click, Ctrl+wheel.
- Make resize available through menu commands, not only Ctrl+MouseWheel.
- Review context-menu grouping so status/update/model/settings actions scan as separate groups.
- Validation: keyboard-access menu traversal, menu command behavior, persisted settings.
- Completed 2026-05-23: added localized Controls submenu with resize actions and disabled help rows.
- Completed 2026-05-23: disabled resize commands when they would no-op and unified menu/wheel resize through shared bubble size.
## Phase 3 - Bubble Legibility And Interaction Robustness
- Status: Planned
- Priority: Medium
- Files: `src/bubble.rs`, optional extracted bubble modules.
- Improve layout for smallest sizes: reserve stable text bounds, handle `100%`, placeholder, `!`, and long countdowns.
- Consider minimum size/shape copy update because code uses 140-360 logical width while README mentions 32-128 pixels.
- Add drag threshold and click behavior review around `WM_EXITSIZEMOVE` to reduce accidental panel opens.
- Add optional pulse reduction path if Windows animation/reduced-motion preference is available.
- Validation: manual checks at min/default/max size, 100/125/150/200% DPI, both models enabled.
## Phase 4 - Expanded Panel Redesign
- Status: Planned
- Priority: Medium
- Files: `src/panel.rs`, locale TOMLs, maybe `src/app.rs`.
- Replace fixed 280x120 assumptions with measured or wider adaptive layout.
- Make rows self-explanatory: model header, 5h and 7d labels, percent plus reset countdown.
- Add explicit error/auth/loading state rendering instead of only symbols/placeholders.
- Improve panel placement near screen edges and multi-monitor boundaries.
- Consider extracting panel layout/painting into smaller modules before behavior changes.
- Validation: all locales, long countdown text, light/dark theme, focus-loss close behavior.
## Phase 5 - Tray And Notification Polish
- Status: Planned
- Priority: Medium
- Files: `src/tray/mod.rs`, `src/tray/badge.rs`, `src/app.rs`.
- Make tray icon state readable without exact color distinction: tooltip carries exact data, icon bands remain coarse.
- Review notification throttling and text for threshold crossings.
- Ensure tray left-click/right-click behavior matches Windows notification-area conventions.
- Add manual test matrix for one-provider and two-provider modes.
- Validation: tray add/modify/delete, balloon messages, no stale icons after exit/restart.
## Phase 6 - Structure And Verification
- Status: Planned
- Priority: Medium
- Files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, docs if behavior changes.
- Split only where it reduces real risk: bubble layout/rendering/interaction and panel layout/rendering first.
- Keep public behavior stable while extracting.
- Add unit tests for pure functions where practical: color bands, size clamps, layout math, countdown formatting.
- Run `cargo check`; run `cargo test` if tests are added.
- Update README/docs after behavior changes, especially controls and size range.
- Completed 2026-05-23: added locale schema tests covering embedded locale parsing and Controls/tray strings.
## Success Criteria
- Bubble and panel remain readable at min/default/max sizes and common DPI scales.
- Warning/critical/auth/error states are understandable without relying only on color.
- Hidden interactions have menu alternatives or discoverable help text.
- Panel handles all existing locales without clipping core data.
- Tray tooltip and notifications communicate exact state.
- Source still compiles; new pure behavior has focused tests where feasible.
## Risks
- Native Win32 UI changes require manual Windows runtime verification; screenshots/tests are limited.
- `src/bubble.rs` and `src/app.rs` are large and coupled; extract before broad changes when touching multiple concerns.
- Adaptive text/layout can regress small-size readability if not verified at 140 logical width.
## Unresolved Questions
- Should the bubble stay stadium-shaped, or should compact circular mode return as an option?
- Should menu help be always present, or only shown on first run/first right-click?
- Should reduced-motion preference disable only pulse, or all nonessential animation?
@@ -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,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)
-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;
}
}
-1597
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);
}
}
+866 -1971
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)));
}
}
+35 -1
View File
@@ -2,7 +2,7 @@
// and the tray badge. Keeping the function in one place ensures the three
// surfaces never disagree about what "78% used" looks like.
use crate::os::Rgb as Color;
use crate::core::color::Rgb as Color;
use crate::usage::ProviderId;
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex tracks
@@ -47,3 +47,37 @@ pub fn bar_fill_color(model: ProviderId, is_dark: bool, percent: f64) -> Color {
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)]))
}
}
-60
View File
@@ -1,60 +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"
providers = "Providers"
claude_label = "Claude Code"
chatgpt_label = "Codex"
opencode_go_label = "OpenCode Go"
settings = "Settings"
start_with_windows = "Start with Windows"
reset_position = "Reset position"
size_smaller = "Make smaller"
size_larger = "Make larger"
reset_size = "Reset size"
controls = "Controls"
control_left_click = "Left-click: details"
control_right_click = "Right-click: menu"
control_drag = "Drag: move/snap"
control_ctrl_wheel = "Ctrl+Wheel: resize"
control_tray_click = "Tray click: show/hide"
tray_left_click = "Left-click: show/hide"
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."
update_applied_title = "Update applied"
update_applied_body = "Updated to v"
update_rollback_failed_body = "Update failed. Your original binary is saved at: "
-60
View File
@@ -1,60 +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時間"
providers = "プロバイダー"
claude_label = "Claude Code"
chatgpt_label = "Codex"
opencode_go_label = "OpenCode Go"
settings = "設定"
start_with_windows = "Windows起動時に開始"
reset_position = "位置をリセット"
size_smaller = "小さくする"
size_larger = "大きくする"
reset_size = "サイズをリセット"
controls = "操作"
control_left_click = "左クリック: 詳細"
control_right_click = "右クリック: メニュー"
control_drag = "ドラッグ: 移動/吸着"
control_ctrl_wheel = "Ctrl+ホイール: サイズ変更"
control_tray_click = "トレイ左クリック: 表示/非表示"
tray_left_click = "左クリック: 表示/非表示"
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 = "上限に近づきました — ペースを落としましょう。"
update_applied_title = "更新を適用しました"
update_applied_body = "バージョン v"
update_rollback_failed_body = "更新に失敗しました。元のバイナリは次の場所に保存されています: "
-60
View File
@@ -1,60 +0,0 @@
code = "ko"
native_name = "한국어"
window_title = "Claude Code Usage Bubble"
refresh = "새로 고침"
update_frequency = "업데이트 주기"
one_minute = "1분"
five_minutes = "5분"
fifteen_minutes = "15분"
one_hour = "1시간"
providers = "제공자"
claude_label = "Claude Code"
chatgpt_label = "Codex"
opencode_go_label = "OpenCode Go"
settings = "설정"
start_with_windows = "Windows 시작 시 실행"
reset_position = "위치 초기화"
size_smaller = "작게"
size_larger = "크게"
reset_size = "크기 초기화"
controls = "조작"
control_left_click = "왼쪽 클릭: 상세"
control_right_click = "오른쪽 클릭: 메뉴"
control_drag = "드래그: 이동/스냅"
control_ctrl_wheel = "Ctrl+휠: 크기 조절"
control_tray_click = "트레이 클릭: 표시/숨김"
tray_left_click = "왼쪽 클릭: 표시/숨김"
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 = "매시간"
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 = "한도 임박 — 잠시 쉬어가세요."
update_applied_title = "업데이트가 적용되었습니다"
update_applied_body = "버전 v"
update_rollback_failed_body = "업데이트 실패. 원본 바이너리는 다음 위치에 저장되었습니다: "
-60
View File
@@ -1,60 +0,0 @@
code = "vi"
native_name = "Tiếng Việt"
window_title = "Claude Code Usage Bubble"
refresh = "Làm mới"
update_frequency = "Tần suất cập nhật"
one_minute = "1 phút"
five_minutes = "5 phút"
fifteen_minutes = "15 phút"
one_hour = "1 giờ"
providers = "Nhà cung cấp"
claude_label = "Claude Code"
chatgpt_label = "Codex"
opencode_go_label = "OpenCode Go"
settings = "Cài đặt"
start_with_windows = "Khởi động cùng Windows"
reset_position = "Đặt lại vị trí"
size_smaller = "Thu nhỏ"
size_larger = "Phóng to"
reset_size = "Đặt lại kích thước"
controls = "Điều khiển"
control_left_click = "Nhấp trái: chi tiết"
control_right_click = "Nhấp phải: menu"
control_drag = "Kéo: di chuyển/bám"
control_ctrl_wheel = "Ctrl+cuộn: đổi cỡ"
control_tray_click = "Khay: hiện/ẩn"
tray_left_click = "Nhấp trái: hiện/ẩn"
language = "Ngôn ngữ"
system_default = "Mặc định hệ thống"
check_for_updates = "Kiểm tra cập nhật"
checking_for_updates = "Đang kiểm tra cập nhật…"
up_to_date = "Đã là phiên bản mới nhất"
update_failed = "Cập nhật thất bại"
applying_update = "Đang áp dụng cập nhật…"
update_available = "Có bản cập nhật mới"
update_via_winget = "qua WinGet"
auto_update_check = "Tự động kiểm tra cập nhật"
auto_check_disabled = "Tắt"
auto_check_hourly = "Mỗi giờ"
auto_check_daily = "Hằng ngày"
auto_check_weekly = "Hằng tuần"
exit = "Thoát"
restart = "Khởi động lại"
show_widget = "Hiện widget"
session_window = "5g"
weekly_window = "7n"
now = "ngay"
day_suffix = "n"
hour_suffix = "g"
minute_suffix = "p"
second_suffix = "s"
token_expired_title = "Phiên Claude Code đã hết hạn"
token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
chatgpt_token_expired_title = "Phiên Codex đã hết hạn"
chatgpt_token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
threshold_80_body = "Sắp chạm giới hạn 5 giờ."
threshold_95_body = "Sắp tới giới hạn — hãy cân nhắc giảm tốc."
update_applied_title = "Đã áp dụng cập nhật"
update_applied_body = "Đã cập nhật lên v"
update_rollback_failed_body = "Cập nhật thất bại. Tệp gốc của bạn được lưu tại: "
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more