mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-08 18:17:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2791022e7a | ||
|
|
ed9b8b2042 | ||
|
|
8ad718d9c1 | ||
|
|
1ef1bfa7b2 | ||
|
|
0f3acd40d4 |
Generated
+59
-705
@@ -8,12 +8,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -26,12 +20,6 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -39,10 +27,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
@@ -68,38 +59,30 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.2"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"embed-resource",
|
||||
"log",
|
||||
"native-tls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"simplelog",
|
||||
"thiserror",
|
||||
"tiny-skia",
|
||||
"toml 0.8.23",
|
||||
"ureq",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -109,6 +92,16 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -118,6 +111,16 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
@@ -139,17 +142,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.9"
|
||||
@@ -170,22 +162,6 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -212,33 +188,13 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -252,149 +208,12 @@ dependencies = [
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -402,9 +221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -413,12 +230,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@@ -434,18 +245,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
@@ -468,23 +267,6 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -500,111 +282,31 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.115"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -623,19 +325,13 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"getrandom",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
]
|
||||
@@ -649,51 +345,6 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
@@ -761,6 +412,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -784,18 +446,6 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
@@ -813,30 +463,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -925,16 +551,6 @@ dependencies = [
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
@@ -1015,6 +631,12 @@ version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -1022,49 +644,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"log",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
@@ -1092,58 +675,6 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -1330,183 +861,6 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
+2
-6
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.2"
|
||||
version = "0.1.6"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||
@@ -8,11 +8,6 @@ homepage = "https://github.com/tiennm99/claude-code-usage-bubble"
|
||||
repository = "https://github.com/tiennm99/claude-code-usage-bubble"
|
||||
|
||||
[dependencies]
|
||||
# `ureq` + `native-tls` are kept while the legacy `poller.rs` and `updater.rs`
|
||||
# modules survive. Phase 4 deletes `poller.rs` and Phase 6 deletes `updater.rs`,
|
||||
# at which point these two deps go away in favour of `net::winhttp`.
|
||||
ureq = { version = "2", default-features = false, features = ["native-tls", "json", "proxy-from-env"] }
|
||||
native-tls = "0.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "6"
|
||||
@@ -21,6 +16,7 @@ simplelog = "0.12"
|
||||
thiserror = "2"
|
||||
toml = "0.8"
|
||||
tiny-skia = "0.11"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dependencies.windows]
|
||||
version = "0.58"
|
||||
|
||||
@@ -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 1–2 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** ($150–300/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 1–2 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"; 100s–1000s typical, not documented precisely.)
|
||||
|
||||
---
|
||||
|
||||
## Top 3 Changes by ROI
|
||||
|
||||
1. **Apply SignPath Foundation signing** (free, 1–2 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 6–12 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
@@ -6,7 +6,8 @@
|
||||
// message-only window owned by this module.
|
||||
|
||||
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 windows::core::PCWSTR;
|
||||
@@ -33,6 +34,11 @@ use crate::usage::{self, ProviderId, Registry, UsageWindows};
|
||||
|
||||
// Win32 message IDs owned by this module.
|
||||
pub const WM_APP_USAGE_UPDATED: u32 = 0x8001;
|
||||
// Posted from the update worker thread 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, …)`.
|
||||
const TIMER_POLL: usize = 1;
|
||||
@@ -106,8 +112,13 @@ struct AppState {
|
||||
i18n: I18n,
|
||||
is_dark: bool,
|
||||
install_channel: InstallChannel,
|
||||
http: net::Client,
|
||||
registry: Registry,
|
||||
// http and registry live behind Arc so worker threads can hold them
|
||||
// without keeping the global state mutex locked across blocking I/O.
|
||||
// The registry's own mutex is only contended by other workers
|
||||
// (the in-flight gate ensures at most one poll runs at a time), so
|
||||
// the UI thread never waits on it.
|
||||
http: Arc<net::Client>,
|
||||
registry: Arc<Mutex<Registry>>,
|
||||
snapshots: HashMap<ProviderId, ProviderUiState>,
|
||||
last_poll_ok: bool,
|
||||
update_status: UpdateStatus,
|
||||
@@ -182,8 +193,8 @@ pub fn run() {
|
||||
i18n,
|
||||
is_dark,
|
||||
install_channel,
|
||||
http,
|
||||
registry: Registry::with_defaults(),
|
||||
http: Arc::new(http),
|
||||
registry: Arc::new(Mutex::new(Registry::with_defaults())),
|
||||
snapshots: HashMap::new(),
|
||||
last_poll_ok: false,
|
||||
update_status: UpdateStatus::Idle,
|
||||
@@ -293,6 +304,10 @@ unsafe extern "system" fn msg_wnd_proc(
|
||||
propagate_to_ui();
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_APP_UPDATE_APPLIED => {
|
||||
PostQuitMessage(0);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_APP_TRAY => {
|
||||
let action = tray::callback::handle(lparam);
|
||||
handle_tray_action(action);
|
||||
@@ -394,13 +409,29 @@ fn on_timer(hwnd: HWND, id: usize) {
|
||||
|
||||
// ---------- Poll thread ----------
|
||||
|
||||
/// At-most-one-in-flight gate for the poll worker. Spam-clicking Refresh
|
||||
/// would otherwise stack concurrent HTTPS calls onto the same registry,
|
||||
/// which both wastes bandwidth and (before the registry mutex landed)
|
||||
/// could let two poll cycles interleave their writes to the snapshot map.
|
||||
static POLL_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn spawn_poll_thread() {
|
||||
if POLL_IN_FLIGHT
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let msg_hwnd = match lock_state().as_ref() {
|
||||
Some(s) => s.msg_hwnd,
|
||||
None => return,
|
||||
None => {
|
||||
POLL_IN_FLIGHT.store(false, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
};
|
||||
std::thread::spawn(move || {
|
||||
do_poll();
|
||||
POLL_IN_FLIGHT.store(false, Ordering::Release);
|
||||
unsafe {
|
||||
let _ = PostMessageW(
|
||||
msg_hwnd.to_hwnd(),
|
||||
@@ -413,17 +444,23 @@ fn spawn_poll_thread() {
|
||||
}
|
||||
|
||||
fn do_poll() {
|
||||
let results = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
// Snapshot the inputs we need, then DROP the global lock before any
|
||||
// HTTPS call. Holding `lock_state()` through `poll_enabled` would block
|
||||
// the UI thread on every paint/menu for the duration of the request.
|
||||
let (http, registry, settings) = {
|
||||
let s = lock_state();
|
||||
let Some(s) = s.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let settings = s.settings.clone();
|
||||
s.registry.poll_enabled(&s.http, &settings)
|
||||
(s.http.clone(), s.registry.clone(), s.settings.clone())
|
||||
};
|
||||
let results = {
|
||||
let mut reg = registry.lock().expect("registry mutex poisoned");
|
||||
reg.poll_enabled(&http, &settings)
|
||||
};
|
||||
let auth_failures = apply_results(results);
|
||||
if !auth_failures.is_empty() {
|
||||
attempt_refresh(auth_failures);
|
||||
attempt_refresh(auth_failures, ®istry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,57 +468,78 @@ fn apply_results(
|
||||
results: Vec<(ProviderId, Result<UsageWindows, usage::Error>)>,
|
||||
) -> Vec<ProviderId> {
|
||||
let mut auth_failures = Vec::new();
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
return auth_failures;
|
||||
};
|
||||
if results.is_empty() {
|
||||
return auth_failures;
|
||||
}
|
||||
let strings = s.i18n.strings().clone();
|
||||
let mut any_ok = false;
|
||||
for (id, outcome) in results {
|
||||
match outcome {
|
||||
Ok(windows) => {
|
||||
let entry = s.snapshots.entry(id).or_default();
|
||||
entry.windows = windows;
|
||||
entry.primary_text = i18n::format_window(&windows.primary, &strings);
|
||||
entry.secondary_text = i18n::format_window(&windows.secondary, &strings);
|
||||
any_ok = true;
|
||||
}
|
||||
Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => {
|
||||
auth_failures.push(id);
|
||||
let entry = s.snapshots.entry(id).or_default();
|
||||
entry.primary_text = "!".into();
|
||||
entry.secondary_text = "!".into();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("provider {id:?} poll failed: {e}");
|
||||
let entry = s.snapshots.entry(id).or_default();
|
||||
entry.primary_text = "…".into();
|
||||
entry.secondary_text = "…".into();
|
||||
let mut crossings: Vec<(ProviderId, u8)> = Vec::new();
|
||||
{
|
||||
let mut guard = lock_state();
|
||||
let Some(state) = guard.as_mut() else {
|
||||
return auth_failures;
|
||||
};
|
||||
if results.is_empty() {
|
||||
return auth_failures;
|
||||
}
|
||||
let strings = state.i18n.strings().clone();
|
||||
let mut any_ok = false;
|
||||
for (id, outcome) in results {
|
||||
match outcome {
|
||||
Ok(windows) => {
|
||||
let entry = state.snapshots.entry(id).or_default();
|
||||
let old_pct = entry.windows.primary.utilization;
|
||||
let new_pct = windows.primary.utilization;
|
||||
// Fire a balloon only the cycle a provider CROSSES a
|
||||
// threshold so the user is nudged once, not on every
|
||||
// subsequent poll while parked above it.
|
||||
for threshold in [80u8, 95u8] {
|
||||
if old_pct < threshold as f64 && new_pct >= threshold as f64 {
|
||||
crossings.push((id, threshold));
|
||||
}
|
||||
}
|
||||
entry.windows = windows;
|
||||
entry.primary_text = i18n::format_window(&windows.primary, &strings);
|
||||
entry.secondary_text = i18n::format_window(&windows.secondary, &strings);
|
||||
any_ok = true;
|
||||
}
|
||||
Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => {
|
||||
auth_failures.push(id);
|
||||
let entry = state.snapshots.entry(id).or_default();
|
||||
entry.primary_text = "!".into();
|
||||
entry.secondary_text = "!".into();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("provider {id:?} poll failed: {e}");
|
||||
let entry = state.snapshots.entry(id).or_default();
|
||||
entry.primary_text = "…".into();
|
||||
entry.secondary_text = "…".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
state.last_poll_ok = any_ok;
|
||||
}
|
||||
// Lock released. Fire any threshold balloons outside the critical
|
||||
// section so tray::notify and i18n cloning don't block the UI thread.
|
||||
for (id, threshold) in crossings {
|
||||
show_threshold_balloon(id, threshold);
|
||||
}
|
||||
s.last_poll_ok = any_ok;
|
||||
auth_failures
|
||||
}
|
||||
|
||||
fn attempt_refresh(failures: Vec<ProviderId>) {
|
||||
fn attempt_refresh(failures: Vec<ProviderId>, registry: &Arc<Mutex<Registry>>) {
|
||||
let orchestrator = usage::refresh::Orchestrator::new(REFRESH_TIMEOUT);
|
||||
let mut needs_balloon = false;
|
||||
// Pick the first provider whose refresh did not succeed and balloon
|
||||
// for it specifically. If both providers fail in the same cycle the
|
||||
// second will resurface on the next poll once the first is re-auth'd.
|
||||
let mut balloon_for: Option<ProviderId> = None;
|
||||
for id in failures {
|
||||
let outcome = match lock_state().as_ref() {
|
||||
Some(s) => s.registry.try_refresh(id, &orchestrator),
|
||||
None => return,
|
||||
let outcome = {
|
||||
let reg = registry.lock().expect("registry mutex poisoned");
|
||||
reg.try_refresh(id, &orchestrator)
|
||||
};
|
||||
log::info!("refresh for {id:?}: {outcome:?}");
|
||||
if !matches!(outcome, usage::refresh::Outcome::Refreshed) {
|
||||
needs_balloon = true;
|
||||
balloon_for.get_or_insert(id);
|
||||
}
|
||||
}
|
||||
if needs_balloon {
|
||||
show_token_expired_balloon();
|
||||
if let Some(provider) = balloon_for {
|
||||
show_token_expired_balloon(provider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,7 +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 mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
@@ -725,20 +837,17 @@ fn show_token_expired_balloon() {
|
||||
}
|
||||
s.last_balloon_at = Some(Instant::now());
|
||||
let strings = s.i18n.strings();
|
||||
let (kind, title, body) = if s.settings.show_claude_code {
|
||||
(
|
||||
ProviderId::Claude,
|
||||
let (title, body) = match failed {
|
||||
ProviderId::Claude => (
|
||||
strings.token_expired_title.clone(),
|
||||
strings.token_expired_body.clone(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
ProviderId::ChatGpt,
|
||||
),
|
||||
ProviderId::ChatGpt => (
|
||||
strings.chatgpt_token_expired_title.clone(),
|
||||
strings.chatgpt_token_expired_body.clone(),
|
||||
)
|
||||
),
|
||||
};
|
||||
(s.msg_hwnd, kind, title, body)
|
||||
(s.msg_hwnd, failed, title, body)
|
||||
};
|
||||
tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
||||
}
|
||||
@@ -787,7 +896,11 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
|
||||
append_item(menu, IDM_REFRESH, &snap.strings.refresh, MENU_ITEM_FLAGS(0));
|
||||
|
||||
let freq = CreatePopupMenu().unwrap();
|
||||
let Ok(freq) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(freq) failed");
|
||||
let _ = DestroyMenu(menu);
|
||||
return;
|
||||
};
|
||||
for (id, interval, label) in [
|
||||
(IDM_FREQ_1MIN, POLL_1_MIN, &snap.strings.one_minute),
|
||||
(IDM_FREQ_5MIN, POLL_5_MIN, &snap.strings.five_minutes),
|
||||
@@ -803,7 +916,11 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
}
|
||||
append_submenu(menu, freq, &snap.strings.update_frequency);
|
||||
|
||||
let models = CreatePopupMenu().unwrap();
|
||||
let Ok(models) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(models) failed");
|
||||
let _ = DestroyMenu(menu);
|
||||
return;
|
||||
};
|
||||
append_item(
|
||||
models,
|
||||
IDM_MODEL_CLAUDE,
|
||||
@@ -818,7 +935,11 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
);
|
||||
append_submenu(menu, models, &snap.strings.models);
|
||||
|
||||
let settings_menu = CreatePopupMenu().unwrap();
|
||||
let Ok(settings_menu) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(settings_menu) failed");
|
||||
let _ = DestroyMenu(menu);
|
||||
return;
|
||||
};
|
||||
append_item(
|
||||
settings_menu,
|
||||
IDM_START_WITH_WINDOWS,
|
||||
@@ -832,7 +953,12 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
MENU_ITEM_FLAGS(0),
|
||||
);
|
||||
|
||||
let lang = CreatePopupMenu().unwrap();
|
||||
let Ok(lang) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(lang) failed");
|
||||
let _ = DestroyMenu(settings_menu);
|
||||
let _ = DestroyMenu(menu);
|
||||
return;
|
||||
};
|
||||
append_item(
|
||||
lang,
|
||||
IDM_LANG_SYSTEM,
|
||||
@@ -867,7 +993,12 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
};
|
||||
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
|
||||
|
||||
let auto_update = CreatePopupMenu().unwrap();
|
||||
let Ok(auto_update) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(auto_update) failed");
|
||||
let _ = DestroyMenu(settings_menu);
|
||||
let _ = DestroyMenu(menu);
|
||||
return;
|
||||
};
|
||||
for (id, value, label) in [
|
||||
(IDM_UPDATE_AUTO_OFF, None, &snap.strings.auto_check_disabled),
|
||||
(
|
||||
@@ -1086,31 +1217,46 @@ fn version_action() {
|
||||
};
|
||||
match act {
|
||||
Act::Apply(release, channel) => {
|
||||
if let Some(s) = lock_state().as_mut() {
|
||||
// Set the Applying status synchronously so the menu reflects
|
||||
// it immediately, then move the (potentially several-second)
|
||||
// download to a worker thread. Holding the UI thread here
|
||||
// would freeze paints and menus until the download finished.
|
||||
let (http, msg_hwnd) = {
|
||||
let mut guard = lock_state();
|
||||
let Some(s) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
s.update_status = UpdateStatus::Applying;
|
||||
}
|
||||
let result: Result<(), Box<dyn std::error::Error>> = match channel {
|
||||
InstallChannel::Winget => {
|
||||
// Winget channel is reserved for future use; until a
|
||||
// winget package ships, this branch is unreachable.
|
||||
Err("winget channel not supported yet".into())
|
||||
}
|
||||
InstallChannel::Portable => {
|
||||
match net::Client::new(HTTP_USER_AGENT) {
|
||||
Ok(c) => update::install::begin(&c, &release).map_err(|e| e.into()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
(s.http.clone(), s.msg_hwnd)
|
||||
};
|
||||
match result {
|
||||
Ok(()) => unsafe { PostQuitMessage(0) },
|
||||
Err(e) => {
|
||||
log::error!("update apply failed: {e}");
|
||||
if let Some(s) = lock_state().as_mut() {
|
||||
s.update_status = UpdateStatus::Failed;
|
||||
std::thread::spawn(move || {
|
||||
let result: Result<(), Box<dyn std::error::Error + Send + Sync>> = match channel {
|
||||
InstallChannel::Winget => {
|
||||
// Winget channel is reserved for future use; until a
|
||||
// winget package ships, this branch is unreachable.
|
||||
Err("winget channel not supported yet".into())
|
||||
}
|
||||
InstallChannel::Portable => {
|
||||
update::install::begin(&http, &release).map_err(|e| e.into())
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(()) => unsafe {
|
||||
let _ = PostMessageW(
|
||||
msg_hwnd.to_hwnd(),
|
||||
WM_APP_UPDATE_APPLIED,
|
||||
WPARAM(0),
|
||||
LPARAM(0),
|
||||
);
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("update apply failed: {e}");
|
||||
if let Some(s) = lock_state().as_mut() {
|
||||
s.update_status = UpdateStatus::Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Act::Check(hwnd) => begin_update_check(hwnd.to_hwnd()),
|
||||
}
|
||||
|
||||
+18
-6
@@ -478,10 +478,13 @@ unsafe extern "system" fn wnd_proc(
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_SETTINGCHANGE => {
|
||||
// Taskbar move / auto-hide toggle / DPI change all post this.
|
||||
// Just re-clamp into the new work area so the bubble can't end up
|
||||
// hidden behind the new taskbar position.
|
||||
// Taskbar move / auto-hide toggle / DPI change / theme toggle
|
||||
// all post this. Re-clamp into the new work area (bubble must
|
||||
// not end up hidden behind the new taskbar position) and ask
|
||||
// the app to re-read the light/dark setting — Windows fires
|
||||
// this message when the user flips the OS theme in Settings.
|
||||
clamp_into_work_area(hwnd);
|
||||
crate::app::recheck_theme();
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_DESTROY => {
|
||||
@@ -888,7 +891,12 @@ fn check_fullscreen(bubble_hwnd: HWND) {
|
||||
|
||||
const ACCENT_STRIPE_W_LOGICAL: i32 = 4;
|
||||
const LABEL_PAD_LOGICAL: i32 = 6;
|
||||
const COUNTDOWN_TEMPLATE: &str = "999d";
|
||||
// Sized for the widest countdown across all shipped locales. Korean
|
||||
// "999시간" (3 digits + 2 CJK chars for the hour suffix) is the current
|
||||
// worst case; ASCII-only "999d" was too narrow and let CJK text spill
|
||||
// out of the column. Update this when adding a locale with a longer
|
||||
// suffix.
|
||||
const COUNTDOWN_TEMPLATE: &str = "999시간";
|
||||
// Percent now lives in its own column between the bar and the countdown so
|
||||
// the two numeric readouts ("44%" and "3h") sit next to each other for
|
||||
// quick scanning, and the percent never has to fight the bar's fill colour
|
||||
@@ -1296,8 +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);
|
||||
SetBkMode(hdc, TRANSPARENT);
|
||||
|
||||
// Row labels in the left column.
|
||||
SelectObject(hdc, label_font);
|
||||
// Save the DC's original font so we can restore it before deleting
|
||||
// ours. DeleteObject silently fails on a still-selected HFONT,
|
||||
// which would leak the handle on every paint frame.
|
||||
let prev_font = SelectObject(hdc, label_font);
|
||||
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
|
||||
draw_label(hdc, layout, layout.row1_y, "5h");
|
||||
draw_label(hdc, layout, layout.row2_y, "7d");
|
||||
@@ -1314,6 +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.row2_y, &inputs.weekly_text);
|
||||
|
||||
// Restore the original font, then it is safe to delete ours.
|
||||
SelectObject(hdc, prev_font);
|
||||
let _ = DeleteObject(main_font);
|
||||
let _ = DeleteObject(bold_font);
|
||||
let _ = DeleteObject(label_font);
|
||||
|
||||
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code-Sitzung abgelaufen"
|
||||
token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
||||
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
|
||||
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
||||
threshold_80_body = "5-Stunden-Limit naht."
|
||||
threshold_95_body = "Limit fast erreicht — gönn dir eine Pause."
|
||||
|
||||
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code session expired"
|
||||
token_expired_body = "Sign in again to keep tracking your usage."
|
||||
chatgpt_token_expired_title = "Codex session expired"
|
||||
chatgpt_token_expired_body = "Sign in again to keep tracking your usage."
|
||||
threshold_80_body = "Approaching the 5-hour limit."
|
||||
threshold_95_body = "Limit is close — consider easing up."
|
||||
|
||||
@@ -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."
|
||||
chatgpt_token_expired_title = "Sesión de Codex caducada"
|
||||
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
|
||||
threshold_80_body = "Cerca del límite de 5 horas."
|
||||
threshold_95_body = "Límite casi alcanzado — reduce el ritmo."
|
||||
|
||||
@@ -41,3 +41,5 @@ token_expired_title = "Session Claude Code expirée"
|
||||
token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
||||
chatgpt_token_expired_title = "Session Codex expirée"
|
||||
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
||||
threshold_80_body = "Approche de la limite de 5 heures."
|
||||
threshold_95_body = "Limite proche — pensez à lever le pied."
|
||||
|
||||
@@ -41,3 +41,5 @@ token_expired_title = "Claude Codeのセッションが切れました"
|
||||
token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||
chatgpt_token_expired_title = "Codexのセッションが切れました"
|
||||
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||
threshold_80_body = "5時間の上限に近づいています。"
|
||||
threshold_95_body = "上限に近づきました — ペースを落としましょう。"
|
||||
|
||||
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code 세션 만료"
|
||||
token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
||||
chatgpt_token_expired_title = "Codex 세션 만료"
|
||||
chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
||||
threshold_80_body = "5시간 한도에 가까워지고 있어요."
|
||||
threshold_95_body = "한도 임박 — 잠시 쉬어가세요."
|
||||
|
||||
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code-sessie verlopen"
|
||||
token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
||||
chatgpt_token_expired_title = "Codex-sessie verlopen"
|
||||
chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
||||
threshold_80_body = "Je nadert de 5-uurslimiet."
|
||||
threshold_95_body = "Limiet bijna bereikt — overweeg even gas terug te nemen."
|
||||
|
||||
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code 工作階段已過期"
|
||||
token_expired_body = "請重新登入以繼續追蹤使用量。"
|
||||
chatgpt_token_expired_title = "Codex 工作階段已過期"
|
||||
chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。"
|
||||
threshold_80_body = "接近 5 小時上限。"
|
||||
threshold_95_body = "上限將至 — 建議稍作休息。"
|
||||
|
||||
@@ -61,6 +61,12 @@ pub struct LocaleStrings {
|
||||
pub token_expired_body: String,
|
||||
pub chatgpt_token_expired_title: String,
|
||||
pub chatgpt_token_expired_body: String,
|
||||
/// Body text for "your usage just crossed 80% of the 5h limit". The
|
||||
/// title is composed from the provider label + percent so it does not
|
||||
/// need to be translated separately.
|
||||
pub threshold_80_body: String,
|
||||
/// Body text for the 95% threshold balloon.
|
||||
pub threshold_95_body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
+17
-9
@@ -504,19 +504,27 @@ fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
|
||||
// Anchor below the bubble by default; flip above if it would clip.
|
||||
let mut x = anchor.left;
|
||||
let mut y = anchor.bottom + 8;
|
||||
let virtual_screen_h = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
|
||||
let virtual_screen_w = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
|
||||
if y + panel_h > virtual_screen_h {
|
||||
// The virtual screen spans all monitors. Its origin is offset from
|
||||
// the primary monitor when a secondary monitor sits left of / above
|
||||
// the primary, so clamps must include SM_XVIRTUALSCREEN /
|
||||
// SM_YVIRTUALSCREEN — not just the width/height of the union.
|
||||
let vx = unsafe { GetSystemMetrics(SM_XVIRTUALSCREEN) };
|
||||
let vy = unsafe { GetSystemMetrics(SM_YVIRTUALSCREEN) };
|
||||
let vw = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
|
||||
let vh = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
|
||||
let right = vx + vw;
|
||||
let bottom = vy + vh;
|
||||
if y + panel_h > bottom {
|
||||
y = anchor.top - panel_h - 8;
|
||||
}
|
||||
if y < 0 {
|
||||
y = anchor.top;
|
||||
if y < vy {
|
||||
y = anchor.top.max(vy);
|
||||
}
|
||||
if x + panel_w > virtual_screen_w {
|
||||
x = virtual_screen_w - panel_w - 8;
|
||||
if x + panel_w > right {
|
||||
x = right - panel_w - 8;
|
||||
}
|
||||
if x < 0 {
|
||||
x = 8;
|
||||
if x < vx {
|
||||
x = vx + 8;
|
||||
}
|
||||
(x, y)
|
||||
}
|
||||
|
||||
+49
-3
@@ -9,6 +9,8 @@ use std::os::windows::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::net::Client;
|
||||
|
||||
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()?;
|
||||
ensure_writable(¤t)?;
|
||||
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(¤t)?;
|
||||
reject_unsafe_path(&staging)?;
|
||||
if let Some(parent) = staging.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, ¤t)?;
|
||||
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
|
||||
.get(url)
|
||||
.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()) {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ pub enum Error {
|
||||
NotWritable(String),
|
||||
#[error("malformed version: {0}")]
|
||||
BadVersion(String),
|
||||
#[error("asset checksum mismatch: expected {expected}, got {actual}")]
|
||||
ChecksumMismatch { expected: String, actual: String },
|
||||
#[error("path rejected for safety: {0}")]
|
||||
UnsafePath(String),
|
||||
}
|
||||
|
||||
pub use channel::{current as current_channel, Channel};
|
||||
|
||||
@@ -12,6 +12,10 @@ const REPO_NAME: &str = "claude-code-usage-bubble";
|
||||
pub struct Release {
|
||||
pub version: Version,
|
||||
pub asset_url: String,
|
||||
/// SHA-256 of the asset bytes, parsed from the GitHub Releases
|
||||
/// API `digest` field. `None` if GitHub omitted it (older
|
||||
/// releases predate the digest field).
|
||||
pub asset_sha256: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
@@ -71,9 +75,28 @@ pub fn fetch_latest(http: &Client) -> Result<super::CheckOutcome, super::Error>
|
||||
Ok(super::CheckOutcome::Available(Release {
|
||||
version: candidate,
|
||||
asset_url: asset.browser_download_url.clone(),
|
||||
asset_sha256: asset.digest.as_deref().and_then(parse_sha256_digest),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Parse a GitHub `digest` field of the form `"sha256:<64 hex chars>"`
|
||||
/// into a 32-byte array. Returns `None` for any other algorithm or
|
||||
/// malformed input — callers should treat a missing digest as "no
|
||||
/// integrity check available" rather than as a parse failure.
|
||||
fn parse_sha256_digest(raw: &str) -> Option<[u8; 32]> {
|
||||
let hex = raw.strip_prefix("sha256:")?;
|
||||
if hex.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
for (i, byte_chars) in hex.as_bytes().chunks(2).enumerate() {
|
||||
let high = (byte_chars[0] as char).to_digit(16)?;
|
||||
let low = (byte_chars[1] as char).to_digit(16)?;
|
||||
out[i] = ((high << 4) | low) as u8;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn user_agent() -> &'static str {
|
||||
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
@@ -88,4 +111,9 @@ struct GhRelease {
|
||||
struct GhAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
/// GitHub started returning `digest: "sha256:..."` on the asset
|
||||
/// object in 2024. Older releases omit it; we treat that as
|
||||
/// "verification unavailable" rather than a hard error.
|
||||
#[serde(default)]
|
||||
digest: Option<String>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user