Compare commits

...
21 Commits
Author SHA1 Message Date
tiennm99 4e0f32591b chore: bump version to 0.1.11 2026-05-21 16:51:46 +07:00
tiennm99 3e1af07ec2 feat(i18n): switch supported languages to en/ja/ko/vi/zh-TW
Drop nl/es/fr/de locales (no native-speaker maintenance) and add
Vietnamese. The supported set is now the languages with active
users we can support: English, Japanese, Korean, Vietnamese, and
Traditional Chinese.
2026-05-21 16:51:15 +07:00
tiennm99 27aa935a9b chore: bump version to 0.1.10 2026-05-21 16:29:05 +07:00
tiennm99 1cd5b778f4 feat(update): replace cmd.exe handoff with native Win32 spawn
Both the in-app restart and the auto-update install previously
shelled out to cmd.exe so the new instance could wait for the old
one to release the singleton mutex and the locked exe file. On
some Windows configurations the `start ""` inside `cmd /c ...` can
flash a console window despite CREATE_NO_WINDOW + DETACHED_PROCESS
flags. The replacement spawns the child binary directly via
CreateProcessW; since the main exe is built with
windows_subsystem = "windows", no console is ever allocated.

- New `src/update/handoff.rs` exposes `spawn_detached`,
  `wait_for_parent_exit`, and `cleanup_stale_old_exes`.
- New CLI flags `--wait-pid <pid>` and `--updated-to <version>`
  parsed early in `main`; the child waits up to 5s on the parent
  PID via OpenProcess+WaitForSingleObject before falling through
  to a 3s mutex-acquisition retry.
- `restart_app` and `install::begin` both spawn detached children
  using the new helper.
- Update install now uses MoveFileExW twice (rename running exe
  sideways, then move staged exe into place with
  MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED so portable
  installs on non-system drives still work). Rollback restores
  the backup if either the swap OR the post-swap detached spawn
  fails, and a MessageBoxW modal surfaces the backup path if the
  rollback itself fails.
- First launch after an auto-update shows a blue-info tray
  balloon "Updated to vX.Y.Z" via a new `tray::notify_info` (the
  existing `tray::notify` is split into `notify_warning` +
  `notify_info` sharing a `notify_inner`).
- Startup sweeps stale `<exe>.old.<pid>` siblings left by past
  in-place updates.
- Three new `LocaleStrings` fields translated across all 8
  supported locales (en/nl/es/fr/de/ja/ko/zh-TW).
2026-05-21 16:28:25 +07:00
tiennm99 1ba2883989 ci: switch back to windows-latest
Prefer staying on the floating tag — accept GitHub's auto-redirect to
windows-2025-vs2026 in mid-2026 rather than pin and chase.
2026-05-18 13:23:37 +07:00
tiennm99 858d7f1139 chore: bump version to 0.1.9 2026-05-18 11:28:26 +07:00
tiennm99 5a2e4f1c60 fix(menu): repaint cached data and poll after Reset Position
reset_positions() destroys and recreates the bubbles, which leaves them
displaying the spawn_bubble "…" placeholder until the next 5-minute
TIMER_POLL fires. Push the cached snapshot via propagate_to_ui() so the
last-known values appear immediately, and kick spawn_poll_thread() (idempotent
via POLL_IN_FLIGHT gate) so fresh data follows shortly after.
2026-05-18 11:28:26 +07:00
tiennm99 713eb5bbde ci: bump actions to latest, pin windows runner
- actions/checkout v4 -> v6 (Node 24, addresses Node 20 deprecation).
- runs-on windows-latest -> windows-2025 (current image; pin avoids
  silent surprise when GitHub redirects windows-latest to a newer
  image mid-2026).
- Swatinem/rust-cache@v2 retained (v2 floating tag still maintained;
  latest is v2.9.1 under that major).
2026-05-18 11:00:39 +07:00
tiennm99 bcce939f72 chore: bump version to 0.1.8 2026-05-18 10:46:16 +07:00
tiennm99 e089a1b420 docs(reports): code-reviewer report for restart-button impl
DONE_WITH_CONCERNS — flagged M1 (match-arm vs IDM_LANG_BASE guard) and
L3 (lock-during-save). Both addressed in the feat commit.
2026-05-18 10:39:33 +07:00
tiennm99 457d5274da docs(plans): record menu-restart-button plan
Plan + phase-01. Plan-context decisions: placement above Exit, no confirm
dialog, cmd-handoff mechanism reused from update::install.
2026-05-18 10:39:29 +07:00
tiennm99 f1dfe15000 feat(menu): add Restart action between separator and Exit
One-click relaunch of the running binary via a detached cmd.exe handoff:
timeout /t 1 /nobreak >/dev/null & start "" "<exe>" — the 1s wait outlives the
parent so the relaunched instance acquires Global\ClaudeCodeUsageBubble
without ERROR_ALREADY_EXISTS.

- New IDM_RESTART (33) wired into show_context_menu + on_menu_command.
- Match arm placed above the IDM_LANG_BASE guard so future ids in the
  static band can't be swallowed by the dynamic-language catch-all.
- Settings flushed defensively before quit (clone-then-save to avoid
  blocking the UI thread on disk I/O while holding lock_state).
- Rejects current_exe paths containing '%' (same defense as
  update::install — cmd.exe expands %var% inside quotes).
- New 'restart' string in LocaleStrings + translation in all 8 locales.
2026-05-18 10:39:19 +07:00
tiennm99 38ae4dff09 docs(reports): add reviews for offscreen-bubble fix
Code-reviewer flagged the clamp-before-render ordering nit; brainstormer
ranked the layered (validate + clamp) approach over topology-hash / per-monitor
pinning alternatives.
2026-05-18 09:43:35 +07:00
tiennm99 3c0878f6cc fix(bubble): recover off-screen position from disconnected monitor
Saved bubble_positions could land on a secondary monitor that was later
disconnected, leaving the bubble created off-screen with no visual feedback
on toggle-show.

- settings::load now drops any position whose 140px probe rect intersects
  no connected monitor (MonitorFromRect + MONITOR_DEFAULTTONULL).
- bubble::create calls clamp_into_work_area before the first render as a
  defense-in-depth catch for partial overflows or load/create monitor races.
- clamp_into_work_area preserves the Codex-above-Claude stagger from
  default_position when both bubbles get clamped to the same corner.
- Added info/warn log lines on create + clamp paths so future visibility
  bugs are diagnosable via --diagnose.
2026-05-18 09:43:27 +07:00
tiennm99 eca430ccc6 feat(menu): show current version on the Check-for-Updates entry
Appends ' · v{CARGO_PKG_VERSION}' to every state of the version-action
menu item so users can see what they are running without opening an
About dialog. Reads as 'Check for updates · v0.1.7', 'Up to date ·
v0.1.7', etc. Winget channel decoration is preserved as a trailing
parenthetical.

Bumps version to 0.1.7.
2026-05-16 14:10:02 +07:00
tiennm99 2791022e7a chore: drop dead ureq + native-tls deps
The legacy poller.rs and updater.rs modules they served were deleted
in the clean-room rewrite; everything now goes through net::winhttp.
Grep confirms no source references to either crate. Removing them
trims the dep graph noticeably.

Bumps version to 0.1.6.
2026-05-16 13:59:18 +07:00
tiennm99 ed9b8b2042 feat: threshold balloons + dark/light auto-follow
- Threshold balloons fire the cycle utilization crosses 80% or 95%
  on either provider, with the title showing "{Provider} · {N}%" and
  body translated per shipped locale. Reuses the existing
  BALLOON_COOLDOWN so notifications stay calm.
- Dark/light auto-follow: bubble's WM_SETTINGCHANGE handler now calls
  app::recheck_theme(), which re-reads HKCU\…\Personalize, updates
  state.is_dark if changed, and triggers a UI repaint + tray refresh.
  Windows posts WM_SETTINGCHANGE to every top-level window when the
  user toggles light/dark in Settings, so the bubble repaints in
  near-real-time.
- Adds 2 new i18n keys (threshold_80_body, threshold_95_body) across
  all eight shipped locales.

Bumps version to 0.1.5.
2026-05-16 13:57:15 +07:00
tiennm99 8ad718d9c1 docs(reports): add code-reviewer / brainstormer / researcher reports
Advisory artifacts produced by the three agents spawned to audit the
project. Code-reviewer found the P0/P1 set fixed in 1ef1bfa;
brainstormer ranked feature ideas; researcher surveyed the
self-update / code-signing / distribution space.
2026-05-16 13:51:25 +07:00
tiennm99 1ef1bfa7b2 fix: phase 2 — UI-freeze + GDI-leak + panic-on-GDI-exhaustion fixes
- P0: pull blocking HTTPS out from under the global mutex. AppState's
  http and registry now live behind Arc<Client> and Arc<Mutex<Registry>>;
  do_poll, attempt_refresh, and version_action's Apply branch clone
  these out, drop lock_state, then do their I/O. Apply now spawns a
  worker thread that posts WM_APP_UPDATE_APPLIED back to the message-
  only window when the cmd handoff is launched, so the UI no longer
  freezes for the duration of the download.
- P1: bubble.rs paint_text_layer saves and restores the DC's previous
  HFONT before DeleteObject. The old code's DeleteObject on a still-
  selected HFONT silently failed and leaked one handle per paint frame
  (up to ~12/s under the ≥95% pulse animation).
- P1: replace 5x CreatePopupMenu().unwrap() with let-else early returns
  that destroy any half-built menus and log. GDI exhaustion no longer
  panics the UI thread.
- P1: at-most-one-in-flight gate (static AtomicBool) on the poll thread
  so rapid Refresh clicks don't stack concurrent HTTPS calls.
- P1: token-expired balloon now picks the title/body for the provider
  that actually failed, instead of always falling back to Claude when
  show_claude_code is on.
- P1: panel place_near honors SM_XVIRTUALSCREEN / SM_YVIRTUALSCREEN so
  multi-monitor setups with a secondary display left of the primary
  no longer mis-clamp the panel position.
- P1: COUNTDOWN_TEMPLATE bumped from "999d" to "999시간" — Korean has
  the widest suffix among shipped locales and was overflowing the
  countdown column.

Bumps version to 0.1.4.
2026-05-16 13:51:16 +07:00
tiennm99 0f3acd40d4 feat(update): SHA-256 verification + reject paths containing '%'
GitHub's Releases API exposes a `digest: "sha256:..."` field on every
asset since 2024. We now parse it, hash the downloaded bytes locally,
and abort with ChecksumMismatch if they disagree. Releases that predate
the field (none currently exist for this repo) skip verification rather
than fail, so v0.1.0 / v0.1.1 / v0.1.2 still update normally.

cmd.exe expands `%var%` even inside double-quoted arguments, which
would let a path like `C:\Users\%PATHEXT%\bubble.exe` substitute the
expansion. Real Windows paths with `%` are vanishingly rare, so we
fail fast with UnsafePath rather than ship a bespoke cmd-escape
implementation.

Bumps version to 0.1.3.
2026-05-16 13:37:37 +07:00
tiennm99 a132c02711 fix(update): bypass Rust arg escaping in cmd.exe handoff
Rust's std::process::Command escapes inner double quotes as \" when
wrapping the args(["/c", &cmd]) array. cmd.exe does not understand the
\" escape, so the swap-and-restart command got mangled: `start ""
"PATH"` arrived as `start \"\" \"PATH\"`, which the cmd parser
collapsed into `start \ PATH` — producing the "Windows cannot find
'\'" dialog and aborting the update.

Switching to raw_arg lets us hand cmd.exe the literal command line it
expects. The two quote characters cmd needs to keep are the outer pair
wrapping the whole /c argument; cmd's "more than two quotes, special
chars present" branch then preserves the inner path quotes intact.

