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 | |
|---|---|---|---|
|
|
bfcf3b56cd | ||
|
|
4022d3f2de | ||
|
|
67f3b8a7ec | ||
|
|
c46ab57f73 | ||
|
|
0ac698e2d1 | ||
|
|
2c67dd1217 | ||
|
|
38752ae5c5 | ||
|
|
e099f0a91f | ||
|
|
4e9a66cbc8 | ||
|
|
1d9c59b43b | ||
|
|
7039bf7e98 | ||
|
|
cc73c3f9b4 | ||
|
|
a68d80415b | ||
|
|
cce22cc3b7 | ||
|
|
5e00009e5a | ||
|
|
c91dc996f8 | ||
|
|
b2e48cc119 | ||
|
|
bee9f3cfa0 | ||
|
|
860eb62d1b | ||
|
|
c424b17bd7 | ||
|
|
3b0f96f51c | ||
|
|
5976181cb2 | ||
|
|
f2b31d3211 | ||
|
|
5c2b14fc03 | ||
|
|
51889d3c39 | ||
|
|
6661a7a10b | ||
|
|
6cafffc883 | ||
|
|
b58811bfe0 | ||
|
|
e50aa3522a | ||
|
|
a3f1323154 | ||
|
|
081a70a537 | ||
|
|
391ad0cba2 | ||
|
|
77325b1e00 | ||
|
|
c3d01f36d2 | ||
|
|
7bbf80e5f7 | ||
|
|
8cbc3dda5b | ||
|
|
526786b902 | ||
|
|
f96d88074a | ||
|
|
e243c589a8 | ||
|
|
a5dec52aaa | ||
|
|
4e0f32591b | ||
|
|
3e1af07ec2 | ||
|
|
27aa935a9b | ||
|
|
1cd5b778f4 | ||
|
|
1ba2883989 | ||
|
|
858d7f1139 | ||
|
|
5a2e4f1c60 | ||
|
|
713eb5bbde | ||
|
|
bcce939f72 | ||
|
|
e089a1b420 | ||
|
|
457d5274da | ||
|
|
f1dfe15000 | ||
|
|
38ae4dff09 | ||
|
|
3c0878f6cc | ||
|
|
eca430ccc6 | ||
|
|
2791022e7a | ||
|
|
ed9b8b2042 | ||
|
|
8ad718d9c1 | ||
|
|
1ef1bfa7b2 | ||
|
|
0f3acd40d4 |
@@ -16,7 +16,7 @@ jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
|
||||
Generated
+80
-483
@@ -26,12 +26,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"
|
||||
@@ -44,6 +38,15 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
@@ -68,38 +71,30 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"embed-resource",
|
||||
"log",
|
||||
"native-tls",
|
||||
"self-replace",
|
||||
"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 +104,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 +123,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 +154,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"
|
||||
@@ -159,7 +163,7 @@ dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"toml",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
@@ -218,27 +222,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
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]]
|
||||
@@ -286,115 +276,12 @@ 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"
|
||||
@@ -440,12 +327,6 @@ 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 +349,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"
|
||||
@@ -506,67 +370,12 @@ 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"
|
||||
@@ -580,15 +389,6 @@ dependencies = [
|
||||
"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"
|
||||
@@ -663,35 +463,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
name = "self-replace"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7"
|
||||
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",
|
||||
"fastrand",
|
||||
"tempfile",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -743,15 +522,6 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
@@ -761,6 +531,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 +565,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,17 +582,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"
|
||||
@@ -925,28 +683,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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
@@ -955,20 +691,11 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -980,41 +707,27 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow 1.0.3",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
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"
|
||||
@@ -1028,43 +741,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
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"
|
||||
@@ -1223,6 +903,15 @@ dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -1305,15 +994,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.3"
|
||||
@@ -1424,89 +1104,6 @@ dependencies = [
|
||||
"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"
|
||||
|
||||
+5
-7
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||
@@ -8,31 +8,29 @@ 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"
|
||||
log = "0.4"
|
||||
simplelog = "0.12"
|
||||
thiserror = "2"
|
||||
toml = "0.8"
|
||||
tiny-skia = "0.11"
|
||||
sha2 = "0.10"
|
||||
self-replace = "1.5"
|
||||
|
||||
[dependencies.windows]
|
||||
version = "0.58"
|
||||
features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Globalization",
|
||||
"Win32_Graphics_Dwm",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_System_Registry",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_Security",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
|
||||
@@ -18,7 +18,7 @@ which solves the same "how close am I to the Claude Code limit?" problem
|
||||
with a horizontal taskbar widget. This project takes the UX in a different
|
||||
direction — a floating, draggable circular bubble that the user can place
|
||||
anywhere on screen — and is a clean-room implementation: the HTTP client,
|
||||
provider polling, credential discovery, localisation, tray rendering, and
|
||||
provider polling, credential discovery, tray rendering, and
|
||||
self-updater are all written from scratch against the same public APIs
|
||||
(Anthropic, ChatGPT, GitHub Releases).
|
||||
|
||||
@@ -28,24 +28,22 @@ self-updater are all written from scratch against the same public APIs
|
||||
Codex usage as a percentage and a colored progress ring
|
||||
- Drag anywhere — the bubble snaps to monitor work-area edges when
|
||||
released
|
||||
- Resize with `Ctrl + MouseWheel` on the bubble (32–128 pixels)
|
||||
- Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls** →
|
||||
**Make smaller / Make larger / Reset size** from the right-click menu
|
||||
(140–360 logical pixels)
|
||||
- Left-click the bubble for an expanded panel with both **5h** and **7d**
|
||||
bars plus reset countdowns
|
||||
- Right-click for refresh, displayed models, update frequency, language,
|
||||
startup, updates, exit
|
||||
- Optional system tray icons (one per enabled model)
|
||||
- Right-click for refresh, displayed providers, update frequency, startup,
|
||||
updates, exit
|
||||
- Optional system tray icons (one per enabled provider)
|
||||
- Auto-hide when a fullscreen app is in the foreground (games, video,
|
||||
presentations) — reappears when you leave fullscreen
|
||||
|
||||
## Who this is for
|
||||
|
||||
Windows 10/11 users who already have **Claude Code (CLI or App) installed
|
||||
and signed in**. Codex support is optional — install and sign in to the
|
||||
Codex CLI, then enable Codex from the right-click **Models** menu.
|
||||
|
||||
If you use Claude Code through WSL, that is supported too. The monitor
|
||||
can read your Claude Code credentials from Windows or from your WSL
|
||||
environment.
|
||||
and signed in on Windows**. Codex support is optional — install and sign in
|
||||
to the Codex CLI, then enable Codex from the right-click **Providers** menu.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -83,22 +81,26 @@ corner of your primary monitor on first launch. Drag it where you want it,
|
||||
release to snap to the nearest edge if you let go close to one.
|
||||
|
||||
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
|
||||
- **Right-click** for refresh, models, refresh frequency, language, "Start
|
||||
with Windows", auto-update check (Disabled / Hourly / Daily / Weekly),
|
||||
- **Right-click** for refresh, providers, refresh frequency, "Start with
|
||||
Windows", controls, auto-update check (Disabled / Hourly / Daily / Weekly),
|
||||
manual "Check for updates", exit
|
||||
- **Drag** anywhere — it floats on top of all other windows
|
||||
- **Ctrl + MouseWheel** on the bubble to resize it
|
||||
- **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click
|
||||
menu, to resize it
|
||||
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
|
||||
right-click opens the same menu
|
||||
|
||||
### Models
|
||||
### Providers
|
||||
|
||||
Use the right-click **Models** menu to choose what is shown:
|
||||
Use the right-click **Providers** menu to choose what is shown:
|
||||
|
||||
- **Claude Code** is enabled by default
|
||||
- **Codex** can be enabled alongside Claude Code or shown by itself
|
||||
- **OpenCode Go** is listed for future support; usage bars remain disabled
|
||||
until OpenCode exposes a stable quota source or console integration is
|
||||
explicitly enabled
|
||||
|
||||
When both models are shown, each gets its own bubble that you can position
|
||||
When multiple providers are shown, each gets its own bubble that you can position
|
||||
independently.
|
||||
|
||||
## Diagnostics
|
||||
@@ -124,7 +126,6 @@ Settings are saved to:
|
||||
What the app reads:
|
||||
|
||||
- Your local Claude Code OAuth credentials from `~/.claude/.credentials.json`
|
||||
- If needed, the same credentials file inside an installed WSL distro
|
||||
- If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json`
|
||||
or `~/.codex/auth.json`
|
||||
|
||||
@@ -139,9 +140,8 @@ What the app stores locally:
|
||||
- Bubble position(s) per model
|
||||
- Bubble size
|
||||
- Polling frequency
|
||||
- Language preference
|
||||
- Last update check time
|
||||
- Displayed model preferences
|
||||
- Displayed provider preferences
|
||||
|
||||
What it does **not** do: send credentials to any third-party server, run a
|
||||
backend service, collect analytics, upload your project files, or write to
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# Phase 1: Bootstrap Repo
|
||||
|
||||
## Context Links
|
||||
|
||||
- Source `Cargo.toml`: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/Cargo.toml`
|
||||
- Source `build.rs`: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/build.rs`
|
||||
- Source icons: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/icons/`
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Must-first (every other phase depends on this)
|
||||
- **Status:** pending
|
||||
- **Description:** Create the new repo's foundation: `Cargo.toml` with the right windows-rs features, `build.rs` for icon embedding, LICENSE (MIT, dual attribution), README with attribution to source, fresh icon assets, project layout.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Source uses `windows = 0.58` with 12 feature flags. New repo can **drop** `Win32_UI_Accessibility` (no more `SetWinEventHook` on TrayNotifyWnd) and **drop** `Win32_UI_Input_KeyboardAndMouse` if drag is handled via `WM_NCHITTEST` + `HTCAPTION` instead of manual `SetCapture`.
|
||||
- Source uses `ureq` + `native-tls` + `serde` + `dirs` — all keep verbatim.
|
||||
- Source release profile: `opt-level="z"`, `lto=true`, `strip=true`, `codegen-units=1`, `panic="abort"` — keep all (produces ~2MB binary).
|
||||
- Package name change: `claude-code-usage-bubble` (binary name `claude-code-usage-bubble.exe`).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- `cargo build --release` produces a single-file `.exe`
|
||||
- `winres` embeds icon resource so the .exe has a proper Windows icon
|
||||
- README clearly attributes original repo + MIT license
|
||||
- Project compiles with no implementation yet (empty `main.rs` returning `()`)
|
||||
|
||||
### Non-functional
|
||||
- Binary size target: < 3 MB stripped
|
||||
- No Linux/Mac build (Windows-only `#![windows_subsystem]` in main)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
claude-code-usage-bubble/
|
||||
├── Cargo.toml
|
||||
├── build.rs
|
||||
├── LICENSE # MIT, with attribution clause
|
||||
├── README.md # Attribution + usage
|
||||
├── src/
|
||||
│ ├── main.rs # entry, mod declarations only
|
||||
│ └── icons/
|
||||
│ ├── icon.ico
|
||||
│ ├── 16x16.png, 32x32.png, 48x48.png, 256x256.png
|
||||
│ └── *.svg sources
|
||||
└── plans/ # this directory
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
**To create:**
|
||||
- `Cargo.toml`
|
||||
- `build.rs`
|
||||
- `LICENSE`
|
||||
- `README.md`
|
||||
- `src/main.rs` (stub)
|
||||
- `src/icons/*` (placeholder copies from source; can be replaced with bubble-specific art later)
|
||||
- `.gitignore`
|
||||
|
||||
**To modify:** none (greenfield repo)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create `Cargo.toml`** with package metadata, the same `windows-rs` features minus accessibility + keyboard/mouse:
|
||||
```toml
|
||||
[package]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Floating bubble showing Claude Code / Codex usage on Windows"
|
||||
repository = "<set this>"
|
||||
```
|
||||
Features to include: `Win32_Foundation`, `Win32_Globalization`, `Win32_Graphics_Gdi`, `Win32_System_LibraryLoader`, `Win32_UI_Shell`, `Win32_UI_WindowsAndMessaging`, `Win32_System_Registry`, `Win32_System_Threading`, `Win32_Security`, `Win32_UI_HiDpi`.
|
||||
2. **Create `build.rs`** mirroring source `build.rs`; embed `src/icons/icon.ico` via `winres`.
|
||||
3. **Copy `src/icons/*`** from source verbatim (placeholder; designer can replace).
|
||||
4. **Write `LICENSE`** — MIT text with a header line crediting CodeZeno/Claude-Code-Usage-Monitor.
|
||||
5. **Write `README.md`** — short, includes:
|
||||
- One-paragraph what-it-is
|
||||
- Attribution: "This project ports usage-polling, updater, and tray-icon code from [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) (MIT)."
|
||||
- Install/run section (placeholder)
|
||||
6. **Stub `src/main.rs`**:
|
||||
```rust
|
||||
#![windows_subsystem = "windows"]
|
||||
fn main() {}
|
||||
```
|
||||
7. **Run `cargo build`** — must compile clean.
|
||||
8. **Run `cargo build --release`** — confirm binary produced, check size.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Cargo.toml with correct features
|
||||
- [ ] build.rs with winres
|
||||
- [ ] Icons copied from source
|
||||
- [ ] LICENSE with attribution
|
||||
- [ ] README.md with attribution
|
||||
- [ ] src/main.rs stub
|
||||
- [ ] .gitignore (target/, Cargo.lock for libs only — keep Cargo.lock for binaries)
|
||||
- [ ] `cargo build` succeeds
|
||||
- [ ] `cargo build --release` succeeds, binary < 3 MB
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- `cargo build --release` on Windows produces a runnable .exe with embedded icon
|
||||
- README links source repo
|
||||
- LICENSE includes attribution clause
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Low.** Pure configuration; no logic.
|
||||
- Cross-compile note: if developer is on Linux/Mac, will need MinGW or Windows machine for `winres` step. Document this in README.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- N/A in this phase.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ Phase 2: port portable modules into `src/`
|
||||
@@ -1,124 +0,0 @@
|
||||
# Phase 2: Port Portable Modules
|
||||
|
||||
## Context Links
|
||||
|
||||
- Source: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/`
|
||||
- Modules to port verbatim (or near-verbatim): `models.rs`, `diagnose.rs`, `theme.rs`, `poller.rs`, `updater.rs`, `tray_icon.rs`, `localization/*`
|
||||
- Module to trim: `native_interop.rs`
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** High (must precede phases 3-4)
|
||||
- **Status:** pending
|
||||
- **Description:** Bring over the portable subsystems from the source repo with minimal changes. These represent ~2,700 lines of working, tested code — the goal is to keep them intact and only edit what's required for the new project name and the simplified Win32 surface.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- `poller.rs` (1099 lines) is fully self-contained — depends only on `serde`, `ureq`, `native-tls`, `dirs`, plus `diagnose` and `models` from the same crate.
|
||||
- `updater.rs` (510 lines) embeds `env!("CARGO_PKG_REPOSITORY")` to resolve GitHub owner/repo automatically — no hardcoded references.
|
||||
- `tray_icon.rs` uses `WM_APP_TRAY` (`WM_APP + 3`) and `IDM_TOGGLE_WIDGET = 50`. Keep the constants; `app.rs` (phase 4) will own dispatch.
|
||||
- `native_interop.rs` is the trimming target: drop `find_taskbar`, `find_child_window`, `get_taskbar_rect`, `embed_in_taskbar`, `set_tray_event_hook`, `get_window_thread_id`, `unhook_win_event`. Keep `wide_str`, `colorref`, `Color`, timer-ID constants, custom-message constants.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- `mod models; mod diagnose; mod theme; mod poller; mod updater; mod tray_icon; mod localization; mod native_interop;` all compile against current `main.rs`
|
||||
- `cargo check` passes with zero warnings beyond unused-symbol warnings (which will resolve in phases 3-4)
|
||||
- All `pub` symbols documented above remain reachable
|
||||
|
||||
### Non-functional
|
||||
- No behavioral changes vs source — diffs limited to module boundaries
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs (stub from phase 1)
|
||||
├── models.rs COPIED
|
||||
├── diagnose.rs COPIED
|
||||
├── theme.rs COPIED
|
||||
├── poller.rs COPIED
|
||||
├── updater.rs COPIED + 1-line stub for current_install_channel
|
||||
├── tray_icon.rs COPIED
|
||||
├── native_interop.rs TRIMMED (~80 lines vs source 179)
|
||||
└── localization/
|
||||
├── mod.rs COPIED
|
||||
├── english.rs COPIED
|
||||
├── dutch.rs COPIED
|
||||
├── french.rs COPIED
|
||||
├── german.rs COPIED
|
||||
├── japanese.rs COPIED
|
||||
├── korean.rs COPIED
|
||||
├── spanish.rs COPIED
|
||||
└── traditional_chinese.rs COPIED
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
**To create (copy from source):**
|
||||
- All files listed in Architecture section above.
|
||||
|
||||
**To modify after copying:**
|
||||
- `src/updater.rs` — replace `current_install_channel()` body with `InstallChannel::Portable` while keeping the rest of the function intact (preserves code for future winget enablement).
|
||||
- `src/native_interop.rs` — delete taskbar/WinEvent functions and their imports.
|
||||
- `src/main.rs` — declare modules; do NOT yet call `window::run` (window module doesn't exist yet).
|
||||
|
||||
**To delete:** none.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Copy modules verbatim:**
|
||||
```
|
||||
cp -r ../Claude-Code-Usage-Monitor/src/{models,diagnose,theme,poller,updater,tray_icon}.rs src/
|
||||
cp -r ../Claude-Code-Usage-Monitor/src/localization src/
|
||||
```
|
||||
2. **Copy & trim `native_interop.rs`:**
|
||||
- Keep: `wide_str`, `colorref`, `Color`, `TIMER_*` constants, `WM_APP_*` constants, `get_window_rect_safe`, `move_window`
|
||||
- Delete: `WS_POPUP_STYLE`, `WS_CHILD_STYLE`, `WS_CLIPSIBLINGS_STYLE` (bubble uses standard windows-rs constants), `EVENT_OBJECT_LOCATIONCHANGE`, `WINEVENT_OUTOFCONTEXT`, `find_taskbar`, `find_child_window`, `get_taskbar_rect`, `embed_in_taskbar`, `set_tray_event_hook`, `get_window_thread_id`, `unhook_win_event`
|
||||
- Drop imports for `Accessibility`, `Shell::SHAppBarMessage`/`APPBARDATA`, `Foundation::RECT` (if no longer used after trim)
|
||||
3. **Stub winget detection in `updater.rs`:**
|
||||
```rust
|
||||
pub fn current_install_channel() -> InstallChannel {
|
||||
// Bubble repo is not yet published to winget; once it is, restore the
|
||||
// is_winget_install_path probe by reading the source repo's logic.
|
||||
InstallChannel::Portable
|
||||
}
|
||||
```
|
||||
Keep `is_winget_install_path` + `winget_install_roots` + `normalize_path` as `#[allow(dead_code)]` to preserve the code path.
|
||||
4. **Update `src/main.rs`** to declare the modules:
|
||||
```rust
|
||||
#![windows_subsystem = "windows"]
|
||||
mod diagnose; mod localization; mod models; mod native_interop;
|
||||
mod poller; mod theme; mod tray_icon; mod updater;
|
||||
fn main() { /* phase-04 will wire this */ }
|
||||
```
|
||||
5. **Run `cargo check`** — expect warnings about unused public items; should be zero errors.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Copy 7 source modules + localization directory
|
||||
- [ ] Trim `native_interop.rs` to ~80 lines (drop taskbar/WinEvent helpers)
|
||||
- [ ] Stub `updater::current_install_channel`
|
||||
- [ ] Wire `mod` declarations in `main.rs`
|
||||
- [ ] `cargo check` clean (only dead-code warnings)
|
||||
- [ ] `cargo build --release` still produces a binary
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- All ported modules compile in the new crate without modification beyond what is listed above
|
||||
- No warnings about missing imports
|
||||
- Source code license headers (if any) are preserved
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Low.** Copy-with-rename operation; the trimming of `native_interop.rs` is the only judgment call.
|
||||
- Edge case: `tray_icon.rs` imports `crate::native_interop::WM_APP_TRAY` — verify constant survives the trim.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- `poller.rs` reads OAuth credentials from `~/.claude/.credentials.json` and (optionally) WSL distros. No new attack surface vs source.
|
||||
- `updater.rs` downloads .exe from GitHub. Same trust model as source. Until the new repo has releases published, this code is dormant.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ Phase 3: build the bubble window (replaces 2847 lines of `window.rs`)
|
||||
@@ -1,166 +0,0 @@
|
||||
# Phase 3: Build Floating Bubble Window
|
||||
|
||||
## Context Links
|
||||
|
||||
- Source `window.rs` painting + drag logic: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/window.rs` (lines around `UpdateLayeredWindow`, `WM_LBUTTONDOWN`, `SetCapture`)
|
||||
- Reference UX: 360 Security floating ball, IObit Advanced SystemCare RAM-boost ball — both are circular, top-most, draggable-anywhere with edge snap.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Critical — this is the heart of the new UX
|
||||
- **Status:** pending
|
||||
- **Description:** Build a circular floating bubble window that floats on top of everything, can be dragged anywhere, snaps to monitor edges, and shows usage percentage in the center over a colored progress ring. Replaces the 2847-line `window.rs` taskbar embedding code.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Use `WS_POPUP | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW` — `TOOLWINDOW` keeps it out of Alt+Tab; `NOACTIVATE` prevents focus theft.
|
||||
- Drag-anywhere = handle `WM_NCHITTEST`, return `HTCAPTION` for the entire bubble area. The OS handles drag automatically, including with proper cursor and Win+drag behavior. No need for `SetCapture`.
|
||||
- Circular alpha mask: render to a DIB section with per-pixel alpha. Pixels outside the circle = `0x00000000` (fully transparent). Then `UpdateLayeredWindow` with `ULW_ALPHA`. Click-through outside the circle happens automatically because alpha=0 doesn't hit-test (default behavior of layered windows with `WS_EX_LAYERED` + per-pixel alpha; verify via test).
|
||||
- Snap to edge: in `WM_EXITSIZEMOVE`, query current position via `GetWindowRect`, find nearest monitor via `MonitorFromPoint`, get its work area via `GetMonitorInfo`. If center is within 12px of any work-area edge, snap that edge.
|
||||
- HiDPI: use `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` early in `main`. Query `GetDpiForWindow` on `WM_DPICHANGED` and rescale bubble size + font.
|
||||
- GDI ring drawing: parametric — sweep angle proportional to percentage. Use `Polygon` filled with brush, or `AngleArc` with thick pen. For clean anti-aliased look on layered window, draw into DIB section manually with alpha-weighted line algorithm. Simpler path: use GDI+ via `gdiplus` crate, or accept GDI-aliased look for v1.
|
||||
- Bubble size default: **56×56 px** at 100% DPI (matches reference apps). Allow user to tweak in `Cargo.toml` constant for v1.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- Window appears as a circular bubble, can be dragged anywhere on any monitor
|
||||
- Bubble shows percentage text (e.g. "73%") in center
|
||||
- Colored progress ring around the percentage; color matches the source app's color stops (orange → red gradient from 50% to 100%)
|
||||
- Top-most: stays visible over other windows
|
||||
- No taskbar entry, no Alt-Tab entry
|
||||
- Left-click → posts `WM_APP_PANEL_TOGGLE` to self (panel implementation in phase 4)
|
||||
- Right-click → context menu (phase 4 owns menu items; bubble owns the right-click detection)
|
||||
- Drag releases → snap to nearest monitor edge if within 12 px of it
|
||||
- Repaints when percentage / theme / DPI changes
|
||||
|
||||
### Non-functional
|
||||
- 60 FPS not required; redraws on data update only (every 60s poll cycle plus countdown ticks)
|
||||
- Per-monitor DPI aware
|
||||
- Visible on dark and light Windows themes (use `theme::is_dark_mode` for ring background tint)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── bubble.rs NEW — Window class, message loop owner, GDI painting,
|
||||
│ drag + snap, DPI, hit-testing
|
||||
└── (other modules unchanged from phase 2)
|
||||
```
|
||||
|
||||
Bubble owns:
|
||||
- HWND lifecycle (`RegisterClassExW` + `CreateWindowExW`)
|
||||
- DIB section + `UpdateLayeredWindow` call
|
||||
- Percentage state (`Option<f64>` for each enabled model)
|
||||
- Drag state (managed by OS via `HTCAPTION`)
|
||||
- Snap math
|
||||
- DPI scale factor cache
|
||||
|
||||
Bubble delegates:
|
||||
- Polling → `poller::poll` (phase 4 wires the background thread)
|
||||
- Panel toggle → `app::on_panel_toggle` (phase 4)
|
||||
- Right-click menu → `app::on_show_context_menu` (phase 4)
|
||||
- Settings → `settings` module (phase 4)
|
||||
|
||||
## Related Code Files
|
||||
|
||||
**To create:**
|
||||
- `src/bubble.rs` (target: 400–700 lines)
|
||||
|
||||
**To modify:**
|
||||
- `src/main.rs` — eventually call `bubble::run()` (wired in phase 4)
|
||||
- `src/native_interop.rs` — may add helpers if hit-testing geometry math gets gnarly
|
||||
|
||||
**To delete:** none.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Window class registration:**
|
||||
- Class name: `ClaudeCodeUsageBubble`
|
||||
- Style: `CS_DBLCLKS` (allow `WM_LBUTTONDBLCLK` if we want double-click later)
|
||||
- WndProc: `bubble_wnd_proc`
|
||||
2. **Window creation:**
|
||||
- `WS_POPUP`, ext `WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW`
|
||||
- Initial position: load from settings (phase 4); fall back to "near bottom-right corner of primary monitor"
|
||||
- Size: 56×56 logical px scaled by current DPI
|
||||
3. **DPI awareness:**
|
||||
- In `bubble::run`, call `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)`.
|
||||
- On `WM_CREATE`, cache DPI via `GetDpiForWindow`.
|
||||
- On `WM_DPICHANGED`, update scale and resize.
|
||||
4. **Painting (the hard part):**
|
||||
- On every state change (percentage, DPI, theme), call `redraw()`.
|
||||
- `redraw()`:
|
||||
- Create DIB section sized to bubble pixel dimensions (`CreateDIBSection` with `BI_RGB` and 32bpp).
|
||||
- Clear to fully transparent (`0x00000000`).
|
||||
- For each pixel inside the circle radius, write background fill (theme-adjusted: dark theme → semi-opaque dark with high alpha; light theme → semi-opaque white).
|
||||
- Stroke progress ring: for the sweep angle proportional to current percentage, draw a thick arc using either GDI `AngleArc` with rounded `Pen`, or manual pixel writes (4 px ring thickness at 100% DPI).
|
||||
- Draw percentage text in center via `DrawTextW` with `DT_CENTER | DT_VCENTER | DT_SINGLELINE`. Font: bold 14 pt at 100% DPI, scaled by DPI factor.
|
||||
- Call `UpdateLayeredWindow` with `ULW_ALPHA` and the DIB.
|
||||
5. **Drag-anywhere via `WM_NCHITTEST`:**
|
||||
```rust
|
||||
WM_NCHITTEST => {
|
||||
// Convert lparam (screen coords) to client coords
|
||||
let p = screen_to_client(hwnd, lparam);
|
||||
if inside_circle(p, radius) { LRESULT(HTCAPTION as isize) }
|
||||
else { LRESULT(HTTRANSPARENT as isize) }
|
||||
}
|
||||
```
|
||||
OS handles drag + cursor. `HTTRANSPARENT` outside the circle ensures clicks pass through.
|
||||
6. **Snap on drag release:**
|
||||
- `WM_EXITSIZEMOVE` → snap logic.
|
||||
- `MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)` → `GetMonitorInfo` → work area rect.
|
||||
- Compare bubble's center to each edge of work area. If distance < 12 logical px (scaled by DPI), adjust window position to snap.
|
||||
- Persist new position via `app::on_bubble_moved(model, x, y)` (phase 4).
|
||||
7. **Click handling:**
|
||||
- `WM_LBUTTONUP` → if no drag occurred (compare with `WM_LBUTTONDOWN` position), `PostMessageW(WM_APP_PANEL_TOGGLE)`.
|
||||
- `WM_RBUTTONUP` → call into `app::show_context_menu(hwnd, screen_pos)` (phase 4 implements).
|
||||
8. **Public API:**
|
||||
```rust
|
||||
pub fn run(initial: BubbleConfig) -> ! { /* never returns; spins message loop */ }
|
||||
pub struct BubbleConfig {
|
||||
pub model: TrayIconKind, // Claude or Codex
|
||||
pub initial_position: Option<(i32, i32)>,
|
||||
pub initial_percentage: Option<f64>,
|
||||
}
|
||||
pub fn update_percentage(hwnd: HWND, percentage: Option<f64>); // called from poll thread via PostMessage
|
||||
```
|
||||
For dual-bubble mode, phase 4 spawns one `bubble::run` per enabled model on separate threads (each with its own message loop) — simpler than juggling two HWNDs in one thread.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Window class + creation with correct styles
|
||||
- [ ] Per-monitor DPI awareness on entry
|
||||
- [ ] DIB section + layered window painting pipeline
|
||||
- [ ] Circle fill with theme-aware background
|
||||
- [ ] Progress ring painted at correct sweep angle, correct color stop
|
||||
- [ ] Percentage text drawn centered
|
||||
- [ ] `WM_NCHITTEST` returns `HTCAPTION` inside circle, `HTTRANSPARENT` outside
|
||||
- [ ] Drag works smoothly across monitors
|
||||
- [ ] Snap on release within 12px of work-area edge
|
||||
- [ ] Left-click (no drag) posts panel-toggle message
|
||||
- [ ] Right-click posts context-menu request
|
||||
- [ ] Public API `run`, `update_percentage`
|
||||
- [ ] Manual test: bubble visible, draggable, snaps, percentage updates
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Bubble appears on Windows 10/11 with a Visual Studio-clean cargo build
|
||||
- Drag works smoothly with no flicker
|
||||
- Edge snap engages reliably from 12 px
|
||||
- Bubble survives display reconnection (laptop → external monitor → unplug)
|
||||
- Percentage text remains crisp on 100%, 125%, 150%, 175% DPI
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **High** — this is novel code with no exact analog in source.
|
||||
- Risk: ClearType sub-pixel text rendering on a per-pixel-alpha layered window looks bad. Source repo's `window.rs` solved this with a black background-pixel hack (`alpha = 0x01` so it's nearly transparent but still gets ClearType). Apply the same trick for the circle's interior fill region.
|
||||
- Risk: GDI `AngleArc` doesn't anti-alias. Mitigation: either accept aliased v1, or render to a 2x supersampled DIB and downsample.
|
||||
- Risk: Snap math wrong on rotated taskbar or unusual DPI configurations. Mitigation: clamp to monitor work area only, ignore taskbar position.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- N/A in this phase; bubble does not handle user input beyond mouse position and clicks.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ Phase 4: expanded panel, settings persistence, polling thread, orchestration
|
||||
@@ -1,190 +0,0 @@
|
||||
# Phase 4: Expanded Panel + Settings Persistence + Orchestration
|
||||
|
||||
## Context Links
|
||||
|
||||
- Source `window.rs` — borrow message-loop dispatch, polling-thread orchestration, settings persistence pattern
|
||||
- Source `poller.rs` — `poll`, `credential_watch_snapshot`, `format_line`, `time_until_display_change`
|
||||
- Source `tray_icon.rs` — `add/update/remove/sync`, `handle_message`
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** High
|
||||
- **Status:** pending
|
||||
- **Description:** Build (a) the expanded panel that appears on bubble click and shows both 5h and 7d bars with countdowns, (b) settings persistence to `%APPDATA%\ClaudeCodeUsageBubble\settings.json`, (c) the orchestrating `app.rs` module that owns polling, message routing, context menus, and dual-bubble lifecycle.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Panel is a separate window: `WS_POPUP | WS_EX_LAYERED | WS_EX_TOPMOST`, opaque background, shown adjacent to bubble. Source's draw code for the horizontal bars can be ported almost directly (it already draws progress bars + countdown text via GDI).
|
||||
- Settings file location matches source pattern: `%APPDATA%\ClaudeCodeUsageBubble\settings.json` (renamed dir). Use `dirs::config_dir()`.
|
||||
- Polling: background `std::thread` spawned in `app::run`. Posts `WM_APP_USAGE_UPDATED` to each bubble window when data refreshes. Source's poll-loop logic is copy-friendly.
|
||||
- Context menu: built via `CreatePopupMenu` + `AppendMenuW` + `TrackPopupMenu`. Source has the full menu structure — port it but remove "Reset position" → rename to "Reset bubble position" (per model).
|
||||
- Dual-bubble: each enabled model gets its own HWND + tray icon + bubble window. Settings stores `bubble_positions: { claude: {x, y}, codex: {x, y} }`.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- Settings persist across restarts: window positions, polling frequency, enabled models, language, "Start with Windows" state, last update check
|
||||
- Expanded panel: shows session bar + weekly bar + countdowns + reset times for the model whose bubble was clicked
|
||||
- Panel auto-closes on focus loss or after a brief timeout (optional)
|
||||
- Right-click menu mirrors source's menu structure: Refresh, Models, Update frequency, Language, Start with Windows, Reset position, Updates, Exit
|
||||
- Single-instance enforced via named mutex `Global\ClaudeCodeUsageBubble`
|
||||
- Polling runs in background, posts updates via `PostMessageW(WM_APP_USAGE_UPDATED, ...)`
|
||||
- Countdown timer adapts to display granularity (`time_until_display_change`)
|
||||
|
||||
### Non-functional
|
||||
- Settings file is atomically written (write to `.tmp`, rename)
|
||||
- Polling thread cannot block UI thread
|
||||
- Mutex released on clean shutdown
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── app.rs NEW — orchestrator: spawns bubbles, polls, routes messages,
|
||||
│ owns tray icons, owns context menu builder
|
||||
├── panel.rs NEW — expanded panel window (one per model on demand)
|
||||
├── settings.rs NEW — load/save settings.json, schema
|
||||
└── main.rs modified — calls app::run
|
||||
```
|
||||
|
||||
Message flow:
|
||||
|
||||
```
|
||||
poll thread ────PostMessage(WM_APP_USAGE_UPDATED)──▶ bubble HWND
|
||||
└─▶ updates percentage, redraws
|
||||
bubble click ──PostMessage(WM_APP_PANEL_TOGGLE)─▶ app handler (in bubble wndproc)
|
||||
└─▶ panel::show_for(model)
|
||||
right-click ──app::show_context_menu(hwnd)──▶ TrackPopupMenu ─▶ WM_COMMAND
|
||||
└─▶ menu action dispatch
|
||||
tray icon ────WM_APP_TRAY───────────▶ tray_icon::handle_message ─▶ TrayAction
|
||||
└─▶ toggle/ shutdown / refresh
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
**To create:**
|
||||
- `src/app.rs` (target: 500–800 lines)
|
||||
- `src/panel.rs` (target: 300–500 lines)
|
||||
- `src/settings.rs` (target: 150–250 lines)
|
||||
|
||||
**To modify:**
|
||||
- `src/main.rs`:
|
||||
```rust
|
||||
#![windows_subsystem = "windows"]
|
||||
mod app; mod bubble; mod diagnose; mod localization; mod models;
|
||||
mod native_interop; mod panel; mod poller; mod settings; mod theme;
|
||||
mod tray_icon; mod updater;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.iter().any(|a| a == "--diagnose") {
|
||||
if let Ok(path) = diagnose::init() {
|
||||
diagnose::log(format!("startup args={args:?} log_path={}", path.display()));
|
||||
}
|
||||
}
|
||||
if let Some(exit_code) = updater::handle_cli_mode(&args) {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
app::run();
|
||||
}
|
||||
```
|
||||
|
||||
**To delete:** none.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **`settings.rs`** — define schema:
|
||||
```rust
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
pub show_claude_code: bool, // default true
|
||||
pub show_codex: bool, // default false
|
||||
pub bubble_positions: BubblePositions,
|
||||
pub poll_minutes: u32, // default 5
|
||||
pub language: Option<String>, // None = system
|
||||
pub start_with_windows: bool,
|
||||
pub last_update_check_unix: Option<i64>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct BubblePositions {
|
||||
pub claude: Option<(i32, i32)>,
|
||||
pub codex: Option<(i32, i32)>,
|
||||
}
|
||||
pub fn load() -> Settings { /* dirs::config_dir + read + serde + atomic */ }
|
||||
pub fn save(s: &Settings) { /* write .tmp + rename */ }
|
||||
```
|
||||
2. **`panel.rs`** — port the existing horizontal-bar painting code from source `window.rs`:
|
||||
- Window class `ClaudeCodeUsageBubblePanel`
|
||||
- On `WM_LBUTTONDOWN` outside → close
|
||||
- On `WM_KILLFOCUS` → close (with debounce so it doesn't close instantly when bubble is clicked again to toggle off)
|
||||
- Paints two rows (5h, 7d) for the model that was clicked; uses `poller::format_line` for the countdown text
|
||||
- Position: anchor next to bubble; flip side if would go off-screen
|
||||
3. **`app.rs`** — orchestrator:
|
||||
- `pub fn run() -> !`:
|
||||
1. Acquire single-instance mutex; if already running, exit.
|
||||
2. `SetProcessDpiAwarenessContext`.
|
||||
3. Load settings.
|
||||
4. Resolve language (`localization::resolve_language`).
|
||||
5. Start polling thread.
|
||||
6. For each enabled model, spawn a bubble window thread.
|
||||
7. Run main message loop on UI thread (the bubble windows can be on the same thread — easier than multi-thread message pumps).
|
||||
- Polling thread:
|
||||
```rust
|
||||
loop {
|
||||
match poller::poll(show_claude, show_codex) {
|
||||
Ok(data) => post_update_to_bubbles(data),
|
||||
Err(e) => post_error_to_app(e),
|
||||
}
|
||||
sleep(poll_interval);
|
||||
}
|
||||
```
|
||||
- Context menu builder: replicate source's menu structure verbatim; localized strings via `Strings`. Map `WM_COMMAND` IDs to handlers (refresh, toggle model, change interval, etc.).
|
||||
- "Start with Windows": registry write to `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run\ClaudeCodeUsageBubble`.
|
||||
- Token-expired flow: if `PollError::TokenExpired`, show tray balloon via `tray_icon::notify_balloon` with localized title/body.
|
||||
4. **Wire `bubble.rs` events back to `app.rs`:**
|
||||
- `WM_APP_PANEL_TOGGLE` handler in bubble wndproc → call `panel::show_for_model(model)` (registered via callback or via `app::handle_panel_toggle`).
|
||||
- `WM_RBUTTONUP` → call `app::show_context_menu_at(point, model)`.
|
||||
- `WM_EXITSIZEMOVE` → call `app::on_bubble_moved(model, x, y)` which updates settings.
|
||||
|
||||
5. **Reuse `tray_icon.rs`** as the source-app's secondary indicator:
|
||||
- In `app::run`, after creating bubbles, call `tray_icon::sync(hwnd, &[TrayIconData{kind: Claude, percent: …, tooltip: …}, …])`.
|
||||
- Tray icon clicked → `tray_icon::handle_message` → if `ToggleWidget`, toggle bubble visibility (set `WS_VISIBLE` style); if `ShowContextMenu`, dispatch to `app::show_context_menu_at`.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] `settings.rs` with atomic save
|
||||
- [ ] `panel.rs` with bar painting + auto-close
|
||||
- [ ] `app.rs` orchestrator
|
||||
- [ ] Single-instance mutex acquisition + release
|
||||
- [ ] Polling thread spawning + `PostMessage` updates
|
||||
- [ ] Context menu localized + dispatchable
|
||||
- [ ] Start-with-Windows registry roundtrip
|
||||
- [ ] Tray icons synced from polling updates
|
||||
- [ ] Bubble-to-app event callbacks wired
|
||||
- [ ] Dual-bubble mode tested (both Claude + Codex enabled)
|
||||
- [ ] `cargo build --release` → working binary
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Launching the app twice: second launch exits silently
|
||||
- Bubble + panel + tray icon all show correct usage after first poll
|
||||
- Right-click menu functional for all items
|
||||
- Toggle Claude/Codex via menu: bubbles appear/disappear; settings persist
|
||||
- Restart app: bubble reappears at last saved position
|
||||
- Token-expired triggers tray balloon
|
||||
- Poll frequency change takes effect within one poll cycle
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Medium.** Most logic is structural — message routing and state management. Source repo has all the patterns.
|
||||
- Risk: dual-bubble on same UI thread with two HWNDs — should work, but verify message routing keys off `hwnd` parameter.
|
||||
- Risk: panel auto-close races with bubble re-click. Mitigation: 200 ms debounce on `WM_KILLFOCUS` before destroying panel; if bubble clicked within that window, cancel close.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Settings file written to `%APPDATA%`, user-scoped, no privileged ops.
|
||||
- Single-instance mutex name `Global\ClaudeCodeUsageBubble` — distinct from source app to allow coexistence.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ Phase 5: polish (HiDPI testing, multi-monitor, README, attribution, etc.)
|
||||
@@ -1,120 +0,0 @@
|
||||
# Phase 5: Polish & Finishing
|
||||
|
||||
## Context Links
|
||||
|
||||
- All prior phases
|
||||
- Source README for attribution / language list
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Required before any release
|
||||
- **Status:** pending
|
||||
- **Description:** Multi-monitor and HiDPI verification, accessibility checks, README, license attribution, version 0.1.0 tag, optional CI workflow.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- Bubble renders crisply at 100% / 125% / 150% / 175% / 200% DPI
|
||||
- Bubble survives display add/remove (laptop dock/undock)
|
||||
- Bubble respects monitor work area when snapping (does not overlap taskbar)
|
||||
- README has install + run + uninstall sections
|
||||
- LICENSE has CodeZeno attribution paragraph
|
||||
- `--diagnose` flag works (writes `%TEMP%\claude-code-usage-bubble.log`)
|
||||
|
||||
### Non-functional
|
||||
- Cargo build is fully reproducible
|
||||
- No clippy warnings on `cargo clippy -- -D warnings` (or document the ones you keep)
|
||||
|
||||
## Architecture
|
||||
|
||||
No new modules.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
**To modify:**
|
||||
- `README.md` — fill out final content
|
||||
- `LICENSE` — final attribution paragraph
|
||||
- Maybe `.github/workflows/build.yml` for CI Windows build
|
||||
|
||||
**To delete:** none.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **HiDPI manual test matrix:**
|
||||
- Windows 10 at 100% DPI: bubble visible, text readable, ring smooth
|
||||
- Windows 11 at 150%: same
|
||||
- 4K monitor at 200%: same
|
||||
- Mixed-DPI dual monitor: drag bubble between monitors → verify rescale on `WM_DPICHANGED`
|
||||
2. **Multi-monitor edge tests:**
|
||||
- Snap bubble to right edge of secondary monitor → settings saved with correct coords
|
||||
- Disconnect monitor → bubble should reposition to primary monitor's work area on next start
|
||||
- Test with taskbar on top / left / right (not just default bottom)
|
||||
3. **README content checklist:**
|
||||
- One-paragraph what-it-is + screenshot/gif placeholder
|
||||
- **Attribution section** (required by source MIT license):
|
||||
> This project is a derivative of [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) (MIT, © 2026 Code Zeno Pty Ltd). The usage-polling, updater, tray-icon, and localization modules are ported from that codebase with minor adaptations; the floating-bubble UI is original to this project.
|
||||
- Install: cargo build instructions; future winget block
|
||||
- Use: bubble + panel + tray icon described
|
||||
- Models: same content as source
|
||||
- Diagnostics: `--diagnose` flag, log path
|
||||
- Privacy: same content as source (credentials read locally, GitHub for updates)
|
||||
- License: MIT
|
||||
4. **LICENSE file** — include both:
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 <your name>
|
||||
|
||||
Portions of this software are derived from Claude Code Usage Monitor,
|
||||
Copyright (c) 2026 Code Zeno Pty Ltd, licensed under the MIT License.
|
||||
|
||||
<rest of MIT license text>
|
||||
```
|
||||
5. **Optional CI** (`.github/workflows/build.yml`):
|
||||
- Runs `cargo fmt --check`, `cargo clippy`, `cargo build --release` on `windows-latest`
|
||||
- Uploads artifact on tag
|
||||
6. **Smoke test before tagging:**
|
||||
- Run `claude-code-usage-bubble.exe`
|
||||
- Verify bubble appears with placeholder data (or real data if Claude CLI signed in)
|
||||
- Drag, snap, expand, menu, exit — all work
|
||||
- Re-launch → second instance exits silently
|
||||
- `claude-code-usage-bubble.exe --diagnose` → log file populated
|
||||
7. **Tag v0.1.0** (only after the above passes):
|
||||
- `git tag v0.1.0`
|
||||
- Push to GitHub
|
||||
- Create release with the .exe artifact attached (so `updater.rs` works for future versions)
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] HiDPI matrix tested
|
||||
- [ ] Multi-monitor edge tests done
|
||||
- [ ] README.md final
|
||||
- [ ] LICENSE attribution finalized
|
||||
- [ ] Diagnostic log verified
|
||||
- [ ] Clippy clean
|
||||
- [ ] CI workflow (optional)
|
||||
- [ ] Smoke test green
|
||||
- [ ] v0.1.0 tagged
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- App can be downloaded fresh, built once, and used end-to-end
|
||||
- Source repo attribution is unambiguous
|
||||
- No regressions vs phases 1-4
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Low.** Polish phase.
|
||||
- Possible regression: HiDPI bug discovered late — fix in `bubble.rs` painting code.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Verify `updater.rs` `current_install_channel()` still returns `Portable`. Re-enabling winget detection is a future task — not part of v0.1.0.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ v0.1.0 release; future tasks (out of scope for this port):
|
||||
- Winget package submission (when ready)
|
||||
- Custom bubble art per model (Claude orange, Codex green) replacing inherited icons
|
||||
- Optional bubble-size setting (S/M/L) in right-click menu
|
||||
- Auto-hide when fullscreen apps active
|
||||
@@ -1,83 +0,0 @@
|
||||
# Plan: claude-code-usage-bubble — port from CodeZeno/Claude-Code-Usage-Monitor
|
||||
|
||||
**Mode:** `/ck:xia --port`
|
||||
**Source repo:** `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor` (Rust ~5.8k LOC, MIT)
|
||||
**Target repo:** `/config/workspace/CodeZeno/claude-code-usage-bubble` (new)
|
||||
**Date:** 2026-05-15
|
||||
|
||||
## Source Manifest
|
||||
|
||||
- Path: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor`
|
||||
- Branch: `main` @ `b5f038d` (v1.4.1)
|
||||
- License: MIT (attribution required in new repo README)
|
||||
- Scope: portable subsystems only — see `phase-02-port-portable-modules.md`
|
||||
|
||||
## Decision Matrix (Approved)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Platform | Windows-only (Win32 GDI + layered window) |
|
||||
| WSL credential reading | Keep |
|
||||
| Snap-to-edge | On, 12px zone, monitor work area |
|
||||
| Click behavior | Left-click = toggle panel, right-click = menu |
|
||||
| Dual-model layout | Two independent bubbles, positions persisted per model |
|
||||
| Winget channel | Code kept, `current_install_channel` stubbed to Portable |
|
||||
| Single-instance mutex | `Global\ClaudeCodeUsageBubble` |
|
||||
| Auto-hide when fullscreen | **Yes** (added to phase 3) — detect via `SHQueryUserNotificationState` or `MonitorFromWindow + window-rect == monitor-rect` against foreground HWND |
|
||||
| Bubble size customization | **Yes**, free range 32–128 px persisted in settings.json; resize via Ctrl+MouseWheel on bubble (no S/M/L menu) |
|
||||
| Per-model bubble art | **No** — both models share the same bubble look; differentiation only via usage-percentage ring color |
|
||||
|
||||
## Dependency Matrix (Source → New)
|
||||
|
||||
| Source file | LOC | Action | Target file |
|
||||
|---|---|---|---|
|
||||
| `src/models.rs` | 19 | COPY | `src/models.rs` |
|
||||
| `src/diagnose.rs` | 52 | COPY | `src/diagnose.rs` |
|
||||
| `src/theme.rs` | 52 | COPY | `src/theme.rs` |
|
||||
| `src/poller.rs` | 1099 | COPY | `src/poller.rs` |
|
||||
| `src/updater.rs` | 510 | COPY + stub channel | `src/updater.rs` |
|
||||
| `src/tray_icon.rs` | 441 | COPY | `src/tray_icon.rs` |
|
||||
| `src/localization/*` | ~620 | COPY | `src/localization/*` |
|
||||
| `src/native_interop.rs` | 179 | ADAPT (drop taskbar/WinEvent helpers) | `src/native_interop.rs` |
|
||||
| `src/main.rs` | 40 | ADAPT (call `bubble::run` instead of `window::run`, rename single-instance mutex) | `src/main.rs` |
|
||||
| `src/window.rs` | 2847 | **REWRITE** as `bubble.rs` + `panel.rs` + `settings.rs` + `app.rs` | NEW |
|
||||
| `build.rs`, `Cargo.toml`, `src/icons/*` | — | ADAPT | NEW |
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Status | File |
|
||||
|---|---|---|---|
|
||||
| 1 | Bootstrap repo (Cargo.toml, build.rs, LICENSE, README, icons) | pending | `phase-01-bootstrap-repo.md` |
|
||||
| 2 | Port portable modules verbatim | pending | `phase-02-port-portable-modules.md` |
|
||||
| 3 | Build floating bubble window (layered alpha, GDI ring, drag-anywhere, snap) | pending | `phase-03-build-bubble-window.md` |
|
||||
| 4 | Build expanded panel + settings persistence + orchestration | pending | `phase-04-panel-and-orchestration.md` |
|
||||
| 5 | Polish: HiDPI, multi-monitor, startup registry, mutex, tray icon wiring, README | pending | `phase-05-polish-and-finishing.md` |
|
||||
|
||||
## Risk Score
|
||||
|
||||
**Medium.** Highest-risk surface is **phase 3** — circular layered window with HiDPI-aware GDI ring drawing. Source codebase has no precedent for that exact pattern; needs fresh implementation. All other phases are straightforward ports or thin orchestration.
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|---|---|---|
|
||||
| GDI ring + ClearType text on layered alpha window | High | Reference `window.rs` UpdateLayeredWindow + DIB section pattern (lines around layered painting); keep ring math simple (parametric arc) |
|
||||
| Drag + snap interaction on multi-monitor | Medium | Use `MonitorFromPoint` per move; clamp to nearest monitor work area |
|
||||
| Two-bubble position state | Low | Independent `BubbleState` structs in settings.json |
|
||||
| WSL credential read regressions | Low | Verbatim port; no behavioral changes |
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
- Phase 1: 1–2h
|
||||
- Phase 2: 1h (mostly file copies + import path fixes)
|
||||
- Phase 3: 6–10h (the heavy lift)
|
||||
- Phase 4: 3–5h
|
||||
- Phase 5: 2–4h
|
||||
|
||||
**Total:** ~15–22h of focused implementation.
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
The new repo is greenfield — rollback means `rm -rf /config/workspace/CodeZeno/claude-code-usage-bubble`. No source-repo changes; this plan does not modify the source app.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None. All three formerly-deferred items resolved by user on 2026-05-15 (see Decision Matrix rows 8–10).
|
||||
@@ -1,285 +0,0 @@
|
||||
---
|
||||
phase: 1
|
||||
status: pending
|
||||
estimated_hours: 9
|
||||
---
|
||||
|
||||
# Phase 1 — Infrastructure
|
||||
|
||||
## Context links
|
||||
|
||||
- Brainstorm: [`../reports/brainstorm-260516-0707-cleanroom-reimplementation.md`](../reports/brainstorm-260516-0707-cleanroom-reimplementation.md) (axes 1, 2, 9, 10 + os/)
|
||||
- Source files to be REPLACED later: `src/diagnose.rs`, `src/theme.rs`, `src/native_interop.rs` (do not delete this phase — Phase 2+ depends on them until then)
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Critical (every other phase depends on these primitives)
|
||||
- **Status:** pending
|
||||
- **Brief:** Stand up the foundation modules — logging, Win32 helpers, WinHTTP client, and the build-script swap — without touching any business logic. End of this phase: `cargo build --release` succeeds with the new modules compiled in but not yet referenced by `app.rs` (so behavior is unchanged).
|
||||
|
||||
## Key insights from brainstorm
|
||||
|
||||
- WinHTTP via `windows-rs::Win32::Networking::WinHttp` removes `ureq` + `native-tls` (~600 KB binary savings) and respects the system proxy automatically.
|
||||
- WinHTTP is verbose. Encapsulate in a single ~150 LOC `net::winhttp::Client` so the rest of the codebase stays clean.
|
||||
- `log` + `simplelog` replaces the bespoke `OnceLock<Mutex<File>>` logger. Standard ecosystem, same one-line ergonomics.
|
||||
- `embed-resource` replaces `winres`. Same purpose, different file shape (`res/icon.rc` instead of builder API).
|
||||
- `os/` directory consolidates color, wide-string, DPI, registry, theme helpers (currently scattered across `theme.rs` and `native_interop.rs`).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
- `diag::init(enabled: bool)` writes a log file at `%TEMP%\claude-code-usage-bubble.log` when called with `true`; no-op otherwise.
|
||||
- `log::info!` / `log::warn!` / `log::error!` macros work and route to the file.
|
||||
- `os::wide_str(&str) -> Vec<u16>` returns a NUL-terminated UTF-16 vector.
|
||||
- `os::Color` provides hex parsing + COLORREF conversion.
|
||||
- `os::dpi::for_window(hwnd)` returns u32 DPI ≥ 96.
|
||||
- `os::registry::read_string(hkey, path, name)` returns Option<String>.
|
||||
- `os::registry::write_string(hkey, path, name, value)` returns Result.
|
||||
- `os::registry::delete_value(hkey, path, name)` returns Result.
|
||||
- `os::theme::is_dark()` returns bool from registry.
|
||||
- `net::winhttp::Client::new()` constructs a client with a user-agent string.
|
||||
- `client.get(url).header(k, v).send()` returns `Result<Response, net::Error>`.
|
||||
- `client.post(url).header(k, v).json_body(value).send()` returns `Result<Response, net::Error>`.
|
||||
- `Response::status() -> u32`, `Response::header(&str) -> Option<&str>`, `Response::text() -> Result<String, net::Error>`, `Response::json<T>() -> Result<T, net::Error>`.
|
||||
- Build script (`build.rs`) embeds `res/icon.ico` and version info via `embed-resource`.
|
||||
|
||||
### Non-functional
|
||||
|
||||
- Binary size after this phase: ≤ current (we add `tiny-skia`/`embed-resource`/`simplelog`/`thiserror` deps in later phases; this phase should not balloon).
|
||||
- No new behavior changes vs current build.
|
||||
- All new modules pass `cargo clippy -- -W clippy::all` with zero new warnings.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `diag/mod.rs` (~30 LOC)
|
||||
|
||||
```rust
|
||||
use std::path::PathBuf;
|
||||
use simplelog::{Config, LevelFilter, WriteLogger};
|
||||
use std::fs::File;
|
||||
|
||||
pub fn init(enabled: bool) -> Result<Option<PathBuf>, std::io::Error> {
|
||||
if !enabled { return Ok(None); }
|
||||
let path = std::env::temp_dir().join("claude-code-usage-bubble.log");
|
||||
WriteLogger::init(LevelFilter::Debug, Config::default(), File::create(&path)?)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||
log::info!("diagnostic logging enabled");
|
||||
Ok(Some(path))
|
||||
}
|
||||
```
|
||||
|
||||
Callers use `log::info!` etc directly — no `diagnose::log(...)` indirection.
|
||||
|
||||
### `os/` directory
|
||||
|
||||
| File | Responsibility | LOC |
|
||||
|---|---|---|
|
||||
| `mod.rs` | `pub use` re-exports + module declarations | ~10 |
|
||||
| `color.rs` | `Color { r, g, b }`, `from_hex`, `to_colorref()` | ~30 |
|
||||
| `string.rs` | `wide_str(&str) -> Vec<u16>` | ~5 |
|
||||
| `dpi.rs` | `for_window(hwnd)`, `for_system()`, `scale(logical, dpi)` | ~20 |
|
||||
| `registry.rs` | typed wrapper over `RegOpenKeyExW`/`RegQueryValueExW`/`RegSetValueExW`/`RegDeleteValueW` | ~80 |
|
||||
| `theme.rs` | `is_dark()` → reads `SystemUsesLightTheme` via `os::registry` | ~15 |
|
||||
|
||||
### `net/winhttp.rs` (~150 LOC)
|
||||
|
||||
```rust
|
||||
pub struct Client {
|
||||
session: HINTERNET,
|
||||
user_agent: Vec<u16>,
|
||||
}
|
||||
|
||||
pub struct RequestBuilder<'a> {
|
||||
client: &'a Client,
|
||||
method: Method,
|
||||
url: Url,
|
||||
headers: Vec<(Vec<u16>, Vec<u16>)>,
|
||||
body: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub struct Response {
|
||||
status: u32,
|
||||
headers: HashMap<String, String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("WinHTTP error {code}: {context}")]
|
||||
Win(u32, String),
|
||||
#[error("HTTP {status}")]
|
||||
Status(u32),
|
||||
#[error("JSON parse: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("invalid URL: {0}")]
|
||||
Url(String),
|
||||
#[error("UTF-8 conversion failed")]
|
||||
Utf8,
|
||||
}
|
||||
```
|
||||
|
||||
Internally chains: `WinHttpOpen` → `WinHttpCrackUrl` → `WinHttpConnect` → `WinHttpOpenRequest` → `WinHttpSendRequest` → `WinHttpReceiveResponse` → `WinHttpQueryHeaders` → `WinHttpReadData`.
|
||||
|
||||
### `Cargo.toml` updates
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
# REMOVED: ureq, native-tls
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "6"
|
||||
log = "0.4"
|
||||
simplelog = "0.12"
|
||||
thiserror = "2"
|
||||
|
||||
[dependencies.windows]
|
||||
version = "0.58"
|
||||
features = [
|
||||
# existing features +
|
||||
"Win32_Networking_WinHttp",
|
||||
]
|
||||
|
||||
[build-dependencies]
|
||||
# REMOVED: winres
|
||||
embed-resource = "3"
|
||||
```
|
||||
|
||||
### `build.rs`
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
embed_resource::compile("res/icon.rc", embed_resource::NONE);
|
||||
}
|
||||
```
|
||||
|
||||
### `res/icon.rc` (new file)
|
||||
|
||||
```
|
||||
#include <winver.h>
|
||||
1 ICON "..\\src\\icons\\icon.ico"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,1,0,0
|
||||
PRODUCTVERSION 0,1,0,0
|
||||
FILEOS 0x40004L
|
||||
FILETYPE 0x1L
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904E4"
|
||||
BEGIN
|
||||
VALUE "ProductName", "Claude Code Usage Bubble\0"
|
||||
VALUE "FileDescription", "Claude Code Usage Bubble\0"
|
||||
VALUE "OriginalFilename", "claude-code-usage-bubble.exe\0"
|
||||
VALUE "InternalName", "ClaudeCodeUsageBubble\0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1252
|
||||
END
|
||||
END
|
||||
```
|
||||
|
||||
## Related code files
|
||||
|
||||
**To create:**
|
||||
- `src/diag/mod.rs`
|
||||
- `src/os/mod.rs`
|
||||
- `src/os/color.rs`
|
||||
- `src/os/string.rs`
|
||||
- `src/os/dpi.rs`
|
||||
- `src/os/registry.rs`
|
||||
- `src/os/theme.rs`
|
||||
- `src/net/mod.rs`
|
||||
- `src/net/winhttp.rs`
|
||||
- `res/icon.rc`
|
||||
|
||||
**To modify:**
|
||||
- `Cargo.toml` (add new deps, remove `ureq`+`native-tls`+`winres`, add `Win32_Networking_WinHttp`)
|
||||
- `build.rs` (replace winres with embed-resource)
|
||||
- `src/main.rs` (add `mod diag; mod os; mod net;` declarations — do NOT remove old `mod diagnose; mod theme; mod native_interop;` yet)
|
||||
|
||||
**To delete:** nothing in this phase (Phase 2 removes `theme.rs`, etc.)
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Add new dependencies** to `Cargo.toml`: `log`, `simplelog`, `thiserror`, `embed-resource`. Add `Win32_Networking_WinHttp` to `windows` features. Leave `ureq`, `native-tls`, `winres` in place for now.
|
||||
2. **Create `res/icon.rc`** referencing `src/icons/icon.ico`.
|
||||
3. **Replace `build.rs`** with `embed-resource::compile`.
|
||||
4. **`cargo build --release`** — verify icon embedding works (PE has icon resource).
|
||||
5. **Create `src/os/string.rs`** with `wide_str()`. Trivial.
|
||||
6. **Create `src/os/color.rs`** with `Color` struct + `from_hex` + `to_colorref`.
|
||||
7. **Create `src/os/registry.rs`** with `read_string`, `read_u32`, `write_string`, `delete_value` — `unsafe` wrappers over Win32 registry APIs returning `Result<T, RegistryError>`.
|
||||
8. **Create `src/os/theme.rs`** calling `registry::read_u32(HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", "SystemUsesLightTheme")` and inverting.
|
||||
9. **Create `src/os/dpi.rs`** with `for_window`, `for_system`, `scale`.
|
||||
10. **Create `src/os/mod.rs`** with `pub mod color; pub mod string; pub mod dpi; pub mod registry; pub mod theme;` + re-exports of common items (`Color`, `wide_str`).
|
||||
11. **Create `src/diag/mod.rs`** with `init(bool)` that initialises simplelog. Add `log::set_max_level` if not handled by simplelog.
|
||||
12. **Create `src/net/winhttp.rs`** in 4 commits:
|
||||
- 12a. `Client::new`, `Drop` for `WinHttpCloseHandle`.
|
||||
- 12b. `RequestBuilder` + GET path.
|
||||
- 12c. POST + JSON body.
|
||||
- 12d. `Response::header` + `Response::text` + `Response::json`.
|
||||
13. **Create `src/net/mod.rs`** with `pub mod winhttp;` + `pub use winhttp::{Client, Error, Response};`.
|
||||
14. **Wire modules into `src/main.rs`**:
|
||||
```rust
|
||||
mod diag;
|
||||
mod net;
|
||||
mod os;
|
||||
// OLD ones still present for now:
|
||||
mod diagnose;
|
||||
mod theme;
|
||||
mod native_interop;
|
||||
```
|
||||
15. **`cargo build --release`** — must compile clean. Binary should run identically to current build (we haven't replaced anything yet).
|
||||
16. **`cargo clippy --release`** — fix any clippy warnings on new modules.
|
||||
17. **Manual smoke test on Windows** (or note as deferred to Phase 4 testing):
|
||||
- Run `--diagnose`, confirm log file exists at `%TEMP%\claude-code-usage-bubble.log` (but it's empty for now since nothing calls `log::info!` yet — that's OK).
|
||||
- Run a tiny dev-only test binary or `cargo test` that exercises `net::winhttp::Client.get("https://api.github.com").send()` to validate the HTTP wrapper end-to-end.
|
||||
|
||||
## Todo checklist
|
||||
|
||||
- [ ] Cargo.toml deps updated
|
||||
- [ ] `res/icon.rc` created
|
||||
- [ ] `build.rs` swapped to embed-resource
|
||||
- [ ] Build produces .exe with embedded icon
|
||||
- [ ] `src/os/string.rs`
|
||||
- [ ] `src/os/color.rs`
|
||||
- [ ] `src/os/registry.rs`
|
||||
- [ ] `src/os/theme.rs`
|
||||
- [ ] `src/os/dpi.rs`
|
||||
- [ ] `src/os/mod.rs`
|
||||
- [ ] `src/diag/mod.rs`
|
||||
- [ ] `src/net/winhttp.rs` (incremental, 4 sub-commits)
|
||||
- [ ] `src/net/mod.rs`
|
||||
- [ ] `src/main.rs` declares new modules
|
||||
- [ ] `cargo build --release` clean
|
||||
- [ ] `cargo clippy` clean
|
||||
- [ ] WinHTTP smoke test passes against `api.github.com`
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Phase ends with a runnable binary that behaves exactly like the previous version (no business logic changes).
|
||||
- `net::winhttp::Client` can successfully GET `https://api.github.com/repos/tiennm99/claude-code-usage-bubble/releases/latest` and parse JSON.
|
||||
- `log::info!("test")` from anywhere writes to `%TEMP%\claude-code-usage-bubble.log` when `--diagnose` is passed.
|
||||
- Binary size: not larger than current (we've removed `ureq`+`native-tls`, added smaller crates).
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| WinHTTP TLS handshake fails on older Windows | Low | Test on Win10/Win11; WinHTTP supports TLS 1.2+ since Win10 1607 |
|
||||
| `WinHttpCrackUrl` is awkward; URL parsing has edge cases | Medium | Use `url` crate (~50 KB) for parsing, then pass components to WinHTTP |
|
||||
| `embed-resource` doesn't match `winres`'s VERSIONINFO output exactly | Low | Verify with `mt /inspect output.exe` |
|
||||
| `log` + `simplelog` collide with another logger init | None | App owns the only init |
|
||||
| Chunked transfer / compression auto-decode disabled | Medium | Set `WINHTTP_OPTION_DECOMPRESSION` flag |
|
||||
|
||||
## Security considerations
|
||||
|
||||
- WinHTTP enforces certificate validation by default — don't disable.
|
||||
- `registry` module writes only to `HKEY_CURRENT_USER` (user-scoped). No admin escalation.
|
||||
- Log file path is `%TEMP%` — user-scoped. No secrets logged (verify in Phase 4 when adding token-handling logs).
|
||||
|
||||
## Next steps
|
||||
|
||||
→ Phase 2: replace `models.rs` + `localization/*` with `usage/types.rs` + `i18n/` directory.
|
||||
@@ -1,403 +0,0 @@
|
||||
---
|
||||
phase: 2
|
||||
status: pending
|
||||
estimated_hours: 6
|
||||
---
|
||||
|
||||
# Phase 2 — Types & i18n
|
||||
|
||||
## Context links
|
||||
|
||||
- Brainstorm: axes 3 (provider types) + 6 (localization)
|
||||
- Source files to be REPLACED: `src/models.rs`, `src/localization/*` (9 files)
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** High (Phase 4 providers depend on these types; bubble/panel/app render using them)
|
||||
- **Status:** pending
|
||||
- **Brief:** Define the new provider-result data types and replace the 9 hand-coded localization Rust files with one Rust loader + 9 TOML files. Refactor consumer imports (`bubble.rs`, `panel.rs`, `app.rs`, `settings.rs`) to the new shape. End of phase: source's `models.rs` and `localization/*` deleted.
|
||||
|
||||
## Key insights from brainstorm
|
||||
|
||||
- Source's `UsageData { session, weekly }` is one shape; `UsageWindows { primary, secondary }` (or a `HashMap<Window, Reset>`) is structurally different and works for both Anthropic (5h/7d) and Codex (which already uses "primary/secondary" terminology in its API response).
|
||||
- Source dispatches localization via `enum LanguageId` + matching const tables. Embedded TOML via `include_str!` + parsed-at-startup HashMap is structurally different and easier for translators.
|
||||
- `LocaleStrings` becomes a `serde::Deserialize` struct keyed by TOML section.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
- `usage::types::UsageWindows` carries `primary: Window`, `secondary: Window`, both `pub`.
|
||||
- `usage::types::Window { utilization: f64, resets_at: Option<SystemTime> }`.
|
||||
- `usage::types::ProviderId` enum: `Claude`, `ChatGpt` (note: renamed from "Codex" internally; menu label stays "Codex" via i18n).
|
||||
- `usage::types::ProviderSnapshot { id: ProviderId, windows: Result<UsageWindows, usage::Error> }` for app-level results.
|
||||
- `i18n::I18n::load(active_code: Option<&str>) -> Self` parses all embedded TOMLs at startup.
|
||||
- `i18n::I18n::strings() -> &LocaleStrings` returns the active language's strings.
|
||||
- `i18n::LocaleStrings` is a single struct with all UI strings as named fields (matches what bubble/panel/app need).
|
||||
- `i18n::detect::detect_system_locale() -> Option<String>` mirrors source's `GetUserPreferredUILanguages` chain.
|
||||
|
||||
### Non-functional
|
||||
|
||||
- Adding a new language = drop a new TOML file in `src/i18n/locales/` + add one line in `i18n/mod.rs` `include_str!` map.
|
||||
- TOML parsing happens once at startup; ~9 small files combined < 10 KB; parse time < 5 ms.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `src/usage/types.rs`
|
||||
|
||||
```rust
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
||||
pub enum ProviderId {
|
||||
Claude,
|
||||
ChatGpt,
|
||||
}
|
||||
|
||||
impl ProviderId {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self { Self::Claude => "claude", Self::ChatGpt => "chatgpt" }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Window {
|
||||
pub utilization: f64, // 0.0–100.0
|
||||
pub resets_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct UsageWindows {
|
||||
pub primary: Window, // 5h for Claude / primary_window for ChatGPT
|
||||
pub secondary: Window, // 7d for Claude / secondary_window for ChatGPT
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProviderSnapshot {
|
||||
pub id: ProviderId,
|
||||
pub windows: UsageWindows,
|
||||
}
|
||||
```
|
||||
|
||||
### `src/usage/mod.rs` (Phase 2 portion — provider trait stub goes here, real impls in Phase 4)
|
||||
|
||||
```rust
|
||||
pub mod types;
|
||||
pub use types::{ProviderId, Window, UsageWindows, ProviderSnapshot};
|
||||
|
||||
// Provider trait lives here; impls (anthropic.rs, chatgpt.rs) come in Phase 4.
|
||||
pub trait UsageProvider: Send {
|
||||
fn id(&self) -> ProviderId;
|
||||
fn poll(&mut self, http: &crate::net::Client) -> Result<UsageWindows, Error>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("authentication required")]
|
||||
AuthRequired,
|
||||
#[error("no credentials configured")]
|
||||
NoCredentials,
|
||||
#[error("token expired and refresh failed")]
|
||||
TokenExpired,
|
||||
#[error("network: {0}")]
|
||||
Network(#[from] crate::net::Error),
|
||||
#[error("response shape mismatch: {0}")]
|
||||
BadResponse(String),
|
||||
#[error("credential read: {0}")]
|
||||
Creds(#[from] crate::creds::Error), // forward-declared, real type in Phase 3
|
||||
}
|
||||
```
|
||||
|
||||
In Phase 2 we leave `creds::Error` and the impls as `todo!()` stubs that won't link until Phase 3/4.
|
||||
|
||||
### `src/i18n/mod.rs`
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod detect;
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct LocaleStrings {
|
||||
pub window_title: String,
|
||||
pub refresh: String,
|
||||
pub update_frequency: String,
|
||||
pub one_minute: String,
|
||||
pub five_minutes: String,
|
||||
pub fifteen_minutes: String,
|
||||
pub one_hour: String,
|
||||
pub models: String,
|
||||
pub claude_label: String, // was claude_code_model
|
||||
pub chatgpt_label: String, // was codex_model
|
||||
pub settings: String,
|
||||
pub start_with_windows: String,
|
||||
pub reset_position: String,
|
||||
pub language: String,
|
||||
pub system_default: String,
|
||||
pub check_for_updates: String,
|
||||
pub checking_for_updates: String,
|
||||
pub up_to_date: String,
|
||||
pub update_failed: String,
|
||||
pub applying_update: String,
|
||||
pub update_available: String,
|
||||
pub update_via_winget: String, // was update_via_winget_label
|
||||
pub exit: String,
|
||||
pub show_widget: String,
|
||||
pub session_window: String,
|
||||
pub weekly_window: String,
|
||||
pub now: String,
|
||||
pub day_suffix: String,
|
||||
pub hour_suffix: String,
|
||||
pub minute_suffix: String,
|
||||
pub second_suffix: String,
|
||||
pub token_expired_title: String,
|
||||
pub token_expired_body: String,
|
||||
pub chatgpt_token_expired_title: String,
|
||||
pub chatgpt_token_expired_body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LocaleFile {
|
||||
code: String,
|
||||
native_name: String,
|
||||
#[serde(flatten)]
|
||||
strings: LocaleStrings,
|
||||
}
|
||||
|
||||
pub struct I18n {
|
||||
available: HashMap<String, (String, LocaleStrings)>, // code → (native_name, strings)
|
||||
active: String,
|
||||
}
|
||||
|
||||
impl I18n {
|
||||
pub fn load(active_code: Option<&str>) -> Self {
|
||||
let raw = [
|
||||
("en", include_str!("locales/en.toml")),
|
||||
("nl", include_str!("locales/nl.toml")),
|
||||
("es", include_str!("locales/es.toml")),
|
||||
("fr", include_str!("locales/fr.toml")),
|
||||
("de", include_str!("locales/de.toml")),
|
||||
("ja", include_str!("locales/ja.toml")),
|
||||
("ko", include_str!("locales/ko.toml")),
|
||||
("zh-TW", include_str!("locales/zh-TW.toml")),
|
||||
];
|
||||
let mut available = HashMap::new();
|
||||
for (code, body) in raw {
|
||||
if let Ok(file) = toml::from_str::<LocaleFile>(body) {
|
||||
available.insert(code.to_string(), (file.native_name, file.strings));
|
||||
}
|
||||
}
|
||||
let active = match active_code {
|
||||
Some(c) if available.contains_key(c) => c.to_string(),
|
||||
_ => detect::detect_system_locale()
|
||||
.and_then(|s| Self::normalize(&s, &available))
|
||||
.unwrap_or_else(|| "en".to_string()),
|
||||
};
|
||||
Self { available, active }
|
||||
}
|
||||
|
||||
pub fn strings(&self) -> &LocaleStrings {
|
||||
&self.available[&self.active].1
|
||||
}
|
||||
|
||||
pub fn active_code(&self) -> &str { &self.active }
|
||||
|
||||
pub fn available(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.available.iter().map(|(code, (name, _))| (code.as_str(), name.as_str()))
|
||||
}
|
||||
|
||||
fn normalize(code: &str, available: &HashMap<String, (String, LocaleStrings)>) -> Option<String> {
|
||||
// "en-US" → "en", "zh-Hant-TW" → "zh-TW", etc.
|
||||
// Exact match first, then prefix.
|
||||
let lower = code.to_ascii_lowercase().replace('_', "-");
|
||||
if available.contains_key(&lower) { return Some(lower); }
|
||||
let prefix = lower.split('-').next().unwrap_or("");
|
||||
if prefix == "zh" && (lower.contains("tw") || lower.contains("hant")) {
|
||||
return Some("zh-TW".into());
|
||||
}
|
||||
available.keys()
|
||||
.find(|k| k.split('-').next() == Some(prefix))
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `src/i18n/detect.rs`
|
||||
|
||||
Mirrors source's `preferred_ui_languages` + `default_ui_locale` + `default_locale_name` chain via Win32 globalization APIs, but in one function:
|
||||
|
||||
```rust
|
||||
pub fn detect_system_locale() -> Option<String> {
|
||||
preferred().or_else(default_ui).or_else(default_user)
|
||||
}
|
||||
fn preferred() -> Option<String> { /* GetUserPreferredUILanguages */ }
|
||||
fn default_ui() -> Option<String> { /* GetUserDefaultUILanguage + LCIDToLocaleName */ }
|
||||
fn default_user() -> Option<String> { /* GetUserDefaultLocaleName */ }
|
||||
```
|
||||
|
||||
### `src/i18n/locales/en.toml`
|
||||
|
||||
```toml
|
||||
code = "en"
|
||||
native_name = "English"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Refresh"
|
||||
update_frequency = "Update frequency"
|
||||
one_minute = "1 minute"
|
||||
five_minutes = "5 minutes"
|
||||
fifteen_minutes = "15 minutes"
|
||||
one_hour = "1 hour"
|
||||
models = "Models"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Settings"
|
||||
start_with_windows = "Start with Windows"
|
||||
reset_position = "Reset position"
|
||||
language = "Language"
|
||||
system_default = "System default"
|
||||
check_for_updates = "Check for updates"
|
||||
checking_for_updates = "Checking for updates…"
|
||||
up_to_date = "Up to date"
|
||||
update_failed = "Update failed"
|
||||
applying_update = "Applying update…"
|
||||
update_available = "Update available"
|
||||
update_via_winget = "via WinGet"
|
||||
exit = "Exit"
|
||||
show_widget = "Show widget"
|
||||
session_window = "5h"
|
||||
weekly_window = "7d"
|
||||
now = "now"
|
||||
day_suffix = "d"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Claude Code session expired"
|
||||
token_expired_body = "Sign in again to keep usage reporting."
|
||||
chatgpt_token_expired_title = "Codex session expired"
|
||||
chatgpt_token_expired_body = "Sign in again to keep usage reporting."
|
||||
```
|
||||
|
||||
The 8 other locale files mirror this shape with translated strings. **Important:** copy the translations from `src/localization/*.rs` content (the strings themselves are utilitarian/factual translations and not copyright-eligible the way code is — but for safety, re-translate the most unique strings using your own phrasing).
|
||||
|
||||
### Consumer refactors (in this phase)
|
||||
|
||||
**`src/app.rs`:**
|
||||
- `use crate::localization::{LanguageId, Strings, resolve_language}` → `use crate::i18n::{I18n, LocaleStrings}`
|
||||
- `s.language.strings()` → `s.i18n.strings()`
|
||||
- `LanguageId::ALL.iter()` → `s.i18n.available()`
|
||||
- All field renames: `claude_code_model` → `claude_label`, `codex_model` → `chatgpt_label`, etc.
|
||||
|
||||
**`src/bubble.rs`:** no localization access; only depends on bubble-specific data. Unaffected.
|
||||
|
||||
**`src/panel.rs`:**
|
||||
- `data.strings.session_window` works unchanged (field name preserved).
|
||||
- `data.strings.claude_code_model` → `data.strings.claude_label`.
|
||||
|
||||
**`src/settings.rs`:** unchanged (it stores `language: Option<String>` already, which holds a locale code).
|
||||
|
||||
**`src/models.rs`:** delete. Replace `crate::models::{AppUsageData, UsageData, UsageSection}` consumers:
|
||||
- `AppUsageData` → `Vec<ProviderSnapshot>`
|
||||
- `UsageData` → `UsageWindows`
|
||||
- `UsageSection` → `Window`
|
||||
|
||||
Migration map for `app.rs`:
|
||||
- `s.data.claude_code.as_ref()` → `s.snapshots.iter().find(|sn| sn.id == ProviderId::Claude)`
|
||||
- `c.session.percentage` → `sn.windows.primary.utilization`
|
||||
- `c.weekly.percentage` → `sn.windows.secondary.utilization`
|
||||
|
||||
## Related code files
|
||||
|
||||
**To create:**
|
||||
- `src/usage/mod.rs`
|
||||
- `src/usage/types.rs`
|
||||
- `src/i18n/mod.rs`
|
||||
- `src/i18n/detect.rs`
|
||||
- `src/i18n/locales/en.toml`
|
||||
- `src/i18n/locales/nl.toml`
|
||||
- `src/i18n/locales/es.toml`
|
||||
- `src/i18n/locales/fr.toml`
|
||||
- `src/i18n/locales/de.toml`
|
||||
- `src/i18n/locales/ja.toml`
|
||||
- `src/i18n/locales/ko.toml`
|
||||
- `src/i18n/locales/zh-TW.toml`
|
||||
|
||||
**To modify:**
|
||||
- `Cargo.toml` — add `toml = "0.8"` (with default features)
|
||||
- `src/main.rs` — declare `mod usage; mod i18n;`; remove `mod models; mod localization;`
|
||||
- `src/app.rs` — migrate all `crate::models::*` and `crate::localization::*` imports
|
||||
- `src/panel.rs` — field renames
|
||||
- `src/bubble.rs` — only if it references `LanguageId` (it shouldn't)
|
||||
|
||||
**To delete:**
|
||||
- `src/models.rs`
|
||||
- `src/localization/mod.rs`
|
||||
- `src/localization/english.rs`
|
||||
- `src/localization/dutch.rs`
|
||||
- `src/localization/spanish.rs`
|
||||
- `src/localization/french.rs`
|
||||
- `src/localization/german.rs`
|
||||
- `src/localization/japanese.rs`
|
||||
- `src/localization/korean.rs`
|
||||
- `src/localization/traditional_chinese.rs`
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Add `toml = "0.8"`** to `Cargo.toml`.
|
||||
2. **Create `src/usage/types.rs`** (struct definitions only — no impls yet).
|
||||
3. **Create `src/usage/mod.rs`** with trait `UsageProvider` and `Error` enum. Leave it without any impls.
|
||||
4. **Create `src/i18n/locales/en.toml`** first; verify TOML structure parses.
|
||||
5. **Add `src/i18n/mod.rs` + `src/i18n/detect.rs`** with `I18n::load` reading only `en.toml`.
|
||||
6. **Wire `mod i18n; mod usage;` into `main.rs`** and call `I18n::load(None)` from `app::run` (storing on `AppState`). Build should still compile (no usages downstream yet).
|
||||
7. **Migrate `app.rs`** field-by-field from `Strings` to `LocaleStrings`. Run `cargo check` after each subsystem (menu, balloon, panel-data, tray-tooltip).
|
||||
8. **Translate the other 8 locale TOMLs.** Use your own phrasings for the longer strings (e.g. `token_expired_body`) rather than direct copies of upstream's translations.
|
||||
9. **Add the other 8 `include_str!` entries** to `i18n/mod.rs`.
|
||||
10. **Migrate `panel.rs`** field renames (small).
|
||||
11. **Migrate `app.rs` data model** from `AppUsageData` to `Vec<ProviderSnapshot>`. This is the biggest single edit. Update `apply_data`, `apply_usage_update`, `build_panel_data_from`, `refresh_tray_icons`, `refresh_text_fields`.
|
||||
12. **Delete `src/models.rs` + `src/localization/*`** once nothing references them.
|
||||
13. **`cargo build --release`** — clean.
|
||||
|
||||
## Todo checklist
|
||||
|
||||
- [ ] `usage/types.rs` written
|
||||
- [ ] `usage/mod.rs` written (trait + Error stubs)
|
||||
- [ ] `i18n/mod.rs` + `detect.rs` written
|
||||
- [ ] 9 TOML locale files written (translations are your own paraphrasings)
|
||||
- [ ] `Cargo.toml` adds `toml` dep
|
||||
- [ ] `main.rs` declares new modules + removes old ones
|
||||
- [ ] `app.rs` migrated to `LocaleStrings` + `Vec<ProviderSnapshot>`
|
||||
- [ ] `panel.rs` field renames done
|
||||
- [ ] `bubble.rs` confirmed unaffected
|
||||
- [ ] Old `src/models.rs` + `src/localization/*` deleted
|
||||
- [ ] `cargo build --release` clean
|
||||
- [ ] App still runs (placeholder data since providers aren't wired yet)
|
||||
|
||||
## Success criteria
|
||||
|
||||
- TOML files parse cleanly at startup.
|
||||
- App shows correct language strings based on Windows display language.
|
||||
- No file in `src/` shares a name with upstream's `models.rs` or `localization/*`.
|
||||
- Right-click → Language submenu lists 9 options (system default + 8 languages) and switching them updates UI immediately.
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| TOML serde derive misalignment (typos in field names) | High | Use `#[serde(deny_unknown_fields)]` to catch typos at load time |
|
||||
| Translations differ enough from upstream that meaning drifts | Medium | Compare meaning side-by-side before committing; ask a native speaker for the long strings if you can |
|
||||
| Bubble/panel field references break in subtle places | Medium | `cargo check` after each consumer edit |
|
||||
| App startup slows due to TOML parsing | Negligible | TOML files combined < 10 KB |
|
||||
|
||||
## Security considerations
|
||||
|
||||
- TOML strings are static, no eval. Parse failures fall back to English silently. No injection risk.
|
||||
- No PII in locale files.
|
||||
|
||||
## Next steps
|
||||
|
||||
→ Phase 3: replace credential reading with `creds/` directory.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Translation copyright.** The upstream localization files contain ~30 short UI strings per language. These are utility translations of standard UI vocabulary and are unlikely to be copyrightable individually, but for full clean-room status, re-paraphrase the longest strings (`token_expired_body` and `chatgpt_token_expired_body`). Recommended: write your own phrasing for those two.
|
||||
@@ -1,253 +0,0 @@
|
||||
---
|
||||
phase: 3
|
||||
status: pending
|
||||
estimated_hours: 3
|
||||
---
|
||||
|
||||
# Phase 3 — `creds/` module (credential discovery)
|
||||
|
||||
## Context links
|
||||
|
||||
- Brainstorm: axis 4 (credential discovery) + 5 (refresh — only the discovery part lives here; orchestrator lives in Phase 4)
|
||||
- Source file to be REPLACED: parts of `src/poller.rs` (credential reading/discovery), `src/native_interop.rs` (WSL command execution)
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Medium — Phase 4 providers depend on this.
|
||||
- **Status:** pending
|
||||
- **Brief:** Introduce a `trait CredentialSource` with three impls (local Claude, WSL Claude, local Codex). Replace the source's `enum CredentialSource { Windows, Wsl }` + serial fallback with a registry-pattern + iterator.
|
||||
|
||||
## Key insights from brainstorm
|
||||
|
||||
- Trait-based discovery is structurally different from source's enum + match dispatch.
|
||||
- `Vec<Box<dyn CredentialSource>>` ordered by priority lets future additions (e.g. an environment-variable-based source) drop in with no changes to the locator.
|
||||
- Change-detection signatures (used by app's "watch for re-auth" loop) become a trait method.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
- `creds::Token { access_token: String, expires_at_unix_ms: Option<i64>, account_id: Option<String> }`.
|
||||
- `trait creds::CredentialSource: Send + Sync`:
|
||||
- `fn id(&self) -> &str` — stable identifier ("local-claude", "wsl:Ubuntu-22", "codex").
|
||||
- `fn read(&self) -> Result<Token, Error>`.
|
||||
- `fn signature(&self) -> Option<String>` — opaque hash/key for change detection.
|
||||
- `fn refresh_hint(&self) -> RefreshHint` — what command to spawn for refresh.
|
||||
- `creds::CredentialLocator::default_claude()` builds a locator with local Windows path first, then all installed WSL distros.
|
||||
- `creds::CredentialLocator::default_codex()` builds a locator with the local Codex path.
|
||||
- `locator.first_available() -> Option<&dyn CredentialSource>`.
|
||||
- `locator.signatures() -> Vec<String>`.
|
||||
|
||||
### Non-functional
|
||||
|
||||
- WSL probe (which spawns `wsl.exe -l -q`) must complete in ≤ 5s or be timed out.
|
||||
- WSL token-read must complete in ≤ 5s or be timed out.
|
||||
- No blocking work in `signature()` (it's called frequently from the poll loop) — only stat/file-size, not file-read.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `src/creds/mod.rs`
|
||||
|
||||
```rust
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod local_fs;
|
||||
pub mod wsl_bridge;
|
||||
pub mod codex_auth;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Token {
|
||||
pub access_token: String,
|
||||
pub expires_at_unix_ms: Option<i64>,
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RefreshHint {
|
||||
LocalCliCommand { exe: &'static str },
|
||||
WslCliCommand { distro: String },
|
||||
Codex,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("credential file not found at {path}")]
|
||||
NotFound { path: String },
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("invalid JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("missing field in credential JSON: {0}")]
|
||||
MissingField(&'static str),
|
||||
#[error("WSL command failed: {0}")]
|
||||
WslCommand(String),
|
||||
#[error("timeout waiting for WSL command")]
|
||||
WslTimeout,
|
||||
}
|
||||
|
||||
pub trait CredentialSource: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn read(&self) -> Result<Token, Error>;
|
||||
fn signature(&self) -> Option<String>;
|
||||
fn refresh_hint(&self) -> RefreshHint;
|
||||
}
|
||||
|
||||
pub struct CredentialLocator {
|
||||
sources: Vec<Box<dyn CredentialSource>>,
|
||||
}
|
||||
|
||||
impl CredentialLocator {
|
||||
pub fn new(sources: Vec<Box<dyn CredentialSource>>) -> Self {
|
||||
Self { sources }
|
||||
}
|
||||
|
||||
pub fn default_claude() -> Self {
|
||||
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
|
||||
if let Some(local) = local_fs::LocalClaudeCreds::detect() {
|
||||
sources.push(Box::new(local));
|
||||
}
|
||||
for distro in wsl_bridge::list_distros() {
|
||||
sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro)));
|
||||
}
|
||||
Self { sources }
|
||||
}
|
||||
|
||||
pub fn default_codex() -> Self {
|
||||
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
|
||||
if let Some(codex) = codex_auth::LocalCodexCreds::detect() {
|
||||
sources.push(Box::new(codex));
|
||||
}
|
||||
Self { sources }
|
||||
}
|
||||
|
||||
pub fn first_available(&self) -> Option<&dyn CredentialSource> {
|
||||
self.sources.iter().find(|s| s.signature().is_some()).map(Box::as_ref)
|
||||
}
|
||||
|
||||
pub fn signatures(&self) -> Vec<String> {
|
||||
self.sources.iter().filter_map(|s| s.signature()).collect()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &dyn CredentialSource> {
|
||||
self.sources.iter().map(Box::as_ref)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `src/creds/local_fs.rs`
|
||||
|
||||
```rust
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct LocalClaudeCreds {
|
||||
path: PathBuf,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl LocalClaudeCreds {
|
||||
pub fn detect() -> Option<Self> {
|
||||
let home = dirs::home_dir()?;
|
||||
let path = home.join(".claude").join(".credentials.json");
|
||||
Some(Self { id: format!("local:{}", path.display()), path })
|
||||
}
|
||||
}
|
||||
|
||||
impl super::CredentialSource for LocalClaudeCreds {
|
||||
fn id(&self) -> &str { &self.id }
|
||||
fn read(&self) -> Result<super::Token, super::Error> {
|
||||
let content = std::fs::read_to_string(&self.path)?;
|
||||
parse_claude_json(&content)
|
||||
}
|
||||
fn signature(&self) -> Option<String> {
|
||||
let meta = std::fs::metadata(&self.path).ok()?;
|
||||
let modified = meta.modified().ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs()).unwrap_or(0);
|
||||
Some(format!("{}|{}|{}", self.id, meta.len(), modified))
|
||||
}
|
||||
fn refresh_hint(&self) -> super::RefreshHint {
|
||||
super::RefreshHint::LocalCliCommand { exe: "claude" }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_claude_json(content: &str) -> Result<super::Token, super::Error> {
|
||||
let json: serde_json::Value = serde_json::from_str(content)?;
|
||||
let oauth = json.get("claudeAiOauth")
|
||||
.ok_or(super::Error::MissingField("claudeAiOauth"))?;
|
||||
let access_token = oauth.get("accessToken")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(super::Error::MissingField("accessToken"))?
|
||||
.to_string();
|
||||
let expires_at_unix_ms = oauth.get("expiresAt").and_then(|v| v.as_i64());
|
||||
Ok(super::Token { access_token, expires_at_unix_ms, account_id: None })
|
||||
}
|
||||
```
|
||||
|
||||
### `src/creds/wsl_bridge.rs`
|
||||
|
||||
Spawns `wsl.exe -l -q` to enumerate distros, then per-distro spawns `wsl.exe -d <distro> -- sh -lc 'cat ~/.claude/.credentials.json'`. Includes UTF-16LE-aware text decoder for `wsl.exe -l -q` output. Uses `CREATE_NO_WINDOW` flag. 5s timeout per command via a `run_with_timeout` helper.
|
||||
|
||||
Key public types:
|
||||
- `pub fn list_distros() -> Vec<String>` — empty Vec if WSL not installed.
|
||||
- `pub struct WslClaudeCreds { distro: String, id: String }` implementing `CredentialSource`.
|
||||
|
||||
### `src/creds/codex_auth.rs`
|
||||
|
||||
Reads `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Token includes `account_id` from `tokens.account_id`. Mirrors `local_fs.rs` pattern.
|
||||
|
||||
## Related code files
|
||||
|
||||
**To create:**
|
||||
- `src/creds/mod.rs`
|
||||
- `src/creds/local_fs.rs`
|
||||
- `src/creds/wsl_bridge.rs`
|
||||
- `src/creds/codex_auth.rs`
|
||||
|
||||
**To modify:**
|
||||
- `src/main.rs` — `mod creds;`
|
||||
|
||||
**To delete:** nothing (Phase 4 deletes `src/poller.rs` once `usage::*` providers go live).
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. Create `src/creds/mod.rs` with trait + `Token` + `Error` + `RefreshHint` + `CredentialLocator`.
|
||||
2. Create `local_fs.rs` with `LocalClaudeCreds`.
|
||||
3. Create `wsl_bridge.rs` with distro enumeration + per-distro creds source. Note `decode_wsl_text` handles the UTF-16LE encoding quirk on `wsl.exe -l -q`.
|
||||
4. Create `codex_auth.rs` with `LocalCodexCreds`.
|
||||
5. Wire `mod creds;` into `main.rs`.
|
||||
6. `cargo build --release` clean.
|
||||
|
||||
## Todo checklist
|
||||
|
||||
- [ ] `creds/mod.rs`
|
||||
- [ ] `creds/local_fs.rs`
|
||||
- [ ] `creds/wsl_bridge.rs`
|
||||
- [ ] `creds/codex_auth.rs`
|
||||
- [ ] `main.rs` declares module
|
||||
- [ ] `cargo build --release` clean
|
||||
- [ ] Manual test (Windows): `CredentialLocator::default_claude().first_available()` finds a real credential file
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Trait dispatch works; locator returns the right source based on priority.
|
||||
- WSL probe doesn't hang the process when no WSL is installed.
|
||||
- `signature()` is fast (<1 ms for local, <100 ms for WSL).
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| WSL probe blocks for full 5s when WSL is uninstalled | Low | Test on a WSL-free VM; verify timeout works |
|
||||
| UTF-16LE detection heuristic produces false positives | Low | Source has same heuristic and ships in production |
|
||||
| `wsl.exe` not on PATH | Negligible on Win10+ | Return empty list silently |
|
||||
| `dirs::home_dir()` returns None | Negligible on Windows | Return `None` from `detect()` and let locator skip |
|
||||
|
||||
## Security considerations
|
||||
|
||||
- Tokens stored as `String` in memory; not logged.
|
||||
- WSL `sh -lc` arg is a constant — no user-controlled input → no shell injection.
|
||||
- Don't `log::debug!` the token; log only `token len=N`.
|
||||
|
||||
## Next steps
|
||||
|
||||
→ Phase 4: providers + refresh orchestrator that USES this locator.
|
||||
@@ -1,346 +0,0 @@
|
||||
---
|
||||
phase: 4
|
||||
status: pending
|
||||
estimated_hours: 8
|
||||
---
|
||||
|
||||
# Phase 4 — Providers & refresh orchestrator
|
||||
|
||||
## Context links
|
||||
|
||||
- Brainstorm: axes 3 (provider trait) + 5 (refresh)
|
||||
- Source file to be REPLACED entirely: `src/poller.rs` (~1100 LOC)
|
||||
- Phase deps: 1 (`net::winhttp`), 2 (`usage::types`), 3 (`creds`)
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Critical — replaces the largest single source file.
|
||||
- **Status:** pending
|
||||
- **Brief:** Implement `ClaudeProvider` + `ChatGptProvider` against the trait from Phase 2, plus the `RefreshOrchestrator` that spawns local CLIs to refresh expired tokens. Replace `crate::poller::*` calls in `app.rs` with `usage::registry::poll_all`. End of phase: `src/poller.rs` is gone.
|
||||
|
||||
## Key insights from brainstorm
|
||||
|
||||
- The two providers share rate-limit-header parsing logic — extract to `usage::headers`.
|
||||
- Anthropic's primary endpoint returns the dedicated `oauth/usage` JSON; fallback is the Messages API with rate-limit headers. Both code paths go in `ClaudeProvider`.
|
||||
- ChatGPT's `wham/usage` endpoint is shaped differently (`rate_limit.primary_window.used_percent`) — separate parser.
|
||||
- `RefreshOrchestrator::refresh(source)` uses `RefreshHint` to know which CLI to spawn. 8-second timeout, not 30 — UX wins.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
- `ClaudeProvider::new(locator: CredentialLocator) -> Self`.
|
||||
- `ClaudeProvider::poll(http) -> Result<UsageWindows, usage::Error>`:
|
||||
- Try `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer …` + `anthropic-beta: oauth-2025-04-20`.
|
||||
- If primary returns 401/403 → `usage::Error::AuthRequired`.
|
||||
- If primary returns 2xx but data is incomplete → fall back to Messages API.
|
||||
- Messages-API fallback: `POST https://api.anthropic.com/v1/messages` with minimal payload; parse `anthropic-ratelimit-unified-{5h,7d}-utilization` headers + reset timestamps.
|
||||
- `ChatGptProvider::new(locator: CredentialLocator) -> Self`.
|
||||
- `ChatGptProvider::poll(http) -> Result<UsageWindows, usage::Error>`:
|
||||
- `GET https://chatgpt.com/backend-api/wham/usage` with `Authorization: Bearer …` + `User-Agent: codex-cli` + optional `ChatGPT-Account-Id`.
|
||||
- Parse `rate_limit.{primary_window,secondary_window}.used_percent` + `.reset_at` (Unix seconds).
|
||||
- `RefreshOrchestrator::new(timeout: Duration) -> Self`.
|
||||
- `RefreshOrchestrator::refresh(source: &dyn CredentialSource) -> RefreshOutcome` — spawns appropriate CLI, waits up to timeout, returns outcome.
|
||||
- `usage::registry::Registry`:
|
||||
- `Registry::new()` builds with default providers.
|
||||
- `registry.enabled_providers(settings) -> Vec<ProviderId>`.
|
||||
- `registry.poll_one(id, http) -> Result<UsageWindows, usage::Error>`.
|
||||
|
||||
### Non-functional
|
||||
|
||||
- Total poll time (both providers) must stay under 60s even with refresh attempts.
|
||||
- Refresh timeout is 8s (down from source's 30s) — verify UX feels snappy.
|
||||
- HTTP retries are NOT done at this layer (app retains the retry/backoff loop).
|
||||
|
||||
## Architecture
|
||||
|
||||
### `src/usage/mod.rs` (expanded)
|
||||
|
||||
```rust
|
||||
pub mod types;
|
||||
pub mod headers;
|
||||
pub mod anthropic;
|
||||
pub mod chatgpt;
|
||||
pub mod refresh;
|
||||
pub mod registry;
|
||||
|
||||
pub use types::{ProviderId, Window, UsageWindows, ProviderSnapshot};
|
||||
|
||||
pub trait UsageProvider: Send {
|
||||
fn id(&self) -> ProviderId;
|
||||
fn poll(&mut self, http: &crate::net::winhttp::Client) -> Result<UsageWindows, Error>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("authentication required")]
|
||||
AuthRequired,
|
||||
#[error("no credentials configured")]
|
||||
NoCredentials,
|
||||
#[error("token expired after refresh")]
|
||||
TokenExpired,
|
||||
#[error("network: {0}")]
|
||||
Network(#[from] crate::net::Error),
|
||||
#[error("response shape mismatch: {0}")]
|
||||
BadResponse(String),
|
||||
#[error("credential: {0}")]
|
||||
Creds(#[from] crate::creds::Error),
|
||||
}
|
||||
```
|
||||
|
||||
### `src/usage/headers.rs`
|
||||
|
||||
```rust
|
||||
use super::{Window, UsageWindows};
|
||||
use crate::net::winhttp::Response;
|
||||
|
||||
pub fn parse_anthropic_rate_limit(resp: &Response) -> UsageWindows {
|
||||
let primary = Window {
|
||||
utilization: header_f64(resp, "anthropic-ratelimit-unified-5h-utilization") * 100.0,
|
||||
resets_at: unix_to_system_time(header_i64(resp, "anthropic-ratelimit-unified-5h-reset")),
|
||||
};
|
||||
let secondary = Window {
|
||||
utilization: header_f64(resp, "anthropic-ratelimit-unified-7d-utilization") * 100.0,
|
||||
resets_at: unix_to_system_time(header_i64(resp, "anthropic-ratelimit-unified-7d-reset")),
|
||||
};
|
||||
UsageWindows { primary, secondary }
|
||||
}
|
||||
|
||||
fn header_f64(resp: &Response, name: &str) -> f64 {
|
||||
resp.header(name).and_then(|s| s.parse().ok()).unwrap_or(0.0)
|
||||
}
|
||||
fn header_i64(resp: &Response, name: &str) -> Option<i64> {
|
||||
resp.header(name).and_then(|s| s.parse().ok())
|
||||
}
|
||||
fn unix_to_system_time(secs: Option<i64>) -> Option<std::time::SystemTime> {
|
||||
let s = secs?;
|
||||
if s < 0 { return None; }
|
||||
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(s as u64))
|
||||
}
|
||||
```
|
||||
|
||||
### `src/usage/anthropic.rs`
|
||||
|
||||
```rust
|
||||
use crate::creds::CredentialLocator;
|
||||
use crate::net::winhttp::Client;
|
||||
use super::{UsageProvider, UsageWindows, Window, Error, ProviderId};
|
||||
use serde::Deserialize;
|
||||
|
||||
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
||||
const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
|
||||
pub struct ClaudeProvider {
|
||||
locator: CredentialLocator,
|
||||
}
|
||||
|
||||
impl ClaudeProvider {
|
||||
pub fn new(locator: CredentialLocator) -> Self { Self { locator } }
|
||||
}
|
||||
|
||||
impl UsageProvider for ClaudeProvider {
|
||||
fn id(&self) -> ProviderId { ProviderId::Claude }
|
||||
|
||||
fn poll(&mut self, http: &Client) -> Result<UsageWindows, Error> {
|
||||
let source = self.locator.first_available().ok_or(Error::NoCredentials)?;
|
||||
let token = source.read()?;
|
||||
// … (try usage endpoint; fall back to messages; parse rate-limit headers)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OauthUsageResponse {
|
||||
five_hour: Option<Bucket>,
|
||||
seven_day: Option<Bucket>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Bucket {
|
||||
utilization: f64,
|
||||
resets_at: Option<String>, // ISO 8601
|
||||
}
|
||||
|
||||
fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>, Error> { /* … */ }
|
||||
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> { /* … */ }
|
||||
fn parse_iso8601(s: &str) -> Option<std::time::SystemTime> { /* … minimal date parser, same as source's */ }
|
||||
```
|
||||
|
||||
### `src/usage/chatgpt.rs`
|
||||
|
||||
Mirrors anthropic shape; parses Codex JSON.
|
||||
|
||||
### `src/usage/refresh.rs`
|
||||
|
||||
```rust
|
||||
use crate::creds::{CredentialSource, RefreshHint};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum RefreshOutcome { Refreshed, StillExpired, CliMissing, Timeout }
|
||||
|
||||
pub struct RefreshOrchestrator { timeout: Duration }
|
||||
|
||||
impl RefreshOrchestrator {
|
||||
pub fn new(timeout: Duration) -> Self { Self { timeout } }
|
||||
|
||||
pub fn refresh(&self, source: &dyn CredentialSource) -> RefreshOutcome {
|
||||
let signature_before = source.signature();
|
||||
let hint = source.refresh_hint();
|
||||
let spawn_ok = match hint {
|
||||
RefreshHint::LocalCliCommand { exe } => self.spawn_local(exe),
|
||||
RefreshHint::WslCliCommand { distro } => self.spawn_wsl(&distro),
|
||||
RefreshHint::Codex => self.spawn_codex(),
|
||||
};
|
||||
if !spawn_ok { return RefreshOutcome::CliMissing; }
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if start.elapsed() > self.timeout { return RefreshOutcome::Timeout; }
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
if source.signature() != signature_before {
|
||||
return RefreshOutcome::Refreshed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_local(&self, exe: &str) -> bool { /* spawn `<exe>.cmd -p .` or `<exe> -p .` */ }
|
||||
fn spawn_wsl(&self, distro: &str) -> bool { /* wsl.exe -d <d> -- bash -lic 'claude -p .' */ }
|
||||
fn spawn_codex(&self) -> bool { /* spawn codex exec . */ }
|
||||
}
|
||||
```
|
||||
|
||||
### `src/usage/registry.rs`
|
||||
|
||||
```rust
|
||||
use super::{UsageProvider, ProviderId, UsageWindows, Error};
|
||||
use crate::net::winhttp::Client;
|
||||
use crate::settings::Settings;
|
||||
|
||||
pub struct Registry {
|
||||
providers: Vec<Box<dyn UsageProvider>>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn with_defaults() -> Self {
|
||||
let claude_locator = crate::creds::CredentialLocator::default_claude();
|
||||
let codex_locator = crate::creds::CredentialLocator::default_codex();
|
||||
Self {
|
||||
providers: vec![
|
||||
Box::new(super::anthropic::ClaudeProvider::new(claude_locator)),
|
||||
Box::new(super::chatgpt::ChatGptProvider::new(codex_locator)),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll_enabled(&mut self, http: &Client, settings: &Settings) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
|
||||
let mut results = Vec::new();
|
||||
for p in self.providers.iter_mut() {
|
||||
let enabled = match p.id() {
|
||||
ProviderId::Claude => settings.show_claude_code,
|
||||
ProviderId::ChatGpt => settings.show_codex,
|
||||
};
|
||||
if !enabled { continue; }
|
||||
results.push((p.id(), p.poll(http)));
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `app.rs` migration
|
||||
|
||||
Replace `poller::poll(show_claude, show_codex)` with `registry.poll_enabled(&http_client, &settings)`. App now holds:
|
||||
- `http_client: net::winhttp::Client`
|
||||
- `registry: usage::registry::Registry`
|
||||
- `refresh: usage::refresh::RefreshOrchestrator`
|
||||
|
||||
Polling thread flow:
|
||||
1. `let results = registry.poll_enabled(http, settings);`
|
||||
2. For each `(id, Err(AuthRequired))`, call `refresh.refresh(source)` — needs locator access; expose via provider trait `fn try_refresh(orchestrator: &Orchestrator) -> RefreshOutcome` OR pass locator to app.
|
||||
3. Post `WM_APP_USAGE_UPDATED`.
|
||||
|
||||
(Detail: simplest is for the provider to expose its locator: `fn locator(&self) -> &CredentialLocator;` but that's leaky. Alternative: provider has an internal `fn refresh(&self, orch) -> RefreshOutcome` that owns the locator-access. Implement option B.)
|
||||
|
||||
## Related code files
|
||||
|
||||
**To create:**
|
||||
- `src/usage/headers.rs`
|
||||
- `src/usage/anthropic.rs`
|
||||
- `src/usage/chatgpt.rs`
|
||||
- `src/usage/refresh.rs`
|
||||
- `src/usage/registry.rs`
|
||||
|
||||
**To modify:**
|
||||
- `src/usage/mod.rs` — expand with new module declarations
|
||||
- `src/app.rs` — migrate poll-thread logic from `poller::*` to `registry::*` + `refresh::*`; remove `crate::poller` import
|
||||
- `src/main.rs` — remove `mod poller;`
|
||||
|
||||
**To delete:**
|
||||
- `src/poller.rs` (1099 LOC removed)
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Implement `headers.rs`** — pure parsing, test in isolation.
|
||||
2. **Implement `anthropic.rs`** in two parts:
|
||||
- 2a. `try_usage_endpoint` — full JSON parse path.
|
||||
- 2b. `try_messages_endpoint` — POST with model fallback chain + header parsing.
|
||||
3. **Implement `chatgpt.rs`** — single endpoint, JSON parse.
|
||||
4. **Implement `refresh.rs`** — orchestrator with 3 spawn paths.
|
||||
5. **Implement `registry.rs`** — registry + `poll_enabled`.
|
||||
6. **Migrate `app.rs::handle_poll_result`** to consume `Vec<(ProviderId, Result<UsageWindows, Error>)>` instead of `Result<AppUsageData, PollError>`.
|
||||
7. **Migrate `app.rs::apply_data`** to update `Vec<ProviderSnapshot>` per provider.
|
||||
8. **Add `fn try_refresh_for_provider(&mut self, id: ProviderId, orch: &Orchestrator) -> RefreshOutcome`** to `Registry`, so app can request refresh without touching internals.
|
||||
9. **Wire `RefreshOrchestrator::new(Duration::from_secs(8))`** into app state.
|
||||
10. **Delete `src/poller.rs`** + `mod poller;` line.
|
||||
11. **`cargo build --release`** clean.
|
||||
12. **End-to-end test on Windows**:
|
||||
- Run app, sign-in via existing Claude CLI session, see polling work.
|
||||
- Force token expiry (delete credentials file), see refresh succeed.
|
||||
- Disconnect network, see graceful degradation.
|
||||
|
||||
## Todo checklist
|
||||
|
||||
- [ ] `headers.rs`
|
||||
- [ ] `anthropic.rs` (usage endpoint)
|
||||
- [ ] `anthropic.rs` (messages fallback)
|
||||
- [ ] `chatgpt.rs`
|
||||
- [ ] `refresh.rs`
|
||||
- [ ] `registry.rs`
|
||||
- [ ] `app.rs` poll-thread + apply_data migration
|
||||
- [ ] `poller.rs` deleted
|
||||
- [ ] `main.rs` updated
|
||||
- [ ] `cargo build --release` clean
|
||||
- [ ] Manual Windows e2e: Claude polls, Codex polls, token-refresh works
|
||||
- [ ] Manual Windows e2e: network down → "..." indicator; back online → recovers
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `src/poller.rs` no longer exists.
|
||||
- App polls both providers concurrently (in poll thread).
|
||||
- Token-expired flow refreshes within 8 s (or shows "..." gracefully if CLI missing).
|
||||
- All ISO 8601 + Unix timestamps parse correctly (test edge cases: end-of-day, leap years).
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| Anthropic API shape changes between dev and ship | Low | Test against live API; pin `anthropic-version: 2023-06-01` |
|
||||
| Codex endpoint changes auth header | Low | Match source's header set exactly: Bearer + User-Agent + optional ChatGPT-Account-Id |
|
||||
| Refresh races multiple poll attempts | Medium | Single refresh per source per poll cycle; signature-based completion detection |
|
||||
| `wsl.exe bash -lic 'claude -p .'` outputs to TTY when no -p flag is recognized in WSL claude version | Medium | Test against actual installed Claude CLI in WSL; consider `--no-prompt` alternative |
|
||||
| Long-running Messages API request | Medium | 30 s HTTP timeout in `net::winhttp::Client` |
|
||||
|
||||
## Security considerations
|
||||
|
||||
- Bearer token is included in HTTPS request → WinHTTP encrypts with TLS.
|
||||
- Token never logged at INFO level; only `len=N` at DEBUG.
|
||||
- CLI refresh spawns process with `CREATE_NO_WINDOW` to avoid console flash.
|
||||
|
||||
## Next steps
|
||||
|
||||
→ Phase 5: replace `tray_icon.rs` with `tray/` directory and tiny-skia badges.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Does the Anthropic OAuth usage endpoint return `seven_day.utilization` consistently or do we still need the messages fallback for 7d data? Source code says yes-fallback-sometimes-needed. Keep the fallback for safety.
|
||||
- Should `ChatGptProvider` skip the request if it has no `account_id` to avoid wasting bandwidth on a guaranteed-401? Source includes the header conditionally; we mirror that.
|
||||
@@ -1,264 +0,0 @@
|
||||
---
|
||||
phase: 5
|
||||
status: pending
|
||||
estimated_hours: 5
|
||||
---
|
||||
|
||||
# Phase 5 — Tray badges with tiny-skia
|
||||
|
||||
## Context links
|
||||
|
||||
- Brainstorm: axis 7 (tray icon drawing)
|
||||
- Source file to be REPLACED entirely: `src/tray_icon.rs` (441 LOC)
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Medium — replaces a non-critical-path file but visibly improves UX (anti-aliased badges).
|
||||
- **Status:** pending
|
||||
- **Brief:** Replace GDI-drawn tray icons with `tiny-skia` path-rendered, anti-aliased badges. Wrap the Win32 tray-notification calls (`Shell_NotifyIconW`) in a new `tray/` module with cleaner add/update/remove semantics.
|
||||
|
||||
## Key insights from brainstorm
|
||||
|
||||
- Source's GDI rendering uses primitive rectangles + text. Output looks aliased on HiDPI.
|
||||
- `tiny-skia` renders vector paths with anti-aliasing in pure Rust. ~200 KB added to binary; UX clearly better.
|
||||
- Tray icons are 16×16 / 24×24 / 32×32 depending on DPI. Render at largest size, downsample.
|
||||
- The tray-icon "add vs update" inefficiency flagged in Phase 4 code review (R5) is fixed here by tracking which icons are registered in module state.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
- `tray::Manager::new(owner_hwnd: HWND) -> Self`.
|
||||
- `manager.sync(state: &[TrayIcon])` adds/updates/removes icons to match the given state.
|
||||
- `manager.notify(id: TrayIconId, title: &str, body: &str)` shows a balloon for an existing icon.
|
||||
- `tray::badge::render(percent: Option<f64>, kind: BadgeKind, dpi: u32) -> HICON` produces an anti-aliased HICON.
|
||||
- `tray::callback::handle(lparam: LPARAM) -> TrayAction` dispatches WM_APP_TRAY messages.
|
||||
- `TrayIcon { id: TrayIconId, percent: Option<f64>, tooltip: String, kind: BadgeKind }`.
|
||||
|
||||
### Non-functional
|
||||
|
||||
- Badge render must complete in < 5 ms per icon (called on every poll cycle, ~1× per minute typically).
|
||||
- Memory: each cached badge HICON is ~4 KB; we cache by `(percent_bucket, kind, dpi)` — at most ~100 entries × 4 KB = 400 KB cache size.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `src/tray/mod.rs`
|
||||
|
||||
```rust
|
||||
use windows::Win32::Foundation::*;
|
||||
|
||||
pub mod badge;
|
||||
pub mod callback;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum BadgeKind {
|
||||
Claude,
|
||||
ChatGpt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct TrayIconId(pub u32);
|
||||
|
||||
pub const ID_CLAUDE: TrayIconId = TrayIconId(1);
|
||||
pub const ID_CHATGPT: TrayIconId = TrayIconId(2);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrayIcon {
|
||||
pub id: TrayIconId,
|
||||
pub percent: Option<f64>,
|
||||
pub tooltip: String,
|
||||
pub kind: BadgeKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TrayAction {
|
||||
None,
|
||||
LeftClick(TrayIconId),
|
||||
RightClick(TrayIconId),
|
||||
}
|
||||
|
||||
pub struct Manager {
|
||||
owner: HWND,
|
||||
registered: std::collections::HashSet<TrayIconId>,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(owner: HWND) -> Self {
|
||||
Self { owner, registered: Default::default() }
|
||||
}
|
||||
|
||||
pub fn sync(&mut self, state: &[TrayIcon]) {
|
||||
let target_ids: std::collections::HashSet<_> = state.iter().map(|i| i.id).collect();
|
||||
// Remove icons not in state
|
||||
let to_remove: Vec<_> = self.registered.difference(&target_ids).copied().collect();
|
||||
for id in to_remove { self.remove(id); }
|
||||
// Add or update
|
||||
for icon in state {
|
||||
if self.registered.contains(&icon.id) {
|
||||
self.update(icon);
|
||||
} else {
|
||||
self.add(icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify(&self, id: TrayIconId, title: &str, body: &str) { /* NIM_MODIFY with NIF_INFO */ }
|
||||
|
||||
fn add(&mut self, icon: &TrayIcon) { /* NIM_ADD + Shell_NotifyIconW */ }
|
||||
fn update(&mut self, icon: &TrayIcon) { /* NIM_MODIFY */ }
|
||||
fn remove(&mut self, id: TrayIconId) { /* NIM_DELETE */ }
|
||||
}
|
||||
```
|
||||
|
||||
### `src/tray/badge.rs`
|
||||
|
||||
```rust
|
||||
use windows::Win32::UI::WindowsAndMessaging::HICON;
|
||||
use tiny_skia::*;
|
||||
|
||||
pub fn render(percent: Option<f64>, kind: super::BadgeKind, dpi: u32) -> Option<HICON> {
|
||||
let size = match dpi {
|
||||
d if d >= 192 => 32,
|
||||
d if d >= 144 => 24,
|
||||
_ => 16,
|
||||
};
|
||||
let mut pixmap = Pixmap::new(size, size)?;
|
||||
|
||||
// 1. Background fill (gradient from kind's tint colors)
|
||||
let bg_color = base_color_for(kind, percent);
|
||||
fill_circle(&mut pixmap, size, bg_color);
|
||||
|
||||
// 2. Ring sweep for percent
|
||||
if let Some(p) = percent {
|
||||
draw_arc(&mut pixmap, size, p, kind);
|
||||
}
|
||||
|
||||
// 3. Center text "%" with size auto-fit
|
||||
if let Some(p) = percent {
|
||||
draw_percent_text(&mut pixmap, size, p);
|
||||
}
|
||||
|
||||
// 4. Convert BGRA pixmap to HICON via CreateIconIndirect
|
||||
pixmap_to_hicon(&pixmap)
|
||||
}
|
||||
|
||||
fn fill_circle(pixmap: &mut Pixmap, size: u32, color: Color) { /* … */ }
|
||||
fn draw_arc(pixmap: &mut Pixmap, size: u32, percent: f64, kind: super::BadgeKind) { /* … */ }
|
||||
fn draw_percent_text(pixmap: &mut Pixmap, size: u32, percent: f64) {
|
||||
// tiny-skia doesn't render text natively. Two options:
|
||||
// a) Use cosmic-text (heavy) or ab_glyph (lighter).
|
||||
// b) Pre-rasterize digits 0-9 + % glyph at build time into tiny PNGs and embed.
|
||||
// c) Skip text — just use the ring sweep for usage indication.
|
||||
// Choose (c) for simplicity; the bubble shows the exact percent already.
|
||||
}
|
||||
|
||||
fn pixmap_to_hicon(pixmap: &Pixmap) -> Option<HICON> {
|
||||
// Win32 ICONINFO with mask + color bitmaps; bitmaps from CreateDIBSection.
|
||||
// BGRA layout matches what tiny-skia produces (premultiplied alpha).
|
||||
// …
|
||||
}
|
||||
```
|
||||
|
||||
**Decision:** drop text from tray badges entirely. The bubble shows the exact percentage; tray badge is a coarse indicator (color + ring fill). This sidesteps the tiny-skia text-rendering hassle and keeps the badge image clearer at 16×16.
|
||||
|
||||
### `src/tray/callback.rs`
|
||||
|
||||
```rust
|
||||
use windows::Win32::Foundation::LPARAM;
|
||||
use super::{TrayAction, TrayIconId};
|
||||
|
||||
const WM_LBUTTONUP: u32 = 0x0202;
|
||||
const WM_RBUTTONUP: u32 = 0x0205;
|
||||
|
||||
pub fn handle(lparam: LPARAM) -> TrayAction {
|
||||
let raw = lparam.0 as u32;
|
||||
let event = raw & 0xFFFF;
|
||||
let id_lo = (raw >> 16) & 0xFFFF;
|
||||
let id = TrayIconId(id_lo);
|
||||
match event {
|
||||
WM_LBUTTONUP => TrayAction::LeftClick(id),
|
||||
WM_RBUTTONUP => TrayAction::RightClick(id),
|
||||
_ => TrayAction::None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cargo.toml addition
|
||||
|
||||
```toml
|
||||
tiny-skia = "0.11"
|
||||
```
|
||||
|
||||
(~250 KB added to binary; no text dependency since we dropped text from badges.)
|
||||
|
||||
## Related code files
|
||||
|
||||
**To create:**
|
||||
- `src/tray/mod.rs`
|
||||
- `src/tray/badge.rs`
|
||||
- `src/tray/callback.rs`
|
||||
|
||||
**To modify:**
|
||||
- `Cargo.toml` — add `tiny-skia`
|
||||
- `src/main.rs` — `mod tray;`; remove `mod tray_icon;`
|
||||
- `src/app.rs` — replace `crate::tray_icon::{sync, add, update, remove, notify_balloon, handle_message, ...}` with `crate::tray::{Manager, TrayIcon, BadgeKind, TrayAction, ID_CLAUDE, ID_CHATGPT}`. Store `Manager` in `AppState`. Update all call sites.
|
||||
|
||||
**To delete:**
|
||||
- `src/tray_icon.rs`
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Add `tiny-skia` dep**.
|
||||
2. **Implement `badge.rs`** — start with `fill_circle` (one path), verify pixmap saves to PNG correctly for visual debugging.
|
||||
3. **Add `draw_arc`** — `PathBuilder::move_to + arc_to`. Use 0° = top (12 o'clock).
|
||||
4. **Implement `pixmap_to_hicon`** — this is the trickiest part. Create AND/XOR DIB sections, populate from pixmap pixels (premultiplied BGRA), build `ICONINFO`, call `CreateIconIndirect`.
|
||||
5. **Test badge rendering** — save 10 sample HICONs at different percents to disk and inspect.
|
||||
6. **Implement `Manager`** with add/update/remove/sync.
|
||||
7. **Implement `callback.rs`**.
|
||||
8. **Migrate `app.rs`** to new API; replace `tray_icon::sync(...)` with `state.tray.sync(&icons)`.
|
||||
9. **Delete `src/tray_icon.rs`** and remove from `main.rs`.
|
||||
10. **`cargo build --release`** clean.
|
||||
11. **Windows e2e**: run app, see tray icons appear with anti-aliased ring. Hover for tooltip. Left-click toggles bubble. Right-click opens menu.
|
||||
|
||||
## Todo checklist
|
||||
|
||||
- [ ] `tiny-skia` added to Cargo.toml
|
||||
- [ ] `badge.rs::fill_circle` works (PNG inspection)
|
||||
- [ ] `badge.rs::draw_arc` works (PNG inspection)
|
||||
- [ ] `badge.rs::pixmap_to_hicon` produces valid HICON
|
||||
- [ ] `tray/mod.rs::Manager` with add/update/remove/sync
|
||||
- [ ] `callback.rs::handle` returns correct TrayAction
|
||||
- [ ] `app.rs` migrated to new tray API
|
||||
- [ ] `tray_icon.rs` deleted
|
||||
- [ ] `cargo build --release` clean
|
||||
- [ ] Tray icons appear with anti-aliased visuals on Windows
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Badge looks visibly smoother than source's GDI version (anti-aliased ring).
|
||||
- Add/update/remove is idempotent (no duplicate icons after `sync`).
|
||||
- Tray callbacks fire correctly for left/right click.
|
||||
- No `src/tray_icon.rs` remains.
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| `pixmap_to_hicon` produces wrong-format icon (alpha channel issues) | High | Test by saving the source pixmap as PNG, then comparing to the rendered icon; iterate on BGRA channel order |
|
||||
| `CreateIconIndirect` requires monochrome mask bitmap; we only have color | Medium | Pass `hbmMask = NULL` to let Windows auto-generate from alpha (works on Win10+) |
|
||||
| 16×16 looks bad even with AA | Medium | Render at 32×32 then downsample with high-quality lanczos (tiny-skia doesn't include downsampling — use `image` crate's resize) |
|
||||
| `tiny-skia` adds too much binary size | Low | Measured ~250 KB; acceptable for the UX win |
|
||||
|
||||
## Security considerations
|
||||
|
||||
- No external input drives badge rendering — all params are internal (`percent`, `kind`, `dpi`). No injection surface.
|
||||
- HICON handles must be `DestroyIcon`'d when cache evicts (avoid handle leak — Windows limit is ~10,000 per process).
|
||||
|
||||
## Next steps
|
||||
|
||||
→ Phase 6: replace `updater.rs` and drop `NOTICE`.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Keep the cached HICONs alive for the process lifetime, or destroy aggressively on each `update`? Source destroys + recreates each cycle (wasteful but simple). Recommend: cache by `(percent_rounded_to_5pct, kind, dpi)` and let the cache grow naturally. Max size ~100 entries × 4 KB = 400 KB.
|
||||
- Need an icon for "no data" state (percent = None). Current spec says "fill_circle + no ring". Verify the visual reads correctly.
|
||||
@@ -1,312 +0,0 @@
|
||||
---
|
||||
phase: 6
|
||||
status: pending
|
||||
estimated_hours: 7
|
||||
---
|
||||
|
||||
# Phase 6 — Updater + remove NOTICE
|
||||
|
||||
## Context links
|
||||
|
||||
- Brainstorm: axis 8 (updater architecture)
|
||||
- Source file to be REPLACED entirely: `src/updater.rs` (512 LOC)
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Final — this phase removes the last copied module and drops the attribution.
|
||||
- **Status:** pending
|
||||
- **Brief:** Replace the source's helper-exe-handoff updater with an inline `cmd /c …` handoff (no duplicate-exe pattern). Replace `winres` legacy paths. Delete `NOTICE`, update README + LICENSE comment so the project no longer claims attribution.
|
||||
|
||||
## Key insights from brainstorm
|
||||
|
||||
- Source spawns a copy of itself as `updater-helper.exe`, which waits for the parent to exit and swaps the binary. Genuinely-different alternative: spawn `cmd.exe` directly with an inline command string that does the same dance.
|
||||
- No temp `.bat` file needed — inline command via `cmd /c "..."` works.
|
||||
- The `Portable` vs `Winget` channel split is kept (we may publish to winget later); the channel detection function returns `Portable` for now (already stubbed in current code).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
- `update::release::fetch_latest() -> Result<Option<Release>, Error>` — GitHub releases API call.
|
||||
- `update::release::Release { version: Version, asset_url: String }` — parsed result.
|
||||
- `update::install::begin(release: &Release) -> Result<(), Error>` — download + handoff.
|
||||
- `update::install::run_cli(args: &[String]) -> Option<i32>` — handle `--apply-update` flag (still kept for parity if a user manually invokes it, even though we don't use the helper-exe path anymore).
|
||||
- `update::channel::current() -> Channel` — returns `Channel::Portable` for now.
|
||||
- `Version` type with parse + ordering.
|
||||
|
||||
### Non-functional
|
||||
|
||||
- Inline `cmd /c` invocation uses `CREATE_NO_WINDOW | DETACHED_PROCESS` — no console flash.
|
||||
- Download timeout: 60 s. Total update apply time: < 30 s after the 2-second wait window.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `src/update/mod.rs`
|
||||
|
||||
```rust
|
||||
pub mod channel;
|
||||
pub mod release;
|
||||
pub mod install;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("network: {0}")]
|
||||
Network(#[from] crate::net::Error),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("no compatible release asset found")]
|
||||
NoAsset,
|
||||
#[error("install location not writable: {0}")]
|
||||
NotWritable(String),
|
||||
#[error("malformed version: {0}")]
|
||||
BadVersion(String),
|
||||
}
|
||||
|
||||
pub use channel::{Channel, current as current_channel};
|
||||
pub use release::{Release, fetch_latest};
|
||||
pub use install::{begin, run_cli};
|
||||
```
|
||||
|
||||
### `src/update/channel.rs`
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Channel { Portable, Winget }
|
||||
|
||||
pub fn current() -> Channel {
|
||||
// Until winget package exists, always Portable.
|
||||
// Future: detect by checking if current_exe path is under
|
||||
// %LOCALAPPDATA%\Microsoft\WinGet\Packages or %ProgramFiles%\WinGet\Packages.
|
||||
Channel::Portable
|
||||
}
|
||||
```
|
||||
|
||||
### `src/update/release.rs`
|
||||
|
||||
```rust
|
||||
use crate::net::winhttp::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
const ASSET_NAME: &str = "claude-code-usage-bubble.exe";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Release {
|
||||
pub version: Version,
|
||||
pub asset_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Version { pub major: u32, pub minor: u32, pub patch: u32 }
|
||||
|
||||
impl Version {
|
||||
pub fn current() -> Self { /* env!("CARGO_PKG_VERSION") */ }
|
||||
pub fn parse(s: &str) -> Option<Self> { /* … */ }
|
||||
}
|
||||
|
||||
pub fn fetch_latest(http: &Client) -> Result<Option<Release>, super::Error> {
|
||||
let url = format!("https://api.github.com/repos/{}/releases/latest", repo_path());
|
||||
let resp = http.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.header("User-Agent", user_agent())
|
||||
.send()?;
|
||||
let body: GhRelease = resp.json()?;
|
||||
let candidate = Version::parse(body.tag_name.trim_start_matches('v'))
|
||||
.ok_or_else(|| super::Error::BadVersion(body.tag_name.clone()))?;
|
||||
if candidate <= Version::current() { return Ok(None); }
|
||||
let asset = body.assets.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(ASSET_NAME))
|
||||
.ok_or(super::Error::NoAsset)?;
|
||||
Ok(Some(Release { version: candidate, asset_url: asset.browser_download_url.clone() }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GhRelease { tag_name: String, assets: Vec<GhAsset> }
|
||||
#[derive(Deserialize)]
|
||||
struct GhAsset { name: String, browser_download_url: String }
|
||||
|
||||
fn repo_path() -> &'static str { "tiennm99/claude-code-usage-bubble" }
|
||||
fn user_agent() -> &'static str { concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) }
|
||||
```
|
||||
|
||||
### `src/update/install.rs`
|
||||
|
||||
```rust
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
pub fn begin(http: &crate::net::winhttp::Client, release: &super::Release) -> Result<(), super::Error> {
|
||||
let current = std::env::current_exe()?;
|
||||
ensure_writable(¤t)?;
|
||||
let staging = stage_path()?;
|
||||
std::fs::create_dir_all(staging.parent().unwrap())?;
|
||||
download(http, &release.asset_url, &staging)?;
|
||||
spawn_handoff(&staging, ¤t)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download(http: &crate::net::winhttp::Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> {
|
||||
let resp = http.get(url).header("User-Agent", super::release::user_agent()).send()?;
|
||||
std::fs::write(to, resp.body())?; // assume Response exposes .body() -> &[u8]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(), super::Error> {
|
||||
let cmd = format!(
|
||||
r#"timeout /t 2 /nobreak >nul & move /y "{src}" "{tgt}" & start "" "{tgt}""#,
|
||||
src = source.to_string_lossy(),
|
||||
tgt = target.to_string_lossy(),
|
||||
);
|
||||
Command::new("cmd.exe")
|
||||
.args(["/c", &cmd])
|
||||
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_cli(args: &[String]) -> Option<i32> {
|
||||
// Keep this for parity: if the user runs the binary with `--apply-update <target> <source> <pid>`
|
||||
// (the source's old helper signature), the inline-cmd handoff has already done the work;
|
||||
// we just exit 0.
|
||||
if args.len() >= 2 && args[1] == "--apply-update" { return Some(0); }
|
||||
None
|
||||
}
|
||||
|
||||
fn stage_path() -> Result<PathBuf, super::Error> {
|
||||
let base = dirs::data_local_dir()
|
||||
.ok_or_else(|| super::Error::NotWritable("no data dir".into()))?;
|
||||
Ok(base.join("ClaudeCodeUsageBubble").join("updates").join("update.exe"))
|
||||
}
|
||||
|
||||
fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> {
|
||||
let parent = target.parent().ok_or_else(|| super::Error::NotWritable("no parent".into()))?;
|
||||
let probe = parent.join(".probe");
|
||||
std::fs::write(&probe, b"").map_err(|e| super::Error::NotWritable(e.to_string()))?;
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Migration in `app.rs`
|
||||
|
||||
Replace `updater::*` imports:
|
||||
- `updater::check_for_updates()` → `update::release::fetch_latest(&http_client)`
|
||||
- `updater::begin_self_update(release)` → `update::install::begin(&http_client, release)`
|
||||
- `updater::current_install_channel()` → `update::current_channel()`
|
||||
- `updater::handle_cli_mode(args)` → `update::run_cli(args)`
|
||||
- `updater::UpdateCheckResult` → `Option<Release>` (None = up to date, Some = available)
|
||||
- `updater::ReleaseDescriptor` → `update::Release`
|
||||
- `updater::InstallChannel` → `update::Channel`
|
||||
|
||||
### Drop NOTICE + final attribution cleanup
|
||||
|
||||
After the rewrite is complete and validated:
|
||||
|
||||
1. Delete `NOTICE` file.
|
||||
2. Update `LICENSE`:
|
||||
- Remove the "Portions ported from …" paragraph (currently at the top of LICENSE).
|
||||
- Keep just the Apache-2.0 text with `Copyright 2026 tiennm99`.
|
||||
3. Update `README.md`:
|
||||
- Replace "Differences vs upstream" section's "derivative of … with minor adaptations" wording with "inspired by [upstream link]".
|
||||
- Remove the "License" section mention of NOTICE.
|
||||
4. Update `Cargo.toml`:
|
||||
- `license = "Apache-2.0"` (unchanged).
|
||||
|
||||
## Related code files
|
||||
|
||||
**To create:**
|
||||
- `src/update/mod.rs`
|
||||
- `src/update/channel.rs`
|
||||
- `src/update/release.rs`
|
||||
- `src/update/install.rs`
|
||||
|
||||
**To modify:**
|
||||
- `src/main.rs` — declare `mod update;`; remove `mod updater;`
|
||||
- `src/app.rs` — migrate `updater::*` call sites
|
||||
- `LICENSE` — drop the upstream-attribution paragraph
|
||||
- `README.md` — drop "derivative of" wording, replace with "inspired by"
|
||||
- `Cargo.toml` — no functional changes
|
||||
|
||||
**To delete:**
|
||||
- `src/updater.rs`
|
||||
- `NOTICE`
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Create `update/channel.rs`** — trivial.
|
||||
2. **Create `update/release.rs`** — Version type + fetch_latest. Test by hitting GitHub API.
|
||||
3. **Create `update/install.rs`** — download + inline-cmd handoff. **Test on a throwaway VM** (the handoff replaces the binary, which is risky).
|
||||
4. **Create `update/mod.rs`** — re-exports.
|
||||
5. **Migrate `app.rs`** — replace all `updater::*` call sites.
|
||||
6. **Delete `src/updater.rs`** + `mod updater;` line.
|
||||
7. **`cargo build --release`** clean.
|
||||
8. **End-to-end test on Windows**:
|
||||
- Stage a v0.1.1 GitHub release with a deliberately-different .exe.
|
||||
- Run v0.1.0 binary, trigger update → confirm new .exe replaces old, new app launches.
|
||||
9. **AFTER end-to-end test succeeds:**
|
||||
- Delete `NOTICE`.
|
||||
- Edit `LICENSE` — drop the upstream-attribution paragraph at the top.
|
||||
- Edit `README.md` — drop "derivative of" paragraph; replace with one-line "Inspired by [CodeZeno/Claude-Code-Usage-Monitor]" (no attribution-required phrasing).
|
||||
10. **Final repo audit:**
|
||||
- `grep -ri "CodeZeno" src/` → must return nothing.
|
||||
- `grep -ri "Claude-Code-Usage-Monitor" src/` → must return nothing.
|
||||
- File names: `find src -type f -name '*.rs' | xargs -I {} basename {} | sort` and compare against upstream's file list (`models.rs`, `diagnose.rs`, `theme.rs`, `poller.rs`, `updater.rs`, `tray_icon.rs`, `native_interop.rs`, `localization/*`). **No file name should match.**
|
||||
- `git log --oneline` shows the initial-port commit + 6 rewrite commits — transparent history.
|
||||
11. **Commit and push:**
|
||||
- Commit message: `chore: complete clean-room rewrite; drop upstream attribution`
|
||||
- Push to GitHub.
|
||||
|
||||
## Todo checklist
|
||||
|
||||
- [ ] `update/channel.rs`
|
||||
- [ ] `update/release.rs`
|
||||
- [ ] `update/install.rs`
|
||||
- [ ] `update/mod.rs`
|
||||
- [ ] `app.rs` migrated to new updater API
|
||||
- [ ] `updater.rs` deleted
|
||||
- [ ] `cargo build --release` clean
|
||||
- [ ] End-to-end update tested on Windows
|
||||
- [ ] `NOTICE` deleted
|
||||
- [ ] `LICENSE` upstream-attribution paragraph removed
|
||||
- [ ] `README.md` updated to drop "derivative" wording
|
||||
- [ ] Grep verifies no upstream references remain in `src/`
|
||||
- [ ] File-name overlap with upstream = 0
|
||||
- [ ] Final commit + push
|
||||
|
||||
## Success criteria
|
||||
|
||||
- App self-updates correctly using inline-cmd handoff.
|
||||
- `NOTICE` file no longer exists in repo.
|
||||
- Repo passes the "no upstream references" grep test.
|
||||
- GitHub's auto-license detection still reports Apache-2.0.
|
||||
- The README still credits inspiration but does not claim derivative status.
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| Inline `cmd /c` flagged by antivirus | Medium | Most AVs allow `cmd.exe` execution; if flagged, fall back to a temp `.bat` |
|
||||
| `move /y` fails if exe is still loaded by Windows | Medium-High | The 2s `timeout` gives parent time to exit, fully releasing file handle |
|
||||
| User has unusual `cmd.exe` path | Negligible on Windows | Use full path `C:\Windows\System32\cmd.exe` if needed |
|
||||
| Drop NOTICE prematurely (before phase done) | High if rushed | Phase order: rewrite first, then drop attribution. Never reorder. |
|
||||
| Legal — is "inspired by" enough? | Low (we did rewrite everything) | This is the entire point of Phases 1-5. After full rewrite, no MIT code remains; attribution is courtesy, not required |
|
||||
|
||||
## Security considerations
|
||||
|
||||
- The inline `cmd /c` command string is built from `std::env::current_exe()` and `stage_path()` — both internal, no user-controlled input. No shell injection.
|
||||
- The downloaded asset is over HTTPS to `api.github.com` → MITM-safe.
|
||||
- Update fails closed: if `move /y` fails, the old exe is still in place; user can retry.
|
||||
|
||||
## Next steps
|
||||
|
||||
→ Project complete. Tag v0.2.0 with the clean-room rewrite as a milestone.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should we sign the binary with a code-signing certificate to satisfy AV heuristics around inline-cmd updates? Out of scope for this plan; future enhancement.
|
||||
- After dropping NOTICE, should we add a small "Acknowledgements" section in README that mentions inspiration from CodeZeno's project without invoking MIT attribution language? Recommended: **yes**, that's the polite move and is legally untainting since we don't claim derivation.
|
||||
@@ -1,134 +0,0 @@
|
||||
---
|
||||
status: pending
|
||||
created: 2026-05-16
|
||||
mode: standard (non-TDD)
|
||||
brainstorm: ../reports/brainstorm-260516-0707-cleanroom-reimplementation.md
|
||||
---
|
||||
|
||||
# Clean-room rewrite of ported modules
|
||||
|
||||
Replace ~2,700 LOC of code copied from `CodeZeno/Claude-Code-Usage-Monitor` with genuinely-original implementations, then drop the `NOTICE` attribution file.
|
||||
|
||||
## Source of truth
|
||||
|
||||
All design decisions live in
|
||||
[`../reports/brainstorm-260516-0707-cleanroom-reimplementation.md`](../reports/brainstorm-260516-0707-cleanroom-reimplementation.md).
|
||||
Do not re-debate them during implementation. If a phase reveals a flaw,
|
||||
note it in that phase's "Open questions" and ask the user before
|
||||
deviating.
|
||||
|
||||
## Architecture (locked)
|
||||
|
||||
| Axis | Decision |
|
||||
|---|---|
|
||||
| HTTP | WinHTTP via `windows-rs` |
|
||||
| Errors | `thiserror` per-module enums |
|
||||
| Providers | `trait UsageProvider` + ClaudeProvider/ChatGptProvider |
|
||||
| Credentials | `trait CredentialSource` + Vec<Box<dyn ...>> |
|
||||
| Token refresh | `RefreshOrchestrator`, 8s timeout |
|
||||
| i18n | TOML files via `include_str!` |
|
||||
| Tray badges | `tiny-skia` anti-aliased |
|
||||
| Updater | inline `cmd /c` handoff (no helper-exe) |
|
||||
| Logging | `log` + `simplelog` |
|
||||
| Build script | `embed-resource` crate |
|
||||
|
||||
## New module layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs (kept — update imports only)
|
||||
app.rs (kept — update imports + call-sites)
|
||||
bubble.rs (kept — update imports only)
|
||||
panel.rs (kept — update imports only)
|
||||
settings.rs (kept — update imports only)
|
||||
|
||||
diag/mod.rs — log facade + simplelog file appender
|
||||
os/ — Win32 helpers (color, string, dpi, registry, theme)
|
||||
net/ — WinHTTP HTTP client
|
||||
usage/ — Provider trait + types + impls + refresh
|
||||
creds/ — CredentialSource trait + impls
|
||||
i18n/ — TOML loader + 9 locale files
|
||||
tray/ — Anti-aliased tray badges
|
||||
update/ — Self-updater (release check, download, handoff, channel)
|
||||
```
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Hours | File |
|
||||
|---|---|---|---|
|
||||
| 1 | Infrastructure (`diag/`, `os/`, `net/winhttp.rs`, Cargo.toml) | ~9 | [`phase-01-infrastructure.md`](phase-01-infrastructure.md) |
|
||||
| 2 | Types & i18n (`usage/types.rs`, `i18n/`) | ~6 | [`phase-02-types-and-i18n.md`](phase-02-types-and-i18n.md) |
|
||||
| 3 | Credentials (`creds/`) | ~3 | [`phase-03-creds-module.md`](phase-03-creds-module.md) |
|
||||
| 4 | Providers & refresh (`usage/anthropic.rs`, `chatgpt.rs`, `refresh.rs`) | ~8 | [`phase-04-providers-and-refresh.md`](phase-04-providers-and-refresh.md) |
|
||||
| 5 | Tray badges (`tray/badge.rs`) | ~5 | [`phase-05-tray-badges.md`](phase-05-tray-badges.md) |
|
||||
| 6 | Updater + remove NOTICE | ~7 | [`phase-06-updater-and-remove-notice.md`](phase-06-updater-and-remove-notice.md) |
|
||||
|
||||
**Total:** ~38h core work + 4–8h Windows-side debugging.
|
||||
|
||||
## Phase dependencies
|
||||
|
||||
```
|
||||
Phase 1 (infra)
|
||||
├─→ Phase 2 (types + i18n)
|
||||
│ └─→ Phase 4 (providers)
|
||||
│ └─→ Phase 5 (tray) — needs UsageProvider results
|
||||
│
|
||||
└─→ Phase 3 (creds)
|
||||
└─→ Phase 4 (providers) — depends on creds API
|
||||
└─→ Phase 6 (updater + cleanup) — last
|
||||
```
|
||||
|
||||
Phases 2 and 3 can technically run in parallel after Phase 1, but
|
||||
serial execution (1→2→3→4→5→6) is cleaner for solo work.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `bubble.rs`, `panel.rs`, `app.rs`, `settings.rs`, `main.rs` — these
|
||||
stay; only their imports/call-sites get touched as new APIs come
|
||||
online.
|
||||
- Adding new features beyond what the current copied code supports.
|
||||
- Changing `bubble.rs`/`panel.rs` rendering or interaction behavior.
|
||||
|
||||
## External contracts that must NOT change
|
||||
|
||||
- Anthropic endpoints + headers
|
||||
- ChatGPT endpoint + `User-Agent: codex-cli`
|
||||
- Credential file paths and JSON shapes
|
||||
- WSL access via `wsl.exe`
|
||||
- CLI-driven token refresh (must invoke `claude` / `codex`)
|
||||
- GitHub releases JSON format
|
||||
- Settings file location (`%APPDATA%\ClaudeCodeUsageBubble\settings.json`)
|
||||
- Windows registry path for startup (`Software\Microsoft\Windows\CurrentVersion\Run`)
|
||||
- Single-instance mutex (`Global\ClaudeCodeUsageBubble`)
|
||||
|
||||
## Success criteria (cross-phase)
|
||||
|
||||
After all 6 phases:
|
||||
|
||||
- [ ] `cargo build --release` clean on Windows
|
||||
- [ ] No file in `src/` shares a name with the upstream source's files
|
||||
- [ ] `NOTICE` file removed from repo root
|
||||
- [ ] `LICENSE` (Apache-2.0) header retained; copyright line updated
|
||||
- [ ] `README.md` updated to drop the "derivative of" paragraph; replace
|
||||
with "inspired by [upstream]" link
|
||||
- [ ] App functional: bubble, panel, tray, polling, auth, updater all
|
||||
working on Windows 10/11
|
||||
- [ ] No regressions vs current behavior (poll cadence, snap, click→panel,
|
||||
Ctrl+Wheel resize, fullscreen auto-hide, dual-bubble)
|
||||
|
||||
## Rollback strategy
|
||||
|
||||
- Each phase lands as a separate commit (or PR). If a phase breaks
|
||||
the build/app, `git revert` that commit to restore the prior phase's
|
||||
state.
|
||||
- Phase 6 is the only commit that removes upstream attribution — if any
|
||||
of Phases 1–5 is incomplete or buggy at that point, **do not** drop
|
||||
NOTICE; finish or revert first.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Bump `windows-rs` from 0.58 → newer? (Brainstorm flagged this.)
|
||||
Defer decision to Phase 1 — try with 0.58 first, bump only if needed.
|
||||
- Keep Git history showing the initial port? Recommended: **yes**.
|
||||
Transparent and consistent with the "I inspired/rewrote from X" framing
|
||||
even after NOTICE is gone.
|
||||
@@ -1,176 +0,0 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Release CI workflow"
|
||||
status: in_progress
|
||||
priority: P1
|
||||
effort: "2h"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Release CI workflow
|
||||
|
||||
## Overview
|
||||
|
||||
Ship `.github/workflows/release.yml`. On a pushed tag matching `v*`,
|
||||
the workflow verifies the tag matches `Cargo.toml`'s `version` field
|
||||
(fail-fast if not), builds `cargo build --release` on `windows-latest`,
|
||||
renames/copies the binary to the exact asset name the updater expects
|
||||
(`claude-code-usage-bubble.exe`), and creates a GitHub Release with
|
||||
that asset attached and auto-generated notes. Also supports
|
||||
`workflow_dispatch` for dry-run testing without cutting a real tag.
|
||||
|
||||
<!-- Updated: Validation Session 1 — version-tag match enforcement added per plan.md Validation Log decision 1 -->
|
||||
<!-- Updated: Validation Session 1 — *.exe fallback at src/update/release.rs:67-69 is out of scope per plan.md Validation Log decision 3 -->
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- Trigger on tag push matching `v*.*.*` (and `workflow_dispatch` for testing).
|
||||
- **Verify the tag matches `Cargo.toml` `version`** before building. Workflow aborts on mismatch.
|
||||
- Build on `windows-latest` with stable Rust toolchain, `x86_64-pc-windows-msvc` target.
|
||||
- Cache cargo registry + target dir to keep wall time under ~5 min.
|
||||
- Upload `target/release/claude-code-usage-bubble.exe` as a Release asset.
|
||||
- Use `gh release create` with `--generate-notes` for the body.
|
||||
- Use `--draft` on `workflow_dispatch` runs so test runs don't become public.
|
||||
- On real tag runs (`vX.Y.Z`), publish immediately (not draft).
|
||||
|
||||
### Non-functional
|
||||
- Workflow file under ~100 lines.
|
||||
- No third-party Marketplace actions other than `actions/checkout` and `Swatinem/rust-cache` (or just `actions/cache`). Avoid `softprops/action-gh-release`-style wrappers — `gh` CLI is preinstalled on the runner and is one less supply-chain risk.
|
||||
- Default `GITHUB_TOKEN` permissions, with explicit `contents: write` only on the release job.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
push tag v0.1.1
|
||||
└─→ release.yml (job: build, runs-on: windows-latest)
|
||||
├─ checkout (at the tag)
|
||||
├─ Swatinem/rust-cache (uses runner-default stable Rust)
|
||||
├─ resolve tag (from refs/tags or workflow_dispatch input)
|
||||
├─ verify Cargo.toml version == tag (strip leading 'v') → abort on mismatch
|
||||
├─ cargo build --release --locked
|
||||
├─ gh release create v0.1.1 target/release/claude-code-usage-bubble.exe \
|
||||
│ --title "v0.1.1" --generate-notes [--draft on workflow_dispatch]
|
||||
└─ done
|
||||
```
|
||||
|
||||
### Asset name verification
|
||||
|
||||
The updater's primary matcher is `eq_ignore_ascii_case("claude-code-usage-bubble.exe")`
|
||||
(`src/update/release.rs:64`). Cargo's `name = "claude-code-usage-bubble"`
|
||||
already produces that exe name in `target/release/`, so no rename is
|
||||
needed — just upload the file as-is.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `.github/workflows/release.yml`
|
||||
- Reference (do not modify in this phase): `src/update/release.rs`, `Cargo.toml`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Create `.github/workflows/release.yml` with this shape:
|
||||
|
||||
```yaml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to release (must already exist, e.g. v0.1.1)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = if ($env:GITHUB_REF -like 'refs/tags/*') {
|
||||
$env:GITHUB_REF -replace '^refs/tags/',''
|
||||
} else {
|
||||
'${{ github.event.inputs.tag }}'
|
||||
}
|
||||
"tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = '${{ steps.tag.outputs.tag }}'
|
||||
$expected = $tag -replace '^v',''
|
||||
$cargoVersion = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1).Matches.Groups[1].Value
|
||||
if ($cargoVersion -ne $expected) {
|
||||
Write-Error "Tag ($tag → $expected) does not match Cargo.toml version ($cargoVersion). Bump Cargo.toml before tagging."
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Cargo version $cargoVersion matches tag $tag"
|
||||
|
||||
- name: Build release
|
||||
run: cargo build --release --locked
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = '${{ steps.tag.outputs.tag }}'
|
||||
$asset = 'target/release/claude-code-usage-bubble.exe'
|
||||
$draft = if ('${{ github.event_name }}' -eq 'workflow_dispatch') { '--draft' } else { '' }
|
||||
gh release create $tag $asset --title $tag --generate-notes $draft
|
||||
```
|
||||
|
||||
2. Sanity-check: `gh workflow list` after pushing the file shows the new "Release" workflow.
|
||||
|
||||
3. Verify the workflow YAML lints clean by viewing it in the GitHub UI (or with `actionlint` locally if installed).
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] `.github/workflows/release.yml` written
|
||||
- [x] `permissions: contents: write` set
|
||||
- [x] Tag-push trigger and `workflow_dispatch` both wired
|
||||
- [x] Cargo.toml-version-vs-tag check step added and fails on mismatch
|
||||
- [x] Asset path is `target/release/claude-code-usage-bubble.exe` exactly
|
||||
- [x] `--generate-notes` enabled
|
||||
- [ ] Committed and pushed to `main`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Workflow appears under Actions tab on GitHub.
|
||||
- [ ] Manually dispatching with a throwaway tag (`v0.0.0-test`) produces a **draft** release with the `.exe` attached.
|
||||
- [ ] Pushing a tag whose value disagrees with `Cargo.toml` fails the workflow before `cargo build` runs (verify by intentionally mismatching once on a throwaway dispatch).
|
||||
- [ ] No third-party actions beyond `actions/checkout@v4` and `Swatinem/rust-cache@v2`.
|
||||
- [ ] Workflow file is under ~100 lines including blank lines.
|
||||
- [ ] Out of scope (do not touch): `*.exe` fallback at `src/update/release.rs:67-69`. Tracked as future cleanup once multi-arch ships.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| `Cargo.lock` drift causes `--locked` to fail | Low | Lockfile is committed; bump it locally before tagging if deps changed |
|
||||
| Build time >10 min and cache cold | Low | `rust-cache` covers cargo registry + `target/`; first run is slow, subsequent fast |
|
||||
| Tag pushed without prior `Cargo.toml` version bump | Was Medium → now Mitigated | CI now fails fast in the "Verify Cargo.toml version matches tag" step; maintainer cannot accidentally ship a version-mismatched binary |
|
||||
| `gh release create` fails because tag does not exist for workflow_dispatch | Medium | Workflow_dispatch input takes a tag string and `actions/checkout` is pinned to it — if the tag does not exist, checkout fails fast with a clear error |
|
||||
| Pre-release tag like `v0.1.0-rc1` triggers workflow but does not match Cargo's stable version | Low | Tag-match check uses string equality after `^v` strip; `0.1.0-rc1 != 0.1.0` fails fast. If pre-releases are wanted later, change Cargo `version` and the check still works |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- `permissions: contents: write` is the minimum scope needed to create a release; no `id-token` or package perms requested.
|
||||
- `GH_TOKEN` is the default `GITHUB_TOKEN`, scoped to this repo only.
|
||||
- No secrets are echoed; `gh` reads `GH_TOKEN` from env.
|
||||
- The published `.exe` is unsigned. SmartScreen will show "Unknown publisher" the first time a user runs it. Document this in Phase 3; code signing is out of scope.
|
||||
@@ -1,120 +0,0 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "End-to-end update test"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "2h"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 2: End-to-end update test
|
||||
|
||||
## Overview
|
||||
|
||||
Cut a real test release pair and prove the in-app updater finds it,
|
||||
downloads it, swaps the running .exe, and relaunches into the new
|
||||
version. The updater code already exists; this phase is about
|
||||
flushing out integration bugs between CI output and the updater's
|
||||
expectations (asset name casing, redirect behavior, file lock release
|
||||
timing, version-comparison edge cases).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- Cut `v0.1.0` from current `main` → CI uploads asset.
|
||||
- Bump `Cargo.toml` to `0.1.1`, commit, push, tag `v0.1.1` → CI uploads asset.
|
||||
- On a Windows test machine, run the v0.1.0 binary downloaded from the v0.1.0 release.
|
||||
- From the right-click menu, "Check for updates" must transition `Idle → Checking → Available`.
|
||||
- Clicking "Update available" must transition `Available → Applying` and exit the process.
|
||||
- ~2 seconds later, v0.1.1 must be running (verify via right-click menu showing "Up to date" after re-check, or via file properties on the .exe).
|
||||
- The `version_action` apply branch (`src/app.rs:1037-1066`) must succeed: `update::install::begin` returns `Ok(())` and the process posts `WM_QUIT` (`PostQuitMessage(0)` at `src/app.rs:1056`).
|
||||
|
||||
### Non-functional
|
||||
- Test machine has no admin privileges → confirms `ensure_writable` (`src/update/install.rs:81-89`) works for `%LOCALAPPDATA%` install.
|
||||
- Run from an install path that contains a space (e.g. `C:\Users\test user\bin\`) to validate `spawn_handoff` quoting (`src/update/install.rs:53-69`).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Test matrix
|
||||
|
||||
| Scenario | Where exe lives | Expected |
|
||||
|---|---|---|
|
||||
| Vanilla user-local install | `%LOCALAPPDATA%\ClaudeCodeUsageBubble\` | Succeeds |
|
||||
| Path with spaces | `C:\Users\test user\bin\` | Succeeds (cmd /c quoting) |
|
||||
| Read-only install dir (e.g. `C:\Program Files\…`) | `C:\Program Files\Bubble\` | `Failed` status surfaces; no crash |
|
||||
| Offline | n/a | `Failed` status, no crash, retries on next 24h timer |
|
||||
| Already on latest | n/a | `UpToDate` status, no download |
|
||||
|
||||
### Observability
|
||||
|
||||
- Run with `claude-code-usage-bubble.exe --diagnose` to capture the
|
||||
log at `%TEMP%\claude-code-usage-bubble.log`. Look for `update apply
|
||||
failed:` lines (`src/app.rs:1058`).
|
||||
- After update, the new process is started by `cmd.exe` (detached);
|
||||
Task Manager parent column will show no parent — that's expected.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Reference only (no edits expected): `src/update/release.rs`, `src/update/install.rs`, `src/app.rs` (lines 1025-1135), `Cargo.toml`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Cut v0.1.0:**
|
||||
```bash
|
||||
git -C D:/tiennm99/claude-code-usage-bubble tag -a v0.1.0 -m "v0.1.0"
|
||||
git -C D:/tiennm99/claude-code-usage-bubble push origin v0.1.0
|
||||
```
|
||||
Wait for the Phase-1 workflow to produce `v0.1.0` release with `claude-code-usage-bubble.exe`. Download the asset locally — this is the "old" binary.
|
||||
|
||||
2. **Smoke-test the v0.1.0 download** on a Windows machine: run it, confirm the bubble appears, right-click → "Check for updates" returns "Up to date" (no v0.1.1 yet).
|
||||
|
||||
3. **Cut v0.1.1:**
|
||||
- Bump `Cargo.toml` `version = "0.1.0"` → `"0.1.1"`.
|
||||
- `cargo build --release` locally to refresh `Cargo.lock`.
|
||||
- Commit: `chore: bump version to 0.1.1`.
|
||||
- Tag: `git tag -a v0.1.1 -m "v0.1.1"`.
|
||||
- Push both: `git push origin main && git push origin v0.1.1`.
|
||||
|
||||
4. **Run the v0.1.0 binary** (still installed from step 1) and right-click → "Check for updates". Status should transition to "Update available". Click it. The process exits, ~2 s pass, the new v0.1.1 binary should launch automatically.
|
||||
|
||||
5. **Verify v0.1.1 is running:** right-click → "Check for updates" should now return "Up to date". Cross-check `claude-code-usage-bubble.exe --diagnose` log for the version line, or check file properties in Explorer.
|
||||
|
||||
6. **Cleanup if it goes wrong:**
|
||||
- Stuck "Applying" status with no swap → kill the detached `cmd.exe` in Task Manager, manually copy `%LOCALAPPDATA%\ClaudeCodeUsageBubble\updates\update.exe` over the running exe location.
|
||||
- `cmd /c` quoting broke → fix in `src/update/install.rs:58-60` and retag as `v0.1.2`.
|
||||
|
||||
7. **Run negative scenarios** (table above): path-with-spaces, read-only install dir, offline. Each must fail-soft without crashing.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] v0.1.0 release cut and asset downloaded
|
||||
- [ ] v0.1.0 binary verified runnable on Windows
|
||||
- [ ] Cargo.toml bumped to 0.1.1, committed, tagged, pushed
|
||||
- [ ] v0.1.1 release produced by CI
|
||||
- [ ] v0.1.0 binary self-updates to v0.1.1 successfully
|
||||
- [ ] Post-update, "Check for updates" returns "Up to date"
|
||||
- [ ] Negative scenario: install in path with space succeeds
|
||||
- [ ] Negative scenario: read-only install dir surfaces "Failed" status, no crash
|
||||
- [ ] Negative scenario: offline → "Failed", retry timer rearmed
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] A v0.1.0 download → click update → v0.1.1 running with no manual file copying.
|
||||
- [ ] No SmartScreen kill (it will warn on first run; that's expected and documented).
|
||||
- [ ] `%TEMP%\claude-code-usage-bubble.log` contains no `update apply failed` lines after the successful run.
|
||||
- [ ] All negative scenarios fail without crashing the bubble.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| File lock not released in 2 s window | Low-Medium | The 2 s `timeout` in `spawn_handoff` is conservative; if it ever races, bump to 3 s |
|
||||
| GitHub CDN redirect not followed by WinHTTP | Very Low | WinHTTP follows redirects by default (no `WINHTTP_OPTION_DISABLE_FEATURE` set); will surface in step 4 if broken |
|
||||
| Antivirus quarantines the freshly-written staging exe | Medium | Document the workaround (allowlist the install dir); future signing fixes this |
|
||||
| Test pollutes real release feed | Low | If you must test with throwaway tags, use `workflow_dispatch` (creates draft) instead of pushing the tag |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- The downloaded asset is fetched over HTTPS from a `*.githubusercontent.com` CDN; WinHTTP validates certs against the system root store.
|
||||
- No checksum verification yet — accepted risk (HTTPS + cert pinning is the floor). Future enhancement: ship `SHA256SUMS.txt` and verify in `install::download`.
|
||||
- The `cmd /c` command string is composed only from `current_exe()` and `stage_path()`; neither is user-controlled. No shell injection vector.
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Docs and release process"
|
||||
status: in_progress
|
||||
priority: P2
|
||||
effort: "1h"
|
||||
dependencies: [2]
|
||||
---
|
||||
|
||||
# Phase 3: Docs and release process
|
||||
|
||||
## Overview
|
||||
|
||||
Now that v0.1.x ships out of CI, update the user-facing docs to point
|
||||
people at the GitHub Release instead of "build from source", and
|
||||
write a short maintainer checklist that future-me can follow to cut
|
||||
a release without re-deriving it from this plan.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- `README.md` "Install" section points to the Releases page and the SmartScreen warning.
|
||||
- A new `docs/release-process.md` (one page) lists the cut-a-release steps: bump `Cargo.toml`, commit, tag, push.
|
||||
- Keep the "build from source" path as a secondary option for developers.
|
||||
|
||||
### Non-functional
|
||||
- `docs/release-process.md` under 60 lines.
|
||||
- No `CHANGELOG.md` — the GitHub auto-generated release notes are the changelog.
|
||||
|
||||
## Architecture
|
||||
|
||||
The README has one "Install" section (`README.md:56-66`). Replace it
|
||||
with a two-track structure:
|
||||
|
||||
```
|
||||
Install
|
||||
├── Download binary (recommended, one paragraph + SmartScreen note)
|
||||
└── Build from source (existing block, kept verbatim)
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `README.md` (Install section)
|
||||
- Create: `docs/release-process.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **README.md** — replace the "Install" section. Sketch:
|
||||
|
||||
```markdown
|
||||
## Install
|
||||
|
||||
### Download the latest release
|
||||
|
||||
Grab `claude-code-usage-bubble.exe` from the
|
||||
[Releases page](https://github.com/tiennm99/claude-code-usage-bubble/releases/latest).
|
||||
Put it anywhere on disk (e.g. `%LOCALAPPDATA%\ClaudeCodeUsageBubble\`)
|
||||
and run it. The app self-updates from the same Releases feed.
|
||||
|
||||
First-run note: the binary is unsigned, so SmartScreen will show
|
||||
"Windows protected your PC". Click "More info" → "Run anyway".
|
||||
Code signing is on the roadmap.
|
||||
|
||||
### Build from source
|
||||
|
||||
<existing block: git clone + cargo build --release>
|
||||
```
|
||||
|
||||
2. **docs/release-process.md** — new file. Sketch:
|
||||
|
||||
```markdown
|
||||
# Cutting a release
|
||||
|
||||
The `release.yml` workflow builds and publishes on every pushed
|
||||
tag matching `v*.*.*`. The workflow asserts that the pushed tag
|
||||
matches `Cargo.toml` `version` and **fails fast on mismatch**, so
|
||||
the order below matters: bump Cargo *before* you tag.
|
||||
|
||||
Steps for a new version:
|
||||
|
||||
1. Bump `Cargo.toml` `version` (`X.Y.Z`).
|
||||
2. `cargo build --release` locally to refresh `Cargo.lock`.
|
||||
3. Commit: `chore: bump version to X.Y.Z`.
|
||||
4. Tag and push:
|
||||
```bash
|
||||
git tag -a vX.Y.Z -m "vX.Y.Z"
|
||||
git push origin main
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
5. Watch the "Release" workflow run; it creates the GitHub Release
|
||||
with `claude-code-usage-bubble.exe` attached and auto-generated
|
||||
notes.
|
||||
|
||||
## Testing without a real tag
|
||||
|
||||
Use the workflow's `workflow_dispatch` input with a throwaway tag
|
||||
like `v0.0.0-test`. The release is created as a **draft**, so it
|
||||
does not show up on the public Releases feed or trigger
|
||||
self-updates for users.
|
||||
|
||||
## Versioning
|
||||
|
||||
Semver-ish: bump patch for fixes, minor for features, major for
|
||||
breaking changes (e.g. settings.json schema change). The in-app
|
||||
updater compares `Version { major, minor, patch }` lexicographically.
|
||||
```
|
||||
|
||||
3. Verify the Releases page link in the README resolves (it will once Phase 2 has cut at least v0.1.0).
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] `README.md` Install section rewritten with two tracks
|
||||
- [x] SmartScreen note added
|
||||
- [x] `docs/release-process.md` created
|
||||
- [ ] Links verified by clicking through (requires Phase 2 v0.1.0 release to exist)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] A new contributor reading only `README.md` knows how to install without building.
|
||||
- [ ] A maintainer reading only `docs/release-process.md` can cut a release without re-reading this plan.
|
||||
- [ ] No mention of "Until packaged binaries are published" remains anywhere.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| README link to `/releases/latest` 404s before first release exists | Certain pre-Phase-2 | Land this phase **after** Phase 2 has cut v0.1.0 |
|
||||
| Users skip the SmartScreen note and panic | Medium | Bold the "Click More info → Run anyway" line; mention it in the Releases body too if needed |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- The SmartScreen warning is the user's signal that the binary is unsigned. Be honest about it; do not obscure it.
|
||||
- Recommending `%LOCALAPPDATA%` as the install location keeps the user inside their writable tree (no UAC needed for self-update).
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
title: "GitHub release CI + auto-update wiring"
|
||||
description: "Publish Windows binaries to GitHub Releases via Actions so the existing in-app updater can self-update."
|
||||
status: pending
|
||||
priority: P2
|
||||
created: 2026-05-16
|
||||
---
|
||||
|
||||
# GitHub release CI + auto-update wiring
|
||||
|
||||
## Overview
|
||||
|
||||
The self-update subsystem already exists end-to-end in `src/update/`:
|
||||
`release::fetch_latest` polls `releases/latest` on GitHub, parses the
|
||||
`tag_name` into a `Version`, picks the asset whose name matches
|
||||
`claude-code-usage-bubble.exe` (or the first `.exe` as fallback), and
|
||||
`install::begin` downloads it + spawns an inline `cmd /c` handoff
|
||||
that swaps the running exe and relaunches. `app.rs` wires this to a
|
||||
24-hour timer (`UPDATE_CHECK_INTERVAL_SECS`) and the right-click menu
|
||||
("Check for updates" / "Update available" / "Applying update…").
|
||||
|
||||
What is missing is the **producer side**: no `.github/workflows/`
|
||||
directory exists, the repo has no tags, and the README explicitly
|
||||
says "Until packaged binaries are published, build from source". The
|
||||
updater therefore has nothing to pull from. Closing that loop is the
|
||||
whole job.
|
||||
|
||||
This plan ships three things: (1) a tag-triggered GitHub Actions
|
||||
workflow that builds release on `windows-latest` and attaches the
|
||||
exe to a GitHub Release, (2) an end-to-end test that proves a running
|
||||
v0.1.0 actually self-updates to v0.1.1 in the wild, and (3) the docs
|
||||
and release-cutting checklist so future versions ship by pushing a
|
||||
tag.
|
||||
|
||||
Out of scope: code signing, winget channel (`Channel::Winget` stays
|
||||
stubbed), SHA256 sidecar verification (HTTPS + cert pinning by WinHTTP
|
||||
is the security floor; checksum is a nice-to-have for later), and any
|
||||
new updater code paths beyond what the existing code already supports.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Release CI workflow](./phase-01-release-ci-workflow.md) | Files written, awaiting commit + push |
|
||||
| 2 | [End-to-end update test](./phase-02-end-to-end-update-test.md) | Pending (user-driven: requires tag pushes + Windows runs) |
|
||||
| 3 | [Docs and release process](./phase-03-docs-and-release-process.md) | Files written, awaiting commit; link verification gated on Phase 2 |
|
||||
|
||||
## Key contracts (must not break)
|
||||
|
||||
The updater is already shipped logic — these constants are the
|
||||
contract the CI workflow has to satisfy:
|
||||
|
||||
| Contract | Source | Value |
|
||||
|---|---|---|
|
||||
| Asset filename (primary match) | `src/update/release.rs:7` | `claude-code-usage-bubble.exe` |
|
||||
| Asset filename (fallback) | `src/update/release.rs:67-69` | any `*.exe` |
|
||||
| Endpoint | `src/update/release.rs:45` | `https://api.github.com/repos/tiennm99/claude-code-usage-bubble/releases/latest` |
|
||||
| Tag → version parse | `src/update/release.rs:33-41` | strips leading `v`, splits on `-`, takes `major.minor.patch` |
|
||||
| Current version source | `Cargo.toml` `version` | bumped per release |
|
||||
|
||||
A tag like `v0.1.1` → parses as `Version { 0, 1, 1 }`. The workflow
|
||||
MUST upload an asset named exactly `claude-code-usage-bubble.exe`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
No cross-plan dependencies. The prior plan
|
||||
[`260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md`](../260516-0707-cleanroom-rewrite/phase-06-updater-and-remove-notice.md)
|
||||
delivered the consumer side and is complete in code (whether its own
|
||||
phase row is checked is independent of this plan).
|
||||
|
||||
## Validation Log
|
||||
|
||||
### Session 1 — 2026-05-16
|
||||
|
||||
#### Verification Results
|
||||
- **Tier:** Standard (3 phases → Fact Checker + Contract Verifier)
|
||||
- **Claims checked:** 9
|
||||
- **Verified:** 9 | **Failed:** 0 | **Unverified:** 0
|
||||
- Claims verified: `ASSET_NAME` constant at `src/update/release.rs:7`, matcher at `src/update/release.rs:64`, fallback at `src/update/release.rs:67-69`, URL endpoint at `src/update/release.rs:45`, version parse at `src/update/release.rs:33-41`, `version_action` apply branch at `src/app.rs:1037-1066`, 24-hour interval at `src/app.rs:48`, Cargo `name = "claude-code-usage-bubble"` and `version = "0.1.0"` at `Cargo.toml:2-3`, README "Until packaged binaries are published" at `README.md:58`.
|
||||
|
||||
#### Decisions
|
||||
|
||||
1. **Version-tag match enforcement: YES, fail-fast in CI.**
|
||||
The release workflow must parse `Cargo.toml` and abort if the tag (e.g. `v0.1.1`) does not equal the Cargo version. Prevents silent mismatch where a binary self-reports a different version than the release tag — which would in turn break the updater's `Version::current() vs Version::parse(tag_name)` comparison and either skip a real update or loop on the same one.
|
||||
→ Propagated to `phase-01-release-ci-workflow.md` as a new step + extra success criterion.
|
||||
|
||||
2. **Initial release strategy: tag v0.1.0 first, then bump to v0.1.1 for the E2E test.**
|
||||
Phase 2 stays as written. Cut v0.1.0 from current `main` (no Cargo bump needed since Cargo.toml is already `0.1.0`), download the asset, bump Cargo to `0.1.1`, tag `v0.1.1`, watch the v0.1.0 binary self-update to v0.1.1. Two real releases, clean test.
|
||||
→ No changes needed in Phase 2 — already aligned.
|
||||
|
||||
3. **Asset matcher `*.exe` fallback: defer.**
|
||||
Today only one asset ships so the fallback at `src/update/release.rs:67-69` is dead code. Once multi-arch lands (`x86_64`/`arm64`), the fallback could pick the wrong binary. Tracked as a future cleanup, NOT in scope for this plan.
|
||||
→ Documented in Phase 1 success criteria as a non-action.
|
||||
|
||||
#### Whole-Plan Consistency Sweep
|
||||
- Files reread: `plan.md`, `phase-01-release-ci-workflow.md`, `phase-02-end-to-end-update-test.md`, `phase-03-docs-and-release-process.md`
|
||||
- Decision deltas checked: 3 (version-match enforcement, initial release version, asset fallback)
|
||||
- Reconciled stale references: 4
|
||||
- Phase 1 architecture flow + todo list + success criteria updated to include version-tag check
|
||||
- Phase 1 non-functional line-count budget bumped 80 → ~100 lines to match the actual YAML after adding the check step
|
||||
- Phase 1 flow diagram cleaned up (removed phantom `rustup default stable` step; added cache + dispatch-draft notation)
|
||||
- Phase 3 `release-process.md` sketch now calls out that CI enforces tag-vs-Cargo match, explaining why step ordering matters
|
||||
- Unresolved contradictions: 0
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
---
|
||||
phase: 1
|
||||
title: Research OpenCode Go contracts
|
||||
status: blocked
|
||||
priority: P1
|
||||
effort: 2h
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Research OpenCode Go Contracts
|
||||
|
||||
## Overview
|
||||
|
||||
Prove the OpenCode Go credential, provider ID, and usage-data contracts before implementation. This is the gate that prevents guessed percentages.
|
||||
|
||||
Current result: blocked. Provider/model IDs and console quota math are verified, but there is no verified stable CLI/public API for this app to fetch current weekly/monthly quota usage.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: Identify exact local credential path(s), JSON shape, auth key name, refresh/login command, weekly/monthly usage percent, and weekly/monthly reset time.
|
||||
- Non-functional: Use primary sources first: official OpenCode docs, OpenCode source, locally installed `opencode` behavior if available.
|
||||
|
||||
## Architecture
|
||||
|
||||
OpenCode Go will only become a supported provider if it can return normalized weekly/monthly data from a stable source. Official docs confirm Go limits are 5h/weekly/monthly and dollar-value based, but not a public usage endpoint. For this app, OpenCode Go must render a custom four-bar layout: weekly usage percent + weekly remaining time in the upper section, monthly usage percent + monthly remaining time in the lower section.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Read: `src/creds/mod.rs`
|
||||
- Read: `src/creds/codex_auth.rs`
|
||||
- Read: `src/usage/chatgpt.rs`
|
||||
- Read: `src/usage/types.rs`
|
||||
- Read: `src/usage/registry.rs`
|
||||
- Created: `plans/260602-1115-opencode-go-provider-support/reports/research-opencode-go-contracts.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Check official OpenCode Go docs for provider ID, limits, endpoints, and console usage semantics.
|
||||
2. Check official OpenCode CLI/source for `auth.json` storage path and schema.
|
||||
3. If OpenCode is installed locally, run safe read-only commands: `opencode stats --help`, `opencode providers --help`, `opencode models --help`.
|
||||
4. Inspect local `~/.local/share/opencode/auth.json` only with user approval if privacy hook blocks or if file may contain API keys.
|
||||
5. Search for an official usage endpoint or CLI command that returns current weekly/monthly usage percent or used/limit values plus reset/period end times.
|
||||
6. Record exact request/response shape or command output needed by Phase 3, including how to compute remaining time for weekly and monthly sections.
|
||||
7. If only console-authenticated usage exists, stop and ask user before planning implementation beyond auth detection.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] Exact credential docs path documented.
|
||||
- [x] Exact provider key documented: `opencode-go`.
|
||||
- [x] Weekly/monthly usage percent and reset-time shape verified in console source.
|
||||
- [x] Stable app-callable quota source confirmed unavailable from current CLI/docs.
|
||||
- [ ] User decision recorded: console integration, limited detector, or defer.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Main risk: OpenCode docs mention tracking usage in the console but do not document a public usage API. Mitigation: make Phase 3 blocked until a stable source exists; do not infer usage from price tables.
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
---
|
||||
phase: 2
|
||||
title: Refactor provider metadata and settings
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: 4h
|
||||
dependencies:
|
||||
- 1
|
||||
---
|
||||
|
||||
# Phase 2: Refactor Provider Metadata And Settings
|
||||
|
||||
## Overview
|
||||
|
||||
Remove hard-coded two-provider assumptions before adding OpenCode Go. Keep behavior identical for Claude Code and Codex.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: Represent enabled providers and positions by `ProviderId`, not one field per provider.
|
||||
- Non-functional: Preserve old `settings.json` compatibility for `show_claude_code`, `show_codex`, and `bubble_positions.{claude,codex}`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Introduce a small provider metadata layer near `usage::types` or `usage::registry`: stable ID, slug, display label key, tray icon ID, default enabled, and display mode. Existing providers use the current two-window mode. OpenCode Go uses a custom weekly/monthly four-bar mode.
|
||||
|
||||
Keep `ProviderId::ChatGpt` internally unless touching the code anyway. The user-facing label remains Codex. Avoid a mechanical rename that adds risk without feature value.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `src/usage/types.rs`
|
||||
- Modify: `src/usage/registry.rs`
|
||||
- Modify: `src/settings.rs`
|
||||
- Modify: `src/app.rs`
|
||||
- Modify: `src/tray/mod.rs`
|
||||
- Modify: `src/tray/badge.rs`
|
||||
- Modify: `src/usage_color.rs`
|
||||
- Modify: `src/bubble.rs`
|
||||
- Modify: `src/panel.rs`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `ProviderId::OpenCodeGo` and a stable `slug()` value `opencode-go`.
|
||||
2. Add `ProviderId::all()` or equivalent ordered provider list: Claude, Codex, OpenCode Go.
|
||||
3. Replace `Settings { show_claude_code, show_codex }` runtime logic with a provider-enabled map or compact struct that can hold `opencode_go`.
|
||||
4. Preserve serde compatibility: deserialize old fields and write new fields only after migration, or keep old fields plus add `show_opencode_go` if map migration is too broad.
|
||||
5. Extend `BubblePositions` to store OpenCode Go position while keeping old `claude` and `codex` JSON keys.
|
||||
6. Update tray icon IDs and badge colors for third provider.
|
||||
7. Add provider-specific display metadata: Claude/Codex mode=`two-window`; OpenCode Go mode=`weekly-monthly-four-bar`.
|
||||
8. Update app loops to iterate providers instead of hand-writing Claude/Codex branches where this reduces match duplication.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Existing `settings.json` with only Claude/Codex still loads.
|
||||
- [ ] If all providers disabled, Claude Code is re-enabled as today.
|
||||
- [ ] Three providers can have separate positions and tray icon IDs.
|
||||
- [ ] OpenCode Go can select a custom four-bar renderer without changing Claude/Codex labels.
|
||||
- [ ] No behavior change for existing Claude Code/Codex users.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Settings migration is the highest regression risk. Mitigation: add serde/default tests with old two-provider JSON and new three-provider JSON.
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Implement OpenCode Go provider"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "5h"
|
||||
dependencies: [1, 2]
|
||||
---
|
||||
|
||||
# Phase 3: Implement OpenCode Go Provider
|
||||
|
||||
## Overview
|
||||
|
||||
Add OpenCode Go credential discovery and polling once Phase 1 proves the source of truth.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: When enabled and authenticated, OpenCode Go returns weekly/monthly usage percent and reset time, enough for four bars: usage percent + remaining time for each section.
|
||||
- Non-functional: Do not store or transmit API keys outside OpenCode/OpenCode Go endpoints. Do not mutate OpenCode auth files.
|
||||
|
||||
## Architecture
|
||||
|
||||
Create an OpenCode Go credential source that reads OpenCode's auth store and a provider implementation that maps verified weekly/monthly usage percent + reset time into a provider-specific snapshot. Do not force OpenCode Go into the existing `UsageWindows.primary/secondary` shape if that would lose the grouped weekly/monthly rendering semantics.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `src/creds/opencode_auth.rs`
|
||||
- Create: `src/usage/opencode_go.rs`
|
||||
- Modify: `src/creds/mod.rs`
|
||||
- Modify: `src/usage/mod.rs`
|
||||
- Modify: `src/usage/registry.rs`
|
||||
- Modify: `src/usage/refresh.rs`
|
||||
- Modify: `src/app.rs`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `LocalOpenCodeGoCreds` with path detection from Phase 1. Expected primary path: `%LOCALAPPDATA%`/XDG-equivalent for `opencode/auth.json`; verify exact Windows path before coding.
|
||||
2. Parse only the OpenCode Go auth entry. Support API-key style auth if Phase 1 confirms schema.
|
||||
3. Add `RefreshHint::LocalOpenCodeCli` and spawn `opencode auth login` or equivalent only if Phase 1 confirms the command.
|
||||
4. Add `OpenCodeGoProvider::poll`, using the verified usage endpoint/command.
|
||||
5. Add a provider-specific snapshot type if needed, for example `ProviderUsage::TwoWindow(UsageWindows)` and `ProviderUsage::WeeklyMonthly { weekly: Window, monthly: Window }`.
|
||||
6. Map returned weekly/monthly source data to usage percent and reset-time windows. Existing renderer can then draw usage and remaining-time bars for each window.
|
||||
7. Return `AuthRequired` on 401/403 or missing provider auth.
|
||||
8. Add unit tests for credential parsing and response-to-snapshot mapping.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Provider compiles behind the existing registry.
|
||||
- [ ] Missing OpenCode auth produces `NoCredentials`, not panic.
|
||||
- [ ] Expired/invalid auth produces `AuthRequired`.
|
||||
- [ ] Weekly/monthly usage percent clamps to 0-100 and remaining-time bars derive from verified reset times.
|
||||
- [ ] If Phase 1 cannot verify usage, this phase is not implemented.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
OpenCode Go limits are dollar-value based. If the usage source returns dollars instead of percentages, compute percent only as `used / limit * 100` from server-provided weekly/monthly values. Never estimate usage from model prices. If reset times are unavailable, stop and ask before shipping remaining-time bars with guessed periods.
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
---
|
||||
phase: 4
|
||||
title: Rename Models UI to Providers
|
||||
status: in-progress
|
||||
priority: P2
|
||||
effort: 2h
|
||||
dependencies:
|
||||
- 2
|
||||
---
|
||||
|
||||
# Phase 4: Rename Models UI To Providers
|
||||
|
||||
## Overview
|
||||
|
||||
Rename the menu and documentation wording from "Models" to "Providers" where the app is selecting quota sources.
|
||||
|
||||
Current result: partial. Provider wording and OpenCode Go listing are implemented. The OpenCode Go four-bar renderer is still blocked by Phase 1 because no stable quota source is available.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: Context menu uses "Providers" and lists Claude Code, Codex, OpenCode Go.
|
||||
- Functional: OpenCode Go panel/bubble layout renders two grouped parts: Weekly on top and Monthly below, each with usage percent and remaining-time bars. Claude/Codex keep their current 5h/7d labels.
|
||||
- Non-functional: Keep real model wording where it means actual LLM models, not provider selection.
|
||||
|
||||
## Architecture
|
||||
|
||||
This is mostly i18n and README copy. Keep internal `model` variable renames scoped to files touched by provider iteration. Do not churn every `model` local in renderer code just for style.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `src/i18n/mod.rs`
|
||||
- Modify: `src/i18n/locales/en.toml`
|
||||
- Modify: `src/i18n/locales/ja.toml`
|
||||
- Modify: `src/i18n/locales/ko.toml`
|
||||
- Modify: `src/i18n/locales/vi.toml`
|
||||
- Modify: `src/i18n/locales/zh-TW.toml`
|
||||
- Modify: `src/app.rs`
|
||||
- Modify: `README.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Rename `LocaleStrings.models` to `providers`, or keep field name and change values if minimizing code churn is preferred.
|
||||
2. Update English label to `Providers`; update other locales with best available translation.
|
||||
3. Add `opencode_go_label = "OpenCode Go"` to all locale files.
|
||||
4. Add localized generic labels for Weekly and Monthly group headers. Reuse existing usage-percent and remaining-time visual conventions.
|
||||
5. Add or branch rendering for OpenCode Go's four-bar layout in `bubble.rs` and `panel.rs`; do not distort Claude/Codex two-bar layout.
|
||||
6. Update README sections: "displayed models" -> "displayed providers"; "### Models" -> "### Providers".
|
||||
7. Add locale schema tests for the new label.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] Right-click menu says Providers.
|
||||
- [x] README consistently describes selectable services as providers.
|
||||
- [ ] OpenCode Go UI has Weekly and Monthly grouped sections, each with usage percent and remaining-time bars.
|
||||
- [x] No accidental rename of OpenCode's documented `/models` command.
|
||||
- [x] All embedded locale tests pass.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Translations may be imperfect. Mitigation: keep provider product names untranslated and only translate the generic "Providers" label.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
phase: 5
|
||||
title: "Verify and document"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "3h"
|
||||
dependencies: [3, 4]
|
||||
---
|
||||
|
||||
# Phase 5: Verify And Document
|
||||
|
||||
## Overview
|
||||
|
||||
Prove the third-provider workflow works and document setup/privacy accurately.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: Claude Code, Codex, and OpenCode Go can be toggled independently; polling and tray refresh do not regress.
|
||||
- Non-functional: Privacy docs must list every local file read and network endpoint called.
|
||||
|
||||
## Architecture
|
||||
|
||||
Verification must cover pure tests, build, and Windows manual smoke tests. OpenCode Go cannot be fully claimed without a real authenticated account or a captured fixture from Phase 1.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `README.md`
|
||||
- Optional Modify: `docs/release-process.md` only if release steps change
|
||||
- Read: `src/app.rs`
|
||||
- Read: `src/settings.rs`
|
||||
- Read: `src/usage/*`
|
||||
- Read: `src/creds/*`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Run `cargo test`.
|
||||
2. Run `cargo build --release`.
|
||||
3. Run app with old settings file and confirm migration/defaults.
|
||||
4. Run app with all three providers enabled; verify separate bubbles, positions, tray icons, panel labels, and menu toggles.
|
||||
5. Verify missing OpenCode Go auth shows auth/no-credentials state without breaking other providers.
|
||||
6. With real OpenCode Go auth, verify weekly/monthly percent and remaining-time bars match the official console/source found in Phase 1.
|
||||
7. Update README privacy section with OpenCode auth file and endpoints.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Tests pass.
|
||||
- [ ] Release build passes.
|
||||
- [ ] Existing two-provider settings migrate.
|
||||
- [ ] Three-provider UI works on Windows.
|
||||
- [ ] OpenCode Go renders four bars: weekly usage percent, weekly remaining time, monthly usage percent, monthly remaining time.
|
||||
- [ ] README setup/privacy docs are accurate.
|
||||
- [ ] No unresolved OpenCode Go usage-source uncertainty remains.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Manual verification needs real OpenCode Go auth. If unavailable, ship code only behind disabled-by-default toggle and explicitly mark e2e verification deferred.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: OpenCode Go provider support and providers terminology
|
||||
description: >-
|
||||
Add OpenCode Go as a usage provider and rename user-facing Models wording to
|
||||
Providers.
|
||||
status: in-progress
|
||||
priority: P2
|
||||
branch: main
|
||||
tags:
|
||||
- providers
|
||||
- opencode-go
|
||||
- usage
|
||||
- ui-copy
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: '2026-06-02T04:20:10.983Z'
|
||||
createdBy: 'ck:plan'
|
||||
source: skill
|
||||
---
|
||||
|
||||
# OpenCode Go Provider Support And Providers Terminology
|
||||
|
||||
## Overview
|
||||
|
||||
Add OpenCode Go as a third selectable usage provider beside Claude Code and Codex. Rename the app's user-facing "Models" menu/copy to "Providers" because the app chooses quota sources/services, not individual model IDs.
|
||||
|
||||
I agree with the rename. OpenCode itself still has a `/models` command, but this app's menu toggles Claude Code, Codex, and OpenCode Go providers. Internals already use `ProviderId`, so the naming is conceptually aligned.
|
||||
|
||||
## Codebase Findings
|
||||
|
||||
- Rust Win32 app; usage abstraction lives under `src/usage/*`.
|
||||
- Current extension points: `UsageProvider`, `ProviderId`, `Registry`, `CredentialSource`, `RefreshHint`.
|
||||
- Two-provider assumptions remain in `src/settings.rs`, `src/app.rs`, `src/tray/mod.rs`, `src/tray/badge.rs`, `src/panel.rs`, `src/usage_color.rs`, i18n TOMLs, and README.
|
||||
- OpenCode official docs say Go is configured as an OpenCode provider, uses `/connect`, stores credentials in `~/.local/share/opencode/auth.json`, has 5h/weekly/monthly dollar-value limits, and current usage is visible in the console.
|
||||
- User decision: OpenCode Go should display four bars in two parts: upper part is weekly usage, lower part is monthly usage. Each part has the same two concepts as current Claude/Codex: usage percent and remaining time. Keep Claude/Codex on their existing two-bar presentation unless a separate UI redesign decides otherwise.
|
||||
- OpenCode docs expose model endpoints and `https://opencode.ai/zen/go/v1/models`, but do not document a stable usage endpoint. Do not fake usage percentages.
|
||||
- Phase 1 found the OpenCode console computes `weeklyUsage` and `monthlyUsage` with `usagePercent` and `resetInSec`, but only through an authenticated console server query. `opencode stats` is local cost/token history and cannot drive the requested Go bars.
|
||||
|
||||
## Related Plans
|
||||
|
||||
- `plans/260516-0707-cleanroom-rewrite/plan.md`: introduced current provider abstraction; related, no blocking dependency.
|
||||
- `plans/260523-ui-ux-improvement-plan/plan.md`: overlaps labels/tooltips; related, no blocking dependency.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Research OpenCode Go contracts](./phase-01-research-opencode-go-contracts.md) | Blocked |
|
||||
| 2 | [Refactor provider metadata and settings](./phase-02-refactor-provider-metadata-and-settings.md) | Completed |
|
||||
| 3 | [Implement OpenCode Go provider](./phase-03-implement-opencode-go-provider.md) | Pending |
|
||||
| 4 | [Rename Models UI to Providers](./phase-04-rename-models-ui-to-providers.md) | In Progress |
|
||||
| 5 | [Verify and document](./phase-05-verify-and-document.md) | Pending |
|
||||
|
||||
## Dependencies
|
||||
|
||||
Phase 1 gates Phase 3. If no stable OpenCode Go usage source is found, pause before Phase 3 and ask user whether to defer usage support or ship a limited connectivity/auth detector.
|
||||
|
||||
Phase 2 is implemented. Phase 4 provider wording is implemented; OpenCode Go remains listed but disabled until Phase 1's quota-source decision is resolved.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- OpenCode Go can be enabled/disabled independently without breaking Claude Code or Codex.
|
||||
- Existing settings migrate safely; at least one provider remains enabled.
|
||||
- UI and README say "Providers" where the app means selectable services.
|
||||
- No guessed OpenCode Go usage. Weekly/monthly percent and reset-time data comes from verified source, or feature pauses.
|
||||
- `cargo test` and `cargo build --release` pass.
|
||||
|
||||
## Sources
|
||||
|
||||
- OpenCode Go docs: https://dev.opencode.ai/docs/go/
|
||||
- OpenCode CLI docs: https://opencode.ai/docs/cli/
|
||||
- Current repo README: `README.md`
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Research: OpenCode Go Contracts
|
||||
|
||||
Date: 2026-06-02
|
||||
Status: blocked
|
||||
|
||||
## Summary
|
||||
|
||||
OpenCode Go provider/model identity is verified, and the official console computes the exact quota fields we want. Implementation should not proceed yet because the verified quota source is an authenticated console server query, not a documented CLI command or public endpoint this desktop app can call safely.
|
||||
|
||||
## Verified Facts
|
||||
|
||||
- Provider/model ID format: OpenCode Go models use `opencode-go/<model-id>`, for example `opencode-go/kimi-k2.6`.
|
||||
- Official limits: Go has a rolling 5-hour window, a weekly window, and a monthly window. The requested UI only needs weekly and monthly sections.
|
||||
- Limits are cost based, not request-count based: weekly `$30`, monthly `$60`, and rolling 5-hour `$12`.
|
||||
- CLI auth docs say credentials are stored at `~/.local/share/opencode/auth.json`; locally, `opencode providers` is an alias for auth management.
|
||||
- Local `opencode stats` only aggregates local sessions from the OpenCode database and reports cost/token totals. It does not report Go quota percentages or reset times.
|
||||
- Console source has the desired shape:
|
||||
- `weeklyUsage: { status, resetInSec, usagePercent }`
|
||||
- `monthlyUsage: { status, resetInSec, usagePercent }`
|
||||
- Console source derives those from `LiteTable.weeklyUsage`, `LiteTable.monthlyUsage`, `LiteTable.timeWeeklyUpdated`, `LiteTable.timeMonthlyUpdated`, `LiteTable.timeCreated`, and `LiteData.getLimits()`.
|
||||
|
||||
## Sources
|
||||
|
||||
- OpenCode Go docs: https://dev.opencode.ai/docs/go/
|
||||
- OpenCode CLI docs: https://opencode.ai/docs/cli/
|
||||
- OpenCode source: `packages/opencode/src/cli/cmd/stats.ts`
|
||||
- OpenCode source: `packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx`
|
||||
- OpenCode source: `packages/console/core/src/subscription.ts`
|
||||
- Local command: `opencode stats --help`
|
||||
|
||||
## Decision
|
||||
|
||||
Do not implement OpenCode Go four-bar usage yet. The app cannot honestly render weekly/monthly usage percent and remaining time until one of these is chosen:
|
||||
|
||||
1. Use an official/detected console endpoint with user-approved authentication.
|
||||
2. Ship only provider/auth detection now and hide/disable Go usage bars until quota data is available.
|
||||
3. Ask OpenCode upstream for a documented quota API or CLI output.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
- Should this app integrate with the authenticated OpenCode console, or should Go support be limited to detection until OpenCode exposes a stable quota API?
|
||||
- Do we still want to continue Phase 2/4 now for the provider terminology rename, while deferring Phase 3?
|
||||
@@ -0,0 +1,192 @@
|
||||
# Brainstorm: high-leverage improvements for claude-code-usage-bubble
|
||||
|
||||
Date: 2026-05-16
|
||||
Baseline: v0.1.2 — bubble UX just polished (commits 5df75c9...7f8ccf0), updater bug just fixed (a132c02), auto-update frequency just added (2ca5052), windows release pipeline just added (60cde29).
|
||||
|
||||
Read scope: README, src/app.rs, src/bubble.rs, src/panel.rs, src/usage/{types,mod,anthropic}.rs, Cargo.toml, last 10 commits.
|
||||
|
||||
---
|
||||
|
||||
## Reality-check (cuts I refuse to dress up)
|
||||
|
||||
- Codebase is heavily Win32. windows-rs 0.58, GDI, AppBar, WinHTTP, registry-based autostart. Anything multi-platform is a near-rewrite of the UI shell. Most polish effort beats most expand effort.
|
||||
- The product is small and good. Two bars x two providers x one bubble. Do not bloat it. The risk is feature creep that turns it into the thing it was reacting against.
|
||||
- The user is also the author — there is no marketing motion to feed. Distribution work only pays off if you actually want strangers using it. Decide that first; everything in section 4 and 7 is conditional on yes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bubble UX
|
||||
|
||||
What 360 Security / IObit memory balls do that this does not: a one-tap action (their boost button), idle micro-animation that draws the eye, a state-change pulse when a number crosses a threshold, edge-dock that fully tucks against the screen edge showing only a sliver.
|
||||
|
||||
### 1a. Threshold pulse + colour-state escalation
|
||||
- One-liner: when 5h utilization crosses 80 / 95 percent, the ring pulses once (already have TIMER_PULSE) and the accent shifts amber to red; when it drops after reset, single subtle release animation.
|
||||
- Why: the user reason to look at the bubble is "am I close?" — passive colour is fine when sitting at 30 percent, but at 92 percent you want the bubble to grab you once and then shut up.
|
||||
- Effort: S. Pulse timer already exists; need state-machine on percentage threshold + hysteresis (do not pulse every poll).
|
||||
- Mistake if: you make it pulse continuously above a threshold. That is a notification, not a bubble. One pulse per crossing, that is it.
|
||||
|
||||
### 1b. Edge-dock sliver mode
|
||||
- One-liner: when snapped to an edge for >5s with no interaction, contract to a thin coloured stripe (~6px) along the edge showing only the higher-of-(5h, 7d) percentage as a bar. Hover or click expands back.
|
||||
- Why: the bubble at 200px is a lot of permanent screen real estate. The memory ball UX wins because at rest it is almost invisible.
|
||||
- Effort: M. Need new render path + hover-region + state transition. Risk of getting the hit-test wrong.
|
||||
- Mistake if: the dock is so subtle the user cannot find it again. Keep a 1-2px coloured accent.
|
||||
|
||||
### 1c. Dark/light auto-follow (Windows system theme)
|
||||
- One-liner: subscribe to WM_SETTINGCHANGE ImmersiveColorSet + read AppsUseLightTheme from HKCU; AppState.is_dark follows instead of being static.
|
||||
- Why: bubble currently looks alien on the opposite theme. This is the cheapest feels-native win available.
|
||||
- Effort: S. Single registry read on startup + WM_SETTINGCHANGE handler.
|
||||
- Mistake if: you also try to do per-bubble theme override. YAGNI — system theme is correct ~100 percent of the time.
|
||||
|
||||
### 1d. Accent-colour follow (system)
|
||||
- One-liner: read HKCU Software Microsoft Windows DWM AccentColor and tint the ring fill at low utilization (where the colour is currently arbitrary).
|
||||
- Why: makes the widget feel like a system component. Cheap perceived-quality lift.
|
||||
- Effort: S.
|
||||
- Mistake if: you let accent colour override the amber/red threshold states. Threshold > accent.
|
||||
|
||||
### Cut from section 1
|
||||
- Custom theming UI. No. YAGNI. System theme is enough.
|
||||
- Bubble shapes other than rounded-rect. The whole circle framing in the README is already a fib (it is a 3:1 rounded rect). Do not add more shape options.
|
||||
|
||||
---
|
||||
|
||||
## 2. Information density
|
||||
|
||||
The data already in UsageWindows is only utilization (0-100) + resets_at. Anything else needs new endpoint work or local computation. Be honest about that cost.
|
||||
|
||||
### 2a. Burn rate + projected exhaustion (computed, no new API)
|
||||
- One-liner: track utilization samples in a small ring buffer; in the panel, show "at this rate, you will hit 100 percent in ~Xh" beneath the 5h bar.
|
||||
- Why: the actual decision the user is making is "do I context-switch now or finish this thought?" Projected exhaustion answers that; raw percentage does not.
|
||||
- Effort: S-M. Ring buffer + linear regression over last N samples; only show when slope is meaningfully positive.
|
||||
- Mistake if: you put the projection on the bubble itself. It is noise there. Panel-only.
|
||||
|
||||
### 2b. Delta-since-last-reset summary in panel
|
||||
- One-liner: when the 5h window resets, snapshot the previous peak percentage. Show "Last cycle peaked at 88 percent" in the panel.
|
||||
- Why: builds intuition over time about whether usage is growing or shrinking without any backend.
|
||||
- Effort: S. One extra field in settings/state.
|
||||
- Mistake if: you try to show a chart. Tiny number, one line, done.
|
||||
|
||||
### 2c. Do NOT add: token count, dollar cost, model breakdown
|
||||
- Cut. Anthropic oauth/usage endpoint returns utilization buckets, not token counts. The fallback path scrapes rate-limit headers. Neither gives reliable dollar cost. Inventing one will be wrong and erode trust. Do not ship guesses as facts.
|
||||
|
||||
### Cut from section 2
|
||||
- Per-conversation/per-project breakdown. Anthropic does not expose this in oauth/usage. Do not promise what you cannot deliver.
|
||||
|
||||
---
|
||||
|
||||
## 3. Workflow integrations
|
||||
|
||||
### 3a. Threshold balloon notification (one-shot)
|
||||
- One-liner: at 80 / 95 percent crossings, fire a Shell_NotifyIcon balloon ("Claude 5h at 95 percent — resets in 42m"). Already have BALLOON_COOLDOWN and last_balloon_at plumbed; extend the trigger from "update available" to "threshold crossed".
|
||||
- Why: a user with the bubble auto-hidden during fullscreen game/video still wants to know they are about to run out. This is the single highest-impact integration because the integration target is the user, not another app.
|
||||
- Effort: S. Plumbing exists; just add the trigger.
|
||||
- Mistake if: you fire it every poll above 95 percent. Once per crossing, per reset cycle.
|
||||
|
||||
### 3b. Cut: tailing Claude Code logs / hooks
|
||||
- The Claude Code CLI does emit logs (~/.claude/) and supports hooks, but inferring usage from them is fragile vs. the official oauth/usage endpoint you are already hitting. Do not dual-source the same number.
|
||||
|
||||
### Cut from section 3
|
||||
- Slack/Discord/webhook out. No. This is a personal desktop widget. If you want webhooks, you are building a different product.
|
||||
- Pause Claude Code when over limit. Out of scope. The bubble observes, does not control.
|
||||
|
||||
---
|
||||
|
||||
## 4. Distribution + onboarding
|
||||
|
||||
Only worthwhile if you want strangers using it. State the goal explicitly before doing any of this.
|
||||
|
||||
### 4a. winget manifest
|
||||
- One-liner: submit a manifest to microsoft/winget-pkgs once you have at least one signed (or accepted-unsigned-with-hash) release.
|
||||
- Why: winget install tiennm99.ClaudeCodeUsageBubble is the only Windows install command anyone actually wants to run. Free distribution.
|
||||
- Effort: S (one PR to winget-pkgs) once the release artifact has a stable URL + SHA256, which it now does.
|
||||
- Mistake if: you submit before code signing. winget accepts unsigned packages but SmartScreen still nags; that user pain accrues to your repo, not winget.
|
||||
|
||||
### 4b. First-run is-everything-working check
|
||||
- One-liner: on first launch with no settings.json, run the same checks as --diagnose once: can I find Claude creds? Can I reach Anthropic? Show a tiny one-time panel that says "Claude OK, Codex not configured (enable in Models menu)" and dismisses.
|
||||
- Why: silent failure is the worst onboarding outcome. The bubble showing dash-percent tells users nothing.
|
||||
- Effort: S. --diagnose already exists; reuse the logic.
|
||||
- Mistake if: it becomes a wizard. One panel, one dismiss, never again.
|
||||
|
||||
### Cut from section 4
|
||||
- MSIX. Cut. MSIX requires the Store or sideload pain. Cost > benefit for an indie widget.
|
||||
- MSI installer. Cut. A 4-MB single exe that drops in LOCALAPPDATA is better than an MSI for this audience.
|
||||
- Crash dumps. The app is small and runs in a single Win32 message loop. simplelog to TEMP claude-code-usage-bubble.log already covers 95 percent of post-mortem needs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Multi-platform reality check
|
||||
|
||||
Skip this axis. The codebase is windows::Win32:: from top to bottom — bubble window, panel, tray, AppBar, registry autostart, WinHTTP. Porting macOS/Linux is a full UI-shell rewrite (~70 percent of the code), and the value proposition (a desktop memory ball) does not translate cleanly — macOS users expect a menu-bar app, Linux users expect a tray icon and most distros do not have a stable always-on-top floating layer.
|
||||
|
||||
If you genuinely want cross-platform, the right move is not to port this — it is a separate claude-code-usage-menubar for macOS that reuses src/usage/ and src/creds/ as a library crate. Worth noting but not worth doing unless someone asks.
|
||||
|
||||
---
|
||||
|
||||
## 6. Updater roadmap
|
||||
|
||||
### 6a. SHA256 verification of downloaded artifact
|
||||
- One-liner: GitHub Releases shipped via your windows-release.yml already produce a stable URL; publish a SHA256SUMS file as part of the release, fetch + verify before swapping the exe.
|
||||
- Why: defends against a compromised CDN / MITM regardless of code signing. Way cheaper than signing.
|
||||
- Effort: S. Add to the GH Actions release step; verify in src/update/.
|
||||
- Mistake if: you skip this because GitHub releases use HTTPS so MITM is impossible. HTTPS is not integrity; an attacker with a release-asset upload token or a compromised CI also matters.
|
||||
|
||||
### 6b. Code signing — defer, do not romanticise
|
||||
- A standard EV cert is ~300-600 dollars/year and an OV cert ~200-400 dollars/year, and even with OV you still wait weeks for SmartScreen reputation. EV gets you immediate SmartScreen trust but the HSM-bound key is operationally annoying for solo devs.
|
||||
- Verdict: defer until install volume justifies it (>1000 downloads/release, say). The Run-anyway friction is real but survivable; the cost-per-user of a cert at low volume is very high.
|
||||
- Effort if pursued: M (cert setup) + ongoing key custody pain.
|
||||
- Mistake if: you sign without rotating to an HSM-backed solution. A leaked signing key is worse than no signing.
|
||||
|
||||
### 6c. Beta channel via GitHub pre-releases
|
||||
- One-liner: settings.json already supports install_channel; surface it in right-click menu as "Channel Stable / Beta" so people can dogfood.
|
||||
- Why: low-cost, high-trust signal for early adopters; gives you canaries before stable.
|
||||
- Effort: S. One menu item, one settings field, one filter on the GH Releases list.
|
||||
- Mistake if: you ship a beta that bricks the updater (see v0.1.2). Add a "downgrade to last stable" affordance.
|
||||
|
||||
### Cut from section 6
|
||||
- Delta updates. No. The exe is ~4 MB. Bandwidth is not the bottleneck. Delta-patching machinery is bug surface.
|
||||
- Rollback. Mostly cut. Keeping the previous exe as .bak on update and a --rollback flag is fine (S), but a full rollback UI is overkill.
|
||||
|
||||
---
|
||||
|
||||
## 7. Brand / discovery
|
||||
|
||||
Only worth doing if you actually want users beyond yourself. Sketched at low cost:
|
||||
|
||||
### 7a. Demo GIF in README (top, above install)
|
||||
- One-liner: 6-8 second loop showing bubble at idle, drag-to-edge snap, left-click expand panel, right-click menu.
|
||||
- Why: the entire product is visual. Words do not sell a floating bubble — the GIF will convert orders-of-magnitude better than the current shield-badges.
|
||||
- Effort: S. ScreenToGif then optimise to <1 MB.
|
||||
- Mistake if: you record it on a 4K monitor and it weighs 8 MB and breaks the README. Keep it <1.5 MB.
|
||||
|
||||
### 7b. GitHub topics + a short tagline
|
||||
- Topics: windows-desktop, rust, claude-code, codex, usage-monitor, widget, system-tray. The README H1 is fine but the GitHub repo description / About should be one tweet-length line.
|
||||
- Effort: 5 minutes. Free.
|
||||
|
||||
### Cut from section 7
|
||||
- Landing page / dedicated site. Cut until install volume justifies. The repo is the landing page.
|
||||
- Twitter/Bluesky launch posts. Author call. Not a product question.
|
||||
|
||||
---
|
||||
|
||||
## Top 5 I would ship first, ranked
|
||||
|
||||
1. Dark/light auto-follow (1c). S effort, immediate feels-native lift, zero risk. Ship today.
|
||||
2. Threshold balloon at 80/95 percent (3a). S effort. The single biggest jump in usefulness on the entire list — it converts the widget from a thing you have to look at into a thing that tells you.
|
||||
3. Demo GIF in README (7a). S effort, only useful if you want strangers, but if you do it is the unlock.
|
||||
4. SHA256 verification in updater (6a). S effort, plugs a real integrity hole that exists today, cheaper than signing.
|
||||
5. Edge-dock sliver mode (1b). M effort but this is the differentiator vs. the upstream taskbar-widget approach — it is what floating bubble actually wants to be. Worth the M.
|
||||
|
||||
Honourable mention: first-run sanity check (4b) — only if you ship #3 first and start getting "it shows nothing, is it broken?" issues.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Do you actually want external users? Section 4 and 7 are no-ops if not.
|
||||
2. Are you willing to take ~200-600 dollars/yr on code signing within the next year, or is "unsigned + SmartScreen warning" the permanent stance? Affects whether 6b is a roadmap item or a no.
|
||||
3. Is there a deliberate reason Settings already has install_channel but no UI surface (6c)? If it was intentional dormancy, fine; if it was an oversight, that is a free win.
|
||||
4. macOS port — yes/no/later? Affects whether src/usage/ and src/creds/ should be refactored into a separate crate now (cheap) vs. later (painful).
|
||||
|
||||
---
|
||||
|
||||
Status: DONE
|
||||
Summary: 14 picks across 6 of 7 axes (multi-platform skipped with rationale). Top-5 ranked. 4 unresolved questions for the user.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Brainstorm: Off-Screen Bubble Recovery
|
||||
|
||||
## 1. Recovery strategies ranked
|
||||
|
||||
### RECOMMEND — Layered: validate on load + clamp on create
|
||||
|
||||
**A. Validate position in `settings::load`** (primary defense)
|
||||
- After deserialize, walk `bubble_positions`. For each `Some((x,y))`, build a probe rect `(x, y, x+min_w, y+min_h)` and check via `MonitorFromRect(... MONITOR_DEFAULTTONULL)`. If null → set to `None`.
|
||||
- One pass, ~15 lines, runs before any window code sees the value.
|
||||
- Pro: KISS, no race with `ShowWindow`, fixes related bugs (hand-edited JSON, dpi-changed coords).
|
||||
|
||||
**B. Clamp on create** (defense-in-depth, kept as proposed)
|
||||
- After `CreateWindowExW`, before `ShowWindow`, call `clamp_into_work_area(hwnd)`.
|
||||
- Catches monitor unplug **between** `load()` and `create()` (rare but possible: laptop closed mid-startup).
|
||||
- Cost: one extra call, idempotent.
|
||||
|
||||
Both together are the right answer. Neither alone covers all cases.
|
||||
|
||||
### CONSIDER — Visual cue
|
||||
|
||||
**C. Tray balloon "Widget repositioned to primary monitor"**
|
||||
- Only when validator actually relocated. Uses existing `Shell_NotifyIconW NIF_INFO`. ~20 lines.
|
||||
- Risk: balloon spam on dock/undock cycles. Fire only when *saved* position was killed.
|
||||
|
||||
### AVOID
|
||||
|
||||
**D. Topology fingerprint** — overkill, doesn't preserve intent better than (A).
|
||||
**E. Per-monitor relative pinning** — future feature, not a fix. YAGNI.
|
||||
|
||||
## 2. Trade-off matrix
|
||||
|
||||
| Approach | Preserves intent on replug | Surprise on cold start | LOC | Risk |
|
||||
|----------|---------------------------|------------------------|-----|------|
|
||||
| A (validate-on-load) | No — wipes saved coord | Low | ~15 | None |
|
||||
| B (clamp-on-create) | Partial — moves to nearest edge | Low | ~3 | None |
|
||||
| A+B | No | Low | ~18 | None |
|
||||
| A+B+C | Same + explains itself | Very low | ~38 | Balloon fatigue |
|
||||
| D (topology hash) | Yes if same monitor before next launch | Medium | ~80 | Maintenance |
|
||||
| E (relative pin) | Yes | Medium | ~150 | Premature |
|
||||
|
||||
## 3. Edge cases proposed fix misses
|
||||
|
||||
1. **Saved-pos monitor asleep / no input** — `MonitorFromRect` still returns handle; A+B no-op. Correct.
|
||||
2. **DPI change while app closed** — px coords technically valid by topology; A passes, B no-op. Acceptable.
|
||||
3. **Negative-coord monitors (secondary left of primary)** — validator MUST use `MONITOR_DEFAULTTONULL`, not `DEFAULTTONEAREST` (would silently snap valid secondary coord to primary).
|
||||
4. **Dual bubbles overlap after relocate** — both clamped to bottom-right of primary → stacked. `default_position` staggers Codex; clamp doesn't. Minor.
|
||||
5. **User drags to secondary, unplugs, restarts** — A+B: bubble at primary default; saved pos destroyed. No recovery on replug. Acceptable for v1.
|
||||
6. **Dock-daily multi-monitor user** — every undock wipes pos; every dock back gives default. Annoying. Case where E would win. Punt unless reported.
|
||||
|
||||
## 4. Logging strategy (minimal)
|
||||
|
||||
On the visibility-affecting path only:
|
||||
|
||||
- `info`: `bubble create model={} pos=({},{}) size={}x{} dpi={}` — one line per bubble at create.
|
||||
- `warn`: `bubble position ({},{}) outside all monitors, resetting to default` — fires in validator. **This is the line that would have solved this bug in 5 seconds.**
|
||||
- `warn`: `clamp_into_work_area moved bubble from ({},{}) to ({},{})` — fires on create-time clamp.
|
||||
- `debug`: monitor enumeration on startup.
|
||||
|
||||
Skip: per-render logs, drag logs, timer ticks.
|
||||
|
||||
## 5. "Reset position" discoverability — secondary
|
||||
|
||||
Menu item exists but buried. If A+B work, this path is unreachable. Don't add a "Reset position" balloon prompt — confirmation fatigue. Just fix silently and the §4 warn + §C balloon explain it once.
|
||||
|
||||
## Recommended action
|
||||
|
||||
1. Add `BubblePositions::validate(&mut self)` called from `settings::load`. Use `MonitorFromRect(... MONITOR_DEFAULTTONULL)` with `(x, y, x+MIN_BUBBLE_SIZE, y+MIN_BUBBLE_SIZE)`. Set to `None` on miss. Log `warn`.
|
||||
2. Call `clamp_into_work_area(hwnd)` in `bubble::create` between `CreateWindowExW` and `ShowWindow`. Log `warn` on movement.
|
||||
3. Add the 4 log lines from §4.
|
||||
4. Single tray balloon "Widget repositioned: previous monitor not connected" once per launch when validator killed any saved position.
|
||||
5. Defer monitor-index pinning (E) and topology hash (D).
|
||||
|
||||
Total: ~40 lines, one new function, two log statements, one balloon call.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- Balloon (item 4): opt-in or always-on? Default always-on.
|
||||
- `MIN_BUBBLE_SIZE` as probe rect, or account for current `bubble_size_logical`? Min safer.
|
||||
- Re-attempt last-known coord on replug? Probably no — YAGNI.
|
||||
- Codex/Claude stagger preservation on auto-relocate? Currently they'd stack. Worth fixing in same patch?
|
||||
@@ -0,0 +1,124 @@
|
||||
# Silent in-app update + restart (no cmd.exe)
|
||||
|
||||
**Date:** 2026-05-21
|
||||
**Author:** brainstormer
|
||||
**Status:** approved (ready for `/ck:plan`)
|
||||
|
||||
## Problem
|
||||
|
||||
User sees an occasional flash terminal window. Two paths today spawn `cmd.exe /c "timeout ... & start ..."` for update install and app restart, with `CREATE_NO_WINDOW | DETACHED_PROCESS`. Combination of those flags + the inner `start ""` invocation can still emit a brief console flash on some Windows configs (Defender hooks, conhost init, AV inspection). Goal: zero-flash, fully silent auto-update + restart, with in-app notification.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope**
|
||||
- `src/update/install.rs::begin` — kill cmd.exe handoff
|
||||
- `src/app.rs::restart_app` — kill cmd.exe handoff
|
||||
- New CLI flag `--wait-pid <pid>` on the main binary (cooperates with itself across update)
|
||||
- Cleanup of stale `bubble.exe.old.*` siblings at startup
|
||||
- Tray balloon "Updated to vX.Y.Z" on first run after auto-update
|
||||
|
||||
**Out of scope (split as follow-up)**
|
||||
- `src/usage/refresh.rs` CLI spawns (`claude.cmd`, `codex.cmd`, `powershell.exe`, `wsl.exe`) — already use `CREATE_NO_WINDOW`; address separately if flash persists after this change
|
||||
- `src/creds/wsl_bridge.rs` `wsl.exe` calls — same reasoning
|
||||
|
||||
## Approaches evaluated
|
||||
|
||||
| Approach | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| A: Native rename + direct `CreateProcessW` + `--wait-pid` | **CHOSEN** | Zero cmd.exe ⇒ zero flash possible; single-binary preserved; matches existing Win32 style; recoverable on interrupt |
|
||||
| B: Helper-exe pattern (`bubble-updater.exe`) | rejected | Reverses deliberate "no helper exe" decision (`src/update/install.rs:3-5`); release-pipeline change; helper bootstrap problem |
|
||||
| C: NTFS POSIX atomic replace (`FileRenameInfoEx`) | rejected | Obscure API; harder failure modes with AV / image-protection; not worth the elegance trade-off |
|
||||
|
||||
## Final design — Approach A
|
||||
|
||||
### Update install flow
|
||||
|
||||
```
|
||||
1. fetch_latest() (unchanged — pure HTTP via WinHTTP)
|
||||
2. download(release_url, staging_path) (unchanged — sha256 verify)
|
||||
3. rename current.exe -> current.exe.old.<pid> (MoveFileExW, allowed while running)
|
||||
4. move staging.exe -> current.exe (MoveFileExW REPLACE_EXISTING)
|
||||
5. settings::save(snap) (defensive flush, unchanged)
|
||||
6. release singleton mutex (explicit ReleaseMutex + CloseHandle)
|
||||
7. CreateProcessW(current.exe, "--wait-pid <our_pid> --updated-to vX.Y.Z",
|
||||
CREATE_NO_WINDOW | DETACHED_PROCESS)
|
||||
8. PostQuitMessage(0)
|
||||
```
|
||||
|
||||
### Restart flow (settings-change path)
|
||||
|
||||
```
|
||||
1. settings::save(snap)
|
||||
2. CreateProcessW(current.exe, "--wait-pid <our_pid>",
|
||||
CREATE_NO_WINDOW | DETACHED_PROCESS)
|
||||
3. release singleton mutex
|
||||
4. PostQuitMessage(0)
|
||||
```
|
||||
|
||||
### New instance startup additions
|
||||
|
||||
```rust
|
||||
// Before acquiring Global\ClaudeCodeUsageBubble mutex:
|
||||
if let Some(parent_pid) = parse_wait_pid_arg() {
|
||||
let h = OpenProcess(SYNCHRONIZE, FALSE, parent_pid)?;
|
||||
WaitForSingleObject(h, 5000); // 5s cap; proceed regardless
|
||||
CloseHandle(h);
|
||||
}
|
||||
|
||||
// After main window is up:
|
||||
cleanup_old_exes(current_dir, "bubble.exe.old.*");
|
||||
|
||||
if let Some(version) = parse_updated_to_arg() {
|
||||
tray::show_balloon(t!("update.toast.updated_to", v = version));
|
||||
}
|
||||
```
|
||||
|
||||
### Why `--wait-pid` instead of cmd's `timeout /t 2`
|
||||
|
||||
- `timeout` is a cmd.exe builtin; using it requires cmd.exe.
|
||||
- `WaitForSingleObject` on the parent process handle is the canonical Win32 idiom: zero delay if parent already exited, exact timing when it actually exits, no console involvement.
|
||||
- Bonus: removes the magic "2 seconds is enough" guess.
|
||||
|
||||
### Path safety
|
||||
|
||||
The current `reject_unsafe_path` (`%` rejection) becomes unnecessary — no cmd.exe to expand `%var%`. Keep the function for defense-in-depth; revisit in code review.
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/update/install.rs` | Replace `spawn_handoff` with `swap_and_spawn` using `MoveFileExW` + `CreateProcessW`; drop `cmd` arg-quoting code |
|
||||
| `src/update/mod.rs` | Add `Error::SwapFailed` variant if needed |
|
||||
| `src/app.rs::restart_app` | Replace cmd.exe spawn with `CreateProcessW` + mutex release ordering |
|
||||
| `src/app.rs` | Add CLI flag parsing for `--wait-pid` and `--updated-to`; cleanup pass for `.old.*` siblings; balloon tray call on successful update boot |
|
||||
| `src/main.rs` | Wire `--wait-pid` into the early-startup mutex-acquisition path (BEFORE `update::run_cli` check) |
|
||||
| `src/tray/mod.rs` (or `tray/badge.rs`) | Confirm `Shell_NotifyIconW` with `NIF_INFO` balloon is supported by current tray code; add helper if missing |
|
||||
| `src/i18n/*` | New string keys: `update.toast.updated_to` |
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `MoveFileExW` rename fails (NTFS permission denied, AV scanner holding handle) | Surface `Error::NotWritable` to user; do NOT proceed to step 4 (still recoverable — original exe untouched at this point) |
|
||||
| New instance crashes before clearing old exe ⇒ `.old.*` accumulates | Cleanup glob `bubble.exe.old.*` on every startup is idempotent and cheap |
|
||||
| Mutex race: new instance acquires before old releases | `--wait-pid` + `WaitForSingleObject(5000ms)` covers it; if it times out the new instance retries `CreateMutexW` in a 200ms loop for ~3s before giving up |
|
||||
| User on FAT32 / non-NTFS volume | `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` still works on FAT32; renaming-while-running is the NTFS-specific concern but the .exe is rarely on FAT32. Document the edge case |
|
||||
| `--wait-pid` arg parsed in legacy build that doesn't recognize it | Old builds will ignore unknown args (cargo CLI parser behavior — verify). If they crash, the user can manually launch. Acceptable: this is a one-way migration; once the new flag is in a release, future updates are smooth |
|
||||
|
||||
## Success criteria
|
||||
|
||||
1. Manual update from v0.1.9 → test-tagged v0.1.99 produces ZERO visible console window across 20 consecutive runs on Win11 + Win10
|
||||
2. Restart triggered from menu (e.g. language change) produces ZERO visible console
|
||||
3. Auto-update at scheduled interval (Hourly) produces a tray balloon "Updated to v0.1.99" on next launch
|
||||
4. `.old.*` files do not accumulate after 5 update cycles
|
||||
5. App still launches cleanly when no parent PID was passed (i.e. fresh user start)
|
||||
6. SHA-256 verification path unchanged and still rejects tampered binaries
|
||||
|
||||
## Out-of-scope follow-ups
|
||||
|
||||
1. **Audit `usage::refresh::spawn_local` / `spawn_wsl`**: the `wsl.exe` invocation in particular has known console-flash quirks even with `CREATE_NO_WINDOW`. If the user still sees occasional flashes after this change ships, that is the next investigation target.
|
||||
2. **`creds::wsl_bridge::wsl_run`**: same family of `wsl.exe` invocations.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- None blocking implementation. (Open follow-up: whether to also pipe `--wait-pid` into the legacy `--apply-update` compatibility branch in `update::run_cli`, in case a very old build is doing the spawning.)
|
||||
@@ -0,0 +1,183 @@
|
||||
# Full-Project Code Review — claude-code-usage-bubble v0.1.2
|
||||
|
||||
Scope: full src/ tree (~6.2k LOC across 35 files). Recent shipped releases 0.1.0-0.1.2; v0.1.2 fixed cmd.exe arg escaping in the self-updater.
|
||||
|
||||
## Severity counts
|
||||
|
||||
- **P0**: 3
|
||||
- **P1**: 9
|
||||
- **P2**: 7
|
||||
|
||||
---
|
||||
|
||||
## P0
|
||||
|
||||
### P0-1. Poll thread holds global state mutex during blocking HTTPS
|
||||
`src/app.rs:415-423`
|
||||
|
||||
`do_poll()` acquires `lock_state()`, calls `s.registry.poll_enabled(&s.http, &settings)` which dispatches to `ClaudeProvider::poll` / `ChatGptProvider::poll` — each issues a synchronous WinHTTP request inside the locked critical section. Lock is held for the full RTT (can be seconds, hang on dead network = whole poll cycle).
|
||||
|
||||
While locked, every UI-thread path that touches `lock_state()` stalls:
|
||||
- left/right click on bubble (`on_bubble_click`, `on_bubble_right_click` → `build_panel_data`, `show_context_menu`)
|
||||
- countdown timer (`refresh_countdowns`)
|
||||
- WM_APP_USAGE_UPDATED dispatch (`propagate_to_ui`)
|
||||
- menu commands (toggle, set_poll_interval, version_action, etc.)
|
||||
- bubble move/resize callbacks
|
||||
|
||||
Symptom: bubble appears frozen / right-click menu won't open whenever the network is slow.
|
||||
|
||||
Fix: clone the data needed (settings + a separate `Mutex<Registry>` or split state) before issuing HTTP. Build a snapshot in one short lock, do HTTP outside the lock, take the lock again only to write results.
|
||||
|
||||
### P0-2. `attempt_refresh` holds global lock across up to 8s sleep loop
|
||||
`src/app.rs:470-482`, `src/usage/refresh.rs:36-54`
|
||||
|
||||
`attempt_refresh` calls `s.registry.try_refresh(...)` while holding `lock_state()`. `try_refresh → Orchestrator::refresh` spawns the CLI then loops `thread::sleep(500ms)` for up to `REFRESH_TIMEOUT = 8s` per failed provider. The UI thread is unresponsive for the full duration.
|
||||
|
||||
Fix: same pattern as P0-1 — release the lock before calling `orchestrator.refresh(src)`. Re-acquire only for the balloon decision.
|
||||
|
||||
### P0-3. Synchronous update download blocks UI thread inside WM_COMMAND
|
||||
`src/app.rs:1098-1104`, `src/update/install.rs:24,41-50`
|
||||
|
||||
`version_action` runs on the UI thread (called from `msg_wnd_proc → WM_COMMAND → on_menu_command`). On "Apply update" it calls `update::install::begin(&c, &release)` which downloads the .exe synchronously (`http.get().send()` + `fs::write`). For a multi-MB asset on a slow link the bubble + every other window message handler is blocked.
|
||||
|
||||
Fix: spawn a thread for the download; on completion post a custom message back to the UI thread that triggers `spawn_handoff` + `PostQuitMessage`.
|
||||
|
||||
---
|
||||
|
||||
## P1
|
||||
|
||||
### P1-1. GDI font handles deleted while still selected into DC
|
||||
`src/bubble.rs:1293-1320`
|
||||
|
||||
In `paint_text_layer` the pattern is `SelectObject(hdc, label_font) → SelectObject(hdc, bold_font) → SelectObject(hdc, main_font) → DeleteObject(main_font); DeleteObject(bold_font); DeleteObject(label_font);`. The original font is never saved/restored, and `main_font` is still the currently selected object when `DeleteObject(main_font)` runs.
|
||||
|
||||
GDI rule: deleting a GDI object that is selected into a DC is an error; the call returns FALSE and the object is not freed. This leaks ~3 HFONT slots per `render()` call — and `render` fires on every poll, every WM_TIMER countdown tick, and on every TIMER_PULSE tick (every 80 ms while a bar is ≥95%). Process will eventually exhaust the GDI handle quota (10000 per process by default) under prolonged alarm conditions.
|
||||
|
||||
Fix: save first SelectObject return, restore it before deleting all three fonts. Same fix used correctly in `measure_text_w` at lines 1009-1013.
|
||||
|
||||
### P1-2. Update handoff: `cmd.exe` percent expansion + cmd-metachar exposure on usernames
|
||||
`src/update/install.rs:53-74`
|
||||
|
||||
`src_str`/`tgt_str` strip `"` but not `%`, `&`, `^`, `(`, `)`, `|`. These come from `dirs::data_local_dir()` and `std::env::current_exe()`. Both can include the username segment.
|
||||
|
||||
Real-world risk is low (most usernames are alphanumeric), but a username containing `%VAR%` would expand inside the inner `"..."` quotes (cmd.exe percent-expansion happens inside quotes too) and a username containing `&` or `^` bypasses the inner quoting in known cmd.exe corner cases.
|
||||
|
||||
Fix: validate `current_exe()` / `stage_path()` against `[A-Za-z0-9 \\:.\-_/]` before substitution, or use the helper-exe pattern the comments dismiss. At minimum, reject paths containing any of `%&^|<>`.
|
||||
|
||||
### P1-3. Downloaded binary is not verified before swap
|
||||
`src/update/install.rs:24,41-50`
|
||||
|
||||
`begin` downloads the asset URL and immediately stages it for `move /y` replacement. No SHA256, signature, or even file-size sanity check. If GitHub's release CDN delivers a truncated/corrupted asset (network blip), the next launch is broken with no rollback. If an attacker controls the release-publishing pipeline (compromised PAT, repo takeover) every existing install gets RCE.
|
||||
|
||||
Fix: ship the release with a `claude-code-usage-bubble.exe.sha256` sidecar (or use the GH API's `digest` field — populated in newer releases), download both, compare before `spawn_handoff`. Also `fsync` the staged file.
|
||||
|
||||
### P1-4. Token-expiry balloon picks wrong provider when both are enabled
|
||||
`src/app.rs:715-744`
|
||||
|
||||
`show_token_expired_balloon` ignores which provider actually failed: `if s.settings.show_claude_code { (Claude, claude title, claude body) } else { (ChatGpt, ...) }`. If both providers are enabled and only Codex's token expired, the balloon claims the Claude token expired. Sent through `attempt_refresh` the `failures: Vec<ProviderId>` already knows the real victim.
|
||||
|
||||
Fix: pass `failures` (or one chosen provider) into `show_token_expired_balloon` and pick the kind + strings from it.
|
||||
|
||||
### P1-5. Multiple Refresh clicks pile up concurrent poll threads
|
||||
`src/app.rs:351,397-413,353-356,963-1000`
|
||||
|
||||
`spawn_poll_thread()` is called unconditionally from `IDM_REFRESH`, `IDM_FREQ_*`, `toggle_model`, and timer callbacks. There's no in-flight flag. Each thread serializes on `lock_state()` (so HTTP is sequential per P0-1) but the threads themselves accumulate and the UI fires N redundant `WM_APP_USAGE_UPDATED` posts.
|
||||
|
||||
Fix: an `AtomicBool` poll-in-flight gate, or coalesce by skipping the spawn when one is already running.
|
||||
|
||||
### P1-6. CJK suffix overflows the bubble's countdown column
|
||||
`src/bubble.rs:891`, `src/i18n/locales/{ja,ko,zh-TW}.toml`
|
||||
|
||||
`COUNTDOWN_TEMPLATE = "999d"` measures column width against 4 ASCII glyphs. Korean uses `시간` and `분` (multi-codepoint, wider full-width characters); Japanese/Chinese use `日 時 分`. The right-side text column is sized to the ASCII template and gets clipped or runs into the percent column on these locales.
|
||||
|
||||
Fix: measure the template using the actual active locale's suffix strings (e.g. `format!("999{}", strings.hour_suffix)`) and pick the longest among day/hour/minute/second so all variants fit.
|
||||
|
||||
### P1-7. Panel `place_near` ignores monitor origin on multi-monitor
|
||||
`src/panel.rs:503-522`
|
||||
|
||||
Comparing `y < 0` and `x + panel_w > virtual_screen_w` only works when the primary monitor is at origin (0,0). With a left-side secondary monitor at `(-1920, 0)`, the bubble may sit at `x = -1500`; `x < 0` triggers and the panel jumps to `x = 8` on the primary monitor — far from the anchor. Same for negative Y.
|
||||
|
||||
Fix: use `MonitorFromWindow(anchor_hwnd, MONITOR_DEFAULTTONEAREST)` + `GetMonitorInfoW` to clamp into the anchor's monitor work area, mirroring what `clamp_into_work_area` already does in `bubble.rs:767-806`.
|
||||
|
||||
### P1-8. `CreatePopupMenu().unwrap()` x5 panics UI thread on low-resource failure
|
||||
`src/app.rs:790,806,821,835,870`
|
||||
|
||||
GDI/USER object limits or session lockup can cause `CreatePopupMenu` to return null. The unwrap propagates to the message loop and kills the app rather than just declining to show the menu.
|
||||
|
||||
Fix: `let Ok(freq) = CreatePopupMenu() else { let _ = DestroyMenu(menu); return; };` (and propagate cleanup). Use the existing `DestroyMenu(menu)` pattern already in place.
|
||||
|
||||
### P1-9. Dead `ureq` + `native-tls` deps in shipped binary
|
||||
`Cargo.toml:11-14`
|
||||
|
||||
Comment claims poller.rs / updater.rs keep ureq alive; both files are gone (`Glob` confirms). `ureq`, `native-tls`, and their transitive deps (foreign-types, core-foundation, etc.) are still linked into the release binary, increasing exe size and attack surface for no behavioral reason.
|
||||
|
||||
Fix: drop `ureq` and `native-tls` from `[dependencies]`; let `cargo build` confirm nothing breaks.
|
||||
|
||||
---
|
||||
|
||||
## P2
|
||||
|
||||
### P2-1. Bubble window WM_SETICON leaks HICONs at exit
|
||||
`src/bubble.rs:170-198`
|
||||
|
||||
`ExtractIconExW` returns two HICONs that are sent via `WM_SETICON`. The window manager does not take ownership — application is expected to `DestroyIcon` them when the window is destroyed (or before replacing). Both leak for the process lifetime.
|
||||
|
||||
Fix: on `WM_DESTROY`, send WM_SETICON with null and DestroyIcon the previous ones.
|
||||
|
||||
### P2-2. `kind_to_provider` is dead identity code
|
||||
`src/app.rs:566-571`, type alias `TrayIconKind = ProviderId` in app.rs:29
|
||||
|
||||
`TrayIconKind` is just `ProviderId`. The match arm-by-arm conversion is identity. Whole function and all call sites can be deleted (or replaced by direct passing).
|
||||
|
||||
Fix: inline; remove the `TrayIconKind` alias too — it's confusing scaffolding from an earlier refactor.
|
||||
|
||||
### P2-3. Per-frame DC measure: `compute_layout` creates+destroys 4 HFONTs per render
|
||||
`src/bubble.rs:945-948, 988-1015`
|
||||
|
||||
`measure_text_w` is called 4× from `compute_layout`, each making a `CreateFontW + DeleteObject`. For static templates ("999d", "100%", "5h", "7d") at fixed DPI/breakpoint, this could be cached. Hot path during pulse (every 80ms).
|
||||
|
||||
Fix: cache layout per `(size_logical, dpi, label_font_px, font_px)` key. Invalidate on WM_DPICHANGED.
|
||||
|
||||
### P2-4. `apply_alpha_mask` re-runs `point_in_rounded_rect` for every pixel
|
||||
`src/bubble.rs:1411-1422`, also `paint_background` 1149-1159, `paint_accent_stripe` 1173-1180
|
||||
|
||||
Three full-canvas passes each rechecking the same rounded-rect predicate. For a 360x140 bubble that's 3×50k = 150k branchy point-in-rect tests per frame. Painful at 12.5fps pulse.
|
||||
|
||||
Fix: precompute a row span (`x_min..x_max`) per scanline once, share across the three passes. Or set alpha in `paint_background` directly and drop the separate mask pass.
|
||||
|
||||
### P2-5. Tray icon loses registration after explorer.exe restart
|
||||
`src/tray/mod.rs:52-82`
|
||||
|
||||
No handler for the `TaskbarCreated` registered shell message. If Explorer restarts (crash or DPI/theme change), every NIM_MODIFY for our tray icon silently fails and the icon vanishes for the rest of the session.
|
||||
|
||||
Fix: register the `RegisterWindowMessageW("TaskbarCreated")` message in `msg_wnd_proc`, and on receipt clear `registered` set and let the next `sync()` re-issue NIM_ADD.
|
||||
|
||||
### P2-6. `parse_iso8601` has unreachable shadow + custom calendar math
|
||||
`src/usage/anthropic.rs:168-218`
|
||||
|
||||
`let trimmed = ...; let _ = trimmed;` discards the work; the parser re-splits on 'T' and rebuilds. Custom leap/days math is brittle — works for current decade but invites subtle bugs.
|
||||
|
||||
Fix: small adjustment now — remove the dead `trimmed` shadow. Long-term: import `time` (already pulled in transitively) and use `OffsetDateTime::parse`.
|
||||
|
||||
### P2-7. `bubble.rs` is 1496 lines — past file-size threshold
|
||||
`src/bubble.rs`
|
||||
|
||||
Project rule (CLAUDE.md) targets <200 LOC/file for context manageability. Bubble has accreted hit-testing, snap geometry, fullscreen detection, GDI painting, layout math, and pulse animation in one file. Splitting (`bubble/wnd_proc.rs`, `bubble/snap.rs`, `bubble/paint.rs`, `bubble/layout.rs`) would localize future changes.
|
||||
|
||||
Fix: low priority — refactor only when next painting/layout pass is needed.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. **Update channel auto-detect**: `update::current_channel()` always returns `Portable`. Is that intended for v0.1.x ship, or did the winget probe get cut and forgotten? If winget is on the roadmap, P1-3 (binary verification) becomes optional for that channel since winget signs.
|
||||
|
||||
2. **GitHub release asset digest field**: as of late-2025 GitHub returns a `digest` ("sha256:…") on release assets via the REST API. Worth confirming whether to consume that (P1-3 fix) or ship a sidecar.
|
||||
|
||||
3. **Provider-aware balloon**: P1-4's fix assumes one balloon per failed provider is the desired UX. Alternative: aggregate ("Claude + Codex tokens expired"). Which does the product owner prefer?
|
||||
|
||||
---
|
||||
|
||||
**Status:** DONE
|
||||
**Summary:** Reviewed full 6.2k LOC tree. Found 3 P0 (all are lock-while-blocking-IO patterns hanging the UI), 9 P1 (GDI font leak on every paint, update handoff still has minor injection surface + no binary verification, wrong-provider balloon, dead deps, multi-monitor + CJK layout bugs), 7 P2. No mutations applied.
|
||||
**Concerns/Blockers:** none.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Code Review: Bubble Off-Screen Clamp Fix
|
||||
|
||||
**Scope:** Proposed bug-fix for v0.1.7 "widget enabled but not shown" — saved positions on disconnected monitor.
|
||||
**Files:** `src/bubble.rs` (create, clamp_into_work_area, set_user_visible, default_position), `src/app.rs` (spawn_bubble, toggle_widget_visibility, reset_positions).
|
||||
|
||||
## Overall Assessment
|
||||
|
||||
**Fix is correct and minimal. Ship it with two small refinements.** Root-cause matches code (verified: `bubble.rs:143-160` passes saved `position` straight into `CreateWindowExW`; `clamp_into_work_area` at `:770` only wired into `WM_SETTINGCHANGE` at `:486`). Approach is the right shape: clamp post-create, pre-show.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
None.
|
||||
|
||||
## High Priority
|
||||
|
||||
1. **Call order — clamp must run BEFORE `render(hwnd)` at `bubble.rs:220`, not just before `ShowWindow` at `:222`.** `render` calls `GetWindowRect` (`:1108`) for the `UpdateLayeredWindow` destination point. If clamp runs after `render`, the first frame paints at the off-screen coords; second paint only happens on next update_data tick. Move `clamp_into_work_area(hwnd)` to between line `:218` (state insert) and `:220` (render).
|
||||
|
||||
## Medium Priority
|
||||
|
||||
2. **`MonitorFromWindow` on a not-yet-shown off-screen window — verified safe.** Win32 sets the window rect immediately at `CreateWindowExW` return (visibility is irrelevant to `GetWindowRect`). With `MONITOR_DEFAULTTONEAREST` and a window whose entire rect lies on a disconnected monitor, the OS computes intersection with each *currently attached* monitor's rect; none intersect → falls back to nearest by Euclidean distance → returns the primary on a single-monitor setup. Saved `[2407,1282]` on a 1920-wide primary → nearest = primary → clamp pulls to `(1920-w, …)`. Correct.
|
||||
|
||||
3. **Multi-monitor edge case is preserved.** If the saved position is on a still-connected secondary, `MonitorFromWindow` returns that secondary monitor and clamps within its work area — no unwanted pull to primary. Good.
|
||||
|
||||
4. **Partial off-screen.** `clamp_into_work_area` only adjusts when fully outside (clamps each axis independently to `[wa.left, wa.right-w]`). A window whose top-left is on-screen but bottom-right spills off → it pulls the whole window inside. Behaviour is fine; matches `snap_to_edge` (`:644-645`).
|
||||
|
||||
5. **DPI mismatch (saved from 4K → 1080p primary):** the saved coords are physical pixels but the new bubble's `width_px/height_px` are recomputed against the *current* primary DPI (`:140-142`). Clamp uses the new size against the new monitor's work area — correct. No DPI bug.
|
||||
|
||||
6. **`default_position` case:** no-op (already inside work area). Safe.
|
||||
|
||||
## Low Priority
|
||||
|
||||
7. **Log levels are appropriate.** `info!` in `create` (fires once per bubble creation), `set_user_visible` (fires only on user-toggle — verified at `app.rs:1152` only called from `toggle_widget_visibility`), and `toggle_widget_visibility` (one event per click). None on the render hot path. Approved.
|
||||
|
||||
8. **Alternative call site (clamp in `app::spawn_bubble`):** Less attractive. `spawn_bubble` doesn't own the HWND lifecycle and would need a fresh `GetWindowRect` round-trip. Keeping the clamp inside `bubble::create` keeps the bubble module the sole owner of window geometry and means future call sites (e.g. tests, a hypothetical re-create-on-DPI-change) also benefit for free. The "bubble module stays position-agnostic" argument is weak — it already calls `default_position`, `snap_to_edge`, and `clamp_into_work_area`. Position-aware is the status quo.
|
||||
|
||||
## Side Effects
|
||||
|
||||
- No callers of `bubble::create` assert the returned HWND is at the exact requested coords. `app::spawn_bubble` (`:277`) ignores position post-create; `reset_positions` (`:1156`) destroys + recreates. Safe.
|
||||
- `position(hwnd)` (`:322`) reads live `GetWindowRect`, so any subsequent `on_bubble_moved` save reflects the clamped coords — this self-heals the persisted bad value on first drag.
|
||||
|
||||
## Positive Observations
|
||||
|
||||
- Clamp helper already exists and is correct (`:770-809`).
|
||||
- Fix is one line + three log statements; minimal blast radius.
|
||||
- Persisted-corruption auto-heal via first interaction is a nice property.
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
1. **MUST:** Place `clamp_into_work_area(hwnd)` between `lock_bubbles().insert(...)` (`:218`) and `render(hwnd)` (`:220`) — not after `render`.
|
||||
2. **SHOULD:** Add an `info!` in `clamp_into_work_area` that fires only when `nx != r.left || ny != r.top` (i.e. the actual reposition path). Free diagnostic for future "bubble moved itself" reports.
|
||||
3. **CONSIDER:** Persist the clamped position immediately after `create` so `settings.json` is self-healed on next launch, not only after a drag. Trade-off: writes settings on every startup; current behaviour writes only on user action. Probably YAGNI — drift gets repaired on first interaction.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
- Should we also persist the corrected position eagerly (action 3)? Default to no per YAGNI; flag for user.
|
||||
- Does Windows ever defer `CreateWindowExW` window-rect commit until `ShowWindow`? Per MSDN and verified by existing `snap_to_edge` using the same pattern in `WM_EXITSIZEMOVE`, no — rect is committed synchronously.
|
||||
|
||||
**Status:** DONE_WITH_CONCERNS
|
||||
**Summary:** Fix is correct and small. One ordering bug: clamp must precede `render`, not just `ShowWindow`, otherwise the first paint targets the off-screen coords.
|
||||
**Concerns:** Action 1 (clamp before render) is a real correctness issue — the proposal as written ("after CreateWindowExW succeeds and before ShowWindow") technically permits ordering after `render`, which would defeat the fix until the next data update.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Code Review — Tray "Restart" Action
|
||||
|
||||
**Scope:** uncommitted changes on clean tree
|
||||
**Files:** `src/app.rs`, `src/i18n/mod.rs`, 8x `src/i18n/locales/*.toml`
|
||||
**Plan:** `plans/260518-0945-menu-restart-button/phase-01-implement-restart-action.md`
|
||||
|
||||
## Verdict
|
||||
Clean implementation. All 7 acceptance criteria met. Build is `cargo check`-clean. No security regressions. Pattern faithfully borrowed from `update/install.rs`.
|
||||
|
||||
## Acceptance Criteria — all PASS
|
||||
1. Menu order verified `app.rs:1040-1042`: separator → `IDM_RESTART` → `IDM_EXIT`.
|
||||
2. `IDM_RESTART => restart_app()` arm wired `app.rs:393`.
|
||||
3. `restart_app()` `app.rs:1382-1421` flushes settings, gets `current_exe`, rejects `%`, spawns detached `cmd.exe`, `PostQuitMessage(0)`.
|
||||
4. 1 s `timeout` matches install.rs precedent (2 s there; 1 s sufficient — current process exits as soon as `PostQuitMessage(0)` drains the loop).
|
||||
5. Verified `restart = "..."` in all 8 TOMLs at line 32 (en/de/es/fr/ja/ko/nl/zh-TW). `LocaleStrings` field at `mod.rs:52`. No `#[serde(default)]` → missing key = hard fail; all present.
|
||||
6. Match-arm ordering unambiguous: `IDM_RESTART=33` < guard `x >= IDM_LANG_BASE=100`. Guard won't match 33. `tray::IDM_TOGGLE_WIDGET=50` likewise < 100. Safe.
|
||||
7. No new clippy issues; no new unsafe blocks (`PostQuitMessage(0)` already unsafe at `IDM_EXIT`; matches that idiom).
|
||||
|
||||
## Critical
|
||||
None.
|
||||
|
||||
## High
|
||||
None.
|
||||
|
||||
## Medium
|
||||
**M1. Restart arm sits below the `IDM_LANG_BASE` guard arm.** `app.rs:391-393`. The guard `x if x >= IDM_LANG_BASE => …` is exhaustive for any id `>= 100`. Today `IDM_RESTART=33` is fine, but future readers adding a static id `>= 100` between lines 392 and 393 would silently route into language switching. Cheap fix: move `IDM_RESTART => restart_app()` and `tray::IDM_TOGGLE_WIDGET => …` ABOVE the guard arm. Plan note at `app.rs:82-84` already warns about this — the new arm violates that guidance.
|
||||
|
||||
## Low / Info
|
||||
**L1. Double-restart not deduped.** Rapid clicks queue multiple `cmd.exe` children. First wins the mutex; second's `start ""` succeeds, the resulting bubble process exits at `ERROR_ALREADY_EXISTS`. Acceptable per plan §Risk Assessment. No fix needed.
|
||||
|
||||
**L2. `to_string_lossy()` on `current_exe()` will mangle non-UTF-8 paths.** Same pattern in `install.rs:100`. On real Windows installs paths are UTF-16; lossy → UTF-8 is virtually always faithful. Consistent with existing precedent.
|
||||
|
||||
**L3. `settings::save()` runs while `lock_state()` read-guard is held** (`app.rs:1385-1387`). If `save` ever takes a lock on the same mutex this would deadlock — it currently does not, but the pattern elsewhere (e.g. `set_poll_interval` at 1087-1100) clones, releases, then saves. Recommend matching that pattern: clone snapshot inside scope, drop guard, then `settings::save(&snap)`. Defensive only.
|
||||
|
||||
**L4. No regression to existing menu wiring** — verified by inspection: `show_widget` append at 1034-1039 still preceded by no separator, then separator 1040, then Restart, then Exit. Matches plan exactly.
|
||||
|
||||
## Pattern-Parity Check vs `install.rs`
|
||||
- Flags: `CREATE_NO_WINDOW | DETACHED_PROCESS` → identical bit pattern (`0x0800_0000 | 0x0000_0008`). New constants `RESTART_*` duplicate the values; minor DRY nit but they're file-local and the comment explains why. Acceptable.
|
||||
- `raw_arg` quoting: `/c` then `"<cmd>"` with inner `"` preserved → byte-for-byte same shape as `install.rs:113-114`. Correct.
|
||||
- `%` rejection: present, logs and aborts. Matches `install.rs:89-96`.
|
||||
- `stdin/out/err = Null`: present, matches.
|
||||
|
||||
## PostQuitMessage on UI thread
|
||||
`IDM_EXIT` does the same at `app.rs:370`, called from `on_menu_command` via WM_COMMAND on the UI thread. `restart_app()` is reached the same way. Safe — identical control-flow shape.
|
||||
|
||||
## Metrics
|
||||
- New code: ~40 LOC in `app.rs`, 1 field in `mod.rs`, 8x 1-line TOML adds.
|
||||
- Type coverage: 100%.
|
||||
- New warnings: 0 (`cargo check` clean per user).
|
||||
|
||||
## Recommended Actions
|
||||
1. **M1** (nice-to-have): reorder match arms so `IDM_RESTART` / `IDM_TOGGLE_WIDGET` precede the `x if x >= IDM_LANG_BASE` guard. Defends against future id collisions.
|
||||
2. **L3** (optional): mirror `set_poll_interval`'s clone-then-save pattern in `restart_app()` for consistency.
|
||||
|
||||
## Unresolved Questions
|
||||
- None blocking. Plan §Next Steps suggests a semver patch bump and analogous bubble-menu entry; out of scope for this review.
|
||||
|
||||
---
|
||||
|
||||
**Status:** DONE_WITH_CONCERNS
|
||||
**Summary:** Implementation matches plan and acceptance criteria; cmd-handoff faithfully mirrors `update/install.rs`; all 8 locales updated; no critical or high issues. One medium suggestion (reorder match arms to defend against future static-id collisions with the `IDM_LANG_BASE` guard) and two low/optional refinements.
|
||||
**Concerns:** M1 — new `IDM_RESTART` and `tray::IDM_TOGGLE_WIDGET` arms sit below a catch-all `x >= IDM_LANG_BASE` guard. Today safe (33, 50 < 100); future-fragile. Code comment at `app.rs:82-84` already flags the rule that was bent.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Best Practices Research: claude-code-usage-bubble
|
||||
**Date:** 2026-05-16 | **Scope:** Current Rust desktop Windows ecosystem (2025/2026)
|
||||
|
||||
## 1. Self-Updating Rust Apps on Windows
|
||||
|
||||
**Current state (2025/2026):**
|
||||
- **Tauri plugin-updater** ([tauri-plugin-updater](https://crates.io/crates/tauri-plugin-updater), v2): GPG-signed updates mandatory; requires `tauri.conf.json` with public key + private key env var for signing. Built for Tauri apps.
|
||||
- **Velopack** ([velopack](https://velopack.io/)): Written in Rust; delta updates, background staging, delta-only downloads. Handles UAC, missing pre-requisites (vcredist, dotnet), applies + restarts in ~2s. Active 2026, used by real apps.
|
||||
- **cargo-dist** ([axodotdev/cargo-dist](https://github.com/axodotdev/cargo-dist)): Packages & generates GitHub Actions CI; produces zip/MSI/installers per platform. No built-in self-update; you handle that separately.
|
||||
- **self_update crate** (sparse docs 2026): Deprecated/unmaintained; avoid.
|
||||
- **Current app's cmd.exe handoff**: Inline timeout + move + relaunch. Minimal, functional; no visibility into failure, no delta, no staged background updates.
|
||||
|
||||
**Recommendation:**
|
||||
For hobby/indie scope: stay on cmd.exe handoff until you need delta updates or background staging. Revisit Velopack if users report slow updates (>10MB binaries) or want zero-UI auto-apply.
|
||||
|
||||
---
|
||||
|
||||
## 2. GitHub Actions Windows Release Patterns
|
||||
|
||||
**Current state (2025/2026):**
|
||||
- **cargo-dist** ([cargo-dist quickstart](https://axodotdev.github.io/cargo-dist/book/quickstart/rust.html)): Generates GitHub Actions `.yml` for multi-platform builds, code signing, release creation. Handles Windows builds natively. Requires `dist init` + `Cargo.toml` config.
|
||||
- **release-plz** ([release-plz](https://github.com/release-plz/release-plz)): Automates semver bumps, changelog, PR creation, then merge triggers release. Windows support via GitHub Actions matrix.
|
||||
- **Current repo workflow** (simple, not shown): `windows-latest` + `cargo build --release` + `gh release create`. Sufficient for early-stage indie apps.
|
||||
- **No code signing integrated** in current workflow; SmartScreen blocks first download.
|
||||
- **MSI generation**: cargo-dist can auto-generate; current app uses plain exe.
|
||||
|
||||
**Recommendation:**
|
||||
Keep current simple workflow for now (YAGNI). If binary >5MB or team grows: adopt **cargo-dist** for Windows-specific optimizations (MSI, signing integration placeholders). Skip release-plz unless you manage multiple Rust crates.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Signing for Windows Hobby/OSS
|
||||
|
||||
**Current state (2025/2026):**
|
||||
- **SignPath Foundation** ([signpath.io](https://signpath.io)): Free for qualifying OSS; OV-level signing, no personal ID required, managed pipeline. Application process 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)
|
||||
@@ -0,0 +1,221 @@
|
||||
# UI Design Review — Floating Bubble
|
||||
|
||||
Date: 2026-05-23
|
||||
Scope: Visual-only redesign of the stadium bubble (head ring + tail bars).
|
||||
Constraints: tiny-skia primitives only (AA fills, AA stroked arcs, AA pills) + GDI text. No new deps. No animations beyond existing pulse. Logical px values; `scale_to_dpi` handles HiDPI.
|
||||
|
||||
Files reviewed:
|
||||
- `D:\tiennm99\claude-code-usage-bubble\src\bubble.rs` (lines 1032–1703)
|
||||
- `D:\tiennm99\claude-code-usage-bubble\src\usage_color.rs`
|
||||
- `D:\tiennm99\claude-code-usage-bubble\src\os\color.rs`
|
||||
|
||||
---
|
||||
|
||||
## 1. Issues Found
|
||||
|
||||
### A. Information hierarchy is flat
|
||||
The big head "31%" and the tail "64%" are typographically equal in weight against their backgrounds — but they answer different questions (now vs. weekly). The eye has no anchor. Add weight contrast.
|
||||
|
||||
### B. Tail text is cramped
|
||||
`tail_usage_pct_rect` and `tail_time_text_rect` share the same right edge (`content_right`) with no rule for the gap between the bar end and the percent label. With `pad = 6 logical` and `pct_reserve_w` literally just `measure("100%") + 2 logical`, the "64% / 3d" pair reads as one glyph blob. The two right-aligned tokens stack with only ~2 logical px of internal breathing.
|
||||
|
||||
### C. The inner time ring is nearly invisible
|
||||
At ring_stroke_w 3 logical and time_ring_stroke_w 2 logical with only a 3-logical gap between them (line 1074), and using `#303030` track on a `#1F1F1F` background, the ratio is ~1.13:1. Below the visibility threshold; users won't read it as a ring.
|
||||
|
||||
### D. Two bubbles read as one merged blob
|
||||
Claude and Codex stagger vertically by `height_px + gap=24` (line 1750) but visually the dark-on-dark stadia float without anchor — there's no provider identifier inside the bubble itself. The accent color is the *only* differentiator, and it disappears below 60% (Codex teal vs. orange both reduce to white at that range for tail text — see line 1646).
|
||||
|
||||
### E. Track contrast vs. fill is loud
|
||||
`track = #3A3A3A` on `#1F1F1F` bg (4.0:1) is louder than the fill at low percentages. At 5% usage the dim track screams more than the bright fill — backwards visual priority.
|
||||
|
||||
### F. Time-bar reads as a second-quota
|
||||
The grey time bar fills *left-to-right* same direction as the usage bar, and shares the same shape, position, and visual weight class. A user glancing sees "two progress bars" and assumes both are quotas. The grey hue helps but the *gestalt* fights it.
|
||||
|
||||
### G. Head "5h" label is buried
|
||||
`small_font_px ≈ 55% of big_font_px` and uses `#888888`. At 200 logical width the label is ~10px and dim. It's the only thing telling the user the ring is the *5-hour* window.
|
||||
|
||||
### H. Ring uses round caps but track does not — visual mismatch
|
||||
Active arc has `LineCap::Round` (line 1249) but the track is a full circle. At low percentages the rounded start cap juts out above the track — looks unfinished. The track should be `LineCap::Butt` (default closed circle is fine) but the *visual idiom* would benefit from the track being a hint subtler.
|
||||
|
||||
### I. Corner radius of pill = `canvas_h / 2` is fine, but the head circle inscribed in the same height feels visually small
|
||||
`ring_radius = head_diameter/2 - 4 - stroke/2` makes the ring fill ~92% of the head square — but the head_square equals the canvas height, so the head looks slightly under-sized vs. the visual weight of the tail bars. Slight padding nudge.
|
||||
|
||||
### J. No separator/cue between the two stacked bubbles
|
||||
Not a per-bubble issue, but worth noting: when both providers run, a faint provider mark inside each bubble would let users disambiguate without remembering "the upper one is Codex."
|
||||
|
||||
---
|
||||
|
||||
## 2. Proposed Changes
|
||||
|
||||
All values are **logical px**. Hex colors are dark-theme; the light-theme entry shown after `/`.
|
||||
|
||||
### Change 1 — Demote the head "5h" label, promote into a chip
|
||||
**What:** Keep the small label, but render it as an uppercase, letter-spaced micro-cap inside a 1-px-stroke pill (no fill).
|
||||
**Why:** Reads as a "window selector" tag rather than disambiguated noise.
|
||||
**Values:**
|
||||
- text: `"5H"` (uppercase, was `"5h"`)
|
||||
- font weight: `FW_SEMIBOLD` (was normal)
|
||||
- letter-spacing: simulate via `+1 logical px` between glyphs — actually, just keep tracking from font; the uppercase alone reads stronger.
|
||||
- color: `#A8A8A8` / `#5E5E5E` (was `#888888` / `#6E6E6E`)
|
||||
- no chip border for v1 — KISS. Just style the text. If chip is wanted later, AA stroke a pill rect.
|
||||
- Keep current vertical position; the `label_pct_gap` is fine.
|
||||
|
||||
### Change 2 — Bump big-percent weight + tighten size
|
||||
**What:** Big number gets heavier and slightly smaller; tightens visual mass.
|
||||
**Why:** Heavier weight = stronger anchor without taking more space.
|
||||
**Values:**
|
||||
- weight: `FW_BOLD` (was `FW_SEMIBOLD`)
|
||||
- size factor: `big_font_px = head_diameter * 24/100` (was `26/100`)
|
||||
- color unchanged: `#EAEAEA` / `#1F1F1F`
|
||||
|
||||
### Change 3 — Lift the inner time ring above noise
|
||||
**What:** Increase contrast of the time-ring track and fill; thicken slightly.
|
||||
**Why:** Current ratio of 1.13:1 against bg is invisible. Per WCAG 1.4.11 non-text 3:1 minimum.
|
||||
**Values:**
|
||||
- `time_ring_stroke_w`: `scale_to_dpi(2, dpi).clamp(2, 3)` (was `1..3`, effective 1px floor → too thin)
|
||||
- gap between outer ring inner edge and inner ring outer edge: `4 logical` (was `3`)
|
||||
- time_track: `#2F2F2F` → **`#404040`** (3.5:1) / light unchanged
|
||||
- time_fill (used for inner-ring active arc *and* tail time-bar fill): `#9A9A9A` → **`#B0B0B0`** / `#777777` → `#666666`
|
||||
- Keep `LineCap::Round` on the active arc.
|
||||
|
||||
### Change 4 — Reserve a real gap between tail bar and tail text
|
||||
**What:** Add a `bar_text_gap = 8` between bar end and text left edge (currently `pad = 6`).
|
||||
**Why:** Eight is the eyeballed minimum where the eye registers "two columns" instead of "one wall of glyphs."
|
||||
**Values:**
|
||||
- new constant: `bar_text_gap = scale_to_dpi(8, dpi)` (was effectively `pad = 6`)
|
||||
- `bar_right = (text_left - bar_text_gap).max(bar_left + bar_min);` (line 1126)
|
||||
- `pad` stays `6` for the head→tail content_left inset.
|
||||
|
||||
### Change 5 — Right-edge inset
|
||||
**What:** Increase inner right margin of the tail.
|
||||
**Why:** The current `scale_to_dpi(12, dpi)` insetinto the pill's rounded right cap leaves text near the curvature. Bump to clear the cap visually.
|
||||
**Values:**
|
||||
- `tail_right = width_px - scale_to_dpi(14, dpi)` (was `12`)
|
||||
|
||||
### Change 6 — Reweight tail percent vs. tail countdown
|
||||
**What:** Make the tail percent a touch heavier than the countdown so the *number* anchors the lane.
|
||||
**Why:** Today both are FW_NORMAL same size, both `text_color` — flat. Bigger number with smaller dimmer suffix establishes hierarchy.
|
||||
**Values:**
|
||||
- weekly percent: `FW_SEMIBOLD`, color `text_color` (`#EAEAEA` / `#1F1F1F`)
|
||||
- weekly countdown: `FW_NORMAL`, color **`muted_color`** (`#A8A8A8` / `#5E5E5E`) — was `text_color`
|
||||
- Font sizes unchanged: both `small_font_px` / `main_font_px`.
|
||||
|
||||
### Change 7 — Tone down the usage-bar track
|
||||
**What:** Drop track contrast so the *fill* dominates, not the track.
|
||||
**Why:** At low percent (5–10%) the bright track outscreams the fill. Track should be a hint.
|
||||
**Values:**
|
||||
- `track`: `#3A3A3A` → **`#2C2C2C`** (was 4.0:1 vs. bg; now 1.6:1 — the *fill* hits 4.5:1+ from accent colors and carries the signal)
|
||||
- light theme: `#D6D6D6` → `#E2E2E2`
|
||||
|
||||
### Change 8 — Differentiate the time-bar shape from the usage-bar shape
|
||||
**What:** Make the time bar visibly *thinner and lower-contrast* so it doesn't read as a second quota.
|
||||
**Why:** Current ratio: usage_bar 9% of height, time_bar 5% — close. Push the spread.
|
||||
**Values:**
|
||||
- `usage_bar_h = (height_px * 10 / 100).clamp(6, 12)` (was 9% / clamp 5–12)
|
||||
- `time_bar_h = (height_px * 4 / 100).clamp(3, 6)` (was 5% / clamp 3–7)
|
||||
- `lane_gap = scale_to_dpi(6, dpi)` (was 5)
|
||||
- This gives the usage bar ~2.5× the visual mass of the time bar — clear "primary" vs. "context."
|
||||
|
||||
### Change 9 — Pull the head ring in by 1 px so the head circle feels deliberate
|
||||
**What:** Slightly more head padding; ring sits 1 logical px farther in.
|
||||
**Why:** Ring currently kisses the visual edge of the head square; a touch of breathing room makes the head feel composed and balances vs. the heavier tail.
|
||||
**Values:**
|
||||
- `head_pad = scale_to_dpi(5, dpi)` (was 4)
|
||||
- `ring_stroke_w`: keep `scale_to_dpi(3, dpi).clamp(2, 4)` — already good.
|
||||
|
||||
### Change 10 — Provider mark dot (subtle disambiguator)
|
||||
**What:** A 4×4 logical solid circle in the accent color, positioned at the *outer* edge of the ring at 12 o'clock — between the ring and the head's left edge.
|
||||
**Why:** Today the only provider tell is the accent of the active arc; below 60% the arc *is* the accent so it works, but at >60% the arc shifts to amber/red and the provider identity vanishes. A constant dot fixes that. Also helps when two bubbles stack.
|
||||
**Values:**
|
||||
- center: `(ring_cx, ring_cy - ring_radius - ring_stroke_w/2 - 4)` — i.e. 4 logical px above the ring's outer edge
|
||||
- radius: `scale_to_dpi(2, dpi)` (logical 2 → diameter 4)
|
||||
- color: `accent_color_for(model, is_dark)` (existing function — `#D97757` Claude / `#10A37F` Codex)
|
||||
- Implemented as one extra `pb.push_circle(...)` fill before the ring strokes — zero new dependencies.
|
||||
|
||||
### Change 11 — Round-cap the active tail bars; flat-cap the tracks
|
||||
**What:** Keep the existing `paint_pill` for the *track*, but reduce its end-cap radius. For the *fill*, keep full cap. Actually, simpler: leave both as full-cap pills (current behavior) — just ensure the fill never paints below `2 * cap` width.
|
||||
**Why:** Already correct in code (`paint_pill` does both end-caps). No change needed visually, but lock in a min-fill so the bar at 1% doesn't render as a dot.
|
||||
**Values:**
|
||||
- in the weekly-pct render block (line 1300+): if `fill_w > 0.0 && fill_w < bar_h`, set `fill_w = bar_h` (a one-cap-diameter minimum). Cosmetic only — preserves "I see some progress" cue when usage is 0.1–2%.
|
||||
|
||||
### Change 12 — Text-color for tail percent when bar is in alarm range
|
||||
**What:** When `weekly_pct >= 95`, tint the percent text toward the alarm color instead of bumping its luminance via `brighten` only.
|
||||
**Why:** Today, at 98%, the text just gets *brighter* via the pulse — but in dark mode the bar is already pulsing deep red. Tinting the number red ties it to the bar.
|
||||
**Values:**
|
||||
- if `pct >= 95.0`: `text_color_for_pct = #E08070` (dark) / `#B02810` (light), then apply pulse `brighten` on top.
|
||||
- Keep the FW_SEMIBOLD from Change 6.
|
||||
- 80 ≤ pct < 95: leave at default text color (the bar carries the warning).
|
||||
|
||||
---
|
||||
|
||||
## 3. ASCII Mockup (one bubble, dark, ~270 logical px wide)
|
||||
|
||||
```
|
||||
canvas_w = 270 (logical)
|
||||
<─────────────────────────────────────────────────────────────────>
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ • <─ accent dot (2-logical r, 4 above ring outer) │
|
||||
│ ╭───╮ │
|
||||
│ / \ ┌─────────────────────────────────────┐ │ ^
|
||||
│ │ ┌───┐ │ │▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░│ 64% │ ←lane │ |
|
||||
│ │ │5H │ │ └─────────────────────────────────┴─────┘ │ |
|
||||
│ │ │31%│ │ ┌─────────────────────────────┐ 3d ←FW_NORMAL│ | height
|
||||
│ │ └───┘ │ │██░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ muted│ |
|
||||
│ \ / └─────────────────────────────┘ │ |
|
||||
│ ╰───╯ │ v
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
<──head_diameter──><pad=6><────────bar_w────────><gap=8><text_w>
|
||||
= canvas_h <r-inset=14>
|
||||
```
|
||||
|
||||
Key:
|
||||
- `•` accent dot (4-logical px diameter, provider color)
|
||||
- Outer thick ring = 5h usage (active arc in accent / amber / red)
|
||||
- Inner thinner ring = 5h remaining time (now `#B0B0B0` on `#404040`, visible)
|
||||
- `5H` = uppercase semibold label, `muted` color, sits above the big number
|
||||
- `31%` = big bold pct, `text_color`
|
||||
- `▓▓▓` top tail bar = weekly usage fill (accent/amber/red), `usage_bar_h ≈ 10% of canvas_h`
|
||||
- `░░░` track = `#2C2C2C` (toned down)
|
||||
- `64%` = right-aligned, FW_SEMIBOLD, text_color
|
||||
- `██░░` bottom tail bar = remaining time, `time_bar_h ≈ 4% of canvas_h` (visibly thinner)
|
||||
- `3d` = right-aligned, FW_NORMAL, muted_color
|
||||
- 8-logical gap between bar end and right-aligned text column (Change 4)
|
||||
|
||||
---
|
||||
|
||||
## 4. Do Not Change
|
||||
|
||||
- **Overall stadium shape with `corner_radius = canvas_h/2`** — clean and iconic, photographs well in screenshots.
|
||||
- **Head-on-left, tail-on-right layout** — well-established mental model; sweeping arc + horizontal bar = "circular thing for now-ish, linear thing for week-ish."
|
||||
- **Accent ramp by percent** (60/80/95 thresholds in `usage_color.rs`) — solid color logic; don't touch.
|
||||
- **Pulse animation at ≥95%** — subtle, draws the right amount of attention. Keep.
|
||||
- **`paint_pill` two-circle + middle-rect construction** — exactly right for tiny-skia. Don't refactor to a rounded-rect path; the current is faster and AA-clean.
|
||||
- **`LineCap::Round` on the active arc** — feels alive; the "unfinished" look at low % I mentioned in issue (H) is acceptable trade-off.
|
||||
- **DPI scaling via `scale_to_dpi`** — keep all proposed values in logical px and let the function do its job.
|
||||
- **Locale-aware countdown width via `COUNTDOWN_TEMPLATE = "999시간"`** — clever and right; preserve.
|
||||
- **`DT_END_ELLIPSIS` on tail text** — graceful degradation at narrow widths.
|
||||
- **Existing fallback from countdown to `"5H"` when label rect is too narrow** — keep, just change the static fallback to uppercase per Change 1.
|
||||
- **Aspect ratio taper (`aspect_at_width`)** — tail breathes better at wider sizes; preserve.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All proposed color tokens belong inline in `paint_bubble_pixmap` and `paint_bubble_text` — no new modules needed.
|
||||
- The accent dot (Change 10) is ~3 new lines in the ring block.
|
||||
- Bar height and gap changes (Change 8) are 3 single-line edits in `compute_bubble_layout`.
|
||||
- Min-fill (Change 11) is a one-line guard before `paint_pill` for the weekly fill.
|
||||
- No new geometry struct fields needed. No new fonts. No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
1. **Light-theme accent dot**: Claude `#D97757` and Codex `#10A37F` on `#F3F3F3` — Codex teal contrast is ~3.0:1, borderline. Accept (it's a 4-px decorative dot, not text) or use a darkened variant for light theme?
|
||||
2. **Change 12 alarm-tint**: should it apply to the head big-pct too, or only to the tail pct? Current proposal is tail-only. Confirm whether the head should also tint red at ≥95%.
|
||||
3. **Change 10 dot position**: 12 o'clock is canonical but could be at 10–11 o'clock so the start of the active arc (which begins at 12 sweeping clockwise) doesn't visually merge with the dot. Open to either.
|
||||
|
||||
---
|
||||
|
||||
**Status:** DONE
|
||||
**Summary:** Twelve concrete, primitive-implementable changes that hierarchically organize percentages, raise the invisible inner time ring above WCAG 1.4.11, breathe the cramped tail text by 8 logical px, and add a 4-px provider dot for identity — all without new deps or animation engines.
|
||||
@@ -0,0 +1,187 @@
|
||||
# UI Rendering Code Review
|
||||
|
||||
Scope: `src/bubble.rs` (renderer), `src/usage_color.rs`, `src/os/color.rs`,
|
||||
drawing portions of `src/panel.rs`, `src/tray/badge.rs`.
|
||||
|
||||
## Findings
|
||||
|
||||
1. **`src/bubble.rs:1178-1197` — severity: high.** Eight neutral surface
|
||||
colours (`#1F1F1F`, `#F3F3F3`, `#3A3A3A`, `#D6D6D6`, `#303030`, `#E0E0E0`,
|
||||
`#9A9A9A`, `#777777`) are hex literals inside `paint_bubble_pixmap` and
|
||||
duplicated near-verbatim in `panel.rs:279-293` (`#1F1F1F`, `#FAFAFA`,
|
||||
`#EAEAEA`, `#3A3A3A`, `#D6D6D6`) plus `bubble.rs:1590-1597` text colours.
|
||||
Three surfaces silently disagree (`#F3F3F3` bubble bg vs `#FAFAFA` panel
|
||||
bg) and a designer cannot retune the palette without grepping. Fix:
|
||||
centralise into a `palette` module alongside `usage_color.rs` exposing
|
||||
`bg(is_dark)`, `track(is_dark)`, `time_track(is_dark)`, `time_fill(is_dark)`,
|
||||
`text(is_dark)`, `muted(is_dark)`; have `panel.rs` consume the same helpers.
|
||||
|
||||
2. **`src/tray/badge.rs:53, 112, 114` — severity: high.** Badge colours go
|
||||
through `paint.set_color_rgba8(0x3a, 0x3a, 0x3a, 255)` and raw
|
||||
`[u8; 3]` arrays instead of `Rgb` / `os::color`. `#3A3A3A` already exists
|
||||
as the shared "track" colour in `bubble.rs:1184`, but the tray hard-codes
|
||||
it. The two Claude/Codex base tints (`#2A1F1C`, `#1A1F26`) also live only
|
||||
here. Fix: route through the same palette module, even if the badge keeps
|
||||
its own dark inner-disk variants (named constants beat magic byte arrays).
|
||||
|
||||
3. **`src/bubble.rs:1064-1117` — severity: high.** Padding/gap literals
|
||||
`scale_to_dpi(2|4|5|6|8|12, dpi)` appear 15+ times inside
|
||||
`compute_bubble_layout` with no naming. Same logical "edge padding"
|
||||
(`scale_to_dpi(4, dpi)`) is used for `head_pad`, head-label left/right,
|
||||
head-pct left/right (lines 1064, 1086, 1088, 1092, 1094); same "small
|
||||
nudge" (`scale_to_dpi(2, dpi)`) is the ring stroke clamp, label/pct
|
||||
row vertical breathing room, pct-reserve gap, and time-text padding. A
|
||||
designer tweaking head-text padding will touch four lines and miss the
|
||||
fifth. Fix: hoist named DPI-scaled constants at the top of the function
|
||||
(`HEAD_PAD`, `TEXT_VPAD`, `LANE_GAP`, `TAIL_PAD`, `RIGHT_INSET`,
|
||||
`BAR_MIN_W`) and reuse — same pattern `panel.rs` uses with its
|
||||
`*_LOGICAL` constants (line 24-30).
|
||||
|
||||
4. **`src/panel.rs:333-340` — severity: med.** Bar-x / bar-w / row-y math
|
||||
mixes `scaled(PADDING_LOGICAL)`, `scaled(LABEL_W_LOGICAL)`,
|
||||
`scaled(RIGHT_TEXT_W_LOGICAL)` with bare `scaled(4)`, `scaled(8)`,
|
||||
`scaled(24)`, `scaled(18)` — four un-named "small" values doing
|
||||
semantically distinct jobs (label-bar gap, bar-text gap, header height,
|
||||
row-1 offset). Fix: name them (`LABEL_BAR_GAP_LOGICAL`,
|
||||
`BAR_TEXT_GAP_LOGICAL`, `HEADER_H_LOGICAL`, `HEADER_OFFSET_LOGICAL`) so
|
||||
the row geometry is auditable in one place.
|
||||
|
||||
5. **`src/panel.rs:340` — severity: med.** `row2_y = row1_y +
|
||||
scale_to_dpi(BAR_HEIGHT_LOGICAL, dpi) + scale_to_dpi(ROW_GAP_LOGICAL, dpi)
|
||||
+ scaled(8)`. The trailing `+ scaled(8)` is an unexplained extra gap on
|
||||
top of `ROW_GAP_LOGICAL`; this is exactly the inconsistency `ROW_GAP_LOGICAL`
|
||||
was created to prevent. Fix: fold into `ROW_GAP_LOGICAL` (16) or rename
|
||||
the extra into a labelled `ROW_TEXT_GAP_LOGICAL`.
|
||||
|
||||
6. **`src/bubble.rs:1099, 1126` — severity: med.** `tail_right = width_px -
|
||||
scale_to_dpi(12, dpi)` and `bar_right = (text_left - pad).max(bar_left +
|
||||
bar_min)`. The `12` is the right-edge inset to clear the stadium's right
|
||||
end-cap; this is conceptually `corner_radius / 2`-ish but encoded as a
|
||||
constant that won't track if aspect ratio changes. Fix: derive from
|
||||
`layout.corner_radius` or hoist a `TAIL_RIGHT_INSET` constant with a
|
||||
comment tying it to the end-cap curvature.
|
||||
|
||||
7. **`src/bubble.rs:1360-1377` and `src/tray/badge.rs:86-106` — severity:
|
||||
med.** `build_arc` is duplicated verbatim between the bubble renderer
|
||||
and the tray badge — same 64-segment sampling, same `FRAC_PI_2` start,
|
||||
same edge-case `.max(1)` segment count. Fix: lift into a small
|
||||
`geometry` / `tiny_skia_helpers` module shared by both call sites;
|
||||
change neither call site to keep behaviour identical.
|
||||
|
||||
8. **`src/bubble.rs:1339-1352` — severity: med.** `paint_pill` is a perfect
|
||||
helper candidate for `panel.rs`'s bar drawing — `panel.rs` uses
|
||||
`FillRect` rectangles with hard corners (`draw_row` lines 406-428),
|
||||
visually inconsistent with the bubble's rounded pill caps. Fix: extract
|
||||
`paint_pill` to a shared rendering helper module and have panel use it
|
||||
so the two surfaces have matching bar geometry. (Cross-surface
|
||||
consistency was the stated reason for `usage_color.rs` existing — same
|
||||
logic applies to bar shape.)
|
||||
|
||||
9. **`src/bubble.rs:1080-1083, 1111-1112` — severity: med.** `head_label_h`,
|
||||
`head_pct_h`, `time_text_h`, `usage_pct_h` all add `scale_to_dpi(2, dpi)`
|
||||
of "breathing room" to a font height, but never call it that — and the
|
||||
computed rect height is then used by `DrawTextW` with `DT_VCENTER` so a
|
||||
too-tight value would clip ascenders/descenders. Currently safe because
|
||||
2 px (logical) ≈ font leading, but the magic `2` is load-bearing. Fix:
|
||||
`const FONT_VPAD_LOGICAL: i32 = 2;` with a one-line comment "ascender/
|
||||
descender slack for DT_VCENTER".
|
||||
|
||||
10. **`src/bubble.rs:1141-1146, 1153-1158` — severity: low.** The vertical
|
||||
centring expression `usage_bar_top + (usage_bar_h - usage_pct_h) / 2`
|
||||
is computed twice for `tail_usage_pct_rect` (top + bottom). Tiny but
|
||||
if a designer asks "where does the % text sit relative to the bar?"
|
||||
they have to mentally simplify. Fix: compute `pct_text_top` /
|
||||
`time_text_top` as named locals before the struct literal.
|
||||
|
||||
11. **`src/bubble.rs:1076, 1077` — severity: low.** `big_font_px =
|
||||
head_diameter * 26 / 100`; `small_font_px = big_font_px * 55 / 100`.
|
||||
The 26 % and 55 % ratios are the core typographic scale of the head
|
||||
text — promote to `BIG_FONT_RATIO_PCT`, `SMALL_TO_BIG_FONT_PCT`
|
||||
constants with a "tweak these to retune head proportions" comment.
|
||||
|
||||
12. **`src/bubble.rs:1078` — severity: low (dead-code adjacent).**
|
||||
`main_font_px = small_font_px;` — `main_font_px` is identical to
|
||||
`small_font_px` but kept as a separate field on `BubbleLayout`
|
||||
(line 1056) and used for the countdown (line 1604, 1658). If the
|
||||
intent is "may diverge in future", document it; otherwise drop the
|
||||
duplicate field and use `small_font_px` directly.
|
||||
|
||||
13. **`src/bubble.rs:1085-1096` — severity: low.** `head_label_rect` and
|
||||
`head_pct_rect` both use `left: scale_to_dpi(4, dpi)` and `right:
|
||||
head_diameter - scale_to_dpi(4, dpi)` — identical horizontal extents.
|
||||
Could share a single `head_text_left`/`head_text_right` pair to make
|
||||
"head text is centered in the head circle" structurally visible.
|
||||
|
||||
14. **`src/bubble.rs:1216 vs 1339-1352` — severity: low.** Stadium
|
||||
background uses inline two-circle-plus-rect path; the pill helper
|
||||
does the same shape. The stadium could call `paint_pill(pixmap, 0.0,
|
||||
0.0, w, h, h/2.0, bg)` and shed ~15 lines. Worth doing once
|
||||
`paint_pill` moves to a shared module (finding 8).
|
||||
|
||||
15. **`src/bubble.rs:1653` — severity: low (text layout).**
|
||||
`draw_tail_text_in_rect` is called with `DT_RIGHT | DT_VCENTER |
|
||||
DT_SINGLELINE | DT_END_ELLIPSIS`. The `DT_END_ELLIPSIS` on a
|
||||
right-aligned 3-char string ("100%") inside a tight rect will produce
|
||||
`1…` if the rect collapses by even a pixel — fine, but worth
|
||||
confirming the `pct_reserve_w` (line 1103) leaves a 1-px AA safety
|
||||
margin. Current `+ scale_to_dpi(2, dpi)` looks adequate. No fix
|
||||
needed; flag for future locale changes.
|
||||
|
||||
16. **`src/bubble.rs:1674-1685` — severity: low.** `draw_text_in_rect`
|
||||
always uses `DT_NOCLIP`; `draw_tail_text_in_rect` uses
|
||||
`DT_END_ELLIPSIS` (no `DT_NOCLIP`). The two helpers diverge silently
|
||||
on whether text may escape its rect. Document the contract on each
|
||||
helper ("head text trusts layout, tail text fits-or-ellipsises").
|
||||
|
||||
17. **`src/panel.rs:447-501` — severity: low.** `draw_text` creates and
|
||||
destroys a font on every call (4× per `paint`). Same anti-pattern in
|
||||
`bubble.rs:paint_bubble_text` is amortised by caching `big_font`,
|
||||
`small_font`, `main_font` for the whole paint. Not a correctness
|
||||
issue but means the panel allocates 4 GDI fonts on every
|
||||
InvalidateRect. Cache by `(size, bold)` keyed on the HDC.
|
||||
|
||||
## Quick wins
|
||||
|
||||
1. **Centralise the neutral palette** (findings 1, 2). One new module
|
||||
`palette.rs` exposing `bg / track / time_track / time_fill / text /
|
||||
muted` plus tray-specific tints. Replace all `Color::from_hex(...)`
|
||||
calls in `bubble.rs:1178-1197`, `bubble.rs:1589-1598`,
|
||||
`panel.rs:279-293`, and the byte arrays in `tray/badge.rs`. ~20-line
|
||||
diff, kills cross-surface drift.
|
||||
|
||||
2. **Name the padding constants in `compute_bubble_layout`** (finding 3,
|
||||
9). Add ~6 `const` declarations at the top of the function — keeps
|
||||
them locally scoped, matches `panel.rs` style. Designer can retune
|
||||
metrics from one block.
|
||||
|
||||
3. **Lift `build_arc` to a shared `tiny_skia_helpers` module** (finding
|
||||
7). Two-file delete-and-import. Identical behaviour.
|
||||
|
||||
4. **Promote `paint_pill` and reuse in `panel.rs` rows + stadium bg**
|
||||
(findings 8, 14). Makes bar visual style consistent between bubble
|
||||
and panel; bonus simplification of the stadium fill.
|
||||
|
||||
5. **Drop / rename `main_font_px`** (finding 12). Either delete the field
|
||||
and use `small_font_px` directly, or split the constants (`MAIN_FONT_RATIO`)
|
||||
so future divergence is intentional.
|
||||
|
||||
## Defer
|
||||
|
||||
- Finding 4-6 (panel/bubble padding naming, derived `TAIL_RIGHT_INSET`):
|
||||
worth doing alongside the palette refactor but not blocking.
|
||||
- Finding 10, 13 (local temporaries for centring math, shared
|
||||
`head_text_*` extents): pure readability, low payoff.
|
||||
- Finding 15 (ellipsis safety margin on locale changes): keep an eye on
|
||||
this when adding a locale wider than `999시간`.
|
||||
- Finding 17 (panel GDI font caching): only matters if the panel starts
|
||||
refreshing more often than once per poll cycle.
|
||||
|
||||
Out-of-scope sighting (one-line flag as instructed): `compute_bubble_layout`
|
||||
takes an `HDC` purely to measure text — couples geometry calc to a live GDI
|
||||
device. Not a render bug, but it makes the function untestable without a
|
||||
window and is worth refactoring when convenient.
|
||||
|
||||
**Status:** DONE
|
||||
**Summary:** Renderer is functionally solid; the dominant problem is
|
||||
duplicated/un-named geometry and colour literals scattered across bubble /
|
||||
panel / badge that drift independently and resist designer-led tuning.
|
||||
+573
-273
File diff suppressed because it is too large
Load Diff
+1025
-480
File diff suppressed because it is too large
Load Diff
@@ -51,8 +51,7 @@ impl super::CredentialSource for LocalClaudeCreds {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared between local-fs and wsl-bridge sources — both parse the same
|
||||
/// JSON shape, the only difference is how they get to the file content.
|
||||
/// Parse the Claude credential JSON shape written by the official CLI.
|
||||
pub(crate) fn parse_claude_json(content: &str) -> Result<super::Token, super::Error> {
|
||||
let value: serde_json::Value = serde_json::from_str(content)?;
|
||||
let oauth = value
|
||||
|
||||
+3
-14
@@ -1,13 +1,12 @@
|
||||
// Pluggable credential discovery.
|
||||
//
|
||||
// Each `CredentialSource` knows how to read a single OAuth token from
|
||||
// somewhere (a local JSON file, a WSL filesystem, …). The `Locator`
|
||||
// somewhere, such as a local JSON file. The `Locator`
|
||||
// holds a priority-ordered list and serves the first source that
|
||||
// actually has a token. New sources drop in without touching the locator.
|
||||
|
||||
pub mod codex_auth;
|
||||
pub mod local_fs;
|
||||
pub mod wsl_bridge;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Token {
|
||||
@@ -23,8 +22,6 @@ pub struct Token {
|
||||
pub enum RefreshHint {
|
||||
/// `claude.cmd` / `claude.exe` on PATH.
|
||||
LocalClaudeCli,
|
||||
/// Run `claude -p .` inside a specific WSL distro.
|
||||
WslClaudeCli { distro: String },
|
||||
/// `codex` / `codex.cmd` / `codex.ps1` on PATH.
|
||||
LocalCodexCli,
|
||||
}
|
||||
@@ -37,10 +34,6 @@ pub enum Error {
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("required field missing from credential JSON: {0}")]
|
||||
MissingField(&'static str),
|
||||
#[error("WSL command in {distro:?} failed: {detail}")]
|
||||
WslCommand { distro: String, detail: String },
|
||||
#[error("timeout while talking to WSL")]
|
||||
WslTimeout,
|
||||
#[error("credential source unavailable")]
|
||||
Unavailable,
|
||||
}
|
||||
@@ -50,7 +43,7 @@ pub trait CredentialSource: Send + Sync {
|
||||
/// signatures (e.g. `"local:C:\\Users\\me\\.claude\\.credentials.json"`).
|
||||
fn id(&self) -> &str;
|
||||
|
||||
/// Read the current token. May spawn subprocesses (for WSL).
|
||||
/// Read the current token.
|
||||
fn read(&self) -> Result<Token, Error>;
|
||||
|
||||
/// Cheap change-detection fingerprint. `None` means "source is missing".
|
||||
@@ -70,16 +63,12 @@ impl Locator {
|
||||
Self { sources }
|
||||
}
|
||||
|
||||
/// Build a Claude locator with the standard search order: Windows
|
||||
/// home directory first, then every installed WSL distro.
|
||||
/// Build a Claude locator with the standard Windows credential path.
|
||||
pub fn for_claude() -> Self {
|
||||
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
|
||||
if let Some(s) = local_fs::LocalClaudeCreds::detect() {
|
||||
sources.push(Box::new(s));
|
||||
}
|
||||
for distro in wsl_bridge::list_distros() {
|
||||
sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro)));
|
||||
}
|
||||
Self { sources }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
// Reach into installed WSL distros to read their Claude credentials.
|
||||
//
|
||||
// We never mount the WSL filesystem ourselves — instead we shell out to
|
||||
// `wsl.exe -d <distro> -- sh -lc '...'` and read stdout. Every call has
|
||||
// a hard timeout so a hung WSL doesn't freeze the poll thread.
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::local_fs::parse_claude_json;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Enumerate installed WSL distributions. Returns an empty vec if WSL is
|
||||
/// not installed or the probe fails.
|
||||
pub fn list_distros() -> Vec<String> {
|
||||
let Some(output) = run_with_timeout(
|
||||
Command::new("wsl.exe").args(["-l", "-q"]),
|
||||
COMMAND_TIMEOUT,
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
decode_wsl_text(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct WslClaudeCreds {
|
||||
distro: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl WslClaudeCreds {
|
||||
pub fn new(distro: String) -> Self {
|
||||
let id = format!("wsl:{distro}");
|
||||
Self { distro, id }
|
||||
}
|
||||
|
||||
pub fn distro(&self) -> &str {
|
||||
&self.distro
|
||||
}
|
||||
}
|
||||
|
||||
impl super::CredentialSource for WslClaudeCreds {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn read(&self) -> Result<super::Token, super::Error> {
|
||||
let output = wsl_run(&self.distro, "cat ~/.claude/.credentials.json")?;
|
||||
if !output.status.success() {
|
||||
return Err(super::Error::WslCommand {
|
||||
distro: self.distro.clone(),
|
||||
detail: format!("cat exited {}", output.status),
|
||||
});
|
||||
}
|
||||
let content = String::from_utf8(output.stdout).map_err(|_| super::Error::WslCommand {
|
||||
distro: self.distro.clone(),
|
||||
detail: "non-UTF-8 stdout".into(),
|
||||
})?;
|
||||
parse_claude_json(&content)
|
||||
}
|
||||
|
||||
fn signature(&self) -> Option<String> {
|
||||
let output = wsl_run(
|
||||
&self.distro,
|
||||
"if [ -f ~/.claude/.credentials.json ]; then \
|
||||
stat -c '%s|%Y' ~/.claude/.credentials.json; \
|
||||
else echo MISSING; fi",
|
||||
)
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let body = decode_wsl_text(&output.stdout).trim().to_string();
|
||||
if body == "MISSING" {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{}|{}", self.id, body))
|
||||
}
|
||||
|
||||
fn refresh_hint(&self) -> super::RefreshHint {
|
||||
super::RefreshHint::WslClaudeCli {
|
||||
distro: self.distro.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wsl_run(distro: &str, script: &str) -> Result<Output, super::Error> {
|
||||
run_with_timeout(
|
||||
Command::new("wsl.exe")
|
||||
.arg("-d")
|
||||
.arg(distro)
|
||||
.arg("--")
|
||||
.arg("sh")
|
||||
.arg("-lc")
|
||||
.arg(script),
|
||||
COMMAND_TIMEOUT,
|
||||
)
|
||||
.ok_or(super::Error::WslTimeout)
|
||||
}
|
||||
|
||||
fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option<Output> {
|
||||
let mut child = cmd
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => return child.wait_with_output().ok(),
|
||||
Ok(None) => {
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(80));
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `wsl.exe -l -q` historically emits UTF-16LE on stdout; other commands
|
||||
/// emit UTF-8. Detect by sampling high bytes and decode appropriately.
|
||||
pub(crate) fn decode_wsl_text(bytes: &[u8]) -> String {
|
||||
if bytes.len() >= 2 && bytes.len() % 2 == 0 {
|
||||
let sample_end = bytes.len().min(256);
|
||||
let mut high_nul = 0usize;
|
||||
for chunk in bytes[..sample_end].chunks_exact(2) {
|
||||
if chunk[1] == 0 {
|
||||
high_nul += 1;
|
||||
}
|
||||
}
|
||||
if high_nul * 2 >= sample_end / 2 {
|
||||
let units: Vec<u16> = bytes
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
return String::from_utf16_lossy(&units);
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Discover the user's preferred Windows UI language.
|
||||
//
|
||||
// We try three sources in priority order and return the first non-empty
|
||||
// result. Callers normalise the returned BCP-47-ish code against the
|
||||
// list of locales we actually ship.
|
||||
|
||||
use windows::core::PWSTR;
|
||||
use windows::Win32::Globalization::{
|
||||
GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages,
|
||||
LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME,
|
||||
};
|
||||
|
||||
/// First non-empty locale code from the user's preferences. May be
|
||||
/// `Some("en-US")` style; callers do prefix normalisation.
|
||||
pub fn detect_system_locale() -> Option<String> {
|
||||
preferred_ui()
|
||||
.into_iter()
|
||||
.next()
|
||||
.or_else(default_ui_language)
|
||||
.or_else(default_locale_name)
|
||||
}
|
||||
|
||||
fn preferred_ui() -> Vec<String> {
|
||||
unsafe {
|
||||
let mut count: u32 = 0;
|
||||
let mut buf_len: u32 = 0;
|
||||
if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &mut count, PWSTR::null(), &mut buf_len)
|
||||
.is_err()
|
||||
|| buf_len == 0
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut buffer = vec![0u16; buf_len as usize];
|
||||
if GetUserPreferredUILanguages(
|
||||
MUI_LANGUAGE_NAME,
|
||||
&mut count,
|
||||
PWSTR(buffer.as_mut_ptr()),
|
||||
&mut buf_len,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
buffer
|
||||
.split(|u| *u == 0)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from_utf16_lossy)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_ui_language() -> Option<String> {
|
||||
unsafe {
|
||||
let lcid = GetUserDefaultUILanguage();
|
||||
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
|
||||
let len = LCIDToLocaleName(lcid as u32, Some(&mut buf), LOCALE_ALLOW_NEUTRAL_NAMES);
|
||||
if len <= 1 {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
|
||||
}
|
||||
}
|
||||
|
||||
fn default_locale_name() -> Option<String> {
|
||||
unsafe {
|
||||
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
|
||||
let len = GetUserDefaultLocaleName(&mut buf);
|
||||
if len <= 1 {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
code = "de"
|
||||
native_name = "Deutsch"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Aktualisieren"
|
||||
update_frequency = "Aktualisierungsintervall"
|
||||
one_minute = "1 Minute"
|
||||
five_minutes = "5 Minuten"
|
||||
fifteen_minutes = "15 Minuten"
|
||||
one_hour = "1 Stunde"
|
||||
models = "Modelle"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Einstellungen"
|
||||
start_with_windows = "Mit Windows starten"
|
||||
reset_position = "Position zurücksetzen"
|
||||
language = "Sprache"
|
||||
system_default = "Systemstandard"
|
||||
check_for_updates = "Nach Updates suchen"
|
||||
checking_for_updates = "Suche läuft…"
|
||||
up_to_date = "Aktuell"
|
||||
update_failed = "Update fehlgeschlagen"
|
||||
applying_update = "Update wird angewendet…"
|
||||
update_available = "Update verfügbar"
|
||||
update_via_winget = "über WinGet"
|
||||
auto_update_check = "Automatische Updateprüfung"
|
||||
auto_check_disabled = "Deaktiviert"
|
||||
auto_check_hourly = "Stündlich"
|
||||
auto_check_daily = "Täglich"
|
||||
auto_check_weekly = "Wöchentlich"
|
||||
exit = "Beenden"
|
||||
show_widget = "Widget anzeigen"
|
||||
session_window = "5h"
|
||||
weekly_window = "7d"
|
||||
now = "jetzt"
|
||||
day_suffix = "T"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Claude Code-Sitzung abgelaufen"
|
||||
token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
||||
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
|
||||
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
||||
@@ -1,43 +0,0 @@
|
||||
code = "en"
|
||||
native_name = "English"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Refresh"
|
||||
update_frequency = "Update frequency"
|
||||
one_minute = "1 minute"
|
||||
five_minutes = "5 minutes"
|
||||
fifteen_minutes = "15 minutes"
|
||||
one_hour = "1 hour"
|
||||
models = "Models"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Settings"
|
||||
start_with_windows = "Start with Windows"
|
||||
reset_position = "Reset position"
|
||||
language = "Language"
|
||||
system_default = "System default"
|
||||
check_for_updates = "Check for updates"
|
||||
checking_for_updates = "Checking for updates…"
|
||||
up_to_date = "Up to date"
|
||||
update_failed = "Update failed"
|
||||
applying_update = "Applying update…"
|
||||
update_available = "Update available"
|
||||
update_via_winget = "via WinGet"
|
||||
auto_update_check = "Auto-update check"
|
||||
auto_check_disabled = "Disabled"
|
||||
auto_check_hourly = "Hourly"
|
||||
auto_check_daily = "Daily"
|
||||
auto_check_weekly = "Weekly"
|
||||
exit = "Exit"
|
||||
show_widget = "Show widget"
|
||||
session_window = "5h"
|
||||
weekly_window = "7d"
|
||||
now = "now"
|
||||
day_suffix = "d"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Claude Code session expired"
|
||||
token_expired_body = "Sign in again to keep tracking your usage."
|
||||
chatgpt_token_expired_title = "Codex session expired"
|
||||
chatgpt_token_expired_body = "Sign in again to keep tracking your usage."
|
||||
@@ -1,43 +0,0 @@
|
||||
code = "es"
|
||||
native_name = "Español"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Actualizar"
|
||||
update_frequency = "Frecuencia de actualización"
|
||||
one_minute = "1 minuto"
|
||||
five_minutes = "5 minutos"
|
||||
fifteen_minutes = "15 minutos"
|
||||
one_hour = "1 hora"
|
||||
models = "Modelos"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Ajustes"
|
||||
start_with_windows = "Iniciar con Windows"
|
||||
reset_position = "Restablecer posición"
|
||||
language = "Idioma"
|
||||
system_default = "Predeterminado del sistema"
|
||||
check_for_updates = "Buscar actualizaciones"
|
||||
checking_for_updates = "Buscando actualizaciones…"
|
||||
up_to_date = "Al día"
|
||||
update_failed = "Actualización fallida"
|
||||
applying_update = "Aplicando actualización…"
|
||||
update_available = "Actualización disponible"
|
||||
update_via_winget = "vía WinGet"
|
||||
auto_update_check = "Búsqueda automática de actualizaciones"
|
||||
auto_check_disabled = "Desactivada"
|
||||
auto_check_hourly = "Cada hora"
|
||||
auto_check_daily = "Cada día"
|
||||
auto_check_weekly = "Cada semana"
|
||||
exit = "Salir"
|
||||
show_widget = "Mostrar widget"
|
||||
session_window = "5h"
|
||||
weekly_window = "7d"
|
||||
now = "ahora"
|
||||
day_suffix = "d"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Sesión de Claude Code caducada"
|
||||
token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
|
||||
chatgpt_token_expired_title = "Sesión de Codex caducada"
|
||||
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
|
||||
@@ -1,43 +0,0 @@
|
||||
code = "fr"
|
||||
native_name = "Français"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Actualiser"
|
||||
update_frequency = "Fréquence de mise à jour"
|
||||
one_minute = "1 minute"
|
||||
five_minutes = "5 minutes"
|
||||
fifteen_minutes = "15 minutes"
|
||||
one_hour = "1 heure"
|
||||
models = "Modèles"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Paramètres"
|
||||
start_with_windows = "Lancer avec Windows"
|
||||
reset_position = "Réinitialiser la position"
|
||||
language = "Langue"
|
||||
system_default = "Paramètre système"
|
||||
check_for_updates = "Rechercher des mises à jour"
|
||||
checking_for_updates = "Recherche en cours…"
|
||||
up_to_date = "À jour"
|
||||
update_failed = "Mise à jour échouée"
|
||||
applying_update = "Mise à jour en cours…"
|
||||
update_available = "Mise à jour disponible"
|
||||
update_via_winget = "via WinGet"
|
||||
auto_update_check = "Vérification automatique des mises à jour"
|
||||
auto_check_disabled = "Désactivée"
|
||||
auto_check_hourly = "Toutes les heures"
|
||||
auto_check_daily = "Quotidienne"
|
||||
auto_check_weekly = "Hebdomadaire"
|
||||
exit = "Quitter"
|
||||
show_widget = "Afficher le widget"
|
||||
session_window = "5h"
|
||||
weekly_window = "7j"
|
||||
now = "maintenant"
|
||||
day_suffix = "j"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Session Claude Code expirée"
|
||||
token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
||||
chatgpt_token_expired_title = "Session Codex expirée"
|
||||
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
||||
@@ -1,43 +0,0 @@
|
||||
code = "ja"
|
||||
native_name = "日本語"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "更新"
|
||||
update_frequency = "更新間隔"
|
||||
one_minute = "1分"
|
||||
five_minutes = "5分"
|
||||
fifteen_minutes = "15分"
|
||||
one_hour = "1時間"
|
||||
models = "モデル"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "設定"
|
||||
start_with_windows = "Windows起動時に開始"
|
||||
reset_position = "位置をリセット"
|
||||
language = "言語"
|
||||
system_default = "システム既定"
|
||||
check_for_updates = "更新を確認"
|
||||
checking_for_updates = "確認中…"
|
||||
up_to_date = "最新です"
|
||||
update_failed = "更新に失敗しました"
|
||||
applying_update = "更新を適用中…"
|
||||
update_available = "更新あり"
|
||||
update_via_winget = "WinGet経由"
|
||||
auto_update_check = "更新の自動確認"
|
||||
auto_check_disabled = "無効"
|
||||
auto_check_hourly = "1時間ごと"
|
||||
auto_check_daily = "毎日"
|
||||
auto_check_weekly = "毎週"
|
||||
exit = "終了"
|
||||
show_widget = "ウィジェットを表示"
|
||||
session_window = "5時間"
|
||||
weekly_window = "7日"
|
||||
now = "今"
|
||||
day_suffix = "日"
|
||||
hour_suffix = "時"
|
||||
minute_suffix = "分"
|
||||
second_suffix = "秒"
|
||||
token_expired_title = "Claude Codeのセッションが切れました"
|
||||
token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||
chatgpt_token_expired_title = "Codexのセッションが切れました"
|
||||
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||
@@ -1,43 +0,0 @@
|
||||
code = "ko"
|
||||
native_name = "한국어"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "새로 고침"
|
||||
update_frequency = "업데이트 주기"
|
||||
one_minute = "1분"
|
||||
five_minutes = "5분"
|
||||
fifteen_minutes = "15분"
|
||||
one_hour = "1시간"
|
||||
models = "모델"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "설정"
|
||||
start_with_windows = "Windows 시작 시 실행"
|
||||
reset_position = "위치 초기화"
|
||||
language = "언어"
|
||||
system_default = "시스템 기본값"
|
||||
check_for_updates = "업데이트 확인"
|
||||
checking_for_updates = "확인 중…"
|
||||
up_to_date = "최신 상태"
|
||||
update_failed = "업데이트 실패"
|
||||
applying_update = "업데이트 적용 중…"
|
||||
update_available = "업데이트 있음"
|
||||
update_via_winget = "WinGet 사용"
|
||||
auto_update_check = "업데이트 자동 확인"
|
||||
auto_check_disabled = "사용 안 함"
|
||||
auto_check_hourly = "매시간"
|
||||
auto_check_daily = "매일"
|
||||
auto_check_weekly = "매주"
|
||||
exit = "종료"
|
||||
show_widget = "위젯 표시"
|
||||
session_window = "5시간"
|
||||
weekly_window = "7일"
|
||||
now = "지금"
|
||||
day_suffix = "일"
|
||||
hour_suffix = "시간"
|
||||
minute_suffix = "분"
|
||||
second_suffix = "초"
|
||||
token_expired_title = "Claude Code 세션 만료"
|
||||
token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
||||
chatgpt_token_expired_title = "Codex 세션 만료"
|
||||
chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
||||
@@ -1,43 +0,0 @@
|
||||
code = "nl"
|
||||
native_name = "Nederlands"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Vernieuwen"
|
||||
update_frequency = "Bijwerkfrequentie"
|
||||
one_minute = "1 minuut"
|
||||
five_minutes = "5 minuten"
|
||||
fifteen_minutes = "15 minuten"
|
||||
one_hour = "1 uur"
|
||||
models = "Modellen"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Instellingen"
|
||||
start_with_windows = "Starten met Windows"
|
||||
reset_position = "Positie herstellen"
|
||||
language = "Taal"
|
||||
system_default = "Systeemstandaard"
|
||||
check_for_updates = "Controleren op updates"
|
||||
checking_for_updates = "Bezig met controleren…"
|
||||
up_to_date = "Up-to-date"
|
||||
update_failed = "Update mislukt"
|
||||
applying_update = "Update toepassen…"
|
||||
update_available = "Update beschikbaar"
|
||||
update_via_winget = "via WinGet"
|
||||
auto_update_check = "Automatische updatecontrole"
|
||||
auto_check_disabled = "Uitgeschakeld"
|
||||
auto_check_hourly = "Per uur"
|
||||
auto_check_daily = "Dagelijks"
|
||||
auto_check_weekly = "Wekelijks"
|
||||
exit = "Afsluiten"
|
||||
show_widget = "Widget tonen"
|
||||
session_window = "5u"
|
||||
weekly_window = "7d"
|
||||
now = "nu"
|
||||
day_suffix = "d"
|
||||
hour_suffix = "u"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Claude Code-sessie verlopen"
|
||||
token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
||||
chatgpt_token_expired_title = "Codex-sessie verlopen"
|
||||
chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
||||
@@ -1,43 +0,0 @@
|
||||
code = "zh-TW"
|
||||
native_name = "繁體中文"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "重新整理"
|
||||
update_frequency = "更新頻率"
|
||||
one_minute = "1 分鐘"
|
||||
five_minutes = "5 分鐘"
|
||||
fifteen_minutes = "15 分鐘"
|
||||
one_hour = "1 小時"
|
||||
models = "模型"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "設定"
|
||||
start_with_windows = "隨 Windows 啟動"
|
||||
reset_position = "重設位置"
|
||||
language = "語言"
|
||||
system_default = "系統預設"
|
||||
check_for_updates = "檢查更新"
|
||||
checking_for_updates = "檢查中…"
|
||||
up_to_date = "已是最新"
|
||||
update_failed = "更新失敗"
|
||||
applying_update = "正在套用更新…"
|
||||
update_available = "有可用更新"
|
||||
update_via_winget = "透過 WinGet"
|
||||
auto_update_check = "自動檢查更新"
|
||||
auto_check_disabled = "停用"
|
||||
auto_check_hourly = "每小時"
|
||||
auto_check_daily = "每天"
|
||||
auto_check_weekly = "每週"
|
||||
exit = "結束"
|
||||
show_widget = "顯示小工具"
|
||||
session_window = "5 小時"
|
||||
weekly_window = "7 日"
|
||||
now = "現在"
|
||||
day_suffix = "日"
|
||||
hour_suffix = "時"
|
||||
minute_suffix = "分"
|
||||
second_suffix = "秒"
|
||||
token_expired_title = "Claude Code 工作階段已過期"
|
||||
token_expired_body = "請重新登入以繼續追蹤使用量。"
|
||||
chatgpt_token_expired_title = "Codex 工作階段已過期"
|
||||
chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。"
|
||||
+117
-134
@@ -1,25 +1,9 @@
|
||||
// Embedded TOML-based localisation.
|
||||
//
|
||||
// Each supported language lives in `locales/<code>.toml`. At startup we
|
||||
// `include_str!` every file, parse them with `toml`, and stash them in a
|
||||
// HashMap keyed by language code. The active language defaults to whatever
|
||||
// Windows reports for the user's preferred UI language; the menu lets the
|
||||
// user override that.
|
||||
//
|
||||
// Adding a translation: copy `en.toml` to `<code>.toml`, translate the
|
||||
// strings, then add one `include_str!` entry to `RAW_LOCALES` below.
|
||||
// English UI strings and formatting helpers.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod detect;
|
||||
|
||||
const FALLBACK_CODE: &str = "en";
|
||||
|
||||
/// The strings every UI module needs. Field names map 1:1 to TOML keys.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
/// The strings every UI module needs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocaleStrings {
|
||||
pub window_title: String,
|
||||
pub refresh: String,
|
||||
@@ -28,14 +12,23 @@ pub struct LocaleStrings {
|
||||
pub five_minutes: String,
|
||||
pub fifteen_minutes: String,
|
||||
pub one_hour: String,
|
||||
pub models: String,
|
||||
pub providers: String,
|
||||
pub claude_label: String,
|
||||
pub chatgpt_label: String,
|
||||
pub opencode_go_label: String,
|
||||
pub settings: String,
|
||||
pub start_with_windows: String,
|
||||
pub reset_position: String,
|
||||
pub language: String,
|
||||
pub system_default: String,
|
||||
pub size_smaller: String,
|
||||
pub size_larger: String,
|
||||
pub reset_size: String,
|
||||
pub controls: String,
|
||||
pub control_left_click: String,
|
||||
pub control_right_click: String,
|
||||
pub control_drag: String,
|
||||
pub control_ctrl_wheel: String,
|
||||
pub control_tray_click: String,
|
||||
pub tray_left_click: String,
|
||||
pub check_for_updates: String,
|
||||
pub checking_for_updates: String,
|
||||
pub up_to_date: String,
|
||||
@@ -49,6 +42,7 @@ pub struct LocaleStrings {
|
||||
pub auto_check_daily: String,
|
||||
pub auto_check_weekly: String,
|
||||
pub exit: String,
|
||||
pub restart: String,
|
||||
pub show_widget: String,
|
||||
pub session_window: String,
|
||||
pub weekly_window: String,
|
||||
@@ -61,132 +55,94 @@ 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.
|
||||
pub threshold_80_body: String,
|
||||
/// Body text for the 95% threshold balloon.
|
||||
pub threshold_95_body: String,
|
||||
/// Title for the tray balloon shown on first launch after an auto-update.
|
||||
pub update_applied_title: String,
|
||||
/// Prefix for the tray balloon body. Call site appends the version.
|
||||
pub update_applied_body: String,
|
||||
/// Prefix for the rollback-failed MessageBox body. Call site appends
|
||||
/// the backup path and a separator with the expected target filename.
|
||||
pub update_rollback_failed_body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LocaleFile {
|
||||
code: String,
|
||||
native_name: String,
|
||||
#[serde(flatten)]
|
||||
pub struct I18n {
|
||||
strings: LocaleStrings,
|
||||
}
|
||||
|
||||
const RAW_LOCALES: &[(&str, &str)] = &[
|
||||
("en", include_str!("locales/en.toml")),
|
||||
("nl", include_str!("locales/nl.toml")),
|
||||
("es", include_str!("locales/es.toml")),
|
||||
("fr", include_str!("locales/fr.toml")),
|
||||
("de", include_str!("locales/de.toml")),
|
||||
("ja", include_str!("locales/ja.toml")),
|
||||
("ko", include_str!("locales/ko.toml")),
|
||||
("zh-TW", include_str!("locales/zh-TW.toml")),
|
||||
];
|
||||
|
||||
pub struct I18n {
|
||||
/// Sorted by code so menus list languages deterministically.
|
||||
available: BTreeMap<String, (String, LocaleStrings)>,
|
||||
active: String,
|
||||
}
|
||||
|
||||
impl I18n {
|
||||
/// Load all embedded TOML files and pick an active language.
|
||||
///
|
||||
/// `requested` overrides system detection. `None` means "ask Windows".
|
||||
pub fn load(requested: Option<&str>) -> Self {
|
||||
let mut available = BTreeMap::new();
|
||||
for (code, body) in RAW_LOCALES {
|
||||
match toml::from_str::<LocaleFile>(body) {
|
||||
Ok(file) => {
|
||||
available.insert(file.code.clone(), (file.native_name, file.strings));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("failed to parse locale {code}: {e}");
|
||||
}
|
||||
}
|
||||
pub fn load() -> Self {
|
||||
Self {
|
||||
strings: english_strings(),
|
||||
}
|
||||
if !available.contains_key(FALLBACK_CODE) {
|
||||
// Embedded TOMLs are validated by tests; this should never
|
||||
// happen in practice. Fall through with whatever we have.
|
||||
log::error!("fallback locale '{FALLBACK_CODE}' missing");
|
||||
}
|
||||
|
||||
let active = requested
|
||||
.and_then(|c| normalise(c, &available))
|
||||
.or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &available)))
|
||||
.unwrap_or_else(|| FALLBACK_CODE.to_string());
|
||||
|
||||
Self { available, active }
|
||||
}
|
||||
|
||||
pub fn strings(&self) -> &LocaleStrings {
|
||||
self.available
|
||||
.get(&self.active)
|
||||
.map(|(_, s)| s)
|
||||
.unwrap_or_else(|| {
|
||||
// Defensive: if `active` was set to something unavailable
|
||||
// (shouldn't happen given `load` validates) — fall back.
|
||||
&self
|
||||
.available
|
||||
.get(FALLBACK_CODE)
|
||||
.expect("fallback locale must exist")
|
||||
.1
|
||||
})
|
||||
}
|
||||
|
||||
pub fn active_code(&self) -> &str {
|
||||
&self.active
|
||||
}
|
||||
|
||||
/// Iterate `(code, native_name)` pairs in stable order.
|
||||
pub fn available(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.available
|
||||
.iter()
|
||||
.map(|(code, (name, _))| (code.as_str(), name.as_str()))
|
||||
}
|
||||
|
||||
pub fn set_active(&mut self, requested: Option<&str>) {
|
||||
let new_active = requested
|
||||
.and_then(|c| normalise(c, &self.available))
|
||||
.or_else(|| {
|
||||
detect::detect_system_locale().and_then(|c| normalise(&c, &self.available))
|
||||
})
|
||||
.unwrap_or_else(|| FALLBACK_CODE.to_string());
|
||||
self.active = new_active;
|
||||
&self.strings
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a user-supplied or system-supplied locale code to one we have.
|
||||
///
|
||||
/// Handles `en_US`, `en-US`, `EN`, `zh-Hant-TW`, etc. by progressive
|
||||
/// fallback: exact → ASCII-lower exact → prefix match.
|
||||
fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>) -> Option<String> {
|
||||
let cleaned = input.trim().replace('_', "-");
|
||||
if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("system") {
|
||||
return None;
|
||||
fn english_strings() -> LocaleStrings {
|
||||
LocaleStrings {
|
||||
window_title: "Claude Code Usage Bubble".into(),
|
||||
refresh: "Refresh".into(),
|
||||
update_frequency: "Update frequency".into(),
|
||||
one_minute: "1 minute".into(),
|
||||
five_minutes: "5 minutes".into(),
|
||||
fifteen_minutes: "15 minutes".into(),
|
||||
one_hour: "1 hour".into(),
|
||||
providers: "Providers".into(),
|
||||
claude_label: "Claude Code".into(),
|
||||
chatgpt_label: "Codex".into(),
|
||||
opencode_go_label: "OpenCode Go".into(),
|
||||
settings: "Settings".into(),
|
||||
start_with_windows: "Start with Windows".into(),
|
||||
reset_position: "Reset position".into(),
|
||||
size_smaller: "Make smaller".into(),
|
||||
size_larger: "Make larger".into(),
|
||||
reset_size: "Reset size".into(),
|
||||
controls: "Controls".into(),
|
||||
control_left_click: "Left-click: details".into(),
|
||||
control_right_click: "Right-click: menu".into(),
|
||||
control_drag: "Drag: move/snap".into(),
|
||||
control_ctrl_wheel: "Ctrl+Wheel: resize".into(),
|
||||
control_tray_click: "Tray click: show/hide".into(),
|
||||
tray_left_click: "Left-click: show/hide".into(),
|
||||
check_for_updates: "Check for updates".into(),
|
||||
checking_for_updates: "Checking for updates...".into(),
|
||||
up_to_date: "Up to date".into(),
|
||||
update_failed: "Update failed".into(),
|
||||
applying_update: "Applying update...".into(),
|
||||
update_available: "Update available".into(),
|
||||
update_via_winget: "via WinGet".into(),
|
||||
auto_update_check: "Auto-update check".into(),
|
||||
auto_check_disabled: "Disabled".into(),
|
||||
auto_check_hourly: "Hourly".into(),
|
||||
auto_check_daily: "Daily".into(),
|
||||
auto_check_weekly: "Weekly".into(),
|
||||
exit: "Exit".into(),
|
||||
restart: "Restart".into(),
|
||||
show_widget: "Show widget".into(),
|
||||
session_window: "5h".into(),
|
||||
weekly_window: "7d".into(),
|
||||
now: "now".into(),
|
||||
day_suffix: "d".into(),
|
||||
hour_suffix: "h".into(),
|
||||
minute_suffix: "m".into(),
|
||||
second_suffix: "s".into(),
|
||||
token_expired_title: "Claude Code session expired".into(),
|
||||
token_expired_body: "Sign in again to keep tracking your usage.".into(),
|
||||
chatgpt_token_expired_title: "Codex session expired".into(),
|
||||
chatgpt_token_expired_body: "Sign in again to keep tracking your usage.".into(),
|
||||
threshold_80_body: "Approaching the 5-hour limit.".into(),
|
||||
threshold_95_body: "Limit is close - consider easing up.".into(),
|
||||
update_applied_title: "Update applied".into(),
|
||||
update_applied_body: "Updated to v".into(),
|
||||
update_rollback_failed_body: "Update failed. Your original binary is saved at: ".into(),
|
||||
}
|
||||
// Exact (case-insensitive)
|
||||
for key in available.keys() {
|
||||
if key.eq_ignore_ascii_case(&cleaned) {
|
||||
return Some(key.clone());
|
||||
}
|
||||
}
|
||||
// Special-case: Traditional Chinese variants → zh-TW
|
||||
let lower = cleaned.to_ascii_lowercase();
|
||||
if lower.starts_with("zh") && (lower.contains("tw") || lower.contains("hk") || lower.contains("hant")) {
|
||||
if available.contains_key("zh-TW") {
|
||||
return Some("zh-TW".to_string());
|
||||
}
|
||||
}
|
||||
// Prefix fallback (e.g. "en-US" → "en")
|
||||
let prefix = lower.split('-').next().unwrap_or("");
|
||||
if !prefix.is_empty() {
|
||||
for key in available.keys() {
|
||||
if key.split('-').next().map(str::to_ascii_lowercase).as_deref() == Some(prefix) {
|
||||
return Some(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------- Free-function helpers ----------
|
||||
@@ -248,3 +204,30 @@ pub fn time_until_display_change(resets_at: Option<SystemTime>) -> Option<Durati
|
||||
};
|
||||
Some(Duration::from_secs(secs.saturating_sub(bucket_start) + 1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn english_strings_include_required_menu_labels() {
|
||||
let strings = english_strings();
|
||||
for (name, value) in [
|
||||
("refresh", strings.refresh.as_str()),
|
||||
("providers", strings.providers.as_str()),
|
||||
("settings", strings.settings.as_str()),
|
||||
("size_smaller", strings.size_smaller.as_str()),
|
||||
("size_larger", strings.size_larger.as_str()),
|
||||
("reset_size", strings.reset_size.as_str()),
|
||||
("controls", strings.controls.as_str()),
|
||||
("control_left_click", strings.control_left_click.as_str()),
|
||||
("control_right_click", strings.control_right_click.as_str()),
|
||||
("control_drag", strings.control_drag.as_str()),
|
||||
("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()),
|
||||
("control_tray_click", strings.control_tray_click.as_str()),
|
||||
("tray_left_click", strings.tray_left_click.as_str()),
|
||||
] {
|
||||
assert!(!value.trim().is_empty(), "empty string: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-3
@@ -13,6 +13,7 @@ mod os;
|
||||
mod tray;
|
||||
mod update;
|
||||
mod usage;
|
||||
mod usage_color;
|
||||
|
||||
// Application surface.
|
||||
mod app;
|
||||
@@ -36,8 +37,34 @@ fn main() {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
if diagnose_enabled {
|
||||
log::info!("entering app::run");
|
||||
let wait_pid = args
|
||||
.iter()
|
||||
.position(|a| a == "--wait-pid")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
if let Some(pid) = wait_pid {
|
||||
if diagnose_enabled {
|
||||
log::info!("waiting up to 5s for parent pid {pid} to exit");
|
||||
}
|
||||
update::handoff::wait_for_parent_exit(pid, 5_000);
|
||||
}
|
||||
app::run();
|
||||
|
||||
let updated_to = args
|
||||
.iter()
|
||||
.position(|a| a == "--updated-to")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.cloned();
|
||||
|
||||
if diagnose_enabled {
|
||||
log::info!("entering app::run (wait_pid={wait_pid:?} updated_to={updated_to:?})");
|
||||
}
|
||||
app::run(AppArgs {
|
||||
wait_pid_present: wait_pid.is_some(),
|
||||
updated_to,
|
||||
});
|
||||
}
|
||||
|
||||
pub struct AppArgs {
|
||||
pub wait_pid_present: bool,
|
||||
pub updated_to: Option<String>,
|
||||
}
|
||||
|
||||
+53
-37
@@ -7,15 +7,18 @@ use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::Graphics::Dwm::{
|
||||
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
|
||||
};
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
use crate::i18n::LocaleStrings;
|
||||
use crate::os::dpi::scale as scale_to_dpi;
|
||||
use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
|
||||
use crate::usage::ProviderId;
|
||||
type TrayIconKind = ProviderId;
|
||||
|
||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
|
||||
const PANEL_W_LOGICAL: i32 = 280;
|
||||
@@ -27,7 +30,7 @@ const RIGHT_TEXT_W_LOGICAL: i32 = 96;
|
||||
const BAR_HEIGHT_LOGICAL: i32 = 14;
|
||||
|
||||
pub struct PanelData {
|
||||
pub model: TrayIconKind,
|
||||
pub model: ProviderId,
|
||||
pub session_pct: f64,
|
||||
pub session_text: String,
|
||||
pub weekly_pct: f64,
|
||||
@@ -83,7 +86,7 @@ pub fn is_visible() -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn current_model() -> Option<TrayIconKind> {
|
||||
pub fn current_model() -> Option<ProviderId> {
|
||||
lock_state().as_ref().map(|p| p.data.model)
|
||||
}
|
||||
|
||||
@@ -118,6 +121,8 @@ pub fn show(data: PanelData, anchor_hwnd: HWND) {
|
||||
},
|
||||
};
|
||||
|
||||
apply_win11_window_chrome(hwnd);
|
||||
|
||||
{
|
||||
let mut guard = lock_state();
|
||||
if let Some(p) = guard.as_mut() {
|
||||
@@ -152,7 +157,7 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
||||
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
||||
PCWSTR::from_raw(class_w.as_ptr()),
|
||||
PCWSTR::from_raw(title_w.as_ptr()),
|
||||
WS_POPUP | WS_BORDER,
|
||||
WS_POPUP,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
@@ -172,6 +177,23 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply Windows 11 rounded corners. Win11-only — `DwmSetWindowAttribute`
|
||||
/// returns an error on Win10 and earlier, which we deliberately swallow so
|
||||
/// the panel falls back to square corners without complaint. Idempotent:
|
||||
/// DWM ignores redundant identical-value sets, so calling this on every
|
||||
/// `show()` is safe.
|
||||
fn apply_win11_window_chrome(hwnd: HWND) {
|
||||
unsafe {
|
||||
let pref = DWMWCP_ROUND;
|
||||
let _ = DwmSetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_WINDOW_CORNER_PREFERENCE,
|
||||
&pref as *const _ as *const _,
|
||||
std::mem::size_of_val(&pref) as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hide() {
|
||||
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
||||
if let Some(hwnd) = hwnd_opt {
|
||||
@@ -269,21 +291,17 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
} else {
|
||||
Color::from_hex("#D6D6D6")
|
||||
};
|
||||
let accent = bar_color_for(data.model, data.session_pct.max(data.weekly_pct), data.is_dark);
|
||||
let session_accent =
|
||||
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.session_pct);
|
||||
let weekly_accent =
|
||||
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.weekly_pct);
|
||||
|
||||
unsafe {
|
||||
let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref()));
|
||||
FillRect(hdc, &rc, bg_brush);
|
||||
let _ = DeleteObject(bg_brush);
|
||||
|
||||
// 4-px accent stripe matching the bubble — same provider color so the
|
||||
// identity carries across both surfaces. Codex is theme-aware so a
|
||||
// pure white stripe doesn't vanish into the light-mode background.
|
||||
let stripe_color = match (data.model, data.is_dark) {
|
||||
(ProviderId::Claude, _) => Color::from_hex("#D97757"),
|
||||
(ProviderId::ChatGpt, true) => Color::from_hex("#FFFFFF"),
|
||||
(ProviderId::ChatGpt, false) => Color::from_hex("#2A2A2A"),
|
||||
};
|
||||
let stripe_color = crate::usage_color::accent_color_for(data.model, data.is_dark);
|
||||
let stripe_w = scaled(4);
|
||||
let stripe_rect = RECT {
|
||||
left: 0,
|
||||
@@ -299,6 +317,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
let header = match data.model {
|
||||
ProviderId::Claude => data.strings.claude_label.clone(),
|
||||
ProviderId::ChatGpt => data.strings.chatgpt_label.clone(),
|
||||
ProviderId::OpenCodeGo => data.strings.opencode_go_label.clone(),
|
||||
};
|
||||
draw_text(
|
||||
hdc,
|
||||
@@ -313,11 +332,8 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
);
|
||||
|
||||
let bar_x = scaled(PADDING_LOGICAL) + scaled(LABEL_W_LOGICAL) + scaled(4);
|
||||
let bar_w = rc.right
|
||||
- bar_x
|
||||
- scaled(PADDING_LOGICAL)
|
||||
- scaled(RIGHT_TEXT_W_LOGICAL)
|
||||
- scaled(4);
|
||||
let bar_w =
|
||||
rc.right - bar_x - scaled(PADDING_LOGICAL) - scaled(RIGHT_TEXT_W_LOGICAL) - scaled(4);
|
||||
let row1_y = scaled(PADDING_LOGICAL) + scaled(24);
|
||||
let row2_y = row1_y + scaled(BAR_HEIGHT_LOGICAL) + scaled(ROW_GAP_LOGICAL) + scaled(8);
|
||||
|
||||
@@ -333,7 +349,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
&data.session_text,
|
||||
text_color,
|
||||
track,
|
||||
accent,
|
||||
session_accent,
|
||||
dpi,
|
||||
);
|
||||
|
||||
@@ -349,7 +365,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
&data.weekly_text,
|
||||
text_color,
|
||||
track,
|
||||
accent,
|
||||
weekly_accent,
|
||||
dpi,
|
||||
);
|
||||
}
|
||||
@@ -482,10 +498,6 @@ fn draw_text(
|
||||
}
|
||||
}
|
||||
|
||||
fn bar_color_for(model: ProviderId, percent: f64, is_dark: bool) -> Color {
|
||||
crate::bubble::bar_fill_color(model, is_dark, percent)
|
||||
}
|
||||
|
||||
fn clone_data() -> Option<PanelData> {
|
||||
let guard = lock_state();
|
||||
let p = guard.as_ref()?;
|
||||
@@ -504,23 +516,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)
|
||||
}
|
||||
|
||||
fn scale_to_dpi(logical: i32, dpi: u32) -> i32 {
|
||||
((logical as i64) * (dpi as i64) / 96) as i32
|
||||
}
|
||||
|
||||
+158
-15
@@ -1,10 +1,16 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windows::Win32::Foundation::RECT;
|
||||
use windows::Win32::Graphics::Gdi::{MonitorFromRect, MONITOR_DEFAULTTONULL};
|
||||
|
||||
use crate::bubble::DEFAULT_BUBBLE_SIZE;
|
||||
use crate::usage::ProviderId;
|
||||
type TrayIconKind = ProviderId;
|
||||
|
||||
// 140px matches MIN_BUBBLE_SIZE — a saved top-left a few px past the work-area
|
||||
// edge still passes the validator, but a position fully on a disconnected
|
||||
// monitor (the bug we're guarding against) fails.
|
||||
const POSITION_PROBE_PX: i32 = 140;
|
||||
|
||||
const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble";
|
||||
const SETTINGS_FILE: &str = "settings.json";
|
||||
@@ -25,6 +31,9 @@ fn default_show_claude() -> bool {
|
||||
fn default_show_codex() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_show_opencode_go() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_widget_visible() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -42,31 +51,77 @@ fn default_update_check_interval_secs() -> Option<u64> {
|
||||
pub struct BubblePositions {
|
||||
pub claude: Option<(i32, i32)>,
|
||||
pub codex: Option<(i32, i32)>,
|
||||
pub opencode_go: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
impl BubblePositions {
|
||||
pub fn get(&self, model: TrayIconKind) -> Option<(i32, i32)> {
|
||||
match model {
|
||||
pub fn get(&self, provider: ProviderId) -> Option<(i32, i32)> {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude,
|
||||
ProviderId::ChatGpt => self.codex,
|
||||
ProviderId::OpenCodeGo => self.opencode_go,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, model: TrayIconKind, pos: (i32, i32)) {
|
||||
match model {
|
||||
pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude = Some(pos),
|
||||
ProviderId::ChatGpt => self.codex = Some(pos),
|
||||
ProviderId::OpenCodeGo => self.opencode_go = Some(pos),
|
||||
}
|
||||
}
|
||||
pub fn reset(&mut self, model: TrayIconKind) {
|
||||
match model {
|
||||
pub fn reset(&mut self, provider: ProviderId) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude = None,
|
||||
ProviderId::ChatGpt => self.codex = None,
|
||||
ProviderId::OpenCodeGo => self.opencode_go = None,
|
||||
}
|
||||
}
|
||||
pub fn reset_all(&mut self) {
|
||||
self.claude = None;
|
||||
self.codex = None;
|
||||
self.opencode_go = None;
|
||||
}
|
||||
|
||||
/// Drop any saved position whose top-left no longer falls on a connected
|
||||
/// monitor. Guards against `bubble::create` placing the window on a
|
||||
/// disconnected secondary monitor (where the user can't see or recover it).
|
||||
pub fn validate(&mut self) {
|
||||
if let Some((x, y)) = self.claude {
|
||||
if !position_on_any_monitor(x, y) {
|
||||
log::warn!(
|
||||
"bubble position claude ({x},{y}) outside all monitors; resetting to default"
|
||||
);
|
||||
self.claude = None;
|
||||
}
|
||||
}
|
||||
if let Some((x, y)) = self.codex {
|
||||
if !position_on_any_monitor(x, y) {
|
||||
log::warn!(
|
||||
"bubble position codex ({x},{y}) outside all monitors; resetting to default"
|
||||
);
|
||||
self.codex = None;
|
||||
}
|
||||
}
|
||||
if let Some((x, y)) = self.opencode_go {
|
||||
if !position_on_any_monitor(x, y) {
|
||||
log::warn!("bubble position opencode-go ({x},{y}) outside all monitors; resetting to default");
|
||||
self.opencode_go = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn position_on_any_monitor(x: i32, y: i32) -> bool {
|
||||
// MONITOR_DEFAULTTONULL returns a null HMONITOR when the rect intersects
|
||||
// no connected monitor — exactly the signal we want.
|
||||
let probe = RECT {
|
||||
left: x,
|
||||
top: y,
|
||||
right: x + POSITION_PROBE_PX,
|
||||
bottom: y + POSITION_PROBE_PX,
|
||||
};
|
||||
let monitor = unsafe { MonitorFromRect(&probe, MONITOR_DEFAULTTONULL) };
|
||||
!monitor.is_invalid()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -75,6 +130,8 @@ pub struct Settings {
|
||||
pub show_claude_code: bool,
|
||||
#[serde(default = "default_show_codex")]
|
||||
pub show_codex: bool,
|
||||
#[serde(default = "default_show_opencode_go")]
|
||||
pub show_opencode_go: bool,
|
||||
#[serde(default)]
|
||||
pub bubble_positions: BubblePositions,
|
||||
#[serde(default = "default_bubble_size")]
|
||||
@@ -82,8 +139,6 @@ pub struct Settings {
|
||||
#[serde(default = "default_poll_interval_ms")]
|
||||
pub poll_interval_ms: u32,
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_update_check_unix: Option<u64>,
|
||||
#[serde(default = "default_update_check_interval_secs")]
|
||||
pub update_check_interval_secs: Option<u64>,
|
||||
@@ -96,10 +151,10 @@ impl Default for Settings {
|
||||
Self {
|
||||
show_claude_code: default_show_claude(),
|
||||
show_codex: default_show_codex(),
|
||||
show_opencode_go: default_show_opencode_go(),
|
||||
bubble_positions: BubblePositions::default(),
|
||||
bubble_size_logical: default_bubble_size(),
|
||||
poll_interval_ms: default_poll_interval_ms(),
|
||||
language: None,
|
||||
last_update_check_unix: None,
|
||||
update_check_interval_secs: default_update_check_interval_secs(),
|
||||
widget_visible: default_widget_visible(),
|
||||
@@ -107,6 +162,35 @@ impl Default for Settings {
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn is_provider_enabled(&self, provider: ProviderId) -> bool {
|
||||
match provider {
|
||||
ProviderId::Claude => self.show_claude_code,
|
||||
ProviderId::ChatGpt => self.show_codex,
|
||||
ProviderId::OpenCodeGo => self.show_opencode_go,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.show_claude_code = enabled,
|
||||
ProviderId::ChatGpt => self.show_codex = enabled,
|
||||
ProviderId::OpenCodeGo => self.show_opencode_go = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_provider(&mut self, provider: ProviderId) {
|
||||
let enabled = !self.is_provider_enabled(provider);
|
||||
self.set_provider_enabled(provider, enabled);
|
||||
}
|
||||
|
||||
pub fn has_enabled_live_provider(&self) -> bool {
|
||||
ProviderId::LIVE_USAGE
|
||||
.iter()
|
||||
.any(|provider| self.is_provider_enabled(*provider))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_dir() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join(APP_DIR_NAME))
|
||||
}
|
||||
@@ -124,17 +208,76 @@ pub fn load() -> Settings {
|
||||
Err(_) => return Settings::default(),
|
||||
};
|
||||
let mut settings: Settings = serde_json::from_str(&content).unwrap_or_default();
|
||||
// At least one model must be visible. Otherwise the app has nothing to show.
|
||||
if !settings.show_claude_code && !settings.show_codex {
|
||||
// At least one live provider must be visible. Otherwise the app has nothing to show.
|
||||
if !settings.has_enabled_live_provider() {
|
||||
settings.show_claude_code = true;
|
||||
}
|
||||
// Clamp bubble size to safe range in case settings.json was hand-edited.
|
||||
settings.bubble_size_logical = settings
|
||||
.bubble_size_logical
|
||||
.clamp(crate::bubble::MIN_BUBBLE_SIZE, crate::bubble::MAX_BUBBLE_SIZE);
|
||||
settings.bubble_size_logical = settings.bubble_size_logical.clamp(
|
||||
crate::bubble::MIN_BUBBLE_SIZE,
|
||||
crate::bubble::MAX_BUBBLE_SIZE,
|
||||
);
|
||||
// Drop positions on monitors that have since been disconnected.
|
||||
settings.bubble_positions.validate();
|
||||
settings
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn old_two_provider_settings_json_still_loads() {
|
||||
let json = r#"{
|
||||
"show_claude_code": false,
|
||||
"show_codex": true,
|
||||
"bubble_positions": {
|
||||
"claude": [10, 20],
|
||||
"codex": [30, 40]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let settings: Settings =
|
||||
serde_json::from_str(json).expect("old settings json should parse");
|
||||
|
||||
assert!(!settings.show_claude_code);
|
||||
assert!(settings.show_codex);
|
||||
assert!(!settings.show_opencode_go);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::Claude),
|
||||
Some((10, 20))
|
||||
);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::ChatGpt),
|
||||
Some((30, 40))
|
||||
);
|
||||
assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_provider_settings_json_loads() {
|
||||
let json = r#"{
|
||||
"show_claude_code": true,
|
||||
"show_codex": false,
|
||||
"show_opencode_go": true,
|
||||
"bubble_positions": {
|
||||
"opencode_go": [50, 60]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let settings: Settings =
|
||||
serde_json::from_str(json).expect("new settings json should parse");
|
||||
|
||||
assert!(settings.show_claude_code);
|
||||
assert!(!settings.show_codex);
|
||||
assert!(settings.show_opencode_go);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::OpenCodeGo),
|
||||
Some((50, 60))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(settings: &Settings) {
|
||||
let path = settings_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
|
||||
+17
-37
@@ -9,8 +9,7 @@
|
||||
use std::ffi::c_void;
|
||||
|
||||
use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::Foundation::{BOOL, HWND};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER,
|
||||
DIB_RGB_COLORS, HBITMAP,
|
||||
@@ -44,7 +43,13 @@ fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_circle(cx, cy, inner);
|
||||
if let Some(path) = pb.finish() {
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
pixmap.fill_path(
|
||||
&path,
|
||||
&paint,
|
||||
FillRule::Winding,
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +71,7 @@ fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
|
||||
if let Some(p) = percent {
|
||||
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
|
||||
if sweep > 0.0 {
|
||||
let fill = usage_color(p);
|
||||
let fill = usage_color(kind, p);
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
|
||||
paint.anti_alias = true;
|
||||
@@ -113,36 +118,17 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
|
||||
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
|
||||
// Cool dark slate for ChatGPT/Codex.
|
||||
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
|
||||
// Deep green for OpenCode Go.
|
||||
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_color(percent: f64) -> [u8; 3] {
|
||||
// Color gradient: soft orange (low usage) → red (near-cap).
|
||||
let stops: [(f64, [u8; 3]); 5] = [
|
||||
(0.0, [0xD9, 0x77, 0x57]),
|
||||
(50.0, [0xD9, 0x77, 0x57]),
|
||||
(75.0, [0xCC, 0x8C, 0x20]),
|
||||
(90.0, [0xC4, 0x50, 0x20]),
|
||||
(100.0, [0xB8, 0x20, 0x20]),
|
||||
];
|
||||
for pair in stops.windows(2) {
|
||||
let (a_p, a_c) = pair[0];
|
||||
let (b_p, b_c) = pair[1];
|
||||
if percent <= b_p {
|
||||
let span = (b_p - a_p).max(f64::EPSILON);
|
||||
let t = ((percent - a_p) / span).clamp(0.0, 1.0);
|
||||
return [
|
||||
lerp(a_c[0], b_c[0], t),
|
||||
lerp(a_c[1], b_c[1], t),
|
||||
lerp(a_c[2], b_c[2], t),
|
||||
];
|
||||
}
|
||||
}
|
||||
stops[stops.len() - 1].1
|
||||
}
|
||||
|
||||
fn lerp(a: u8, b: u8, t: f64) -> u8 {
|
||||
(a as f64 + (b as f64 - a as f64) * t).round() as u8
|
||||
/// Sweep-ring fill color for the tray badge. The badge inner disk is always
|
||||
/// dark regardless of system theme, so we pass `is_dark = true` to keep the
|
||||
/// ring readable (Codex sweep stays white instead of charcoal).
|
||||
fn usage_color(kind: ProviderId, percent: f64) -> [u8; 3] {
|
||||
let c = crate::usage_color::bar_fill_color(kind, true, percent);
|
||||
[c.r, c.g, c.b]
|
||||
}
|
||||
|
||||
// ---------- Pixmap → HICON ----------
|
||||
@@ -220,9 +206,3 @@ fn pixmap_to_hicon(pixmap: &Pixmap) -> Option<HICON> {
|
||||
hicon
|
||||
}
|
||||
}
|
||||
|
||||
// Silence import warnings if we end up not needing PCWSTR after later edits.
|
||||
#[allow(dead_code)]
|
||||
const _: PCWSTR = PCWSTR::null();
|
||||
|
||||
use windows::Win32::Foundation::BOOL;
|
||||
|
||||
+21
-9
@@ -11,8 +11,8 @@ use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::Shell::{
|
||||
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_WARNING, NIM_ADD,
|
||||
NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW,
|
||||
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_INFO, NIIF_WARNING, NIM_ADD,
|
||||
NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::DestroyIcon;
|
||||
|
||||
@@ -81,13 +81,28 @@ pub fn sync(owner: HWND, desired: &[TrayIcon]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a balloon notification on an already-registered icon.
|
||||
pub fn notify(owner: HWND, kind: IconKind, title: &str, body: &str) {
|
||||
/// Show a yellow-warning balloon on an already-registered icon.
|
||||
pub fn notify_warning(owner: HWND, kind: IconKind, title: &str, body: &str) {
|
||||
notify_inner(owner, kind, title, body, NIIF_WARNING);
|
||||
}
|
||||
|
||||
/// Show a blue-info balloon on an already-registered icon.
|
||||
pub fn notify_info(owner: HWND, kind: IconKind, title: &str, body: &str) {
|
||||
notify_inner(owner, kind, title, body, NIIF_INFO);
|
||||
}
|
||||
|
||||
fn notify_inner(
|
||||
owner: HWND,
|
||||
kind: IconKind,
|
||||
title: &str,
|
||||
body: &str,
|
||||
flags: NOTIFY_ICON_INFOTIP_FLAGS,
|
||||
) {
|
||||
let mut data = build_data(owner, kind);
|
||||
data.uFlags = NIF_INFO;
|
||||
write_utf16(&mut data.szInfoTitle, title);
|
||||
write_utf16(&mut data.szInfo, body);
|
||||
data.dwInfoFlags = NIIF_WARNING;
|
||||
data.dwInfoFlags = flags;
|
||||
unsafe {
|
||||
let _ = Shell_NotifyIconW(NIM_MODIFY, &data);
|
||||
}
|
||||
@@ -115,10 +130,7 @@ fn build_data(owner: HWND, kind: IconKind) -> NOTIFYICONDATAW {
|
||||
}
|
||||
|
||||
fn icon_id(kind: IconKind) -> u32 {
|
||||
match kind {
|
||||
IconKind::Claude => 1,
|
||||
IconKind::ChatGpt => 2,
|
||||
}
|
||||
kind.metadata().tray_icon_id
|
||||
}
|
||||
|
||||
fn write_utf16(dst: &mut [u16], src: &str) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Native Win32 process + file handoff primitives used by the in-app
|
||||
// restart path and the auto-update install path. The main binary uses
|
||||
// `windows_subsystem = "windows"`, so spawning the child directly via
|
||||
// `CreateProcessW` allocates no console — nothing can flash.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::path::Path;
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::{CloseHandle, FALSE, HANDLE, WAIT_OBJECT_0};
|
||||
use windows::Win32::System::Threading::{
|
||||
CreateProcessW, OpenProcess, WaitForSingleObject, CREATE_NEW_PROCESS_GROUP,
|
||||
CREATE_NO_WINDOW, DETACHED_PROCESS, PROCESS_INFORMATION, PROCESS_SYNCHRONIZE,
|
||||
STARTUPINFOW,
|
||||
};
|
||||
|
||||
/// Spawn `exe` with the supplied args as a detached, console-less child.
|
||||
///
|
||||
/// Caller is fire-and-forget: the child's handles are closed immediately
|
||||
/// so no zombie wait is required.
|
||||
pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
|
||||
let mut cmdline = build_command_line(exe, args);
|
||||
let exe_w: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
|
||||
let si = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut pi = PROCESS_INFORMATION::default();
|
||||
|
||||
let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
|
||||
let ok = unsafe {
|
||||
CreateProcessW(
|
||||
PCWSTR::from_raw(exe_w.as_ptr()),
|
||||
windows::core::PWSTR(cmdline.as_mut_ptr()),
|
||||
None,
|
||||
None,
|
||||
FALSE,
|
||||
flags,
|
||||
None,
|
||||
PCWSTR::null(),
|
||||
&si,
|
||||
&mut pi,
|
||||
)
|
||||
};
|
||||
|
||||
if ok.is_err() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
if !pi.hThread.is_invalid() {
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
}
|
||||
if !pi.hProcess.is_invalid() {
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
}
|
||||
}
|
||||
// Suppress the unused-variable warning until si.lpReserved fields ever matter.
|
||||
let _ = &si;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait up to `timeout_ms` for `pid` to exit. Silent on any failure —
|
||||
/// caller treats this as a best-effort barrier before acquiring the
|
||||
/// singleton mutex.
|
||||
pub fn wait_for_parent_exit(pid: u32, timeout_ms: u32) {
|
||||
let handle: HANDLE = match unsafe { OpenProcess(PROCESS_SYNCHRONIZE, FALSE, pid) } {
|
||||
Ok(h) if !h.is_invalid() => h,
|
||||
_ => return,
|
||||
};
|
||||
unsafe {
|
||||
let res = WaitForSingleObject(handle, timeout_ms);
|
||||
if res != WAIT_OBJECT_0 {
|
||||
log::debug!("wait_for_parent_exit pid={pid} timeout/err res={:?}", res.0);
|
||||
}
|
||||
let _ = CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove leftover `<exe>.old.<pid>` siblings from previous in-place updates.
|
||||
/// Filled in by phase 4; stubbed here so phase 1 can wire the call sites.
|
||||
pub fn cleanup_stale_old_exes(current_exe: &Path) {
|
||||
let Some(dir) = current_exe.parent() else {
|
||||
return;
|
||||
};
|
||||
let Some(stem) = current_exe.file_name() else {
|
||||
return;
|
||||
};
|
||||
let prefix = format!("{}.old.", stem.to_string_lossy());
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy().starts_with(&prefix) {
|
||||
if let Err(e) = std::fs::remove_file(entry.path()) {
|
||||
log::debug!(
|
||||
"cleanup_stale_old_exes: remove {:?} failed: {e}",
|
||||
entry.path()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_command_line(exe: &Path, args: &[OsString]) -> Vec<u16> {
|
||||
// CreateProcessW parses argv[0] from a quoted exe path. We wrap the
|
||||
// exe in `"…"` and join args separated by spaces. Args are quoted
|
||||
// only when they contain whitespace; our callers pass simple tokens
|
||||
// (--wait-pid <number>, --updated-to <version>) so naive quoting is
|
||||
// sufficient.
|
||||
let mut line = String::new();
|
||||
line.push('"');
|
||||
line.push_str(&exe.to_string_lossy());
|
||||
line.push('"');
|
||||
for a in args {
|
||||
line.push(' ');
|
||||
let s = a.to_string_lossy();
|
||||
if s.chars().any(|c| c.is_whitespace()) {
|
||||
line.push('"');
|
||||
line.push_str(&s);
|
||||
line.push('"');
|
||||
} else {
|
||||
line.push_str(&s);
|
||||
}
|
||||
}
|
||||
let mut wide: Vec<u16> = std::ffi::OsString::from(line).encode_wide().collect();
|
||||
wide.push(0);
|
||||
wide
|
||||
}
|
||||
+323
-46
@@ -1,79 +1,271 @@
|
||||
// Download a release asset and hand off via inline `cmd /c`.
|
||||
// Download a release asset and swap it in through the proven `self_replace`
|
||||
// handoff path.
|
||||
//
|
||||
// We avoid the helper-exe pattern entirely: after writing the new .exe
|
||||
// to a staging path, we spawn cmd.exe with a one-liner that waits 2 s,
|
||||
// moves the new binary over the running one (Windows releases the file
|
||||
// lock when our process exits), and relaunches it.
|
||||
// The running process cannot reliably replace its own mapped image on
|
||||
// Windows. `self_replace` handles the platform-specific rename/copy sequence
|
||||
// for that case. After the replacement succeeds, we start the installed path
|
||||
// with `--wait-pid` and let the current process exit.
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
|
||||
};
|
||||
use windows::Win32::System::Threading::GetCurrentProcessId;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
|
||||
|
||||
use crate::net::Client;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
use crate::os::to_utf16_nul;
|
||||
|
||||
pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error> {
|
||||
match begin_inner(http, release) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
write_update_error(&e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_inner(http: &Client, release: &super::Release) -> Result<(), super::Error> {
|
||||
let current = std::env::current_exe()?;
|
||||
ensure_writable(¤t)?;
|
||||
let staging = stage_path()?;
|
||||
// Defense in depth: `MoveFileExW` itself is immune to `%`-expansion
|
||||
// (no shell parses our paths), but the existing rejection guards
|
||||
// future code paths that might invoke external tools, so keep it.
|
||||
reject_unsafe_path(¤t)?;
|
||||
reject_unsafe_path(&staging)?;
|
||||
if let Some(parent) = staging.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
download(http, &release.asset_url, &staging)?;
|
||||
spawn_handoff(&staging, ¤t)?;
|
||||
download(
|
||||
http,
|
||||
&release.asset_url,
|
||||
&staging,
|
||||
release.asset_sha256.as_ref(),
|
||||
)?;
|
||||
replace_current_exe(&staging)?;
|
||||
spawn_replaced_exe(¤t, &release.version)?;
|
||||
let _ = std::fs::remove_file(&staging);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CLI entry-point compatibility for `--apply-update <target> <source> <pid>`.
|
||||
/// The inline-cmd handoff already does the swap-and-restart; if this binary
|
||||
/// is invoked with the legacy flag (e.g. from an older release's helper)
|
||||
/// just exit cleanly so the upgrade still completes.
|
||||
/// CLI entry point for `--apply-update <target> <source> <parent-pid> <version>`.
|
||||
/// Runs from the staged new binary, waits for the old UI process to exit,
|
||||
/// replaces the installed exe, and starts the installed copy.
|
||||
pub fn run_cli(args: &[String]) -> Option<i32> {
|
||||
if args.len() >= 2 && args[1] == "--apply-update" {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
if args.get(1).map(String::as_str) != Some("--apply-update") {
|
||||
return None;
|
||||
}
|
||||
let Some(target) = args.get(2).map(PathBuf::from) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(source) = args.get(3).map(PathBuf::from) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(parent_pid) = args.get(4).and_then(|s| s.parse::<u32>().ok()) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(version) = args.get(5).cloned() else {
|
||||
return Some(2);
|
||||
};
|
||||
|
||||
super::handoff::wait_for_parent_exit(parent_pid, 15_000);
|
||||
match replace_from_helper(&source, &target, &version) {
|
||||
Ok(()) => Some(0),
|
||||
Err(e) => {
|
||||
log::error!("apply-update failed: {e}");
|
||||
Some(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn download(http: &Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> {
|
||||
fn download(
|
||||
http: &Client,
|
||||
url: &str,
|
||||
to: &Path,
|
||||
expected_sha256: Option<&[u8; 32]>,
|
||||
) -> Result<(), super::Error> {
|
||||
let resp = http
|
||||
.get(url)
|
||||
.header("User-Agent", super::release::user_agent())
|
||||
.send()?;
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Err(super::Error::Network(crate::net::Error::Status(resp.status())));
|
||||
return Err(super::Error::Network(crate::net::Error::Status(
|
||||
resp.status(),
|
||||
)));
|
||||
}
|
||||
std::fs::write(to, resp.body())?;
|
||||
let body = resp.body();
|
||||
if let Some(expected) = expected_sha256 {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(body);
|
||||
let actual = hasher.finalize();
|
||||
if actual.as_slice() != expected {
|
||||
return Err(super::Error::ChecksumMismatch {
|
||||
expected: hex_encode(expected),
|
||||
actual: hex_encode(&actual),
|
||||
});
|
||||
}
|
||||
}
|
||||
std::fs::write(to, body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(), super::Error> {
|
||||
let src_str = source.to_string_lossy().replace('"', "");
|
||||
let tgt_str = target.to_string_lossy().replace('"', "");
|
||||
// 2-second wait gives the current process time to exit and release the
|
||||
// file lock before `move` overwrites it.
|
||||
let cmd = format!(
|
||||
r#"timeout /t 2 /nobreak >nul & move /y "{src_str}" "{tgt_str}" & start "" "{tgt_str}""#
|
||||
);
|
||||
// raw_arg bypasses Rust's std auto-escaping which would turn the inner
|
||||
// `"` characters into `\"`. cmd.exe does not recognise `\"`, so the
|
||||
// escaped form makes `start` see the path as `\\` and emit a
|
||||
// "Windows cannot find '\\'" dialog. Feeding the command line raw
|
||||
// preserves the quotes cmd.exe actually expects.
|
||||
Command::new("cmd.exe")
|
||||
.raw_arg("/c")
|
||||
.raw_arg(format!("\"{cmd}\""))
|
||||
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
out.push(HEX[(b >> 4) as usize] as char);
|
||||
out.push(HEX[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> {
|
||||
let s = p.to_string_lossy();
|
||||
if s.contains('%') {
|
||||
return Err(super::Error::UnsafePath(format!("path contains '%': {s}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_update_helper(
|
||||
helper: &Path,
|
||||
staging: &Path,
|
||||
target: &Path,
|
||||
version: &super::release::Version,
|
||||
) -> Result<(), super::Error> {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
|
||||
let args = vec![
|
||||
OsString::from("--apply-update"),
|
||||
target.as_os_str().to_os_string(),
|
||||
staging.as_os_str().to_os_string(),
|
||||
OsString::from(pid.to_string()),
|
||||
OsString::from(version_str),
|
||||
];
|
||||
super::handoff::spawn_detached(helper, &args).map_err(super::Error::Io)
|
||||
}
|
||||
|
||||
fn replace_current_exe(staging: &Path) -> Result<(), super::Error> {
|
||||
self_replace::self_replace(staging)
|
||||
.map_err(|e| super::Error::SwapFailed(format!("self_replace({}): {e}", staging.display())))
|
||||
}
|
||||
|
||||
fn spawn_replaced_exe(
|
||||
target: &Path,
|
||||
version: &super::release::Version,
|
||||
) -> Result<(), super::Error> {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
|
||||
let args = vec![
|
||||
OsString::from("--wait-pid"),
|
||||
OsString::from(pid.to_string()),
|
||||
OsString::from("--updated-to"),
|
||||
OsString::from(version_str),
|
||||
];
|
||||
super::handoff::spawn_detached(target, &args).map_err(super::Error::Io)
|
||||
}
|
||||
|
||||
fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<(), super::Error> {
|
||||
let backup = backup_path(target);
|
||||
// Parent has exited, so the install target is no longer mapped.
|
||||
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
|
||||
|
||||
let swap_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
|
||||
if let Err(swap_err) = move_file(source, target, swap_flags) {
|
||||
// Compatibility for users updating from a release that invoked
|
||||
// the downloaded binary itself as the helper. A mapped source exe
|
||||
// may not be movable, but it can usually still be copied.
|
||||
let copy_result = std::fs::copy(source, target);
|
||||
if copy_result.is_err() {
|
||||
log::error!("source move failed before copy fallback: {swap_err}");
|
||||
}
|
||||
if let Err(copy_err) = copy_result {
|
||||
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
|
||||
log::error!("rollback also failed: {revert_err}; surfacing modal");
|
||||
let target_name = target
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
|
||||
surface_rollback_failure(&backup, &target_name);
|
||||
}
|
||||
return Err(super::Error::Io(copy_err));
|
||||
}
|
||||
}
|
||||
|
||||
let args = vec![OsString::from("--updated-to"), OsString::from(version)];
|
||||
if let Err(spawn_err) = super::handoff::spawn_detached(target, &args) {
|
||||
log::error!("spawn_detached failed after swap: {spawn_err}; attempting revert");
|
||||
let _ = std::fs::remove_file(target);
|
||||
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
|
||||
log::error!("post-spawn revert failed: {revert_err}");
|
||||
}
|
||||
return Err(super::Error::Io(spawn_err));
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(source);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_file(src: &Path, dst: &Path, flags: MOVE_FILE_FLAGS) -> Result<(), super::Error> {
|
||||
let src_w = to_utf16_nul(&src.to_string_lossy());
|
||||
let dst_w = to_utf16_nul(&dst.to_string_lossy());
|
||||
let result = unsafe {
|
||||
MoveFileExW(
|
||||
PCWSTR::from_raw(src_w.as_ptr()),
|
||||
PCWSTR::from_raw(dst_w.as_ptr()),
|
||||
flags,
|
||||
)
|
||||
};
|
||||
result.map_err(|e| {
|
||||
super::Error::SwapFailed(format!(
|
||||
"MoveFileExW({} -> {}): {e}",
|
||||
src.display(),
|
||||
dst.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn backup_path(target: &Path) -> PathBuf {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let fname = target
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "exe".to_string());
|
||||
let mut p = target.to_owned();
|
||||
p.set_file_name(format!("{fname}.old.{pid}"));
|
||||
p
|
||||
}
|
||||
|
||||
fn surface_rollback_failure(backup: &Path, target_name: &str) {
|
||||
// Pull the UI body text from i18n; the caller passes the
|
||||
// user-meaningful filename so we can format it in-place.
|
||||
let strings = crate::i18n::I18n::load().strings().clone();
|
||||
let body = format!(
|
||||
"{}{}\n\n{}",
|
||||
strings.update_rollback_failed_body,
|
||||
backup.display(),
|
||||
target_name
|
||||
);
|
||||
let title_w = to_utf16_nul(&strings.update_failed);
|
||||
let body_w = to_utf16_nul(&body);
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
None,
|
||||
PCWSTR::from_raw(body_w.as_ptr()),
|
||||
PCWSTR::from_raw(title_w.as_ptr()),
|
||||
MB_OK | MB_ICONERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_path() -> Result<PathBuf, super::Error> {
|
||||
let base = dirs::data_local_dir().ok_or_else(|| {
|
||||
super::Error::NotWritable("no local data directory available".to_string())
|
||||
@@ -84,7 +276,73 @@ fn stage_path() -> Result<PathBuf, super::Error> {
|
||||
.join("update.exe"))
|
||||
}
|
||||
|
||||
fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> {
|
||||
fn update_error_log_path() -> Result<PathBuf, super::Error> {
|
||||
let base = dirs::data_local_dir().ok_or_else(|| {
|
||||
super::Error::NotWritable("no local data directory available".to_string())
|
||||
})?;
|
||||
Ok(base
|
||||
.join("ClaudeCodeUsageBubble")
|
||||
.join("updates")
|
||||
.join("update-error.log"))
|
||||
}
|
||||
|
||||
fn write_update_error(error: &super::Error) {
|
||||
let Ok(path) = update_error_log_path() else {
|
||||
return;
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or_default();
|
||||
let body = format!("unix_time={ts}\nerror={error}\n");
|
||||
let _ = std::fs::write(path, body);
|
||||
}
|
||||
|
||||
fn helper_path() -> Result<PathBuf, super::Error> {
|
||||
let base = dirs::data_local_dir().ok_or_else(|| {
|
||||
super::Error::NotWritable("no local data directory available".to_string())
|
||||
})?;
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
Ok(base
|
||||
.join("ClaudeCodeUsageBubble")
|
||||
.join("updates")
|
||||
.join(format!("updater-helper-{pid}.exe")))
|
||||
}
|
||||
|
||||
fn prepare_update_helper(current: &Path, helper: &Path) -> Result<(), super::Error> {
|
||||
if let Some(parent) = helper.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(current, helper)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cleanup_staged_update_files() {
|
||||
let Ok(stage) = stage_path() else {
|
||||
return;
|
||||
};
|
||||
let Some(dir) = stage.parent() else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name == "update.exe" || (name.starts_with("updater-helper-") && name.ends_with(".exe")) {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
log::debug!("cleanup_staged_update_files: remove {:?} failed: {e}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_writable(target: &Path) -> Result<(), super::Error> {
|
||||
let parent = target.parent().ok_or_else(|| {
|
||||
super::Error::NotWritable("could not resolve install directory".to_string())
|
||||
})?;
|
||||
@@ -93,3 +351,22 @@ fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stage_and_helper_paths_are_distinct_exes() {
|
||||
let stage = stage_path().expect("stage path");
|
||||
let helper = helper_path().expect("helper path");
|
||||
|
||||
assert_eq!(stage.file_name().unwrap(), "update.exe");
|
||||
assert!(helper
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("updater-helper-"));
|
||||
assert_ne!(stage, helper);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -1,10 +1,12 @@
|
||||
// Self-update subsystem.
|
||||
//
|
||||
// Two stages: `release::fetch_latest` checks GitHub releases for a newer
|
||||
// build; `install::begin` downloads the .exe and hands off to a detached
|
||||
// `cmd /c` script that swaps the binary and restarts.
|
||||
// build; `install::begin` downloads the .exe, swaps it in via `self_replace`,
|
||||
// then spawns the new binary detached via `CreateProcessW`. No shell handoff —
|
||||
// nothing can flash a console.
|
||||
|
||||
pub mod channel;
|
||||
pub mod handoff;
|
||||
pub mod install;
|
||||
pub mod release;
|
||||
|
||||
@@ -20,6 +22,12 @@ pub enum Error {
|
||||
NotWritable(String),
|
||||
#[error("malformed version: {0}")]
|
||||
BadVersion(String),
|
||||
#[error("asset checksum mismatch: expected {expected}, got {actual}")]
|
||||
ChecksumMismatch { expected: String, actual: String },
|
||||
#[error("path rejected for safety: {0}")]
|
||||
UnsafePath(String),
|
||||
#[error("file swap failed: {0}")]
|
||||
SwapFailed(String),
|
||||
}
|
||||
|
||||
pub use channel::{current as current_channel, Channel};
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
+46
-20
@@ -133,7 +133,7 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
|
||||
|
||||
fn bucket_to_window(bucket: Bucket) -> Window {
|
||||
Window {
|
||||
utilization: bucket.utilization,
|
||||
utilization: bucket.utilization.clamp(0.0, 100.0),
|
||||
resets_at: bucket.resets_at.as_deref().and_then(parse_iso8601),
|
||||
}
|
||||
}
|
||||
@@ -163,18 +163,21 @@ fn token_is_expired(expires_at_unix_ms: Option<i64>) -> bool {
|
||||
now_ms >= exp_ms
|
||||
}
|
||||
|
||||
// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|+00:00]") ---
|
||||
// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM]") ---
|
||||
|
||||
fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||
let trimmed = s.split('Z').next().unwrap_or(s);
|
||||
let trimmed = trimmed.split('+').next().unwrap_or(trimmed);
|
||||
let trimmed = trimmed.split('-').take(3).collect::<Vec<_>>().join("-");
|
||||
// We want the original `s` for parsing time-part. Re-split on 'T'.
|
||||
let (date, time) = s
|
||||
.split_once('T')
|
||||
.map(|(d, t)| (d, t))
|
||||
.or_else(|| Some(("", "")))?;
|
||||
let _ = trimmed; // shadow; using the raw `date` + `time` below.
|
||||
let (date, time_with_offset) = s.split_once('T')?;
|
||||
|
||||
// Split the time-and-offset on the first 'Z' / '+' / '-' marker.
|
||||
let offset_pos = time_with_offset
|
||||
.char_indices()
|
||||
.find(|(_, c)| matches!(c, 'Z' | '+' | '-'))
|
||||
.map(|(i, _)| i);
|
||||
let (time, offset_str) = match offset_pos {
|
||||
Some(p) => (&time_with_offset[..p], &time_with_offset[p..]),
|
||||
None => (time_with_offset, ""),
|
||||
};
|
||||
let time = time.split_once('.').map_or(time, |(t, _)| t);
|
||||
|
||||
let date_parts: Vec<&str> = date.split('-').collect();
|
||||
if date_parts.len() != 3 {
|
||||
@@ -183,13 +186,16 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||
let y: u64 = date_parts[0].parse().ok()?;
|
||||
let mo: u64 = date_parts[1].parse().ok()?;
|
||||
let d: u64 = date_parts[2].parse().ok()?;
|
||||
if y < 1970 || mo == 0 || mo > 12 || d == 0 {
|
||||
return None;
|
||||
}
|
||||
const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
let max_day = DAYS_IN_MONTH[mo as usize] + if mo == 2 && is_leap(y) { 1 } else { 0 };
|
||||
if d > max_day {
|
||||
return None;
|
||||
}
|
||||
|
||||
let time_no_offset = time
|
||||
.split(|c| c == 'Z' || c == '+' || (c == '-' && time.find(c) != Some(0)))
|
||||
.next()
|
||||
.unwrap_or(time);
|
||||
let time_no_frac = time_no_offset.split('.').next().unwrap_or(time_no_offset);
|
||||
let time_parts: Vec<&str> = time_no_frac.split(':').collect();
|
||||
let time_parts: Vec<&str> = time.split(':').collect();
|
||||
if time_parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
@@ -197,11 +203,26 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||
let mi: u64 = time_parts[1].parse().ok()?;
|
||||
let se: u64 = time_parts[2].parse().ok()?;
|
||||
|
||||
let offset_minutes: i64 = if offset_str.is_empty() || offset_str == "Z" {
|
||||
0
|
||||
} else {
|
||||
let sign: i64 = if offset_str.starts_with('+') {
|
||||
1
|
||||
} else if offset_str.starts_with('-') {
|
||||
-1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let (oh_str, om_str) = offset_str[1..].split_once(':')?;
|
||||
let oh: i64 = oh_str.parse().ok()?;
|
||||
let om: i64 = om_str.parse().ok()?;
|
||||
sign * (oh * 60 + om)
|
||||
};
|
||||
|
||||
let mut days: u64 = 0;
|
||||
for year in 1970..y {
|
||||
days += if is_leap(year) { 366 } else { 365 };
|
||||
}
|
||||
const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
for month in 1..mo {
|
||||
days += DAYS_IN_MONTH[month as usize];
|
||||
if month == 2 && is_leap(y) {
|
||||
@@ -209,8 +230,13 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||
}
|
||||
}
|
||||
days += d - 1;
|
||||
let secs = days * 86_400 + h * 3_600 + mi * 60 + se;
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
|
||||
let local_secs = days * 86_400 + h * 3_600 + mi * 60 + se;
|
||||
let utc_secs = (local_secs as i64) - offset_minutes * 60;
|
||||
if utc_secs < 0 {
|
||||
return None;
|
||||
}
|
||||
Some(UNIX_EPOCH + Duration::from_secs(utc_secs as u64))
|
||||
}
|
||||
|
||||
fn is_leap(year: u64) -> bool {
|
||||
|
||||
@@ -84,7 +84,7 @@ fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
|
||||
|
||||
fn window_from(w: ApiWindow) -> Window {
|
||||
Window {
|
||||
utilization: w.used_percent,
|
||||
utilization: w.used_percent.clamp(0.0, 100.0),
|
||||
resets_at: unix_to_systemtime(Some(w.reset_at)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,14 +15,18 @@ use crate::usage::{UsageWindows, Window};
|
||||
pub fn parse_anthropic(response: &Response) -> UsageWindows {
|
||||
UsageWindows {
|
||||
primary: Window {
|
||||
utilization: header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0,
|
||||
utilization: (header_f64(response, "anthropic-ratelimit-unified-5h-utilization")
|
||||
* 100.0)
|
||||
.clamp(0.0, 100.0),
|
||||
resets_at: unix_to_systemtime(header_i64(
|
||||
response,
|
||||
"anthropic-ratelimit-unified-5h-reset",
|
||||
)),
|
||||
},
|
||||
secondary: Window {
|
||||
utilization: header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0,
|
||||
utilization: (header_f64(response, "anthropic-ratelimit-unified-7d-utilization")
|
||||
* 100.0)
|
||||
.clamp(0.0, 100.0),
|
||||
resets_at: unix_to_systemtime(header_i64(
|
||||
response,
|
||||
"anthropic-ratelimit-unified-7d-reset",
|
||||
|
||||
+11
-26
@@ -56,11 +56,13 @@ impl Orchestrator {
|
||||
|
||||
fn spawn_cli(hint: &RefreshHint) -> bool {
|
||||
match hint {
|
||||
RefreshHint::LocalClaudeCli => spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."]),
|
||||
RefreshHint::WslClaudeCli { distro } => spawn_wsl(distro),
|
||||
RefreshHint::LocalCodexCli => {
|
||||
spawn_local(&["codex.cmd", "codex.ps1", "codex.exe", "codex"], &["exec", "."])
|
||||
RefreshHint::LocalClaudeCli => {
|
||||
spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."])
|
||||
}
|
||||
RefreshHint::LocalCodexCli => spawn_local(
|
||||
&["codex.cmd", "codex.ps1", "codex.exe", "codex"],
|
||||
&["exec", "."],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +71,11 @@ fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let mut cmd = if lower.ends_with(".ps1") {
|
||||
let mut c = Command::new("powershell.exe");
|
||||
c.arg("-NoProfile").arg("-ExecutionPolicy").arg("Bypass").arg("-File").arg(name);
|
||||
c.arg("-NoProfile")
|
||||
.arg("-ExecutionPolicy")
|
||||
.arg("Bypass")
|
||||
.arg("-File")
|
||||
.arg(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
@@ -100,24 +106,3 @@ fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn spawn_wsl(distro: &str) -> bool {
|
||||
let script = "if command -v claude >/dev/null 2>&1; then claude -p .; \
|
||||
elif [ -x \"$HOME/.local/bin/claude\" ]; then \"$HOME/.local/bin/claude\" -p .; \
|
||||
else exit 127; fi";
|
||||
Command::new("wsl.exe")
|
||||
.arg("-d")
|
||||
.arg(distro)
|
||||
.arg("--")
|
||||
.arg("bash")
|
||||
.arg("-lic")
|
||||
.arg(script)
|
||||
.env_remove("CLAUDECODE")
|
||||
.env_remove("CLAUDE_CODE_ENTRYPOINT")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
+22
-7
@@ -7,7 +7,10 @@
|
||||
use crate::creds::Locator;
|
||||
use crate::net::Client;
|
||||
use crate::settings::Settings;
|
||||
use crate::usage::{anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider, UsageWindows};
|
||||
use crate::usage::{
|
||||
anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider,
|
||||
UsageWindows,
|
||||
};
|
||||
|
||||
pub struct Registry {
|
||||
claude: ClaudeProvider,
|
||||
@@ -28,20 +31,32 @@ impl Registry {
|
||||
settings: &Settings,
|
||||
) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
|
||||
let mut out = Vec::new();
|
||||
if settings.show_claude_code {
|
||||
out.push((ProviderId::Claude, self.claude.poll(http)));
|
||||
}
|
||||
if settings.show_codex {
|
||||
out.push((ProviderId::ChatGpt, self.chatgpt.poll(http)));
|
||||
for provider in ProviderId::LIVE_USAGE {
|
||||
if !settings.is_provider_enabled(provider) {
|
||||
continue;
|
||||
}
|
||||
let result = match provider {
|
||||
ProviderId::Claude => self.claude.poll(http),
|
||||
ProviderId::ChatGpt => self.chatgpt.poll(http),
|
||||
ProviderId::OpenCodeGo => {
|
||||
unreachable!("OpenCode Go has no live usage provider yet")
|
||||
}
|
||||
};
|
||||
out.push((provider, result));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Attempt to refresh the active source for one provider.
|
||||
pub fn try_refresh(&self, id: ProviderId, orchestrator: &refresh::Orchestrator) -> refresh::Outcome {
|
||||
pub fn try_refresh(
|
||||
&self,
|
||||
id: ProviderId,
|
||||
orchestrator: &refresh::Orchestrator,
|
||||
) -> refresh::Outcome {
|
||||
let locator = match id {
|
||||
ProviderId::Claude => self.claude.locator(),
|
||||
ProviderId::ChatGpt => self.chatgpt.locator(),
|
||||
ProviderId::OpenCodeGo => return refresh::Outcome::CliMissing,
|
||||
};
|
||||
match locator.first_available() {
|
||||
Some(src) => orchestrator.refresh(src),
|
||||
|
||||
+50
-5
@@ -1,9 +1,4 @@
|
||||
// Usage data shapes shared across providers.
|
||||
//
|
||||
// Every provider reports its quota as two named "windows" (short + long).
|
||||
// For Claude: 5-hour and 7-day. For ChatGPT: primary and secondary. We
|
||||
// normalise to `primary` + `secondary` so the UI layer doesn't care which
|
||||
// provider produced the snapshot.
|
||||
|
||||
use std::time::SystemTime;
|
||||
|
||||
@@ -11,15 +6,65 @@ use std::time::SystemTime;
|
||||
pub enum ProviderId {
|
||||
Claude,
|
||||
ChatGpt,
|
||||
OpenCodeGo,
|
||||
}
|
||||
|
||||
impl ProviderId {
|
||||
pub const ALL: [Self; 3] = [Self::Claude, Self::ChatGpt, Self::OpenCodeGo];
|
||||
pub const LIVE_USAGE: [Self; 2] = [Self::Claude, Self::ChatGpt];
|
||||
|
||||
pub fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "claude",
|
||||
Self::ChatGpt => "chatgpt",
|
||||
Self::OpenCodeGo => "opencode-go",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metadata(self) -> ProviderMetadata {
|
||||
match self {
|
||||
Self::Claude => ProviderMetadata {
|
||||
id: self,
|
||||
slug: "claude",
|
||||
default_enabled: true,
|
||||
tray_icon_id: 1,
|
||||
display_mode: ProviderDisplayMode::TwoWindow,
|
||||
live_usage: true,
|
||||
},
|
||||
Self::ChatGpt => ProviderMetadata {
|
||||
id: self,
|
||||
slug: "chatgpt",
|
||||
default_enabled: false,
|
||||
tray_icon_id: 2,
|
||||
display_mode: ProviderDisplayMode::TwoWindow,
|
||||
live_usage: true,
|
||||
},
|
||||
Self::OpenCodeGo => ProviderMetadata {
|
||||
id: self,
|
||||
slug: "opencode-go",
|
||||
default_enabled: false,
|
||||
tray_icon_id: 3,
|
||||
display_mode: ProviderDisplayMode::WeeklyMonthlyFourBar,
|
||||
live_usage: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProviderDisplayMode {
|
||||
TwoWindow,
|
||||
WeeklyMonthlyFourBar,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ProviderMetadata {
|
||||
pub id: ProviderId,
|
||||
pub slug: &'static str,
|
||||
pub default_enabled: bool,
|
||||
pub tray_icon_id: u32,
|
||||
pub display_mode: ProviderDisplayMode,
|
||||
pub live_usage: bool,
|
||||
}
|
||||
|
||||
/// One usage window: how much you've consumed (0–100), and when it resets.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Shared usage→color ramp used by the floating bubble, the expanded panel,
|
||||
// and the tray badge. Keeping the function in one place ensures the three
|
||||
// surfaces never disagree about what "78% used" looks like.
|
||||
|
||||
use crate::os::Rgb as Color;
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex tracks
|
||||
/// the OpenAI Codex monochrome palette — near-white `#E5E5E5` on dark themes,
|
||||
/// near-black `#1A1A1A` on light — so the accent stays readable against both
|
||||
/// the dark bubble surface and the `#F3F3F3` light background.
|
||||
pub fn accent_color_for(model: ProviderId, is_dark: bool) -> Color {
|
||||
match model {
|
||||
ProviderId::Claude => Color::from_hex("#D97757"),
|
||||
ProviderId::ChatGpt => {
|
||||
if is_dark {
|
||||
Color::from_hex("#E5E5E5")
|
||||
} else {
|
||||
Color::from_hex("#1A1A1A")
|
||||
}
|
||||
}
|
||||
ProviderId::OpenCodeGo => Color::from_hex("#3BAE75"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Discrete 4-band fill color. The "safe" band uses the provider's identity
|
||||
/// color so Codex bars render monochrome (light-on-dark / dark-on-light) while
|
||||
/// Claude bars stay orange; the warning bands are theme-aware so light-mode
|
||||
/// amber stays readable against the `#F3F3F3` background.
|
||||
///
|
||||
/// - <60% → provider accent
|
||||
/// - 60–80% → amber (dark `#E0A040`, light `#B47A20` for WCAG AA contrast)
|
||||
/// - 80–95% → red `#C45020`
|
||||
/// - ≥95% → deep red `#A01818` — paired with pulse animation
|
||||
pub fn bar_fill_color(model: ProviderId, is_dark: bool, percent: f64) -> Color {
|
||||
if percent < 60.0 {
|
||||
accent_color_for(model, is_dark)
|
||||
} else if percent < 80.0 {
|
||||
if is_dark {
|
||||
Color::from_hex("#E0A040")
|
||||
} else {
|
||||
Color::from_hex("#B47A20")
|
||||
}
|
||||
} else if percent < 95.0 {
|
||||
Color::from_hex("#C45020")
|
||||
} else {
|
||||
Color::from_hex("#A01818")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user