Compare commits

...
5 Commits
Author SHA1 Message Date
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
20 changed files with 935 additions and 815 deletions
Generated
+59 -705
View File
@@ -8,12 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.9" version = "0.3.9"
@@ -26,12 +20,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -39,10 +27,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "block-buffer"
version = "2.11.1" version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
@@ -68,38 +59,30 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "claude-code-usage-bubble" name = "claude-code-usage-bubble"
version = "0.1.2" version = "0.1.6"
dependencies = [ dependencies = [
"dirs", "dirs",
"embed-resource", "embed-resource",
"log", "log",
"native-tls",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"simplelog", "simplelog",
"thiserror", "thiserror",
"tiny-skia", "tiny-skia",
"toml 0.8.23", "toml 0.8.23",
"ureq",
"windows", "windows",
] ]
[[package]] [[package]]
name = "core-foundation" name = "cpufeatures"
version = "0.10.1" version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [ dependencies = [
"core-foundation-sys",
"libc", "libc",
] ]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -109,6 +92,16 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -118,6 +111,16 @@ dependencies = [
"powerfmt", "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]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
@@ -139,17 +142,6 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.9" version = "3.0.9"
@@ -170,22 +162,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 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]] [[package]]
name = "fdeflate" name = "fdeflate"
version = "0.3.7" version = "0.3.7"
@@ -212,33 +188,13 @@ dependencies = [
] ]
[[package]] [[package]]
name = "foldhash" name = "generic-array"
version = "0.1.5" version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [ dependencies = [
"foreign-types-shared", "typenum",
] "version_check",
[[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",
] ]
[[package]] [[package]]
@@ -252,149 +208,12 @@ dependencies = [
"wasi", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -402,9 +221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.17.1", "hashbrown",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -413,12 +230,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@@ -434,18 +245,6 @@ dependencies = [
"libc", "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]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.29"
@@ -468,23 +267,6 @@ dependencies = [
"simd-adler32", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.2.1"
@@ -500,111 +282,31 @@ dependencies = [
"libc", "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]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 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]] [[package]]
name = "png" name = "png"
version = "0.17.16" version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags",
"crc32fast", "crc32fast",
"fdeflate", "fdeflate",
"flate2", "flate2",
"miniz_oxide", "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]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -623,19 +325,13 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [ dependencies = [
"getrandom 0.2.17", "getrandom",
"libredox", "libredox",
"thiserror", "thiserror",
] ]
@@ -649,51 +345,6 @@ dependencies = [
"semver", "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]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@@ -761,6 +412,17 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -784,18 +446,6 @@ dependencies = [
"time", "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]] [[package]]
name = "strict-num" name = "strict-num"
version = "0.1.1" version = "0.1.1"
@@ -813,30 +463,6 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "termcolor" name = "termcolor"
version = "1.4.1" version = "1.4.1"
@@ -925,16 +551,6 @@ dependencies = [
"strict-num", "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]] [[package]]
name = "toml" name = "toml"
version = "0.8.23" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -1022,49 +644,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]] [[package]]
name = "unicode-xid" name = "version_check"
version = "0.2.6" version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[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"
[[package]] [[package]]
name = "vswhom" name = "vswhom"
@@ -1092,58 +675,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 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]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -1330,183 +861,6 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+2 -6
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "claude-code-usage-bubble" name = "claude-code-usage-bubble"
version = "0.1.2" version = "0.1.6"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows" 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" repository = "https://github.com/tiennm99/claude-code-usage-bubble"
[dependencies] [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 = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
dirs = "6" dirs = "6"
@@ -21,6 +16,7 @@ simplelog = "0.12"
thiserror = "2" thiserror = "2"
toml = "0.8" toml = "0.8"
tiny-skia = "0.11" tiny-skia = "0.11"
sha2 = "0.10"
[dependencies.windows] [dependencies.windows]
version = "0.58" version = "0.58"
@@ -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,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,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)
+232 -86
View File
@@ -6,7 +6,8 @@
// message-only window owned by this module. // message-only window owned by this module.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard, OnceLock}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use windows::core::PCWSTR; use windows::core::PCWSTR;
@@ -33,6 +34,11 @@ use crate::usage::{self, ProviderId, Registry, UsageWindows};
// Win32 message IDs owned by this module. // Win32 message IDs owned by this module.
pub const WM_APP_USAGE_UPDATED: u32 = 0x8001; pub const WM_APP_USAGE_UPDATED: u32 = 0x8001;
// Posted from the update worker thread when the swap-and-restart cmd
// handoff has been launched successfully. The UI thread responds by
// calling PostQuitMessage(0) to release the file lock on the running
// .exe so cmd.exe can overwrite it.
pub const WM_APP_UPDATE_APPLIED: u32 = 0x8002;
// Timer IDs used with `SetTimer(msg_hwnd, …)`. // Timer IDs used with `SetTimer(msg_hwnd, …)`.
const TIMER_POLL: usize = 1; const TIMER_POLL: usize = 1;
@@ -106,8 +112,13 @@ struct AppState {
i18n: I18n, i18n: I18n,
is_dark: bool, is_dark: bool,
install_channel: InstallChannel, install_channel: InstallChannel,
http: net::Client, // http and registry live behind Arc so worker threads can hold them
registry: Registry, // 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>, snapshots: HashMap<ProviderId, ProviderUiState>,
last_poll_ok: bool, last_poll_ok: bool,
update_status: UpdateStatus, update_status: UpdateStatus,
@@ -182,8 +193,8 @@ pub fn run() {
i18n, i18n,
is_dark, is_dark,
install_channel, install_channel,
http, http: Arc::new(http),
registry: Registry::with_defaults(), registry: Arc::new(Mutex::new(Registry::with_defaults())),
snapshots: HashMap::new(), snapshots: HashMap::new(),
last_poll_ok: false, last_poll_ok: false,
update_status: UpdateStatus::Idle, update_status: UpdateStatus::Idle,
@@ -293,6 +304,10 @@ unsafe extern "system" fn msg_wnd_proc(
propagate_to_ui(); propagate_to_ui();
LRESULT(0) LRESULT(0)
} }
WM_APP_UPDATE_APPLIED => {
PostQuitMessage(0);
LRESULT(0)
}
WM_APP_TRAY => { WM_APP_TRAY => {
let action = tray::callback::handle(lparam); let action = tray::callback::handle(lparam);
handle_tray_action(action); handle_tray_action(action);
@@ -394,13 +409,29 @@ fn on_timer(hwnd: HWND, id: usize) {
// ---------- Poll thread ---------- // ---------- 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() { 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() { let msg_hwnd = match lock_state().as_ref() {
Some(s) => s.msg_hwnd, Some(s) => s.msg_hwnd,
None => return, None => {
POLL_IN_FLIGHT.store(false, Ordering::Release);
return;
}
}; };
std::thread::spawn(move || { std::thread::spawn(move || {
do_poll(); do_poll();
POLL_IN_FLIGHT.store(false, Ordering::Release);
unsafe { unsafe {
let _ = PostMessageW( let _ = PostMessageW(
msg_hwnd.to_hwnd(), msg_hwnd.to_hwnd(),
@@ -413,17 +444,23 @@ fn spawn_poll_thread() {
} }
fn do_poll() { fn do_poll() {
let results = { // Snapshot the inputs we need, then DROP the global lock before any
let mut s = lock_state(); // HTTPS call. Holding `lock_state()` through `poll_enabled` would block
let Some(s) = s.as_mut() else { // 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; return;
}; };
let settings = s.settings.clone(); (s.http.clone(), s.registry.clone(), s.settings.clone())
s.registry.poll_enabled(&s.http, &settings) };
let results = {
let mut reg = registry.lock().expect("registry mutex poisoned");
reg.poll_enabled(&http, &settings)
}; };
let auth_failures = apply_results(results); let auth_failures = apply_results(results);
if !auth_failures.is_empty() { if !auth_failures.is_empty() {
attempt_refresh(auth_failures); attempt_refresh(auth_failures, &registry);
} }
} }
@@ -431,57 +468,78 @@ fn apply_results(
results: Vec<(ProviderId, Result<UsageWindows, usage::Error>)>, results: Vec<(ProviderId, Result<UsageWindows, usage::Error>)>,
) -> Vec<ProviderId> { ) -> Vec<ProviderId> {
let mut auth_failures = Vec::new(); let mut auth_failures = Vec::new();
let mut s = lock_state(); let mut crossings: Vec<(ProviderId, u8)> = Vec::new();
let Some(s) = s.as_mut() else { {
return auth_failures; let mut guard = lock_state();
}; let Some(state) = guard.as_mut() else {
if results.is_empty() { return auth_failures;
return auth_failures; };
} if results.is_empty() {
let strings = s.i18n.strings().clone(); return auth_failures;
let mut any_ok = false; }
for (id, outcome) in results { let strings = state.i18n.strings().clone();
match outcome { let mut any_ok = false;
Ok(windows) => { for (id, outcome) in results {
let entry = s.snapshots.entry(id).or_default(); match outcome {
entry.windows = windows; Ok(windows) => {
entry.primary_text = i18n::format_window(&windows.primary, &strings); let entry = state.snapshots.entry(id).or_default();
entry.secondary_text = i18n::format_window(&windows.secondary, &strings); let old_pct = entry.windows.primary.utilization;
any_ok = true; let new_pct = windows.primary.utilization;
} // Fire a balloon only the cycle a provider CROSSES a
Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => { // threshold so the user is nudged once, not on every
auth_failures.push(id); // subsequent poll while parked above it.
let entry = s.snapshots.entry(id).or_default(); for threshold in [80u8, 95u8] {
entry.primary_text = "!".into(); if old_pct < threshold as f64 && new_pct >= threshold as f64 {
entry.secondary_text = "!".into(); crossings.push((id, threshold));
} }
Err(e) => { }
log::warn!("provider {id:?} poll failed: {e}"); entry.windows = windows;
let entry = s.snapshots.entry(id).or_default(); entry.primary_text = i18n::format_window(&windows.primary, &strings);
entry.primary_text = "".into(); entry.secondary_text = i18n::format_window(&windows.secondary, &strings);
entry.secondary_text = "".into(); 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 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 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 { for id in failures {
let outcome = match lock_state().as_ref() { let outcome = {
Some(s) => s.registry.try_refresh(id, &orchestrator), let reg = registry.lock().expect("registry mutex poisoned");
None => return, reg.try_refresh(id, &orchestrator)
}; };
log::info!("refresh for {id:?}: {outcome:?}"); log::info!("refresh for {id:?}: {outcome:?}");
if !matches!(outcome, usage::refresh::Outcome::Refreshed) { if !matches!(outcome, usage::refresh::Outcome::Refreshed) {
needs_balloon = true; balloon_for.get_or_insert(id);
} }
} }
if needs_balloon { if let Some(provider) = balloon_for {
show_token_expired_balloon(); show_token_expired_balloon(provider);
} }
} }
@@ -712,7 +770,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(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
}
fn show_token_expired_balloon(failed: ProviderId) {
let payload = { let payload = {
let mut s = lock_state(); let mut s = lock_state();
let Some(s) = s.as_mut() else { let Some(s) = s.as_mut() else {
@@ -725,20 +837,17 @@ fn show_token_expired_balloon() {
} }
s.last_balloon_at = Some(Instant::now()); s.last_balloon_at = Some(Instant::now());
let strings = s.i18n.strings(); let strings = s.i18n.strings();
let (kind, title, body) = if s.settings.show_claude_code { let (title, body) = match failed {
( ProviderId::Claude => (
ProviderId::Claude,
strings.token_expired_title.clone(), strings.token_expired_title.clone(),
strings.token_expired_body.clone(), strings.token_expired_body.clone(),
) ),
} else { ProviderId::ChatGpt => (
(
ProviderId::ChatGpt,
strings.chatgpt_token_expired_title.clone(), strings.chatgpt_token_expired_title.clone(),
strings.chatgpt_token_expired_body.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(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
} }
@@ -787,7 +896,11 @@ fn show_context_menu(owner_hwnd: HWND) {
append_item(menu, IDM_REFRESH, &snap.strings.refresh, MENU_ITEM_FLAGS(0)); 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 [ for (id, interval, label) in [
(IDM_FREQ_1MIN, POLL_1_MIN, &snap.strings.one_minute), (IDM_FREQ_1MIN, POLL_1_MIN, &snap.strings.one_minute),
(IDM_FREQ_5MIN, POLL_5_MIN, &snap.strings.five_minutes), (IDM_FREQ_5MIN, POLL_5_MIN, &snap.strings.five_minutes),
@@ -803,7 +916,11 @@ fn show_context_menu(owner_hwnd: HWND) {
} }
append_submenu(menu, freq, &snap.strings.update_frequency); 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( append_item(
models, models,
IDM_MODEL_CLAUDE, IDM_MODEL_CLAUDE,
@@ -818,7 +935,11 @@ fn show_context_menu(owner_hwnd: HWND) {
); );
append_submenu(menu, models, &snap.strings.models); 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( append_item(
settings_menu, settings_menu,
IDM_START_WITH_WINDOWS, IDM_START_WITH_WINDOWS,
@@ -832,7 +953,12 @@ fn show_context_menu(owner_hwnd: HWND) {
MENU_ITEM_FLAGS(0), 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( append_item(
lang, lang,
IDM_LANG_SYSTEM, IDM_LANG_SYSTEM,
@@ -867,7 +993,12 @@ fn show_context_menu(owner_hwnd: HWND) {
}; };
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags); 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 [ for (id, value, label) in [
(IDM_UPDATE_AUTO_OFF, None, &snap.strings.auto_check_disabled), (IDM_UPDATE_AUTO_OFF, None, &snap.strings.auto_check_disabled),
( (
@@ -1086,31 +1217,46 @@ fn version_action() {
}; };
match act { match act {
Act::Apply(release, channel) => { 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; s.update_status = UpdateStatus::Applying;
} (s.http.clone(), s.msg_hwnd)
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()),
}
}
}; };
match result { std::thread::spawn(move || {
Ok(()) => unsafe { PostQuitMessage(0) }, let result: Result<(), Box<dyn std::error::Error + Send + Sync>> = match channel {
Err(e) => { InstallChannel::Winget => {
log::error!("update apply failed: {e}"); // Winget channel is reserved for future use; until a
if let Some(s) = lock_state().as_mut() { // winget package ships, this branch is unreachable.
s.update_status = UpdateStatus::Failed; 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()), Act::Check(hwnd) => begin_update_check(hwnd.to_hwnd()),
} }
+18 -6
View File
@@ -478,10 +478,13 @@ unsafe extern "system" fn wnd_proc(
LRESULT(0) LRESULT(0)
} }
WM_SETTINGCHANGE => { WM_SETTINGCHANGE => {
// Taskbar move / auto-hide toggle / DPI change all post this. // Taskbar move / auto-hide toggle / DPI change / theme toggle
// Just re-clamp into the new work area so the bubble can't end up // all post this. Re-clamp into the new work area (bubble must
// hidden behind the new taskbar position. // 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); clamp_into_work_area(hwnd);
crate::app::recheck_theme();
LRESULT(0) LRESULT(0)
} }
WM_DESTROY => { WM_DESTROY => {
@@ -888,7 +891,12 @@ fn check_fullscreen(bubble_hwnd: HWND) {
const ACCENT_STRIPE_W_LOGICAL: i32 = 4; const ACCENT_STRIPE_W_LOGICAL: i32 = 4;
const LABEL_PAD_LOGICAL: i32 = 6; 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 // 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 // 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 // quick scanning, and the percent never has to fight the bar's fill colour
@@ -1296,8 +1304,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); let label_font = create_font(layout.label_font_px, &font_name, FW_NORMAL.0 as i32);
SetBkMode(hdc, TRANSPARENT); SetBkMode(hdc, TRANSPARENT);
// Row labels in the left column. // Save the DC's original font so we can restore it before deleting
SelectObject(hdc, label_font); // 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())); SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
draw_label(hdc, layout, layout.row1_y, "5h"); draw_label(hdc, layout, layout.row1_y, "5h");
draw_label(hdc, layout, layout.row2_y, "7d"); draw_label(hdc, layout, layout.row2_y, "7d");
@@ -1314,6 +1324,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.row1_y, &inputs.session_text);
draw_countdown(hdc, layout, layout.row2_y, &inputs.weekly_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(main_font);
let _ = DeleteObject(bold_font); let _ = DeleteObject(bold_font);
let _ = DeleteObject(label_font); let _ = DeleteObject(label_font);
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code-Sitzung abgelaufen"
token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen." token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen" chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen." chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
threshold_80_body = "5-Stunden-Limit naht."
threshold_95_body = "Limit fast erreicht — gönn dir eine Pause."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code session expired"
token_expired_body = "Sign in again to keep tracking your usage." token_expired_body = "Sign in again to keep tracking your usage."
chatgpt_token_expired_title = "Codex session expired" chatgpt_token_expired_title = "Codex session expired"
chatgpt_token_expired_body = "Sign in again to keep tracking your usage." 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."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Sesión de Claude Code caducada"
token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso." 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_title = "Sesión de Codex caducada"
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso." chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
threshold_80_body = "Cerca del límite de 5 horas."
threshold_95_body = "Límite casi alcanzado — reduce el ritmo."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Session Claude Code expirée"
token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation." token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
chatgpt_token_expired_title = "Session Codex expirée" chatgpt_token_expired_title = "Session Codex expirée"
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation." chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
threshold_80_body = "Approche de la limite de 5 heures."
threshold_95_body = "Limite proche — pensez à lever le pied."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Codeのセッションが切れました"
token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。" token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
chatgpt_token_expired_title = "Codexのセッションが切れました" chatgpt_token_expired_title = "Codexのセッションが切れました"
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。" chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
threshold_80_body = "5時間の上限に近づいています。"
threshold_95_body = "上限に近づきました — ペースを落としましょう。"
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code 세션 만료"
token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요." token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
chatgpt_token_expired_title = "Codex 세션 만료" chatgpt_token_expired_title = "Codex 세션 만료"
chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요." chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
threshold_80_body = "5시간 한도에 가까워지고 있어요."
threshold_95_body = "한도 임박 — 잠시 쉬어가세요."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code-sessie verlopen"
token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen." token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
chatgpt_token_expired_title = "Codex-sessie verlopen" chatgpt_token_expired_title = "Codex-sessie verlopen"
chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen." chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
threshold_80_body = "Je nadert de 5-uurslimiet."
threshold_95_body = "Limiet bijna bereikt — overweeg even gas terug te nemen."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code 工作階段已過期"
token_expired_body = "請重新登入以繼續追蹤使用量。" token_expired_body = "請重新登入以繼續追蹤使用量。"
chatgpt_token_expired_title = "Codex 工作階段已過期" chatgpt_token_expired_title = "Codex 工作階段已過期"
chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。" chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。"
threshold_80_body = "接近 5 小時上限。"
threshold_95_body = "上限將至 — 建議稍作休息。"
+6
View File
@@ -61,6 +61,12 @@ pub struct LocaleStrings {
pub token_expired_body: String, pub token_expired_body: String,
pub chatgpt_token_expired_title: String, pub chatgpt_token_expired_title: String,
pub chatgpt_token_expired_body: 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,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
+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. // Anchor below the bubble by default; flip above if it would clip.
let mut x = anchor.left; let mut x = anchor.left;
let mut y = anchor.bottom + 8; let mut y = anchor.bottom + 8;
let virtual_screen_h = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) }; // The virtual screen spans all monitors. Its origin is offset from
let virtual_screen_w = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) }; // the primary monitor when a secondary monitor sits left of / above
if y + panel_h > virtual_screen_h { // 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; y = anchor.top - panel_h - 8;
} }
if y < 0 { if y < vy {
y = anchor.top; y = anchor.top.max(vy);
} }
if x + panel_w > virtual_screen_w { if x + panel_w > right {
x = virtual_screen_w - panel_w - 8; x = right - panel_w - 8;
} }
if x < 0 { if x < vx {
x = 8; x = vx + 8;
} }
(x, y) (x, y)
} }
+49 -3
View File
@@ -9,6 +9,8 @@ use std::os::windows::process::CommandExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use sha2::{Digest, Sha256};
use crate::net::Client; use crate::net::Client;
const CREATE_NO_WINDOW: u32 = 0x0800_0000; const CREATE_NO_WINDOW: u32 = 0x0800_0000;
@@ -18,10 +20,17 @@ pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error
let current = std::env::current_exe()?; let current = std::env::current_exe()?;
ensure_writable(&current)?; ensure_writable(&current)?;
let staging = stage_path()?; let staging = stage_path()?;
// Refuse to proceed if either path contains `%`. Inside double quotes
// cmd.exe still expands `%var%` references, so a path containing `%`
// would let cmd substitute environment variables into the swap step.
// Such paths are vanishingly rare on real Windows installs; failing
// fast is safer than rolling a bespoke cmd-escape layer.
reject_unsafe_path(&current)?;
reject_unsafe_path(&staging)?;
if let Some(parent) = staging.parent() { if let Some(parent) = staging.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
download(http, &release.asset_url, &staging)?; download(http, &release.asset_url, &staging, release.asset_sha256.as_ref())?;
spawn_handoff(&staging, &current)?; spawn_handoff(&staging, &current)?;
Ok(()) Ok(())
} }
@@ -38,7 +47,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: &std::path::Path,
expected_sha256: Option<&[u8; 32]>,
) -> Result<(), super::Error> {
let resp = http let resp = http
.get(url) .get(url)
.header("User-Agent", super::release::user_agent()) .header("User-Agent", super::release::user_agent())
@@ -46,7 +60,39 @@ fn download(http: &Client, url: &str, to: &std::path::Path) -> Result<(), super:
if !(200..300).contains(&resp.status()) { if !(200..300).contains(&resp.status()) {
return Err(super::Error::Network(crate::net::Error::Status(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 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: &std::path::Path) -> Result<(), super::Error> {
let s = p.to_string_lossy();
if s.contains('%') {
return Err(super::Error::UnsafePath(format!(
"path contains '%' which cmd.exe expands as a variable: {s}"
)));
}
Ok(()) Ok(())
} }
+4
View File
@@ -20,6 +20,10 @@ pub enum Error {
NotWritable(String), NotWritable(String),
#[error("malformed version: {0}")] #[error("malformed version: {0}")]
BadVersion(String), BadVersion(String),
#[error("asset checksum mismatch: expected {expected}, got {actual}")]
ChecksumMismatch { expected: String, actual: String },
#[error("path rejected for safety: {0}")]
UnsafePath(String),
} }
pub use channel::{current as current_channel, Channel}; 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 struct Release {
pub version: Version, pub version: Version,
pub asset_url: String, 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)] #[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 { Ok(super::CheckOutcome::Available(Release {
version: candidate, version: candidate,
asset_url: asset.browser_download_url.clone(), 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 { pub fn user_agent() -> &'static str {
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))
} }
@@ -88,4 +111,9 @@ struct GhRelease {
struct GhAsset { struct GhAsset {
name: String, name: String,
browser_download_url: 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>,
} }