Bumps version to 0.1.2 since this is the first updater fix that ships
through the updater itself for any future v0.1.2+ user.
2026-05-16 13:04:08 +07:00
38 changed files with 2746 additions and 1060 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.tag || github.ref }}
Generated
+59 -705
View File
@@ -8,12 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -26,12 +20,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -39,10 +27,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bytemuck"
@@ -68,38 +59,30 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-bubble"
version = "0.1.1"
version = "0.1.11"
dependencies = [
"dirs",
"embed-resource",
"log",
"native-tls",
"serde",
"serde_json",
"sha2",
"simplelog",
"thiserror",
"tiny-skia",
"toml 0.8.23",
"ureq",
"windows",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -109,6 +92,16 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -118,6 +111,16 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -139,17 +142,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "embed-resource"
version = "3.0.9"
@@ -170,22 +162,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -212,33 +188,13 @@ dependencies = [
]
[[package]]
name = "foldhash"
version = "0.1.5"
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
"typenum",
"version_check",
]
[[package]]
@@ -252,149 +208,12 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -402,9 +221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
"hashbrown",
]
[[package]]
@@ -413,12 +230,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.186"
@@ -434,18 +245,6 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "log"
version = "0.4.29"
@@ -468,23 +267,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -500,111 +282,31 @@ dependencies = [
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl"
version = "0.10.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"foreign-types",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags 1.3.2",
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -623,19 +325,13 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"getrandom",
"libredox",
"thiserror",
]
@@ -649,51 +345,6 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -761,6 +412,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -784,18 +446,6 @@ dependencies = [
"time",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strict-num"
version = "0.1.1"
@@ -813,30 +463,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -925,16 +551,6 @@ dependencies = [
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "toml"
version = "0.8.23"
@@ -1015,6 +631,12 @@ version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1022,49 +644,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"log",
"native-tls",
"once_cell",
"serde",
"serde_json",
"url",
]
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vswhom"
@@ -1092,58 +675,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -1330,183 +861,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yoke"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
+3 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-bubble"
version = "0.1.1"
version = "0.1.11"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows"
@@ -8,11 +8,6 @@ homepage = "https://github.com/tiennm99/claude-code-usage-bubble"
repository = "https://github.com/tiennm99/claude-code-usage-bubble"
[dependencies]
# `ureq` + `native-tls` are kept while the legacy `poller.rs` and `updater.rs`
# modules survive. Phase 4 deletes `poller.rs` and Phase 6 deletes `updater.rs`,
# at which point these two deps go away in favour of `net::winhttp`.
ureq = { version = "2", default-features = false, features = ["native-tls", "json", "proxy-from-env"] }
native-tls = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6"
@@ -21,6 +16,7 @@ simplelog = "0.12"
thiserror = "2"
toml = "0.8"
tiny-skia = "0.11"
sha2 = "0.10"
[dependencies.windows]
version = "0.58"
@@ -33,6 +29,7 @@ features = [
"Win32_UI_WindowsAndMessaging",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_Storage_FileSystem",
"Win32_Security",
"Win32_UI_HiDpi",
"Win32_UI_Input_KeyboardAndMouse",
@@ -0,0 +1,122 @@
# 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 ~12 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).
@@ -0,0 +1,41 @@
# Plan: Menu Restart Button
**Slug:** menu-restart-button
**Created:** 2026-05-18 09:45
**Branch:** main
**Status:** Implemented (awaiting commit)
## Goal
Add a "Restart" entry to the tray right-click context menu, positioned directly above "Exit". Clicking it relaunches the running binary in-place without prompting for confirmation.
## Why
User-requested. Current flow to apply a config/locale tweak that doesn't hot-reload (or to recover after a hang) is Exit → relaunch from Start menu. A one-click restart is symmetric with Exit and avoids hunting for the binary again.
## Phases
| # | Title | Status |
|---|-------|--------|
| 01 | Implement Restart action | Done — [phase-01-implement-restart-action.md](phase-01-implement-restart-action.md) |
## Key Decisions
- **Placement:** main menu, between `Show widget` separator and `Exit`. NOT inside Settings submenu — keeps top-level discoverability.
- **No confirmation dialog.** Settings auto-save on every change (settings.rs:138-152); restart is non-destructive.
- **Mechanism:** detached `cmd.exe /c timeout /t 1 >nul & start "" "<exe>"` handoff, then `PostQuitMessage(0)`. Same pattern as `update::install::begin` minus the swap step. The 1-second wait lets the current process release `Global\ClaudeCodeUsageBubble` mutex before the new instance's `CreateMutexW` runs.
- **Reject paths containing `%`** — cmd.exe expands `%var%`, same defense the update module already uses (install.rs:90-94).
## Dependencies
- None. Pure Rust + existing `windows` crate features.
## Out of Scope
- Restart after settings change auto-trigger (would be a separate feature).
- Restart-with-args (e.g., toggle `--diagnose`).
- Cross-platform — Windows-only by design.
## Unresolved Questions
None.
@@ -0,0 +1,104 @@
---
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 |
@@ -0,0 +1,113 @@
---
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 |
@@ -0,0 +1,156 @@
---
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 |
@@ -0,0 +1,165 @@
---
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 |
@@ -0,0 +1,104 @@
---
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
@@ -0,0 +1,96 @@
---
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
@@ -0,0 +1,192 @@
# Brainstorm: high-leverage improvements for claude-code-usage-bubble
Date: 2026-05-16
Baseline: v0.1.2 — bubble UX just polished (commits 5df75c9...7f8ccf0), updater bug just fixed (a132c02), auto-update frequency just added (2ca5052), windows release pipeline just added (60cde29).
Read scope: README, src/app.rs, src/bubble.rs, src/panel.rs, src/usage/{types,mod,anthropic}.rs, Cargo.toml, last 10 commits.
---
## Reality-check (cuts I refuse to dress up)
- Codebase is heavily Win32. windows-rs 0.58, GDI, AppBar, WinHTTP, registry-based autostart. Anything multi-platform is a near-rewrite of the UI shell. Most polish effort beats most expand effort.
- The product is small and good. Two bars x two providers x one bubble. Do not bloat it. The risk is feature creep that turns it into the thing it was reacting against.
- The user is also the author — there is no marketing motion to feed. Distribution work only pays off if you actually want strangers using it. Decide that first; everything in section 4 and 7 is conditional on yes.
---
## 1. Bubble UX
What 360 Security / IObit memory balls do that this does not: a one-tap action (their boost button), idle micro-animation that draws the eye, a state-change pulse when a number crosses a threshold, edge-dock that fully tucks against the screen edge showing only a sliver.
### 1a. Threshold pulse + colour-state escalation
- One-liner: when 5h utilization crosses 80 / 95 percent, the ring pulses once (already have TIMER_PULSE) and the accent shifts amber to red; when it drops after reset, single subtle release animation.
- Why: the user reason to look at the bubble is "am I close?" — passive colour is fine when sitting at 30 percent, but at 92 percent you want the bubble to grab you once and then shut up.
- Effort: S. Pulse timer already exists; need state-machine on percentage threshold + hysteresis (do not pulse every poll).
- Mistake if: you make it pulse continuously above a threshold. That is a notification, not a bubble. One pulse per crossing, that is it.
### 1b. Edge-dock sliver mode
- One-liner: when snapped to an edge for >5s with no interaction, contract to a thin coloured stripe (~6px) along the edge showing only the higher-of-(5h, 7d) percentage as a bar. Hover or click expands back.
- Why: the bubble at 200px is a lot of permanent screen real estate. The memory ball UX wins because at rest it is almost invisible.
- Effort: M. Need new render path + hover-region + state transition. Risk of getting the hit-test wrong.
- Mistake if: the dock is so subtle the user cannot find it again. Keep a 1-2px coloured accent.
### 1c. Dark/light auto-follow (Windows system theme)
- One-liner: subscribe to WM_SETTINGCHANGE ImmersiveColorSet + read AppsUseLightTheme from HKCU; AppState.is_dark follows instead of being static.
- Why: bubble currently looks alien on the opposite theme. This is the cheapest feels-native win available.
- Effort: S. Single registry read on startup + WM_SETTINGCHANGE handler.
- Mistake if: you also try to do per-bubble theme override. YAGNI — system theme is correct ~100 percent of the time.
### 1d. Accent-colour follow (system)
- One-liner: read HKCU Software Microsoft Windows DWM AccentColor and tint the ring fill at low utilization (where the colour is currently arbitrary).
- Why: makes the widget feel like a system component. Cheap perceived-quality lift.
- Effort: S.
- Mistake if: you let accent colour override the amber/red threshold states. Threshold > accent.
### Cut from section 1
- Custom theming UI. No. YAGNI. System theme is enough.
- Bubble shapes other than rounded-rect. The whole circle framing in the README is already a fib (it is a 3:1 rounded rect). Do not add more shape options.
---
## 2. Information density
The data already in UsageWindows is only utilization (0-100) + resets_at. Anything else needs new endpoint work or local computation. Be honest about that cost.
### 2a. Burn rate + projected exhaustion (computed, no new API)
- One-liner: track utilization samples in a small ring buffer; in the panel, show "at this rate, you will hit 100 percent in ~Xh" beneath the 5h bar.
- Why: the actual decision the user is making is "do I context-switch now or finish this thought?" Projected exhaustion answers that; raw percentage does not.
- Effort: S-M. Ring buffer + linear regression over last N samples; only show when slope is meaningfully positive.
- Mistake if: you put the projection on the bubble itself. It is noise there. Panel-only.
### 2b. Delta-since-last-reset summary in panel
- One-liner: when the 5h window resets, snapshot the previous peak percentage. Show "Last cycle peaked at 88 percent" in the panel.
- Why: builds intuition over time about whether usage is growing or shrinking without any backend.
- Effort: S. One extra field in settings/state.
- Mistake if: you try to show a chart. Tiny number, one line, done.
### 2c. Do NOT add: token count, dollar cost, model breakdown
- Cut. Anthropic oauth/usage endpoint returns utilization buckets, not token counts. The fallback path scrapes rate-limit headers. Neither gives reliable dollar cost. Inventing one will be wrong and erode trust. Do not ship guesses as facts.
### Cut from section 2
- Per-conversation/per-project breakdown. Anthropic does not expose this in oauth/usage. Do not promise what you cannot deliver.
---
## 3. Workflow integrations
### 3a. Threshold balloon notification (one-shot)
- One-liner: at 80 / 95 percent crossings, fire a Shell_NotifyIcon balloon ("Claude 5h at 95 percent — resets in 42m"). Already have BALLOON_COOLDOWN and last_balloon_at plumbed; extend the trigger from "update available" to "threshold crossed".
- Why: a user with the bubble auto-hidden during fullscreen game/video still wants to know they are about to run out. This is the single highest-impact integration because the integration target is the user, not another app.
- Effort: S. Plumbing exists; just add the trigger.
- Mistake if: you fire it every poll above 95 percent. Once per crossing, per reset cycle.
### 3b. Cut: tailing Claude Code logs / hooks
- The Claude Code CLI does emit logs (~/.claude/) and supports hooks, but inferring usage from them is fragile vs. the official oauth/usage endpoint you are already hitting. Do not dual-source the same number.
### Cut from section 3
- Slack/Discord/webhook out. No. This is a personal desktop widget. If you want webhooks, you are building a different product.
- Pause Claude Code when over limit. Out of scope. The bubble observes, does not control.
---
## 4. Distribution + onboarding
Only worthwhile if you want strangers using it. State the goal explicitly before doing any of this.
### 4a. winget manifest
- One-liner: submit a manifest to microsoft/winget-pkgs once you have at least one signed (or accepted-unsigned-with-hash) release.
- Why: winget install tiennm99.ClaudeCodeUsageBubble is the only Windows install command anyone actually wants to run. Free distribution.
- Effort: S (one PR to winget-pkgs) once the release artifact has a stable URL + SHA256, which it now does.
- Mistake if: you submit before code signing. winget accepts unsigned packages but SmartScreen still nags; that user pain accrues to your repo, not winget.
### 4b. First-run is-everything-working check
- One-liner: on first launch with no settings.json, run the same checks as --diagnose once: can I find Claude creds? Can I reach Anthropic? Show a tiny one-time panel that says "Claude OK, Codex not configured (enable in Models menu)" and dismisses.
- Why: silent failure is the worst onboarding outcome. The bubble showing dash-percent tells users nothing.
- Effort: S. --diagnose already exists; reuse the logic.
- Mistake if: it becomes a wizard. One panel, one dismiss, never again.
### Cut from section 4
- MSIX. Cut. MSIX requires the Store or sideload pain. Cost > benefit for an indie widget.
- MSI installer. Cut. A 4-MB single exe that drops in LOCALAPPDATA is better than an MSI for this audience.
- Crash dumps. The app is small and runs in a single Win32 message loop. simplelog to TEMP claude-code-usage-bubble.log already covers 95 percent of post-mortem needs.
---
## 5. Multi-platform reality check
Skip this axis. The codebase is windows::Win32:: from top to bottom — bubble window, panel, tray, AppBar, registry autostart, WinHTTP. Porting macOS/Linux is a full UI-shell rewrite (~70 percent of the code), and the value proposition (a desktop memory ball) does not translate cleanly — macOS users expect a menu-bar app, Linux users expect a tray icon and most distros do not have a stable always-on-top floating layer.
If you genuinely want cross-platform, the right move is not to port this — it is a separate claude-code-usage-menubar for macOS that reuses src/usage/ and src/creds/ as a library crate. Worth noting but not worth doing unless someone asks.
---
## 6. Updater roadmap
### 6a. SHA256 verification of downloaded artifact
- One-liner: GitHub Releases shipped via your windows-release.yml already produce a stable URL; publish a SHA256SUMS file as part of the release, fetch + verify before swapping the exe.
- Why: defends against a compromised CDN / MITM regardless of code signing. Way cheaper than signing.
- Effort: S. Add to the GH Actions release step; verify in src/update/.
- Mistake if: you skip this because GitHub releases use HTTPS so MITM is impossible. HTTPS is not integrity; an attacker with a release-asset upload token or a compromised CI also matters.
### 6b. Code signing — defer, do not romanticise
- A standard EV cert is ~300-600 dollars/year and an OV cert ~200-400 dollars/year, and even with OV you still wait weeks for SmartScreen reputation. EV gets you immediate SmartScreen trust but the HSM-bound key is operationally annoying for solo devs.
- Verdict: defer until install volume justifies it (>1000 downloads/release, say). The Run-anyway friction is real but survivable; the cost-per-user of a cert at low volume is very high.
- Effort if pursued: M (cert setup) + ongoing key custody pain.
- Mistake if: you sign without rotating to an HSM-backed solution. A leaked signing key is worse than no signing.
### 6c. Beta channel via GitHub pre-releases
- One-liner: settings.json already supports install_channel; surface it in right-click menu as "Channel Stable / Beta" so people can dogfood.
- Why: low-cost, high-trust signal for early adopters; gives you canaries before stable.
- Effort: S. One menu item, one settings field, one filter on the GH Releases list.
- Mistake if: you ship a beta that bricks the updater (see v0.1.2). Add a "downgrade to last stable" affordance.
### Cut from section 6
- Delta updates. No. The exe is ~4 MB. Bandwidth is not the bottleneck. Delta-patching machinery is bug surface.
- Rollback. Mostly cut. Keeping the previous exe as .bak on update and a --rollback flag is fine (S), but a full rollback UI is overkill.
---
## 7. Brand / discovery
Only worth doing if you actually want users beyond yourself. Sketched at low cost:
### 7a. Demo GIF in README (top, above install)
- One-liner: 6-8 second loop showing bubble at idle, drag-to-edge snap, left-click expand panel, right-click menu.
- Why: the entire product is visual. Words do not sell a floating bubble — the GIF will convert orders-of-magnitude better than the current shield-badges.
- Effort: S. ScreenToGif then optimise to <1 MB.
- Mistake if: you record it on a 4K monitor and it weighs 8 MB and breaks the README. Keep it <1.5 MB.
### 7b. GitHub topics + a short tagline
- Topics: windows-desktop, rust, claude-code, codex, usage-monitor, widget, system-tray. The README H1 is fine but the GitHub repo description / About should be one tweet-length line.
- Effort: 5 minutes. Free.
### Cut from section 7
- Landing page / dedicated site. Cut until install volume justifies. The repo is the landing page.
- Twitter/Bluesky launch posts. Author call. Not a product question.
---
## Top 5 I would ship first, ranked
1. Dark/light auto-follow (1c). S effort, immediate feels-native lift, zero risk. Ship today.
2. Threshold balloon at 80/95 percent (3a). S effort. The single biggest jump in usefulness on the entire list — it converts the widget from a thing you have to look at into a thing that tells you.
3. Demo GIF in README (7a). S effort, only useful if you want strangers, but if you do it is the unlock.
4. SHA256 verification in updater (6a). S effort, plugs a real integrity hole that exists today, cheaper than signing.
5. Edge-dock sliver mode (1b). M effort but this is the differentiator vs. the upstream taskbar-widget approach — it is what floating bubble actually wants to be. Worth the M.
Honourable mention: first-run sanity check (4b) — only if you ship #3 first and start getting "it shows nothing, is it broken?" issues.
---
## Unresolved questions
1. Do you actually want external users? Section 4 and 7 are no-ops if not.
2. Are you willing to take ~200-600 dollars/yr on code signing within the next year, or is "unsigned + SmartScreen warning" the permanent stance? Affects whether 6b is a roadmap item or a no.
3. Is there a deliberate reason Settings already has install_channel but no UI surface (6c)? If it was intentional dormancy, fine; if it was an oversight, that is a free win.
4. macOS port — yes/no/later? Affects whether src/usage/ and src/creds/ should be refactored into a separate crate now (cheap) vs. later (painful).
---
Status: DONE
Summary: 14 picks across 6 of 7 axes (multi-platform skipped with rationale). Top-5 ranked. 4 unresolved questions for the user.
@@ -0,0 +1,80 @@
# Brainstorm: Off-Screen Bubble Recovery
## 1. Recovery strategies ranked
### RECOMMEND — Layered: validate on load + clamp on create
**A. Validate position in `settings::load`** (primary defense)
- After deserialize, walk `bubble_positions`. For each `Some((x,y))`, build a probe rect `(x, y, x+min_w, y+min_h)` and check via `MonitorFromRect(... MONITOR_DEFAULTTONULL)`. If null → set to `None`.
- One pass, ~15 lines, runs before any window code sees the value.
- Pro: KISS, no race with `ShowWindow`, fixes related bugs (hand-edited JSON, dpi-changed coords).
**B. Clamp on create** (defense-in-depth, kept as proposed)
- After `CreateWindowExW`, before `ShowWindow`, call `clamp_into_work_area(hwnd)`.
- Catches monitor unplug **between** `load()` and `create()` (rare but possible: laptop closed mid-startup).
- Cost: one extra call, idempotent.
Both together are the right answer. Neither alone covers all cases.
### CONSIDER — Visual cue
**C. Tray balloon "Widget repositioned to primary monitor"**
- Only when validator actually relocated. Uses existing `Shell_NotifyIconW NIF_INFO`. ~20 lines.
- Risk: balloon spam on dock/undock cycles. Fire only when *saved* position was killed.
### AVOID
**D. Topology fingerprint** — overkill, doesn't preserve intent better than (A).
**E. Per-monitor relative pinning** — future feature, not a fix. YAGNI.
## 2. Trade-off matrix
| Approach | Preserves intent on replug | Surprise on cold start | LOC | Risk |
|----------|---------------------------|------------------------|-----|------|
| A (validate-on-load) | No — wipes saved coord | Low | ~15 | None |
| B (clamp-on-create) | Partial — moves to nearest edge | Low | ~3 | None |
| A+B | No | Low | ~18 | None |
| A+B+C | Same + explains itself | Very low | ~38 | Balloon fatigue |
| D (topology hash) | Yes if same monitor before next launch | Medium | ~80 | Maintenance |
| E (relative pin) | Yes | Medium | ~150 | Premature |
## 3. Edge cases proposed fix misses
1. **Saved-pos monitor asleep / no input**`MonitorFromRect` still returns handle; A+B no-op. Correct.
2. **DPI change while app closed** — px coords technically valid by topology; A passes, B no-op. Acceptable.
3. **Negative-coord monitors (secondary left of primary)** — validator MUST use `MONITOR_DEFAULTTONULL`, not `DEFAULTTONEAREST` (would silently snap valid secondary coord to primary).
4. **Dual bubbles overlap after relocate** — both clamped to bottom-right of primary → stacked. `default_position` staggers Codex; clamp doesn't. Minor.
5. **User drags to secondary, unplugs, restarts** — A+B: bubble at primary default; saved pos destroyed. No recovery on replug. Acceptable for v1.
6. **Dock-daily multi-monitor user** — every undock wipes pos; every dock back gives default. Annoying. Case where E would win. Punt unless reported.
## 4. Logging strategy (minimal)
On the visibility-affecting path only:
- `info`: `bubble create model={} pos=({},{}) size={}x{} dpi={}` — one line per bubble at create.
- `warn`: `bubble position ({},{}) outside all monitors, resetting to default` — fires in validator. **This is the line that would have solved this bug in 5 seconds.**
- `warn`: `clamp_into_work_area moved bubble from ({},{}) to ({},{})` — fires on create-time clamp.
- `debug`: monitor enumeration on startup.
Skip: per-render logs, drag logs, timer ticks.
## 5. "Reset position" discoverability — secondary
Menu item exists but buried. If A+B work, this path is unreachable. Don't add a "Reset position" balloon prompt — confirmation fatigue. Just fix silently and the §4 warn + §C balloon explain it once.
## Recommended action
1. Add `BubblePositions::validate(&mut self)` called from `settings::load`. Use `MonitorFromRect(... MONITOR_DEFAULTTONULL)` with `(x, y, x+MIN_BUBBLE_SIZE, y+MIN_BUBBLE_SIZE)`. Set to `None` on miss. Log `warn`.
2. Call `clamp_into_work_area(hwnd)` in `bubble::create` between `CreateWindowExW` and `ShowWindow`. Log `warn` on movement.
3. Add the 4 log lines from §4.
4. Single tray balloon "Widget repositioned: previous monitor not connected" once per launch when validator killed any saved position.
5. Defer monitor-index pinning (E) and topology hash (D).
Total: ~40 lines, one new function, two log statements, one balloon call.
## Unresolved questions
- Balloon (item 4): opt-in or always-on? Default always-on.
- `MIN_BUBBLE_SIZE` as probe rect, or account for current `bubble_size_logical`? Min safer.
- Re-attempt last-known coord on replug? Probably no — YAGNI.
- Codex/Claude stagger preservation on auto-relocate? Currently they'd stack. Worth fixing in same patch?
@@ -0,0 +1,124 @@
# Silent in-app update + restart (no cmd.exe)
**Date:** 2026-05-21
**Author:** brainstormer
**Status:** approved (ready for `/ck:plan`)
## Problem
User sees an occasional flash terminal window. Two paths today spawn `cmd.exe /c "timeout ... & start ..."` for update install and app restart, with `CREATE_NO_WINDOW | DETACHED_PROCESS`. Combination of those flags + the inner `start ""` invocation can still emit a brief console flash on some Windows configs (Defender hooks, conhost init, AV inspection). Goal: zero-flash, fully silent auto-update + restart, with in-app notification.
## Scope
**In scope**
- `src/update/install.rs::begin` — kill cmd.exe handoff
- `src/app.rs::restart_app` — kill cmd.exe handoff
- New CLI flag `--wait-pid <pid>` on the main binary (cooperates with itself across update)
- Cleanup of stale `bubble.exe.old.*` siblings at startup
- Tray balloon "Updated to vX.Y.Z" on first run after auto-update
**Out of scope (split as follow-up)**
- `src/usage/refresh.rs` CLI spawns (`claude.cmd`, `codex.cmd`, `powershell.exe`, `wsl.exe`) — already use `CREATE_NO_WINDOW`; address separately if flash persists after this change
- `src/creds/wsl_bridge.rs` `wsl.exe` calls — same reasoning
## Approaches evaluated
| Approach | Decision | Rationale |
|---|---|---|
| A: Native rename + direct `CreateProcessW` + `--wait-pid` | **CHOSEN** | Zero cmd.exe ⇒ zero flash possible; single-binary preserved; matches existing Win32 style; recoverable on interrupt |
| B: Helper-exe pattern (`bubble-updater.exe`) | rejected | Reverses deliberate "no helper exe" decision (`src/update/install.rs:3-5`); release-pipeline change; helper bootstrap problem |
| C: NTFS POSIX atomic replace (`FileRenameInfoEx`) | rejected | Obscure API; harder failure modes with AV / image-protection; not worth the elegance trade-off |
## Final design — Approach A
### Update install flow
```
1. fetch_latest() (unchanged — pure HTTP via WinHTTP)
2. download(release_url, staging_path) (unchanged — sha256 verify)
3. rename current.exe -> current.exe.old.<pid> (MoveFileExW, allowed while running)
4. move staging.exe -> current.exe (MoveFileExW REPLACE_EXISTING)
5. settings::save(snap) (defensive flush, unchanged)
6. release singleton mutex (explicit ReleaseMutex + CloseHandle)
7. CreateProcessW(current.exe, "--wait-pid <our_pid> --updated-to vX.Y.Z",
CREATE_NO_WINDOW | DETACHED_PROCESS)
8. PostQuitMessage(0)
```
### Restart flow (settings-change path)
```
1. settings::save(snap)
2. CreateProcessW(current.exe, "--wait-pid <our_pid>",
CREATE_NO_WINDOW | DETACHED_PROCESS)
3. release singleton mutex
4. PostQuitMessage(0)
```
### New instance startup additions
```rust
// Before acquiring Global\ClaudeCodeUsageBubble mutex:
if let Some(parent_pid) = parse_wait_pid_arg() {
let h = OpenProcess(SYNCHRONIZE, FALSE, parent_pid)?;
WaitForSingleObject(h, 5000); // 5s cap; proceed regardless
CloseHandle(h);
}
// After main window is up:
cleanup_old_exes(current_dir, "bubble.exe.old.*");
if let Some(version) = parse_updated_to_arg() {
tray::show_balloon(t!("update.toast.updated_to", v = version));
}
```
### Why `--wait-pid` instead of cmd's `timeout /t 2`
- `timeout` is a cmd.exe builtin; using it requires cmd.exe.
- `WaitForSingleObject` on the parent process handle is the canonical Win32 idiom: zero delay if parent already exited, exact timing when it actually exits, no console involvement.
- Bonus: removes the magic "2 seconds is enough" guess.
### Path safety
The current `reject_unsafe_path` (`%` rejection) becomes unnecessary — no cmd.exe to expand `%var%`. Keep the function for defense-in-depth; revisit in code review.
## Files touched
| File | Change |
|---|---|
| `src/update/install.rs` | Replace `spawn_handoff` with `swap_and_spawn` using `MoveFileExW` + `CreateProcessW`; drop `cmd` arg-quoting code |
| `src/update/mod.rs` | Add `Error::SwapFailed` variant if needed |
| `src/app.rs::restart_app` | Replace cmd.exe spawn with `CreateProcessW` + mutex release ordering |
| `src/app.rs` | Add CLI flag parsing for `--wait-pid` and `--updated-to`; cleanup pass for `.old.*` siblings; balloon tray call on successful update boot |
| `src/main.rs` | Wire `--wait-pid` into the early-startup mutex-acquisition path (BEFORE `update::run_cli` check) |
| `src/tray/mod.rs` (or `tray/badge.rs`) | Confirm `Shell_NotifyIconW` with `NIF_INFO` balloon is supported by current tray code; add helper if missing |
| `src/i18n/*` | New string keys: `update.toast.updated_to` |
## Risks + mitigations
| Risk | Mitigation |
|---|---|
| `MoveFileExW` rename fails (NTFS permission denied, AV scanner holding handle) | Surface `Error::NotWritable` to user; do NOT proceed to step 4 (still recoverable — original exe untouched at this point) |
| New instance crashes before clearing old exe ⇒ `.old.*` accumulates | Cleanup glob `bubble.exe.old.*` on every startup is idempotent and cheap |
| Mutex race: new instance acquires before old releases | `--wait-pid` + `WaitForSingleObject(5000ms)` covers it; if it times out the new instance retries `CreateMutexW` in a 200ms loop for ~3s before giving up |
| User on FAT32 / non-NTFS volume | `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` still works on FAT32; renaming-while-running is the NTFS-specific concern but the .exe is rarely on FAT32. Document the edge case |
| `--wait-pid` arg parsed in legacy build that doesn't recognize it | Old builds will ignore unknown args (cargo CLI parser behavior — verify). If they crash, the user can manually launch. Acceptable: this is a one-way migration; once the new flag is in a release, future updates are smooth |
## Success criteria
1. Manual update from v0.1.9 → test-tagged v0.1.99 produces ZERO visible console window across 20 consecutive runs on Win11 + Win10
2. Restart triggered from menu (e.g. language change) produces ZERO visible console
3. Auto-update at scheduled interval (Hourly) produces a tray balloon "Updated to v0.1.99" on next launch
4. `.old.*` files do not accumulate after 5 update cycles
5. App still launches cleanly when no parent PID was passed (i.e. fresh user start)
6. SHA-256 verification path unchanged and still rejects tampered binaries
## Out-of-scope follow-ups
1. **Audit `usage::refresh::spawn_local` / `spawn_wsl`**: the `wsl.exe` invocation in particular has known console-flash quirks even with `CREATE_NO_WINDOW`. If the user still sees occasional flashes after this change ships, that is the next investigation target.
2. **`creds::wsl_bridge::wsl_run`**: same family of `wsl.exe` invocations.
## Unresolved questions
- None blocking implementation. (Open follow-up: whether to also pipe `--wait-pid` into the legacy `--apply-update` compatibility branch in `update::run_cli`, in case a very old build is doing the spawning.)
@@ -0,0 +1,183 @@
# Full-Project Code Review — claude-code-usage-bubble v0.1.2
Scope: full src/ tree (~6.2k LOC across 35 files). Recent shipped releases 0.1.0-0.1.2; v0.1.2 fixed cmd.exe arg escaping in the self-updater.
## Severity counts
- **P0**: 3
- **P1**: 9
- **P2**: 7
---
## P0
### P0-1. Poll thread holds global state mutex during blocking HTTPS
`src/app.rs:415-423`
`do_poll()` acquires `lock_state()`, calls `s.registry.poll_enabled(&s.http, &settings)` which dispatches to `ClaudeProvider::poll` / `ChatGptProvider::poll` — each issues a synchronous WinHTTP request inside the locked critical section. Lock is held for the full RTT (can be seconds, hang on dead network = whole poll cycle).
While locked, every UI-thread path that touches `lock_state()` stalls:
- left/right click on bubble (`on_bubble_click`, `on_bubble_right_click``build_panel_data`, `show_context_menu`)
- countdown timer (`refresh_countdowns`)
- WM_APP_USAGE_UPDATED dispatch (`propagate_to_ui`)
- menu commands (toggle, set_poll_interval, version_action, etc.)
- bubble move/resize callbacks
Symptom: bubble appears frozen / right-click menu won't open whenever the network is slow.
Fix: clone the data needed (settings + a separate `Mutex<Registry>` or split state) before issuing HTTP. Build a snapshot in one short lock, do HTTP outside the lock, take the lock again only to write results.
### P0-2. `attempt_refresh` holds global lock across up to 8s sleep loop
`src/app.rs:470-482`, `src/usage/refresh.rs:36-54`
`attempt_refresh` calls `s.registry.try_refresh(...)` while holding `lock_state()`. `try_refresh → Orchestrator::refresh` spawns the CLI then loops `thread::sleep(500ms)` for up to `REFRESH_TIMEOUT = 8s` per failed provider. The UI thread is unresponsive for the full duration.
Fix: same pattern as P0-1 — release the lock before calling `orchestrator.refresh(src)`. Re-acquire only for the balloon decision.
### P0-3. Synchronous update download blocks UI thread inside WM_COMMAND
`src/app.rs:1098-1104`, `src/update/install.rs:24,41-50`
`version_action` runs on the UI thread (called from `msg_wnd_proc → WM_COMMAND → on_menu_command`). On "Apply update" it calls `update::install::begin(&c, &release)` which downloads the .exe synchronously (`http.get().send()` + `fs::write`). For a multi-MB asset on a slow link the bubble + every other window message handler is blocked.
Fix: spawn a thread for the download; on completion post a custom message back to the UI thread that triggers `spawn_handoff` + `PostQuitMessage`.
---
## P1
### P1-1. GDI font handles deleted while still selected into DC
`src/bubble.rs:1293-1320`
In `paint_text_layer` the pattern is `SelectObject(hdc, label_font) → SelectObject(hdc, bold_font) → SelectObject(hdc, main_font) → DeleteObject(main_font); DeleteObject(bold_font); DeleteObject(label_font);`. The original font is never saved/restored, and `main_font` is still the currently selected object when `DeleteObject(main_font)` runs.
GDI rule: deleting a GDI object that is selected into a DC is an error; the call returns FALSE and the object is not freed. This leaks ~3 HFONT slots per `render()` call — and `render` fires on every poll, every WM_TIMER countdown tick, and on every TIMER_PULSE tick (every 80 ms while a bar is ≥95%). Process will eventually exhaust the GDI handle quota (10000 per process by default) under prolonged alarm conditions.
Fix: save first SelectObject return, restore it before deleting all three fonts. Same fix used correctly in `measure_text_w` at lines 1009-1013.
### P1-2. Update handoff: `cmd.exe` percent expansion + cmd-metachar exposure on usernames
`src/update/install.rs:53-74`
`src_str`/`tgt_str` strip `"` but not `%`, `&`, `^`, `(`, `)`, `|`. These come from `dirs::data_local_dir()` and `std::env::current_exe()`. Both can include the username segment.
Real-world risk is low (most usernames are alphanumeric), but a username containing `%VAR%` would expand inside the inner `"..."` quotes (cmd.exe percent-expansion happens inside quotes too) and a username containing `&` or `^` bypasses the inner quoting in known cmd.exe corner cases.
Fix: validate `current_exe()` / `stage_path()` against `[A-Za-z0-9 \\:.\-_/]` before substitution, or use the helper-exe pattern the comments dismiss. At minimum, reject paths containing any of `%&^|<>`.
### P1-3. Downloaded binary is not verified before swap
`src/update/install.rs:24,41-50`
`begin` downloads the asset URL and immediately stages it for `move /y` replacement. No SHA256, signature, or even file-size sanity check. If GitHub's release CDN delivers a truncated/corrupted asset (network blip), the next launch is broken with no rollback. If an attacker controls the release-publishing pipeline (compromised PAT, repo takeover) every existing install gets RCE.
Fix: ship the release with a `claude-code-usage-bubble.exe.sha256` sidecar (or use the GH API's `digest` field — populated in newer releases), download both, compare before `spawn_handoff`. Also `fsync` the staged file.
### P1-4. Token-expiry balloon picks wrong provider when both are enabled
`src/app.rs:715-744`
`show_token_expired_balloon` ignores which provider actually failed: `if s.settings.show_claude_code { (Claude, claude title, claude body) } else { (ChatGpt, ...) }`. If both providers are enabled and only Codex's token expired, the balloon claims the Claude token expired. Sent through `attempt_refresh` the `failures: Vec<ProviderId>` already knows the real victim.
Fix: pass `failures` (or one chosen provider) into `show_token_expired_balloon` and pick the kind + strings from it.
### P1-5. Multiple Refresh clicks pile up concurrent poll threads
`src/app.rs:351,397-413,353-356,963-1000`
`spawn_poll_thread()` is called unconditionally from `IDM_REFRESH`, `IDM_FREQ_*`, `toggle_model`, and timer callbacks. There's no in-flight flag. Each thread serializes on `lock_state()` (so HTTP is sequential per P0-1) but the threads themselves accumulate and the UI fires N redundant `WM_APP_USAGE_UPDATED` posts.
Fix: an `AtomicBool` poll-in-flight gate, or coalesce by skipping the spawn when one is already running.
### P1-6. CJK suffix overflows the bubble's countdown column
`src/bubble.rs:891`, `src/i18n/locales/{ja,ko,zh-TW}.toml`
`COUNTDOWN_TEMPLATE = "999d"` measures column width against 4 ASCII glyphs. Korean uses `시간` and `분` (multi-codepoint, wider full-width characters); Japanese/Chinese use `日 時 分`. The right-side text column is sized to the ASCII template and gets clipped or runs into the percent column on these locales.
Fix: measure the template using the actual active locale's suffix strings (e.g. `format!("999{}", strings.hour_suffix)`) and pick the longest among day/hour/minute/second so all variants fit.
### P1-7. Panel `place_near` ignores monitor origin on multi-monitor
`src/panel.rs:503-522`
Comparing `y < 0` and `x + panel_w > virtual_screen_w` only works when the primary monitor is at origin (0,0). With a left-side secondary monitor at `(-1920, 0)`, the bubble may sit at `x = -1500`; `x < 0` triggers and the panel jumps to `x = 8` on the primary monitor — far from the anchor. Same for negative Y.
Fix: use `MonitorFromWindow(anchor_hwnd, MONITOR_DEFAULTTONEAREST)` + `GetMonitorInfoW` to clamp into the anchor's monitor work area, mirroring what `clamp_into_work_area` already does in `bubble.rs:767-806`.
### P1-8. `CreatePopupMenu().unwrap()` x5 panics UI thread on low-resource failure
`src/app.rs:790,806,821,835,870`
GDI/USER object limits or session lockup can cause `CreatePopupMenu` to return null. The unwrap propagates to the message loop and kills the app rather than just declining to show the menu.
Fix: `let Ok(freq) = CreatePopupMenu() else { let _ = DestroyMenu(menu); return; };` (and propagate cleanup). Use the existing `DestroyMenu(menu)` pattern already in place.
### P1-9. Dead `ureq` + `native-tls` deps in shipped binary
`Cargo.toml:11-14`
Comment claims poller.rs / updater.rs keep ureq alive; both files are gone (`Glob` confirms). `ureq`, `native-tls`, and their transitive deps (foreign-types, core-foundation, etc.) are still linked into the release binary, increasing exe size and attack surface for no behavioral reason.
Fix: drop `ureq` and `native-tls` from `[dependencies]`; let `cargo build` confirm nothing breaks.
---
## P2
### P2-1. Bubble window WM_SETICON leaks HICONs at exit
`src/bubble.rs:170-198`
`ExtractIconExW` returns two HICONs that are sent via `WM_SETICON`. The window manager does not take ownership — application is expected to `DestroyIcon` them when the window is destroyed (or before replacing). Both leak for the process lifetime.
Fix: on `WM_DESTROY`, send WM_SETICON with null and DestroyIcon the previous ones.
### P2-2. `kind_to_provider` is dead identity code
`src/app.rs:566-571`, type alias `TrayIconKind = ProviderId` in app.rs:29
`TrayIconKind` is just `ProviderId`. The match arm-by-arm conversion is identity. Whole function and all call sites can be deleted (or replaced by direct passing).
Fix: inline; remove the `TrayIconKind` alias too — it's confusing scaffolding from an earlier refactor.
### P2-3. Per-frame DC measure: `compute_layout` creates+destroys 4 HFONTs per render
`src/bubble.rs:945-948, 988-1015`
`measure_text_w` is called 4× from `compute_layout`, each making a `CreateFontW + DeleteObject`. For static templates ("999d", "100%", "5h", "7d") at fixed DPI/breakpoint, this could be cached. Hot path during pulse (every 80ms).
Fix: cache layout per `(size_logical, dpi, label_font_px, font_px)` key. Invalidate on WM_DPICHANGED.
### P2-4. `apply_alpha_mask` re-runs `point_in_rounded_rect` for every pixel
`src/bubble.rs:1411-1422`, also `paint_background` 1149-1159, `paint_accent_stripe` 1173-1180
Three full-canvas passes each rechecking the same rounded-rect predicate. For a 360x140 bubble that's 3×50k = 150k branchy point-in-rect tests per frame. Painful at 12.5fps pulse.
Fix: precompute a row span (`x_min..x_max`) per scanline once, share across the three passes. Or set alpha in `paint_background` directly and drop the separate mask pass.
### P2-5. Tray icon loses registration after explorer.exe restart
`src/tray/mod.rs:52-82`
No handler for the `TaskbarCreated` registered shell message. If Explorer restarts (crash or DPI/theme change), every NIM_MODIFY for our tray icon silently fails and the icon vanishes for the rest of the session.
Fix: register the `RegisterWindowMessageW("TaskbarCreated")` message in `msg_wnd_proc`, and on receipt clear `registered` set and let the next `sync()` re-issue NIM_ADD.
### P2-6. `parse_iso8601` has unreachable shadow + custom calendar math
`src/usage/anthropic.rs:168-218`
`let trimmed = ...; let _ = trimmed;` discards the work; the parser re-splits on 'T' and rebuilds. Custom leap/days math is brittle — works for current decade but invites subtle bugs.
Fix: small adjustment now — remove the dead `trimmed` shadow. Long-term: import `time` (already pulled in transitively) and use `OffsetDateTime::parse`.
### P2-7. `bubble.rs` is 1496 lines — past file-size threshold
`src/bubble.rs`
Project rule (CLAUDE.md) targets <200 LOC/file for context manageability. Bubble has accreted hit-testing, snap geometry, fullscreen detection, GDI painting, layout math, and pulse animation in one file. Splitting (`bubble/wnd_proc.rs`, `bubble/snap.rs`, `bubble/paint.rs`, `bubble/layout.rs`) would localize future changes.
Fix: low priority — refactor only when next painting/layout pass is needed.
---
## Unresolved questions
1. **Update channel auto-detect**: `update::current_channel()` always returns `Portable`. Is that intended for v0.1.x ship, or did the winget probe get cut and forgotten? If winget is on the roadmap, P1-3 (binary verification) becomes optional for that channel since winget signs.
2. **GitHub release asset digest field**: as of late-2025 GitHub returns a `digest` ("sha256:…") on release assets via the REST API. Worth confirming whether to consume that (P1-3 fix) or ship a sidecar.
3. **Provider-aware balloon**: P1-4's fix assumes one balloon per failed provider is the desired UX. Alternative: aggregate ("Claude + Codex tokens expired"). Which does the product owner prefer?
---
**Status:** DONE
**Summary:** Reviewed full 6.2k LOC tree. Found 3 P0 (all are lock-while-blocking-IO patterns hanging the UI), 9 P1 (GDI font leak on every paint, update handoff still has minor injection surface + no binary verification, wrong-provider balloon, dead deps, multi-monitor + CJK layout bugs), 7 P2. No mutations applied.
**Concerns/Blockers:** none.
@@ -0,0 +1,60 @@
# Code Review: Bubble Off-Screen Clamp Fix
**Scope:** Proposed bug-fix for v0.1.7 "widget enabled but not shown" — saved positions on disconnected monitor.
**Files:** `src/bubble.rs` (create, clamp_into_work_area, set_user_visible, default_position), `src/app.rs` (spawn_bubble, toggle_widget_visibility, reset_positions).
## Overall Assessment
**Fix is correct and minimal. Ship it with two small refinements.** Root-cause matches code (verified: `bubble.rs:143-160` passes saved `position` straight into `CreateWindowExW`; `clamp_into_work_area` at `:770` only wired into `WM_SETTINGCHANGE` at `:486`). Approach is the right shape: clamp post-create, pre-show.
## Critical Issues
None.
## High Priority
1. **Call order — clamp must run BEFORE `render(hwnd)` at `bubble.rs:220`, not just before `ShowWindow` at `:222`.** `render` calls `GetWindowRect` (`:1108`) for the `UpdateLayeredWindow` destination point. If clamp runs after `render`, the first frame paints at the off-screen coords; second paint only happens on next update_data tick. Move `clamp_into_work_area(hwnd)` to between line `:218` (state insert) and `:220` (render).
## Medium Priority
2. **`MonitorFromWindow` on a not-yet-shown off-screen window — verified safe.** Win32 sets the window rect immediately at `CreateWindowExW` return (visibility is irrelevant to `GetWindowRect`). With `MONITOR_DEFAULTTONEAREST` and a window whose entire rect lies on a disconnected monitor, the OS computes intersection with each *currently attached* monitor's rect; none intersect → falls back to nearest by Euclidean distance → returns the primary on a single-monitor setup. Saved `[2407,1282]` on a 1920-wide primary → nearest = primary → clamp pulls to `(1920-w, …)`. Correct.
3. **Multi-monitor edge case is preserved.** If the saved position is on a still-connected secondary, `MonitorFromWindow` returns that secondary monitor and clamps within its work area — no unwanted pull to primary. Good.
4. **Partial off-screen.** `clamp_into_work_area` only adjusts when fully outside (clamps each axis independently to `[wa.left, wa.right-w]`). A window whose top-left is on-screen but bottom-right spills off → it pulls the whole window inside. Behaviour is fine; matches `snap_to_edge` (`:644-645`).
5. **DPI mismatch (saved from 4K → 1080p primary):** the saved coords are physical pixels but the new bubble's `width_px/height_px` are recomputed against the *current* primary DPI (`:140-142`). Clamp uses the new size against the new monitor's work area — correct. No DPI bug.
6. **`default_position` case:** no-op (already inside work area). Safe.
## Low Priority
7. **Log levels are appropriate.** `info!` in `create` (fires once per bubble creation), `set_user_visible` (fires only on user-toggle — verified at `app.rs:1152` only called from `toggle_widget_visibility`), and `toggle_widget_visibility` (one event per click). None on the render hot path. Approved.
8. **Alternative call site (clamp in `app::spawn_bubble`):** Less attractive. `spawn_bubble` doesn't own the HWND lifecycle and would need a fresh `GetWindowRect` round-trip. Keeping the clamp inside `bubble::create` keeps the bubble module the sole owner of window geometry and means future call sites (e.g. tests, a hypothetical re-create-on-DPI-change) also benefit for free. The "bubble module stays position-agnostic" argument is weak — it already calls `default_position`, `snap_to_edge`, and `clamp_into_work_area`. Position-aware is the status quo.
## Side Effects
- No callers of `bubble::create` assert the returned HWND is at the exact requested coords. `app::spawn_bubble` (`:277`) ignores position post-create; `reset_positions` (`:1156`) destroys + recreates. Safe.
- `position(hwnd)` (`:322`) reads live `GetWindowRect`, so any subsequent `on_bubble_moved` save reflects the clamped coords — this self-heals the persisted bad value on first drag.
## Positive Observations
- Clamp helper already exists and is correct (`:770-809`).
- Fix is one line + three log statements; minimal blast radius.
- Persisted-corruption auto-heal via first interaction is a nice property.
## Recommended Actions
1. **MUST:** Place `clamp_into_work_area(hwnd)` between `lock_bubbles().insert(...)` (`:218`) and `render(hwnd)` (`:220`) — not after `render`.
2. **SHOULD:** Add an `info!` in `clamp_into_work_area` that fires only when `nx != r.left || ny != r.top` (i.e. the actual reposition path). Free diagnostic for future "bubble moved itself" reports.
3. **CONSIDER:** Persist the clamped position immediately after `create` so `settings.json` is self-healed on next launch, not only after a drag. Trade-off: writes settings on every startup; current behaviour writes only on user action. Probably YAGNI — drift gets repaired on first interaction.
## Unresolved Questions
- Should we also persist the corrected position eagerly (action 3)? Default to no per YAGNI; flag for user.
- Does Windows ever defer `CreateWindowExW` window-rect commit until `ShowWindow`? Per MSDN and verified by existing `snap_to_edge` using the same pattern in `WM_EXITSIZEMOVE`, no — rect is committed synchronously.
**Status:** DONE_WITH_CONCERNS
**Summary:** Fix is correct and small. One ordering bug: clamp must precede `render`, not just `ShowWindow`, otherwise the first paint targets the off-screen coords.
**Concerns:** Action 1 (clamp before render) is a real correctness issue — the proposal as written ("after CreateWindowExW succeeds and before ShowWindow") technically permits ordering after `render`, which would defeat the fix until the next data update.
@@ -0,0 +1,62 @@
# Code Review — Tray "Restart" Action
**Scope:** uncommitted changes on clean tree
**Files:** `src/app.rs`, `src/i18n/mod.rs`, 8x `src/i18n/locales/*.toml`
**Plan:** `plans/260518-0945-menu-restart-button/phase-01-implement-restart-action.md`
## Verdict
Clean implementation. All 7 acceptance criteria met. Build is `cargo check`-clean. No security regressions. Pattern faithfully borrowed from `update/install.rs`.
## Acceptance Criteria — all PASS
1. Menu order verified `app.rs:1040-1042`: separator → `IDM_RESTART``IDM_EXIT`.
2. `IDM_RESTART => restart_app()` arm wired `app.rs:393`.
3. `restart_app()` `app.rs:1382-1421` flushes settings, gets `current_exe`, rejects `%`, spawns detached `cmd.exe`, `PostQuitMessage(0)`.
4. 1 s `timeout` matches install.rs precedent (2 s there; 1 s sufficient — current process exits as soon as `PostQuitMessage(0)` drains the loop).
5. Verified `restart = "..."` in all 8 TOMLs at line 32 (en/de/es/fr/ja/ko/nl/zh-TW). `LocaleStrings` field at `mod.rs:52`. No `#[serde(default)]` → missing key = hard fail; all present.
6. Match-arm ordering unambiguous: `IDM_RESTART=33` < guard `x >= IDM_LANG_BASE=100`. Guard won't match 33. `tray::IDM_TOGGLE_WIDGET=50` likewise < 100. Safe.
7. No new clippy issues; no new unsafe blocks (`PostQuitMessage(0)` already unsafe at `IDM_EXIT`; matches that idiom).
## Critical
None.
## High
None.
## Medium
**M1. Restart arm sits below the `IDM_LANG_BASE` guard arm.** `app.rs:391-393`. The guard `x if x >= IDM_LANG_BASE => …` is exhaustive for any id `>= 100`. Today `IDM_RESTART=33` is fine, but future readers adding a static id `>= 100` between lines 392 and 393 would silently route into language switching. Cheap fix: move `IDM_RESTART => restart_app()` and `tray::IDM_TOGGLE_WIDGET => …` ABOVE the guard arm. Plan note at `app.rs:82-84` already warns about this — the new arm violates that guidance.
## Low / Info
**L1. Double-restart not deduped.** Rapid clicks queue multiple `cmd.exe` children. First wins the mutex; second's `start ""` succeeds, the resulting bubble process exits at `ERROR_ALREADY_EXISTS`. Acceptable per plan §Risk Assessment. No fix needed.
**L2. `to_string_lossy()` on `current_exe()` will mangle non-UTF-8 paths.** Same pattern in `install.rs:100`. On real Windows installs paths are UTF-16; lossy → UTF-8 is virtually always faithful. Consistent with existing precedent.
**L3. `settings::save()` runs while `lock_state()` read-guard is held** (`app.rs:1385-1387`). If `save` ever takes a lock on the same mutex this would deadlock — it currently does not, but the pattern elsewhere (e.g. `set_poll_interval` at 1087-1100) clones, releases, then saves. Recommend matching that pattern: clone snapshot inside scope, drop guard, then `settings::save(&snap)`. Defensive only.
**L4. No regression to existing menu wiring** — verified by inspection: `show_widget` append at 1034-1039 still preceded by no separator, then separator 1040, then Restart, then Exit. Matches plan exactly.
## Pattern-Parity Check vs `install.rs`
- Flags: `CREATE_NO_WINDOW | DETACHED_PROCESS` → identical bit pattern (`0x0800_0000 | 0x0000_0008`). New constants `RESTART_*` duplicate the values; minor DRY nit but they're file-local and the comment explains why. Acceptable.
- `raw_arg` quoting: `/c` then `"<cmd>"` with inner `"` preserved → byte-for-byte same shape as `install.rs:113-114`. Correct.
- `%` rejection: present, logs and aborts. Matches `install.rs:89-96`.
- `stdin/out/err = Null`: present, matches.
## PostQuitMessage on UI thread
`IDM_EXIT` does the same at `app.rs:370`, called from `on_menu_command` via WM_COMMAND on the UI thread. `restart_app()` is reached the same way. Safe — identical control-flow shape.
## Metrics
- New code: ~40 LOC in `app.rs`, 1 field in `mod.rs`, 8x 1-line TOML adds.
- Type coverage: 100%.
- New warnings: 0 (`cargo check` clean per user).
## Recommended Actions
1. **M1** (nice-to-have): reorder match arms so `IDM_RESTART` / `IDM_TOGGLE_WIDGET` precede the `x if x >= IDM_LANG_BASE` guard. Defends against future id collisions.
2. **L3** (optional): mirror `set_poll_interval`'s clone-then-save pattern in `restart_app()` for consistency.
## Unresolved Questions
- None blocking. Plan §Next Steps suggests a semver patch bump and analogous bubble-menu entry; out of scope for this review.
---
**Status:** DONE_WITH_CONCERNS
**Summary:** Implementation matches plan and acceptance criteria; cmd-handoff faithfully mirrors `update/install.rs`; all 8 locales updated; no critical or high issues. One medium suggestion (reorder match arms to defend against future static-id collisions with the `IDM_LANG_BASE` guard) and two low/optional refinements.
**Concerns:** M1 — new `IDM_RESTART` and `tray::IDM_TOGGLE_WIDGET` arms sit below a catch-all `x >= IDM_LANG_BASE` guard. Today safe (33, 50 < 100); future-fragile. Code comment at `app.rs:82-84` already flags the rule that was bent.
@@ -0,0 +1,129 @@
# Best Practices Research: claude-code-usage-bubble
**Date:** 2026-05-16 | **Scope:** Current Rust desktop Windows ecosystem (2025/2026)
## 1. Self-Updating Rust Apps on Windows
**Current state (2025/2026):**
- **Tauri plugin-updater** ([tauri-plugin-updater](https://crates.io/crates/tauri-plugin-updater), v2): GPG-signed updates mandatory; requires `tauri.conf.json` with public key + private key env var for signing. Built for Tauri apps.
- **Velopack** ([velopack](https://velopack.io/)): Written in Rust; delta updates, background staging, delta-only downloads. Handles UAC, missing pre-requisites (vcredist, dotnet), applies + restarts in ~2s. Active 2026, used by real apps.
- **cargo-dist** ([axodotdev/cargo-dist](https://github.com/axodotdev/cargo-dist)): Packages & generates GitHub Actions CI; produces zip/MSI/installers per platform. No built-in self-update; you handle that separately.
- **self_update crate** (sparse docs 2026): Deprecated/unmaintained; avoid.
- **Current app's cmd.exe handoff**: Inline timeout + move + relaunch. Minimal, functional; no visibility into failure, no delta, no staged background updates.
**Recommendation:**
For hobby/indie scope: stay on cmd.exe handoff until you need delta updates or background staging. Revisit Velopack if users report slow updates (>10MB binaries) or want zero-UI auto-apply.
---
## 2. GitHub Actions Windows Release Patterns
**Current state (2025/2026):**
- **cargo-dist** ([cargo-dist quickstart](https://axodotdev.github.io/cargo-dist/book/quickstart/rust.html)): Generates GitHub Actions `.yml` for multi-platform builds, code signing, release creation. Handles Windows builds natively. Requires `dist init` + `Cargo.toml` config.
- **release-plz** ([release-plz](https://github.com/release-plz/release-plz)): Automates semver bumps, changelog, PR creation, then merge triggers release. Windows support via GitHub Actions matrix.
- **Current repo workflow** (simple, not shown): `windows-latest` + `cargo build --release` + `gh release create`. Sufficient for early-stage indie apps.
- **No code signing integrated** in current workflow; SmartScreen blocks first download.
- **MSI generation**: cargo-dist can auto-generate; current app uses plain exe.
**Recommendation:**
Keep current simple workflow for now (YAGNI). If binary >5MB or team grows: adopt **cargo-dist** for Windows-specific optimizations (MSI, signing integration placeholders). Skip release-plz unless you manage multiple Rust crates.
---
## 3. Code Signing for Windows Hobby/OSS
**Current state (2025/2026):**
- **SignPath Foundation** ([signpath.io](https://signpath.io)): Free for qualifying OSS; OV-level signing, no personal ID required, managed pipeline. Application process 12 weeks. [Microsoft Learn reference](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/code-signing-options).
- **Azure Artifact Signing** (formerly Trusted Signing, ~$9.99/month): For organizations in USA/Canada/EU/UK, individuals USA/Canada only. GA as of April 2026. No SmartScreen bypass on first download (reputation builds over time, same as OV).
- **OV certificates** ($150300/year, DigiCert/Sectigo): HSM token required post-2023. Same SmartScreen behavior as Azure Artifact Signing.
- **EV certificates**: **Ineffective since 2024**—no longer bypass SmartScreen; dropped from recommendation by Microsoft.
- **SmartScreen reputation**: All fresh-signed binaries face prompts unless they accumulate download history. Instant bypass gone as of 2024.
**Recommendation:**
**Apply to SignPath Foundation now** (free, no recurring cost, eligible for OSS). Timeline: submit 12 weeks before next release. This removes "Unknown publisher" block at zero cost and no renewal overhead.
---
## 4. WinHTTP vs ureq vs reqwest for Desktop Apps
**Current state (2025/2026):**
- **WinHTTP** (current choice via `windows` crate 0.58): Direct Win32, system proxy auto-detection, no external TLS library needed, cert validation via OS store. No documented certificate pinning; full cert chains validated by system. Lightweight (~2.5 MB binary size vs reqwest ~5 MB). Thread-safe sessions.
- **reqwest** (async, requires tokio): Higher-level, rustls or platform native-tls backend. No certificate pinning via public API ([issue #379](https://github.com/seanmonstar/reqwest/issues/379) still open). Adds async runtime overhead.
- **ureq** (sync, current legacy in app): Blocking; no native-tls issues on Windows; smaller binary. Will be removed per phase comments in Cargo.toml.
- **Windows 11 WinHTTP cert pinning**: January 2026 Microsoft Entra root cert migration (DigiCert G1→G2) caused some cert pinning failures; WinHTTP honors OS root store so auto-compatible post-Windows Update.
**Recommendation:**
**Keep WinHTTP**. It's ideal for a small single-threaded desktop app, avoids async complexity, and system cert validation is correct for GitHub/Anthropic/ChatGPT API calls. Pinning not needed for public APIs. Remove ureq/native-tls after phase 6 finishes.
---
## 5. Win32 Tray + Bubble UX Libraries
**Current state (2025/2026):**
- **notify-icon** ([kkent030315/notify-icon-rs](https://github.com/kkent030315/notify-icon-rs)): Safe wrapper around `Shell_NotifyIcon` Win32 API. Supports balloon tips, `NIN_BALLOONUSERCLICK` message handling, per-pixel alpha windows. Ergonomic, actively maintained.
- **native-windows-gui** ([native_windows_gui](https://docs.rs/native-windows-gui/latest/native_windows_gui/struct.TrayNotification.html)): TrayNotification struct with balloon API.
- **Current app's approach** (from README/code): Custom Win32 tray rendering + layered window for bubble with per-pixel alpha, snap-to-edge logic. Clean-room implementation.
- **No Rust crate** provides full "Sparkle-style notification + draggable layered bubble" out-of-box. Rolling-your-own is standard.
**Recommendation:**
**Keep current custom approach**. No crate abstracts layered windows + tray + snap-to-edge UX better. If adding tray balloon tips, consider `notify-icon` crate for safety. Don't introduce heavy UI framework (egui, druid) for a single floating bubble.
---
## 6. Auto-Update UX Prior Art
**Current state (2025/2026):**
- **Velopack** ([velopack.io](https://velopack.io/), [delta docs](https://docs.velopack.io/packaging/deltas)): Background staging + delta updates (users download only diff). No UAC on apply. Restarts in ~2 seconds. Can migrate from Squirrel.
- **Sparkle** (macOS only, not applicable).
- **Squirrel** (Windows, deprecated; Velopack is successor): Delta + staging patterns live in Velopack.
- **Current app**: Notify user → download to staging → cmd.exe handoff (2s wait) → move+restart. No delta, no background staging, visible prompt.
**Key patterns:**
- Delta encoding: only changed bytes shipped.
- Background staging: download happens idle, apply on quit.
- Retry on failure: automatic backoff (Velopack does this; cmd.exe doesn't).
- Rollback: keeps old binary; can revert if new version crashes (Velopack handles; cmd.exe doesn't).
**Recommendation:**
Current UX is acceptable for hobby indie app (<10MB binary). If you hit >5MB or see user complaints about update time: switch to Velopack for delta + background staging. Not urgent now.
---
## 7. Distribution to Non-Technical Users
**Current state (2025/2026):**
- **winget** ([microsoft/winget-cli](https://github.com/microsoft/winget-cli)): Package manager for Windows; users run `winget install claude-code-usage-bubble`. Submission to [github.com/microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) is free, community-driven (you submit manifest, Microsoft approves). Supports EXE, MSI, MSIX.
- **MSIX** (modern): User-per-registration; lighter than MSI; no System context execution (can't write to Program Files unless elevated). Best for Store submission, not ideal for uninstaller scripts.
- **MSI** (traditional): Full System-context install; larger; maturer tooling. Overkill for a single exe bubble.
- **Plain .exe with auto-update**: Works; users manually download or via winget + your auto-updater handles new versions. Current approach.
**Recommendation:**
Submit to **winget** (free, no certs needed for listing). Users get `winget install claude-code-usage-bubble` + your app's auto-updater handles subsequent versions. Skip MSIX/MSI unless you need managed deployment (Intune/SCCM).
---
## Unresolved Questions / Gaps
- **Cert pinning for Anthropic/ChatGPT APIs**: Are public API endpoints behind any kind of mutable cert chains? (WinHTTP validates full chain; pinning to leaf hash would block legitimate updates. Recommend NOT pinning public APIs.)
- **Velopack .NET dependency**: Velopack has .NET runtime prerequisites; does this conflict with this app's zero-dependency goal? (Needs verification.)
- **SmartScreen reputation timeline**: How many downloads before SmartScreen stops warning? (Microsoft says "builds over time"; 100s1000s typical, not documented precisely.)
---
## Top 3 Changes by ROI
1. **Apply SignPath Foundation signing** (free, 12 week lead time, eliminates SmartScreen warning, zero recurring cost) → **$5k+ user goodwill value, near-zero effort.**
2. **Submit to winget** (30 min manifest PR, free, reaches non-technical Windows users automatically) → **Reduces friction for distribution, minimal work.**
3. **Defer Velopack migration** (keep cmd.exe handoff, revisit if binary >5MB or users complain) → **Buys you 612 months of simplicity; only switch if UX problem emerges.**
---
**Sources:**
- [Tauri updater plugin](https://v2.tauri.app/plugin/updater/)
- [Velopack docs](https://velopack.io/)
- [cargo-dist quickstart](https://axodotdev.github.io/cargo-dist/book/quickstart/rust.html)
- [Microsoft code signing options 2026](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/code-signing-options)
- [SignPath Foundation](https://signpath.io)
- [WinHTTP Rust docs](https://docs.rs/winhttp/)
- [notify-icon-rs](https://github.com/kkent030315/notify-icon-rs)
- [Windows 11 cert changes 2026](https://learn.microsoft.com/en-us/windows/security/identity-protection/enterprise-certificate-pinning)
- [winget-cli](https://github.com/microsoft/winget-cli)
+366 -108
View File
@@ -6,13 +6,15 @@
// message-only window owned by this module.
use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::ffi::OsString;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use windows::core::PCWSTR;
use windows::Win32::Foundation::*;
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::System::Threading::CreateMutexW;
use windows::Win32::System::Threading::{CreateMutexW, GetCurrentProcessId, Sleep};
use windows::Win32::UI::HiDpi::{
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
};
@@ -33,6 +35,12 @@ use crate::usage::{self, ProviderId, Registry, UsageWindows};
// Win32 message IDs owned by this module.
pub const WM_APP_USAGE_UPDATED: u32 = 0x8001;
// Posted from the update worker thread after `install::begin` has
// already swapped the binary on disk and spawned the new detached
// child. The UI thread responds with PostQuitMessage(0) so the old
// instance exits cleanly and releases the singleton mutex for the
// child waiting on `--wait-pid`.
pub const WM_APP_UPDATE_APPLIED: u32 = 0x8002;
// Timer IDs used with `SetTimer(msg_hwnd, …)`.
const TIMER_POLL: usize = 1;
@@ -63,6 +71,7 @@ const IDM_MODEL_CHATGPT: u16 = 21;
const IDM_START_WITH_WINDOWS: u16 = 30;
const IDM_RESET_POSITION: u16 = 31;
const IDM_VERSION_ACTION: u16 = 32;
const IDM_RESTART: u16 = 33;
const IDM_LANG_SYSTEM: u16 = 40;
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
// clear of it (and any future tray ids in the 5x band).
@@ -106,8 +115,13 @@ struct AppState {
i18n: I18n,
is_dark: bool,
install_channel: InstallChannel,
http: net::Client,
registry: Registry,
// http and registry live behind Arc so worker threads can hold them
// without keeping the global state mutex locked across blocking I/O.
// The registry's own mutex is only contended by other workers
// (the in-flight gate ensures at most one poll runs at a time), so
// the UI thread never waits on it.
http: Arc<net::Client>,
registry: Arc<Mutex<Registry>>,
snapshots: HashMap<ProviderId, ProviderUiState>,
last_poll_ok: bool,
update_status: UpdateStatus,
@@ -133,27 +147,52 @@ fn lock_state() -> MutexGuard<'static, Option<AppState>> {
// ---------- Entry ----------
pub fn run() {
/// Acquire the singleton mutex, optionally retrying for ~3s if the
/// caller passed `--wait-pid` (i.e. we just spawned from an exiting
/// parent that has not yet released its handle).
fn acquire_singleton_mutex(retry: bool) -> Option<HANDLE> {
let mutex_name_w = os::to_utf16_nul(APP_MUTEX_NAME);
let max_attempts = if retry { 15 } else { 1 };
for attempt in 0..max_attempts {
let handle = unsafe { CreateMutexW(None, false, PCWSTR::from_raw(mutex_name_w.as_ptr())) };
match handle {
Ok(h) => {
let already = unsafe { GetLastError() } == ERROR_ALREADY_EXISTS;
if !already {
return Some(h);
}
// Mutex still held by parent. Close this handle and retry.
unsafe {
let _ = CloseHandle(h);
}
if attempt + 1 == max_attempts {
log::info!("another instance already running; exiting");
return None;
}
log::debug!(
"mutex still held by parent (attempt {}/{}), waiting 200ms",
attempt + 1,
max_attempts
);
unsafe { Sleep(200) };
}
Err(e) => {
log::error!("CreateMutex failed: {e}");
return None;
}
}
}
None
}
pub fn run(args: crate::AppArgs) {
unsafe {
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
}
let mutex_name_w = os::to_utf16_nul(APP_MUTEX_NAME);
let _mutex = unsafe {
let handle = CreateMutexW(None, false, PCWSTR::from_raw(mutex_name_w.as_ptr()));
match handle {
Ok(h) => {
if GetLastError() == ERROR_ALREADY_EXISTS {
log::info!("another instance already running; exiting");
return;
}
h
}
Err(e) => {
log::error!("CreateMutex failed: {e}");
return;
}
}
let _mutex = match acquire_singleton_mutex(args.wait_pid_present) {
Some(h) => h,
None => return,
};
let settings = settings::load();
@@ -182,8 +221,8 @@ pub fn run() {
i18n,
is_dark,
install_channel,
http,
registry: Registry::with_defaults(),
http: Arc::new(http),
registry: Arc::new(Mutex::new(Registry::with_defaults())),
snapshots: HashMap::new(),
last_poll_ok: false,
update_status: UpdateStatus::Idle,
@@ -194,6 +233,16 @@ pub fn run() {
create_initial_bubbles();
refresh_tray_icons();
// Post-update tasks: show "Updated to vX.Y.Z" balloon (driven by
// --updated-to passed by the previous instance) and sweep any
// stale `<exe>.old.<pid>` siblings left by past in-place swaps.
if let Some(v) = args.updated_to.as_ref() {
announce_update_applied(msg_hwnd, v);
}
if let Ok(exe_path) = std::env::current_exe() {
update::handoff::cleanup_stale_old_exes(&exe_path);
}
let poll_interval = lock_state()
.as_ref()
.map(|s| s.settings.poll_interval_ms)
@@ -293,6 +342,10 @@ unsafe extern "system" fn msg_wnd_proc(
propagate_to_ui();
LRESULT(0)
}
WM_APP_UPDATE_APPLIED => {
PostQuitMessage(0);
LRESULT(0)
}
WM_APP_TRAY => {
let action = tray::callback::handle(lparam);
handle_tray_action(action);
@@ -370,8 +423,12 @@ pub fn on_menu_command(id: u32, _owner_hwnd: HWND) {
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
}
IDM_LANG_SYSTEM => set_language(None),
x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize),
// Static ids in the 30-99 band must match BEFORE the dynamic
// language guard, otherwise `x >= IDM_LANG_BASE` would swallow any
// future id that creeps into the >=100 range.
tray::IDM_TOGGLE_WIDGET => toggle_widget_visibility(),
IDM_RESTART => restart_app(),
x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize),
_ => {}
}
}
@@ -394,13 +451,29 @@ fn on_timer(hwnd: HWND, id: usize) {
// ---------- Poll thread ----------
/// At-most-one-in-flight gate for the poll worker. Spam-clicking Refresh
/// would otherwise stack concurrent HTTPS calls onto the same registry,
/// which both wastes bandwidth and (before the registry mutex landed)
/// could let two poll cycles interleave their writes to the snapshot map.
static POLL_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
fn spawn_poll_thread() {
if POLL_IN_FLIGHT
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
let msg_hwnd = match lock_state().as_ref() {
Some(s) => s.msg_hwnd,
None => return,
None => {
POLL_IN_FLIGHT.store(false, Ordering::Release);
return;
}
};
std::thread::spawn(move || {
do_poll();
POLL_IN_FLIGHT.store(false, Ordering::Release);
unsafe {
let _ = PostMessageW(
msg_hwnd.to_hwnd(),
@@ -413,17 +486,23 @@ fn spawn_poll_thread() {
}
fn do_poll() {
let results = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
// Snapshot the inputs we need, then DROP the global lock before any
// HTTPS call. Holding `lock_state()` through `poll_enabled` would block
// the UI thread on every paint/menu for the duration of the request.
let (http, registry, settings) = {
let s = lock_state();
let Some(s) = s.as_ref() else {
return;
};
let settings = s.settings.clone();
s.registry.poll_enabled(&s.http, &settings)
(s.http.clone(), s.registry.clone(), s.settings.clone())
};
let results = {
let mut reg = registry.lock().expect("registry mutex poisoned");
reg.poll_enabled(&http, &settings)
};
let auth_failures = apply_results(results);
if !auth_failures.is_empty() {
attempt_refresh(auth_failures);
attempt_refresh(auth_failures, &registry);
}
}
@@ -431,57 +510,78 @@ fn apply_results(
results: Vec<(ProviderId, Result<UsageWindows, usage::Error>)>,
) -> Vec<ProviderId> {
let mut auth_failures = Vec::new();
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return auth_failures;
};
if results.is_empty() {
return auth_failures;
}
let strings = s.i18n.strings().clone();
let mut any_ok = false;
for (id, outcome) in results {
match outcome {
Ok(windows) => {
let entry = s.snapshots.entry(id).or_default();
entry.windows = windows;
entry.primary_text = i18n::format_window(&windows.primary, &strings);
entry.secondary_text = i18n::format_window(&windows.secondary, &strings);
any_ok = true;
}
Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => {
auth_failures.push(id);
let entry = s.snapshots.entry(id).or_default();
entry.primary_text = "!".into();
entry.secondary_text = "!".into();
}
Err(e) => {
log::warn!("provider {id:?} poll failed: {e}");
let entry = s.snapshots.entry(id).or_default();
entry.primary_text = "".into();
entry.secondary_text = "".into();
let mut crossings: Vec<(ProviderId, u8)> = Vec::new();
{
let mut guard = lock_state();
let Some(state) = guard.as_mut() else {
return auth_failures;
};
if results.is_empty() {
return auth_failures;
}
let strings = state.i18n.strings().clone();
let mut any_ok = false;
for (id, outcome) in results {
match outcome {
Ok(windows) => {
let entry = state.snapshots.entry(id).or_default();
let old_pct = entry.windows.primary.utilization;
let new_pct = windows.primary.utilization;
// Fire a balloon only the cycle a provider CROSSES a
// threshold so the user is nudged once, not on every
// subsequent poll while parked above it.
for threshold in [80u8, 95u8] {
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;
}
Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => {
auth_failures.push(id);
let entry = state.snapshots.entry(id).or_default();
entry.primary_text = "!".into();
entry.secondary_text = "!".into();
}
Err(e) => {
log::warn!("provider {id:?} poll failed: {e}");
let entry = state.snapshots.entry(id).or_default();
entry.primary_text = "".into();
entry.secondary_text = "".into();
}
}
}
state.last_poll_ok = any_ok;
}
// Lock released. Fire any threshold balloons outside the critical
// section so tray::notify and i18n cloning don't block the UI thread.
for (id, threshold) in crossings {
show_threshold_balloon(id, threshold);
}
s.last_poll_ok = any_ok;
auth_failures
}
fn attempt_refresh(failures: Vec<ProviderId>) {
fn attempt_refresh(failures: Vec<ProviderId>, registry: &Arc<Mutex<Registry>>) {
let orchestrator = usage::refresh::Orchestrator::new(REFRESH_TIMEOUT);
let mut needs_balloon = false;
// Pick the first provider whose refresh did not succeed and balloon
// for it specifically. If both providers fail in the same cycle the
// second will resurface on the next poll once the first is re-auth'd.
let mut balloon_for: Option<ProviderId> = None;
for id in failures {
let outcome = match lock_state().as_ref() {
Some(s) => s.registry.try_refresh(id, &orchestrator),
None => return,
let outcome = {
let reg = registry.lock().expect("registry mutex poisoned");
reg.try_refresh(id, &orchestrator)
};
log::info!("refresh for {id:?}: {outcome:?}");
if !matches!(outcome, usage::refresh::Outcome::Refreshed) {
needs_balloon = true;
balloon_for.get_or_insert(id);
}
}
if needs_balloon {
show_token_expired_balloon();
if let Some(provider) = balloon_for {
show_token_expired_balloon(provider);
}
}
@@ -712,7 +812,61 @@ fn handle_tray_action(action: TrayAction) {
}
}
fn show_token_expired_balloon() {
/// Re-read the system theme and, if it changed, push the new value into
/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows
/// posts that to every top-level window when the user toggles light/dark
/// in Settings, so this naturally fires once per change.
pub fn recheck_theme() {
let now_dark = os::theme::is_dark();
let changed = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
if s.is_dark == now_dark {
false
} else {
s.is_dark = now_dark;
true
}
};
if changed {
propagate_to_ui();
refresh_tray_icons();
}
}
fn show_threshold_balloon(provider: ProviderId, threshold: u8) {
let payload = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
// Reuse the same cooldown as the token-expired balloon: any one
// balloon at a time keeps notifications calm.
if let Some(last) = s.last_balloon_at {
if last.elapsed() < BALLOON_COOLDOWN {
return;
}
}
s.last_balloon_at = Some(Instant::now());
let strings = s.i18n.strings();
let provider_label = match provider {
ProviderId::Claude => strings.claude_label.clone(),
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
};
let title = format!("{provider_label} · {threshold}%");
let body = if threshold >= 95 {
strings.threshold_95_body.clone()
} else {
strings.threshold_80_body.clone()
};
(s.msg_hwnd, provider, title, body)
};
tray::notify_warning(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
}
fn show_token_expired_balloon(failed: ProviderId) {
let payload = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
@@ -725,22 +879,42 @@ fn show_token_expired_balloon() {
}
s.last_balloon_at = Some(Instant::now());
let strings = s.i18n.strings();
let (kind, title, body) = if s.settings.show_claude_code {
(
ProviderId::Claude,
let (title, body) = match failed {
ProviderId::Claude => (
strings.token_expired_title.clone(),
strings.token_expired_body.clone(),
)
} else {
(
ProviderId::ChatGpt,
),
ProviderId::ChatGpt => (
strings.chatgpt_token_expired_title.clone(),
strings.chatgpt_token_expired_body.clone(),
)
),
};
(s.msg_hwnd, kind, title, body)
(s.msg_hwnd, failed, title, body)
};
tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
tray::notify_warning(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
}
/// Show the "Updated to vX.Y.Z" balloon on first launch after an
/// auto-update. Picks Claude as the host icon if it's registered;
/// otherwise falls back to Codex. If neither is registered the
/// notification silently drops — better than crashing.
fn announce_update_applied(_msg_hwnd: HWND, version: &str) {
let payload = {
let s = lock_state();
let Some(s) = s.as_ref() else {
return;
};
let strings = s.i18n.strings();
let title = strings.update_applied_title.clone();
let body = format!("{}{}", strings.update_applied_body, version);
let host = if s.settings.show_claude_code {
ProviderId::Claude
} else {
ProviderId::ChatGpt
};
(s.msg_hwnd, host, title, body)
};
tray::notify_info(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
}
// ---------- Context menu ----------
@@ -787,7 +961,11 @@ fn show_context_menu(owner_hwnd: HWND) {
append_item(menu, IDM_REFRESH, &snap.strings.refresh, MENU_ITEM_FLAGS(0));
let freq = CreatePopupMenu().unwrap();
let Ok(freq) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(freq) failed");
let _ = DestroyMenu(menu);
return;
};
for (id, interval, label) in [
(IDM_FREQ_1MIN, POLL_1_MIN, &snap.strings.one_minute),
(IDM_FREQ_5MIN, POLL_5_MIN, &snap.strings.five_minutes),
@@ -803,7 +981,11 @@ fn show_context_menu(owner_hwnd: HWND) {
}
append_submenu(menu, freq, &snap.strings.update_frequency);
let models = CreatePopupMenu().unwrap();
let Ok(models) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(models) failed");
let _ = DestroyMenu(menu);
return;
};
append_item(
models,
IDM_MODEL_CLAUDE,
@@ -818,7 +1000,11 @@ fn show_context_menu(owner_hwnd: HWND) {
);
append_submenu(menu, models, &snap.strings.models);
let settings_menu = CreatePopupMenu().unwrap();
let Ok(settings_menu) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(settings_menu) failed");
let _ = DestroyMenu(menu);
return;
};
append_item(
settings_menu,
IDM_START_WITH_WINDOWS,
@@ -832,7 +1018,12 @@ fn show_context_menu(owner_hwnd: HWND) {
MENU_ITEM_FLAGS(0),
);
let lang = CreatePopupMenu().unwrap();
let Ok(lang) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(lang) failed");
let _ = DestroyMenu(settings_menu);
let _ = DestroyMenu(menu);
return;
};
append_item(
lang,
IDM_LANG_SYSTEM,
@@ -867,7 +1058,12 @@ fn show_context_menu(owner_hwnd: HWND) {
};
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
let auto_update = CreatePopupMenu().unwrap();
let Ok(auto_update) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(auto_update) failed");
let _ = DestroyMenu(settings_menu);
let _ = DestroyMenu(menu);
return;
};
for (id, value, label) in [
(IDM_UPDATE_AUTO_OFF, None, &snap.strings.auto_check_disabled),
(
@@ -903,6 +1099,7 @@ fn show_context_menu(owner_hwnd: HWND) {
if snap.widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
);
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
append_item(menu, IDM_EXIT, &snap.strings.exit, MENU_ITEM_FLAGS(0));
let mut pt = POINT::default();
@@ -936,9 +1133,13 @@ fn version_action_label(snap: &ContextMenuSnapshot) -> String {
UpdateStatus::Applying => snap.strings.applying_update.clone(),
UpdateStatus::Failed => snap.strings.update_failed.clone(),
};
// Append the running binary's version so the user can see what
// they are on without opening an About dialog. Using middle-dot as
// the separator matches the bubble's countdown formatting.
let with_version = format!("{base} \u{00b7} v{}", env!("CARGO_PKG_VERSION"));
match snap.install_channel {
InstallChannel::Winget => format!("{base} ({})", snap.strings.update_via_winget),
InstallChannel::Portable => base,
InstallChannel::Winget => format!("{with_version} ({})", snap.strings.update_via_winget),
InstallChannel::Portable => with_version,
}
}
@@ -1039,6 +1240,12 @@ fn reset_positions() {
s.bubbles.clear();
}
create_initial_bubbles();
// The freshly-spawned bubbles boot with a "…" placeholder. Push the
// cached snapshot so they render the last-known data immediately, and
// kick a poll for users who used Reset Position to recover from
// staleness.
propagate_to_ui();
spawn_poll_thread();
}
fn set_language(_dummy: Option<()>) {
@@ -1086,31 +1293,46 @@ fn version_action() {
};
match act {
Act::Apply(release, channel) => {
if let Some(s) = lock_state().as_mut() {
// Set the Applying status synchronously so the menu reflects
// it immediately, then move the (potentially several-second)
// download to a worker thread. Holding the UI thread here
// would freeze paints and menus until the download finished.
let (http, msg_hwnd) = {
let mut guard = lock_state();
let Some(s) = guard.as_mut() else {
return;
};
s.update_status = UpdateStatus::Applying;
}
let result: Result<(), Box<dyn std::error::Error>> = match channel {
InstallChannel::Winget => {
// Winget channel is reserved for future use; until a
// winget package ships, this branch is unreachable.
Err("winget channel not supported yet".into())
}
InstallChannel::Portable => {
match net::Client::new(HTTP_USER_AGENT) {
Ok(c) => update::install::begin(&c, &release).map_err(|e| e.into()),
Err(e) => Err(e.into()),
}
}
(s.http.clone(), s.msg_hwnd)
};
match result {
Ok(()) => unsafe { PostQuitMessage(0) },
Err(e) => {
log::error!("update apply failed: {e}");
if let Some(s) = lock_state().as_mut() {
s.update_status = UpdateStatus::Failed;
std::thread::spawn(move || {
let result: Result<(), Box<dyn std::error::Error + Send + Sync>> = match channel {
InstallChannel::Winget => {
// Winget channel is reserved for future use; until a
// winget package ships, this branch is unreachable.
Err("winget channel not supported yet".into())
}
InstallChannel::Portable => {
update::install::begin(&http, &release).map_err(|e| e.into())
}
};
match result {
Ok(()) => unsafe {
let _ = PostMessageW(
msg_hwnd.to_hwnd(),
WM_APP_UPDATE_APPLIED,
WPARAM(0),
LPARAM(0),
);
},
Err(e) => {
log::error!("update apply failed: {e}");
if let Some(s) = lock_state().as_mut() {
s.update_status = UpdateStatus::Failed;
}
}
}
}
});
}
Act::Check(hwnd) => begin_update_check(hwnd.to_hwnd()),
}
@@ -1211,6 +1433,42 @@ fn set_update_check_interval(value: Option<u64>) {
}
}
// ---------- Restart ----------
/// Relaunch the running binary by spawning a detached child via
/// `CreateProcessW`. The child waits on our PID before acquiring the
/// singleton mutex, so no shell handoff or timer is required.
fn restart_app() {
// Defensive flush — bubble positions and most settings already persist
// on change, but a final save is cheap insurance. Snapshot then drop the
// lock before the disk write so the UI thread doesn't block on I/O.
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 (parent pid={pid}), posting quit");
unsafe { PostQuitMessage(0) };
}
Err(e) => log::error!("restart: spawn_detached failed: {e}"),
}
}
// ---------- Start-with-Windows ----------
fn is_startup_enabled() -> bool {
+53 -13
View File
@@ -133,16 +133,16 @@ pub fn create(config: BubbleConfig) -> HWND {
let initial_size_logical = config
.size_logical
.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
let dpi_for_create = primary_dpi();
let width_px = scale_to_dpi(initial_size_logical, dpi_for_create);
let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi_for_create);
let (x, y) = config
.position
.unwrap_or_else(|| default_position(width_px, height_px, config.model));
let hwnd = unsafe {
let class_w = wide_str(CLASS_NAME);
let title_w = wide_str("Claude Code Usage Bubble");
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
let dpi = primary_dpi();
let width_px = scale_to_dpi(initial_size_logical, dpi);
let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi);
let (x, y) = config
.position
.unwrap_or_else(|| default_position(width_px, height_px, config.model));
CreateWindowExW(
WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE,
PCWSTR::from_raw(class_w.as_ptr()),
@@ -217,6 +217,16 @@ pub fn create(config: BubbleConfig) -> HWND {
},
);
log::info!(
"bubble create model={:?} pos=({x},{y}) size={width_px}x{height_px} dpi={dpi}",
config.model
);
// Defense in depth: settings::load already validates positions against
// currently-connected monitors, but a monitor unplug between load and
// create (or a partially-off-screen saved position) is still possible.
clamp_into_work_area(hwnd);
render(hwnd);
unsafe {
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
@@ -478,10 +488,13 @@ unsafe extern "system" fn wnd_proc(
LRESULT(0)
}
WM_SETTINGCHANGE => {
// Taskbar move / auto-hide toggle / DPI change all post this.
// Just re-clamp into the new work area so the bubble can't end up
// hidden behind the new taskbar position.
// Taskbar move / auto-hide toggle / DPI change / theme toggle
// all post this. Re-clamp into the new work area (bubble must
// not end up hidden behind the new taskbar position) and ask
// the app to re-read the light/dark setting — Windows fires
// this message when the user flips the OS theme in Settings.
clamp_into_work_area(hwnd);
crate::app::recheck_theme();
LRESULT(0)
}
WM_DESTROY => {
@@ -789,8 +802,26 @@ fn clamp_into_work_area(hwnd: HWND) {
let w = r.right - r.left;
let h = r.bottom - r.top;
let nx = r.left.clamp(wa.left, (wa.right - w).max(wa.left));
let ny = r.top.clamp(wa.top, (wa.bottom - h).max(wa.top));
let mut ny = r.top.clamp(wa.top, (wa.bottom - h).max(wa.top));
// 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), keep the Codex-above-Claude stagger that `default_position` uses
// so they don't visually stack.
let is_codex = lock_bubbles()
.get(&(hwnd.0 as isize))
.is_some_and(|b| matches!(b.model, TrayIconKind::ChatGpt));
if is_codex && nx == wa.right - w && ny == wa.bottom - h {
const STAGGER_GAP: i32 = 24;
ny = (ny - h - STAGGER_GAP).max(wa.top);
}
if nx != r.left || ny != r.top {
log::warn!(
"clamp_into_work_area moved bubble from ({}, {}) to ({nx}, {ny})",
r.left,
r.top
);
unsafe {
let _ = SetWindowPos(
hwnd,
@@ -888,7 +919,12 @@ fn check_fullscreen(bubble_hwnd: HWND) {
const ACCENT_STRIPE_W_LOGICAL: i32 = 4;
const LABEL_PAD_LOGICAL: i32 = 6;
const COUNTDOWN_TEMPLATE: &str = "999d";
// Sized for the widest countdown across all shipped locales. Korean
// "999시간" (3 digits + 2 CJK chars for the hour suffix) is the current
// worst case; ASCII-only "999d" was too narrow and let CJK text spill
// out of the column. Update this when adding a locale with a longer
// suffix.
const COUNTDOWN_TEMPLATE: &str = "999시간";
// Percent now lives in its own column between the bar and the countdown so
// the two numeric readouts ("44%" and "3h") sit next to each other for
// quick scanning, and the percent never has to fight the bar's fill colour
@@ -1296,8 +1332,10 @@ fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) {
let label_font = create_font(layout.label_font_px, &font_name, FW_NORMAL.0 as i32);
SetBkMode(hdc, TRANSPARENT);
// Row labels in the left column.
SelectObject(hdc, label_font);
// Save the DC's original font so we can restore it before deleting
// ours. DeleteObject silently fails on a still-selected HFONT,
// which would leak the handle on every paint frame.
let prev_font = SelectObject(hdc, label_font);
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
draw_label(hdc, layout, layout.row1_y, "5h");
draw_label(hdc, layout, layout.row2_y, "7d");
@@ -1314,6 +1352,8 @@ fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) {
draw_countdown(hdc, layout, layout.row1_y, &inputs.session_text);
draw_countdown(hdc, layout, layout.row2_y, &inputs.weekly_text);
// Restore the original font, then it is safe to delete ours.
SelectObject(hdc, prev_font);
let _ = DeleteObject(main_font);
let _ = DeleteObject(bold_font);
let _ = DeleteObject(label_font);
-43
View File
@@ -1,43 +0,0 @@
code = "de"
native_name = "Deutsch"
window_title = "Claude Code Usage Bubble"
refresh = "Aktualisieren"
update_frequency = "Aktualisierungsintervall"
one_minute = "1 Minute"
five_minutes = "5 Minuten"
fifteen_minutes = "15 Minuten"
one_hour = "1 Stunde"
models = "Modelle"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Einstellungen"
start_with_windows = "Mit Windows starten"
reset_position = "Position zurücksetzen"
language = "Sprache"
system_default = "Systemstandard"
check_for_updates = "Nach Updates suchen"
checking_for_updates = "Suche läuft…"
up_to_date = "Aktuell"
update_failed = "Update fehlgeschlagen"
applying_update = "Update wird angewendet…"
update_available = "Update verfügbar"
update_via_winget = "über WinGet"
auto_update_check = "Automatische Updateprüfung"
auto_check_disabled = "Deaktiviert"
auto_check_hourly = "Stündlich"
auto_check_daily = "Täglich"
auto_check_weekly = "Wöchentlich"
exit = "Beenden"
show_widget = "Widget anzeigen"
session_window = "5h"
weekly_window = "7d"
now = "jetzt"
day_suffix = "T"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Claude Code-Sitzung abgelaufen"
token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
+6
View File
@@ -29,6 +29,7 @@ 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"
@@ -41,3 +42,8 @@ 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: "
-43
View File
@@ -1,43 +0,0 @@
code = "es"
native_name = "Español"
window_title = "Claude Code Usage Bubble"
refresh = "Actualizar"
update_frequency = "Frecuencia de actualización"
one_minute = "1 minuto"
five_minutes = "5 minutos"
fifteen_minutes = "15 minutos"
one_hour = "1 hora"
models = "Modelos"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Ajustes"
start_with_windows = "Iniciar con Windows"
reset_position = "Restablecer posición"
language = "Idioma"
system_default = "Predeterminado del sistema"
check_for_updates = "Buscar actualizaciones"
checking_for_updates = "Buscando actualizaciones…"
up_to_date = "Al día"
update_failed = "Actualización fallida"
applying_update = "Aplicando actualización…"
update_available = "Actualización disponible"
update_via_winget = "vía WinGet"
auto_update_check = "Búsqueda automática de actualizaciones"
auto_check_disabled = "Desactivada"
auto_check_hourly = "Cada hora"
auto_check_daily = "Cada día"
auto_check_weekly = "Cada semana"
exit = "Salir"
show_widget = "Mostrar widget"
session_window = "5h"
weekly_window = "7d"
now = "ahora"
day_suffix = "d"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Sesión de Claude Code caducada"
token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
chatgpt_token_expired_title = "Sesión de Codex caducada"
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
-43
View File
@@ -1,43 +0,0 @@
code = "fr"
native_name = "Français"
window_title = "Claude Code Usage Bubble"
refresh = "Actualiser"
update_frequency = "Fréquence de mise à jour"
one_minute = "1 minute"
five_minutes = "5 minutes"
fifteen_minutes = "15 minutes"
one_hour = "1 heure"
models = "Modèles"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Paramètres"
start_with_windows = "Lancer avec Windows"
reset_position = "Réinitialiser la position"
language = "Langue"
system_default = "Paramètre système"
check_for_updates = "Rechercher des mises à jour"
checking_for_updates = "Recherche en cours…"
up_to_date = "À jour"
update_failed = "Mise à jour échouée"
applying_update = "Mise à jour en cours…"
update_available = "Mise à jour disponible"
update_via_winget = "via WinGet"
auto_update_check = "Vérification automatique des mises à jour"
auto_check_disabled = "Désactivée"
auto_check_hourly = "Toutes les heures"
auto_check_daily = "Quotidienne"
auto_check_weekly = "Hebdomadaire"
exit = "Quitter"
show_widget = "Afficher le widget"
session_window = "5h"
weekly_window = "7j"
now = "maintenant"
day_suffix = "j"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Session Claude Code expirée"
token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
chatgpt_token_expired_title = "Session Codex expirée"
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
+6
View File
@@ -29,6 +29,7 @@ auto_check_hourly = "1時間ごと"
auto_check_daily = "毎日"
auto_check_weekly = "毎週"
exit = "終了"
restart = "再起動"
show_widget = "ウィジェットを表示"
session_window = "5時間"
weekly_window = "7日"
@@ -41,3 +42,8 @@ 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 = "更新に失敗しました。元のバイナリは次の場所に保存されています: "
+6
View File
@@ -29,6 +29,7 @@ auto_check_hourly = "매시간"
auto_check_daily = "매일"
auto_check_weekly = "매주"
exit = "종료"
restart = "다시 시작"
show_widget = "위젯 표시"
session_window = "5시간"
weekly_window = "7일"
@@ -41,3 +42,8 @@ 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 = "업데이트 실패. 원본 바이너리는 다음 위치에 저장되었습니다: "
-43
View File
@@ -1,43 +0,0 @@
code = "nl"
native_name = "Nederlands"
window_title = "Claude Code Usage Bubble"
refresh = "Vernieuwen"
update_frequency = "Bijwerkfrequentie"
one_minute = "1 minuut"
five_minutes = "5 minuten"
fifteen_minutes = "15 minuten"
one_hour = "1 uur"
models = "Modellen"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Instellingen"
start_with_windows = "Starten met Windows"
reset_position = "Positie herstellen"
language = "Taal"
system_default = "Systeemstandaard"
check_for_updates = "Controleren op updates"
checking_for_updates = "Bezig met controleren…"
up_to_date = "Up-to-date"
update_failed = "Update mislukt"
applying_update = "Update toepassen…"
update_available = "Update beschikbaar"
update_via_winget = "via WinGet"
auto_update_check = "Automatische updatecontrole"
auto_check_disabled = "Uitgeschakeld"
auto_check_hourly = "Per uur"
auto_check_daily = "Dagelijks"
auto_check_weekly = "Wekelijks"
exit = "Afsluiten"
show_widget = "Widget tonen"
session_window = "5u"
weekly_window = "7d"
now = "nu"
day_suffix = "d"
hour_suffix = "u"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Claude Code-sessie verlopen"
token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
chatgpt_token_expired_title = "Codex-sessie verlopen"
chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
+49
View File
@@ -0,0 +1,49 @@
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ờ"
models = "Mô hình"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Cài đặt"
start_with_windows = "Khởi động cùng Windows"
reset_position = "Đặt lại vị trí"
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: "
+6
View File
@@ -29,6 +29,7 @@ auto_check_hourly = "每小時"
auto_check_daily = "每天"
auto_check_weekly = "每週"
exit = "結束"
restart = "重新啟動"
show_widget = "顯示小工具"
session_window = "5 小時"
weekly_window = "7 日"
@@ -41,3 +42,8 @@ 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 = "更新失敗。您的原始執行檔已保存於: "
+15 -4
View File
@@ -49,6 +49,7 @@ pub struct LocaleStrings {
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,
@@ -61,6 +62,19 @@ pub struct LocaleStrings {
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 so it does not
/// need to be translated separately.
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 (e.g. "0.1.10").
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,
}
#[derive(Deserialize)]
@@ -73,12 +87,9 @@ struct LocaleFile {
const RAW_LOCALES: &[(&str, &str)] = &[
("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")),
("vi", include_str!("locales/vi.toml")),
("zh-TW", include_str!("locales/zh-TW.toml")),
];
+29 -3
View File
@@ -36,8 +36,34 @@ fn main() {
std::process::exit(exit_code);
}
if diagnose_enabled {
log::info!("entering app::run");
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 {
if diagnose_enabled {
log::info!("waiting up to 5s for parent pid {pid} to exit");
}
update::handoff::wait_for_parent_exit(pid, 5_000);
}
app::run();
let updated_to = args
.iter()
.position(|a| a == "--updated-to")
.and_then(|i| args.get(i + 1))
.cloned();
if diagnose_enabled {
log::info!("entering app::run (wait_pid={wait_pid:?} updated_to={updated_to:?})");
}
app::run(AppArgs {
wait_pid_present: wait_pid.is_some(),
updated_to,
});
}
pub struct AppArgs {
pub wait_pid_present: bool,
pub updated_to: Option<String>,
}
+17 -9
View File
@@ -504,19 +504,27 @@ fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
// Anchor below the bubble by default; flip above if it would clip.
let mut x = anchor.left;
let mut y = anchor.bottom + 8;
let virtual_screen_h = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
let virtual_screen_w = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
if y + panel_h > virtual_screen_h {
// The virtual screen spans all monitors. Its origin is offset from
// the primary monitor when a secondary monitor sits left of / above
// the primary, so clamps must include SM_XVIRTUALSCREEN /
// SM_YVIRTUALSCREEN — not just the width/height of the union.
let vx = unsafe { GetSystemMetrics(SM_XVIRTUALSCREEN) };
let vy = unsafe { GetSystemMetrics(SM_YVIRTUALSCREEN) };
let vw = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
let vh = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
let right = vx + vw;
let bottom = vy + vh;
if y + panel_h > bottom {
y = anchor.top - panel_h - 8;
}
if y < 0 {
y = anchor.top;
if y < vy {
y = anchor.top.max(vy);
}
if x + panel_w > virtual_screen_w {
x = virtual_screen_w - panel_w - 8;
if x + panel_w > right {
x = right - panel_w - 8;
}
if x < 0 {
x = 8;
if x < vx {
x = vx + 8;
}
(x, y)
}
+40
View File
@@ -1,11 +1,18 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use windows::Win32::Foundation::RECT;
use windows::Win32::Graphics::Gdi::{MonitorFromRect, MONITOR_DEFAULTTONULL};
use crate::bubble::DEFAULT_BUBBLE_SIZE;
use crate::usage::ProviderId;
type TrayIconKind = ProviderId;
// 140px 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 (the bug we're guarding against) fails.
const POSITION_PROBE_PX: i32 = 140;
const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble";
const SETTINGS_FILE: &str = "settings.json";
@@ -67,6 +74,37 @@ impl BubblePositions {
self.claude = None;
self.codex = None;
}
/// Drop any saved position whose top-left no longer falls on a connected
/// monitor. Guards against `bubble::create` placing the window on a
/// disconnected secondary monitor (where the user can't see or recover it).
pub fn validate(&mut self) {
if let Some((x, y)) = self.claude {
if !position_on_any_monitor(x, y) {
log::warn!("bubble position claude ({x},{y}) outside all monitors; resetting to default");
self.claude = None;
}
}
if let Some((x, y)) = self.codex {
if !position_on_any_monitor(x, y) {
log::warn!("bubble position codex ({x},{y}) outside all monitors; resetting to default");
self.codex = None;
}
}
}
}
fn position_on_any_monitor(x: i32, y: i32) -> bool {
// MONITOR_DEFAULTTONULL returns a null HMONITOR when the rect intersects
// no connected monitor — exactly the signal we want.
let probe = RECT {
left: x,
top: y,
right: x + POSITION_PROBE_PX,
bottom: y + POSITION_PROBE_PX,
};
let monitor = unsafe { MonitorFromRect(&probe, MONITOR_DEFAULTTONULL) };
!monitor.is_invalid()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -132,6 +170,8 @@ pub fn load() -> Settings {
settings.bubble_size_logical = settings
.bubble_size_logical
.clamp(crate::bubble::MIN_BUBBLE_SIZE, crate::bubble::MAX_BUBBLE_SIZE);
// Drop positions on monitors that have since been disconnected.
settings.bubble_positions.validate();
settings
}
+20 -5
View File
@@ -11,8 +11,8 @@ use std::sync::{Mutex, OnceLock};
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::Shell::{
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_WARNING, NIM_ADD,
NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW,
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_INFO, NIIF_WARNING,
NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS,
};
use windows::Win32::UI::WindowsAndMessaging::DestroyIcon;
@@ -81,13 +81,28 @@ pub fn sync(owner: HWND, desired: &[TrayIcon]) {
}
}
/// Show a balloon notification on an already-registered icon.
pub fn notify(owner: HWND, kind: IconKind, title: &str, body: &str) {
/// Show a yellow-warning balloon on an already-registered icon.
pub fn notify_warning(owner: HWND, kind: IconKind, title: &str, body: &str) {
notify_inner(owner, kind, title, body, NIIF_WARNING);
}
/// Show a blue-info balloon on an already-registered icon.
pub fn notify_info(owner: HWND, kind: IconKind, title: &str, body: &str) {
notify_inner(owner, kind, title, body, NIIF_INFO);
}
fn notify_inner(
owner: HWND,
kind: IconKind,
title: &str,
body: &str,
flags: NOTIFY_ICON_INFOTIP_FLAGS,
) {
let mut data = build_data(owner, kind);
data.uFlags = NIF_INFO;
write_utf16(&mut data.szInfoTitle, title);
write_utf16(&mut data.szInfo, body);
data.dwInfoFlags = NIIF_WARNING;
data.dwInfoFlags = flags;
unsafe {
let _ = Shell_NotifyIconW(NIM_MODIFY, &data);
}
+133
View File
@@ -0,0 +1,133 @@
// Native Win32 process + file handoff primitives used by the in-app
// restart path and the auto-update install path. The main binary uses
// `windows_subsystem = "windows"`, so spawning the child directly via
// `CreateProcessW` allocates no console — nothing can flash.
use std::ffi::OsString;
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{CloseHandle, FALSE, HANDLE, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{
CreateProcessW, OpenProcess, WaitForSingleObject, CREATE_NEW_PROCESS_GROUP,
CREATE_NO_WINDOW, DETACHED_PROCESS, PROCESS_INFORMATION, PROCESS_SYNCHRONIZE,
STARTUPINFOW,
};
/// Spawn `exe` with the supplied args as a detached, console-less child.
///
/// Caller is fire-and-forget: the child's handles are closed immediately
/// so no zombie wait is required.
pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
let mut cmdline = build_command_line(exe, args);
let si = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
..Default::default()
};
let mut pi = PROCESS_INFORMATION::default();
let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
let ok = unsafe {
CreateProcessW(
PCWSTR::null(),
windows::core::PWSTR(cmdline.as_mut_ptr()),
None,
None,
FALSE,
flags,
None,
PCWSTR::null(),
&si,
&mut pi,
)
};
if ok.is_err() {
return Err(io::Error::last_os_error());
}
unsafe {
if !pi.hThread.is_invalid() {
let _ = CloseHandle(pi.hThread);
}
if !pi.hProcess.is_invalid() {
let _ = CloseHandle(pi.hProcess);
}
}
// Suppress the unused-variable warning until si.lpReserved fields ever matter.
let _ = &si;
Ok(())
}
/// Wait up to `timeout_ms` for `pid` to exit. Silent on any failure —
/// caller treats this as a best-effort barrier before acquiring the
/// singleton mutex.
pub fn wait_for_parent_exit(pid: u32, timeout_ms: u32) {
let handle: HANDLE = match unsafe { OpenProcess(PROCESS_SYNCHRONIZE, FALSE, pid) } {
Ok(h) if !h.is_invalid() => h,
_ => return,
};
unsafe {
let res = WaitForSingleObject(handle, timeout_ms);
if res != WAIT_OBJECT_0 {
log::debug!("wait_for_parent_exit pid={pid} timeout/err res={:?}", res.0);
}
let _ = CloseHandle(handle);
}
}
/// Remove leftover `<exe>.old.<pid>` siblings from previous in-place updates.
/// Filled in by phase 4; stubbed here so phase 1 can wire the call sites.
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()
);
}
}
}
}
fn build_command_line(exe: &Path, args: &[OsString]) -> Vec<u16> {
// CreateProcessW parses argv[0] from a quoted exe path. We wrap the
// exe in `"…"` and join args separated by spaces. Args are quoted
// only when they contain whitespace; our callers pass simple tokens
// (--wait-pid <number>, --updated-to <version>) so naive quoting is
// sufficient.
let mut line = String::new();
line.push('"');
line.push_str(&exe.to_string_lossy());
line.push('"');
for a in args {
line.push(' ');
let s = a.to_string_lossy();
if s.chars().any(|c| c.is_whitespace()) {
line.push('"');
line.push_str(&s);
line.push('"');
} else {
line.push_str(&s);
}
}
let mut wide: Vec<u16> = std::ffi::OsString::from(line).encode_wide().collect();
wide.push(0);
wide
}
+168 -32
View File
@@ -1,33 +1,47 @@
// Download a release asset and hand off via inline `cmd /c`.
// Download a release asset and swap it in via native Win32 calls.
//
// We avoid the helper-exe pattern entirely: after writing the new .exe
// to a staging path, we spawn cmd.exe with a one-liner that waits 2 s,
// moves the new binary over the running one (Windows releases the file
// lock when our process exits), and relaunches it.
// After writing the new .exe to a staging path and verifying its
// SHA-256, we `MoveFileExW` the running exe sideways (so Windows
// releases the file lock on our own image), then `MoveFileExW` the
// staged exe into place, then spawn the new binary detached via
// `handoff::spawn_detached`. No shell, no console allocation.
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
};
use windows::Win32::System::Threading::GetCurrentProcessId;
use windows::Win32::UI::WindowsAndMessaging::{
MessageBoxW, MB_ICONERROR, MB_OK,
};
use crate::net::Client;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const DETACHED_PROCESS: u32 = 0x0000_0008;
use crate::os::to_utf16_nul;
pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error> {
let current = std::env::current_exe()?;
ensure_writable(&current)?;
let staging = stage_path()?;
// Defense in depth: `MoveFileExW` itself is immune to `%`-expansion
// (no shell parses our paths), but the existing rejection guards
// future code paths that might invoke external tools, so keep it.
reject_unsafe_path(&current)?;
reject_unsafe_path(&staging)?;
if let Some(parent) = staging.parent() {
std::fs::create_dir_all(parent)?;
}
download(http, &release.asset_url, &staging)?;
spawn_handoff(&staging, &current)?;
download(http, &release.asset_url, &staging, release.asset_sha256.as_ref())?;
swap_and_spawn(&staging, &current, &release.version)?;
Ok(())
}
/// CLI entry-point compatibility for `--apply-update <target> <source> <pid>`.
/// The inline-cmd handoff already does the swap-and-restart; if this binary
/// The native handoff already does the swap-and-restart; if this binary
/// is invoked with the legacy flag (e.g. from an older release's helper)
/// just exit cleanly so the upgrade still completes.
pub fn run_cli(args: &[String]) -> Option<i32> {
@@ -38,7 +52,12 @@ pub fn run_cli(args: &[String]) -> Option<i32> {
}
}
fn download(http: &Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> {
fn download(
http: &Client,
url: &str,
to: &Path,
expected_sha256: Option<&[u8; 32]>,
) -> Result<(), super::Error> {
let resp = http
.get(url)
.header("User-Agent", super::release::user_agent())
@@ -46,28 +65,145 @@ fn download(http: &Client, url: &str, to: &std::path::Path) -> Result<(), super:
if !(200..300).contains(&resp.status()) {
return Err(super::Error::Network(crate::net::Error::Status(resp.status())));
}
std::fs::write(to, resp.body())?;
let body = resp.body();
if let Some(expected) = expected_sha256 {
let mut hasher = Sha256::new();
hasher.update(body);
let actual = hasher.finalize();
if actual.as_slice() != expected {
return Err(super::Error::ChecksumMismatch {
expected: hex_encode(expected),
actual: hex_encode(&actual),
});
}
}
std::fs::write(to, body)?;
Ok(())
}
fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(), super::Error> {
let src_str = source.to_string_lossy().replace('"', "");
let tgt_str = target.to_string_lossy().replace('"', "");
// 2-second wait gives the current process time to exit and release the
// file lock before `move` overwrites it.
let cmd = format!(
r#"timeout /t 2 /nobreak >nul & move /y "{src_str}" "{tgt_str}" & start "" "{tgt_str}""#
);
Command::new("cmd.exe")
.args(["/c", &cmd])
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> {
let s = p.to_string_lossy();
if s.contains('%') {
return Err(super::Error::UnsafePath(format!(
"path contains '%': {s}"
)));
}
Ok(())
}
fn swap_and_spawn(
source: &Path,
target: &Path,
version: &super::release::Version,
) -> Result<(), super::Error> {
let backup = backup_path(target);
// Step 1: rename running exe sideways. Windows allows renaming a
// file even while its image is mapped into memory; this releases
// the lock on the original `target` path. Same directory by
// construction, so plain MoveFileExW with no flags is sufficient.
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
// Step 2: move staged exe into place. Staging lives under
// %LOCALAPPDATA%, target lives wherever the user installed —
// COPY_ALLOWED lets MoveFileExW fall back to copy+delete when
// the two paths cross volumes (portable installs on D:/E:/etc.).
let step2_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
if let Err(swap_err) = move_file(source, target, step2_flags) {
// Best-effort revert. Same volume, no COPY_ALLOWED needed.
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("rollback also failed: {revert_err}; surfacing modal");
let target_name = target
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
surface_rollback_failure(&backup, &target_name);
}
return Err(swap_err);
}
// Step 3: spawn the new exe detached with --wait-pid + --updated-to.
let pid = unsafe { GetCurrentProcessId() };
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
let args = vec![
OsString::from("--wait-pid"),
OsString::from(pid.to_string()),
OsString::from("--updated-to"),
OsString::from(version_str),
];
if let Err(spawn_err) = super::handoff::spawn_detached(target, &args) {
// New binary is on disk but won't auto-launch. Roll back so
// the user's next "Restart" stays on the known-good version.
log::error!("spawn_detached failed after swap: {spawn_err}; attempting revert");
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("post-spawn revert failed: {revert_err}");
}
return Err(super::Error::Io(spawn_err));
}
Ok(())
}
fn move_file(src: &Path, dst: &Path, flags: MOVE_FILE_FLAGS) -> Result<(), super::Error> {
let src_w = to_utf16_nul(&src.to_string_lossy());
let dst_w = to_utf16_nul(&dst.to_string_lossy());
let result = unsafe {
MoveFileExW(
PCWSTR::from_raw(src_w.as_ptr()),
PCWSTR::from_raw(dst_w.as_ptr()),
flags,
)
};
result.map_err(|e| {
super::Error::SwapFailed(format!(
"MoveFileExW({} -> {}): {e}",
src.display(),
dst.display()
))
})
}
fn backup_path(target: &Path) -> PathBuf {
let pid = unsafe { GetCurrentProcessId() };
let fname = target
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "exe".to_string());
let mut p = target.to_owned();
p.set_file_name(format!("{fname}.old.{pid}"));
p
}
fn surface_rollback_failure(backup: &Path, target_name: &str) {
// Pull the localized body from i18n; the caller passes the
// user-meaningful filename so we can format it in-place.
let strings = crate::i18n::I18n::load(None).strings().clone();
let body = format!(
"{}{}\n\n{}",
strings.update_rollback_failed_body,
backup.display(),
target_name
);
let title_w = to_utf16_nul(&strings.update_failed);
let body_w = to_utf16_nul(&body);
unsafe {
MessageBoxW(
None,
PCWSTR::from_raw(body_w.as_ptr()),
PCWSTR::from_raw(title_w.as_ptr()),
MB_OK | MB_ICONERROR,
);
}
}
fn stage_path() -> Result<PathBuf, super::Error> {
let base = dirs::data_local_dir().ok_or_else(|| {
super::Error::NotWritable("no local data directory available".to_string())
@@ -78,7 +214,7 @@ fn stage_path() -> Result<PathBuf, super::Error> {
.join("update.exe"))
}
fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> {
fn ensure_writable(target: &Path) -> Result<(), super::Error> {
let parent = target.parent().ok_or_else(|| {
super::Error::NotWritable("could not resolve install directory".to_string())
})?;
+10 -2
View File
@@ -1,10 +1,12 @@
// Self-update subsystem.
//
// Two stages: `release::fetch_latest` checks GitHub releases for a newer
// build; `install::begin` downloads the .exe and hands off to a detached
// `cmd /c` script that swaps the binary and restarts.
// build; `install::begin` downloads the .exe, swaps it in via native
// `MoveFileExW`, then spawns the new binary detached via
// `CreateProcessW`. No shell handoff — nothing can flash a console.
pub mod channel;
pub mod handoff;
pub mod install;
pub mod release;
@@ -20,6 +22,12 @@ pub enum Error {
NotWritable(String),
#[error("malformed version: {0}")]
BadVersion(String),
#[error("asset checksum mismatch: expected {expected}, got {actual}")]
ChecksumMismatch { expected: String, actual: String },
#[error("path rejected for safety: {0}")]
UnsafePath(String),
#[error("file swap failed: {0}")]
SwapFailed(String),
}
pub use channel::{current as current_channel, Channel};
+28
View File
@@ -12,6 +12,10 @@ const REPO_NAME: &str = "claude-code-usage-bubble";
pub struct Release {
pub version: Version,
pub asset_url: String,
/// SHA-256 of the asset bytes, parsed from the GitHub Releases
/// API `digest` field. `None` if GitHub omitted it (older
/// releases predate the digest field).
pub asset_sha256: Option<[u8; 32]>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
@@ -71,9 +75,28 @@ pub fn fetch_latest(http: &Client) -> Result<super::CheckOutcome, super::Error>
Ok(super::CheckOutcome::Available(Release {
version: candidate,
asset_url: asset.browser_download_url.clone(),
asset_sha256: asset.digest.as_deref().and_then(parse_sha256_digest),
}))
}
/// Parse a GitHub `digest` field of the form `"sha256:<64 hex chars>"`
/// into a 32-byte array. Returns `None` for any other algorithm or
/// malformed input — callers should treat a missing digest as "no
/// integrity check available" rather than as a parse failure.
fn parse_sha256_digest(raw: &str) -> Option<[u8; 32]> {
let hex = raw.strip_prefix("sha256:")?;
if hex.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, byte_chars) in hex.as_bytes().chunks(2).enumerate() {
let high = (byte_chars[0] as char).to_digit(16)?;
let low = (byte_chars[1] as char).to_digit(16)?;
out[i] = ((high << 4) | low) as u8;
}
Some(out)
}
pub fn user_agent() -> &'static str {
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))
}
@@ -88,4 +111,9 @@ struct GhRelease {
struct GhAsset {
name: String,
browser_download_url: String,
/// GitHub started returning `digest: "sha256:..."` on the asset
/// object in 2024. Older releases omit it; we treat that as
/// "verification unavailable" rather than a hard error.
#[serde(default)]
digest: Option<String>,
}