mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-08 20:19:52 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
+22
-497
@@ -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"
|
||||
@@ -77,39 +71,21 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.5"
|
||||
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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
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 = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -178,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"
|
||||
@@ -198,7 +163,7 @@ dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"toml",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
@@ -256,30 +221,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -335,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"
|
||||
@@ -489,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"
|
||||
@@ -517,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"
|
||||
@@ -555,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"
|
||||
@@ -629,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"
|
||||
@@ -712,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]]
|
||||
@@ -792,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"
|
||||
@@ -844,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"
|
||||
@@ -873,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"
|
||||
@@ -985,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"
|
||||
@@ -1015,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]]
|
||||
@@ -1040,35 +707,15 @@ 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"
|
||||
@@ -1093,45 +740,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"log",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -1295,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"
|
||||
@@ -1377,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"
|
||||
@@ -1496,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"
|
||||
|
||||
+4
-7
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.5"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||
@@ -8,32 +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
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Phase 01 — Implement Restart Action
|
||||
|
||||
## Context Links
|
||||
|
||||
- Reused pattern: `src/update/install.rs:1-120` (cmd-handoff swap-and-restart). Documented in `docs/release-process.md` if it exists.
|
||||
- Menu wiring reference: `src/app.rs:870-1045` (`show_context_menu`) and `src/app.rs:363-392` (`on_menu_command`).
|
||||
- Mutex acquisition: `src/app.rs:152-168` (`Global\ClaudeCodeUsageBubble`).
|
||||
- i18n schema: `src/i18n/mod.rs` (`LocaleStrings` struct around line 22-80).
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Low (UX enhancement).
|
||||
- **Status:** Done. Code-reviewer DONE_WITH_CONCERNS — M1 (match-arm ordering) + L3 (lock-during-save) addressed in follow-up edits.
|
||||
- **Size:** ~50 LOC across 3 files (+ 8 locale TOMLs, one line each).
|
||||
|
||||
## Key Insights
|
||||
|
||||
- The existing mutex check rejects a second instance immediately. A naive "spawn-then-exit" races. The `cmd.exe /c timeout` handoff (1 s sleep, then `start ""`) is the simplest decoupling — same trick `update::install::begin` already uses.
|
||||
- `cmd.exe` expands `%var%` in argument strings. Current `current_exe()` path containing `%` is an injection vector; reject it (existing precedent: `update::install` rejects too).
|
||||
- `std::process::Command` with `creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)` ensures the helper outlives the parent silently.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
- Right-click context menu shows a "Restart" item directly above "Exit".
|
||||
- Clicking "Restart" closes the current process and a new instance starts within ~1–2 seconds, restoring tray icons and bubbles.
|
||||
- No confirmation prompt.
|
||||
- Item label is i18n-aware: all 8 locales get a translation.
|
||||
|
||||
### Non-functional
|
||||
|
||||
- No regression in mutex single-instance behavior — second instance must still be blocked if user accidentally launches manually mid-restart.
|
||||
- No console window flashes during handoff.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User clicks "Restart"
|
||||
→ WM_COMMAND with IDM_RESTART
|
||||
→ app::on_menu_command → app::restart_app()
|
||||
→ settings::save current snapshot (defensive flush)
|
||||
→ spawn detached cmd.exe with delayed `start ""` for current_exe
|
||||
→ PostQuitMessage(0)
|
||||
→ message loop exits → mutex released
|
||||
→ cmd.exe wakes up → new instance launches → acquires mutex → run()
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
**Modify:**
|
||||
|
||||
- `src/app.rs` — add `IDM_RESTART: u16 = 33` const (next free in the 30-39 band), match arm in `on_menu_command`, menu append in `show_context_menu` between `IDM_TOGGLE_WIDGET` row and the separator before `IDM_EXIT`, new `fn restart_app()`.
|
||||
- `src/i18n/mod.rs` — add `pub restart: String,` field to `LocaleStrings` (place near `exit`).
|
||||
- `src/i18n/locales/en.toml`, `de.toml`, `es.toml`, `fr.toml`, `ja.toml`, `ko.toml`, `nl.toml`, `zh-TW.toml` — add `restart = "<translation>"`.
|
||||
|
||||
**Create:** none.
|
||||
|
||||
**Delete:** none.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Add menu ID and i18n field.**
|
||||
- `app.rs`: `const IDM_RESTART: u16 = 33;` (after `IDM_VERSION_ACTION`).
|
||||
- `i18n/mod.rs`: add `pub restart: String,` to `LocaleStrings`. Place adjacent to `exit`.
|
||||
- Add `restart = "Restart"` to `en.toml`. Translate for the other 7 locales (Vietnamese-quality is acceptable; native fluency not required for a single-word menu item).
|
||||
|
||||
2. **Wire the menu entry.**
|
||||
- `app.rs::show_context_menu` — between the `show_widget` append and the `MF_SEPARATOR` before `IDM_EXIT`, add `append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));`.
|
||||
- Add `IDM_RESTART => restart_app(),` arm in `on_menu_command` before the `_ => {}` catch-all.
|
||||
|
||||
3. **Implement `restart_app()`.**
|
||||
- Persist a final settings snapshot (defensive flush). Read current state, call `settings::save(&snap)`.
|
||||
- Resolve `std::env::current_exe()`. If `Err`, log error and `PostQuitMessage(0)` (degrade to plain Exit).
|
||||
- Convert path to string. If it contains `%`, log error and return (refuse — matches `update::install` precedent).
|
||||
- Build the cmd line: `timeout /t 1 >nul & start "" "<exe>"`.
|
||||
- Spawn via `std::process::Command::new("cmd.exe")` with `.creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)` and a `raw_arg` payload `/c "<cmd>"` (mirrors `install.rs:104-114`).
|
||||
- On spawn success: `PostQuitMessage(0)`. On failure: log error and return (app stays running).
|
||||
|
||||
4. **Verify.**
|
||||
- `cargo check` — no warnings.
|
||||
- Manual smoke test: build release, right-click tray, Restart, observe close + relaunch within ~2 s, mutex acquired by new instance, bubbles + tray icons rendered.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] Add `IDM_RESTART` const in `app.rs`.
|
||||
- [x] Add `restart: String` to `LocaleStrings` in `i18n/mod.rs`.
|
||||
- [x] Update all 8 locale `.toml` files.
|
||||
- [x] Append menu item in `show_context_menu`.
|
||||
- [x] Add match arm in `on_menu_command` (placed before `IDM_LANG_BASE` guard per reviewer M1).
|
||||
- [x] Implement `restart_app()` in `app.rs` (clone-then-save per reviewer L3).
|
||||
- [x] `cargo check` clean.
|
||||
- [ ] Manual smoke test on Windows (deferred to user; needs release build).
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Right-click tray → menu shows "Restart" between "Show widget" group and "Exit".
|
||||
- Clicking it closes the process and a new one starts within 2 s with identical state (settings honored, bubble positions persisted, tray icons restored).
|
||||
- No console window flashes.
|
||||
- `cargo check` passes with no new warnings.
|
||||
- All 8 locales include the new key (no fallback to English).
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| Mutex race — new instance starts before old releases | Medium | 1 s `timeout` in cmd handoff; matches update module precedent. |
|
||||
| `current_exe()` path contains `%` (injection) | Low | Reject, log, abort (same as `install.rs:90-94`). |
|
||||
| `cmd.exe` not on PATH (broken Windows install) | Very Low | Log error, app stays running. User can Exit manually. |
|
||||
| Settings not flushed before quit | Low | Explicit `settings::save()` before `PostQuitMessage`. Bubble positions already persist on drag, so worst case is a no-op. |
|
||||
| User restart-spams the menu | Low | Each click queues a new cmd handoff; the timeout dedupes via mutex. Worst case: one extra instance attempt that exits immediately on `ERROR_ALREADY_EXISTS`. |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- `%`-in-path rejection prevents `cmd.exe` variable expansion injection.
|
||||
- No user-supplied input enters the cmd line — only `std::env::current_exe()` output.
|
||||
- Detached process flags prevent inherited stdio from leaking.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- After merge: bump version (semver patch — UX addition with no API change).
|
||||
- Consider an analogous restart action for the bubble's context menu (currently the bubble also fires `on_menu_command` via `WM_COMMAND`, so the same menu id works there for free).
|
||||
@@ -0,0 +1,41 @@
|
||||
# Plan: Menu Restart Button
|
||||
|
||||
**Slug:** menu-restart-button
|
||||
**Created:** 2026-05-18 09:45
|
||||
**Branch:** main
|
||||
**Status:** Implemented (awaiting commit)
|
||||
|
||||
## Goal
|
||||
|
||||
Add a "Restart" entry to the tray right-click context menu, positioned directly above "Exit". Clicking it relaunches the running binary in-place without prompting for confirmation.
|
||||
|
||||
## Why
|
||||
|
||||
User-requested. Current flow to apply a config/locale tweak that doesn't hot-reload (or to recover after a hang) is Exit → relaunch from Start menu. A one-click restart is symmetric with Exit and avoids hunting for the binary again.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Title | Status |
|
||||
|---|-------|--------|
|
||||
| 01 | Implement Restart action | Done — [phase-01-implement-restart-action.md](phase-01-implement-restart-action.md) |
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Placement:** main menu, between `Show widget` separator and `Exit`. NOT inside Settings submenu — keeps top-level discoverability.
|
||||
- **No confirmation dialog.** Settings auto-save on every change (settings.rs:138-152); restart is non-destructive.
|
||||
- **Mechanism:** detached `cmd.exe /c timeout /t 1 >nul & start "" "<exe>"` handoff, then `PostQuitMessage(0)`. Same pattern as `update::install::begin` minus the swap step. The 1-second wait lets the current process release `Global\ClaudeCodeUsageBubble` mutex before the new instance's `CreateMutexW` runs.
|
||||
- **Reject paths containing `%`** — cmd.exe expands `%var%`, same defense the update module already uses (install.rs:90-94).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- None. Pure Rust + existing `windows` crate features.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Restart after settings change auto-trigger (would be a separate feature).
|
||||
- Restart-with-args (e.g., toggle `--diagnose`).
|
||||
- Cross-platform — Windows-only by design.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
None.
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Foundation: CLI flags + native spawn helper + mutex retry"
|
||||
status: complete
|
||||
priority: P1
|
||||
effort: "3h"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Foundation: CLI flags + native spawn helper + mutex retry
|
||||
|
||||
## Overview
|
||||
|
||||
Build the primitives that phases 2-4 reuse: a CLI argument parser for `--wait-pid <pid>` and `--updated-to <version>`, a `spawn_detached_self` helper that calls `CreateProcessW` directly (no cmd.exe), and mutex-acquisition retry logic that activates only when `--wait-pid` was passed.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional**
|
||||
- Parse `--wait-pid <u32>` and `--updated-to <version-string>` from `std::env::args` without breaking existing flags (`--diagnose`, `--apply-update`).
|
||||
- Expose `spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()>` that uses `CreateProcessW` with `CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`.
|
||||
- Expose `wait_for_parent_exit(pid: u32, timeout_ms: u32)` that uses `OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject`. Returns silently on timeout (best-effort).
|
||||
- Mutex acquisition in `app::run` retries `CreateMutexW` for ~3 seconds (200ms backoff) ONLY when `--wait-pid` was present; preserves today's immediate-fail behavior for normal startup.
|
||||
|
||||
**Non-functional**
|
||||
- No new external dependencies. All Win32 calls via the existing `windows = "0.58"` crate features.
|
||||
- Helper module ≤ ~120 lines total.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── update/
|
||||
│ └── handoff.rs ← NEW: spawn_detached, wait_for_parent_exit, cleanup_stale_old_exes
|
||||
├── main.rs ← parse --wait-pid early, call wait_for_parent_exit BEFORE app::run
|
||||
└── app.rs ← run() reads a static "wait_pid_was_passed" flag, retries mutex if set
|
||||
```
|
||||
|
||||
Rationale for `src/update/handoff.rs`: keeps low-level Win32 process/file ops alongside the update module that uses them most. `app.rs` and `main.rs` import it for restart + post-update bootstrap.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- **Create**: `src/update/handoff.rs`
|
||||
- **Modify**: `src/main.rs` (early arg parse + wait + flag handoff to app)
|
||||
- **Modify**: `src/update/mod.rs` (declare `pub mod handoff`)
|
||||
- **Modify**: `src/app.rs` (mutex retry loop, gated on flag from main)
|
||||
- **Modify**: `Cargo.toml` if a new `windows` feature is needed (likely `Win32_System_Threading` already covers `OpenProcess`/`WaitForSingleObject`/`CreateProcessW`)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Audit `windows` crate features.** Confirm `Win32_System_Threading` is in `Cargo.toml` (it is — line 31). Verify `CreateProcessW`, `STARTUPINFOW`, `PROCESS_INFORMATION` are accessible. Add `Win32_Storage_FileSystem` if not already present (needed for phase 3's `MoveFileExW`).
|
||||
|
||||
2. **Create `src/update/handoff.rs`** with three pub fns:
|
||||
- `pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()>`
|
||||
- Build a wide-char command line: quoted exe path + space-joined args, NUL-terminated.
|
||||
- `STARTUPINFOW` zero-initialized, `cb` set.
|
||||
- `CreateProcessW(NULL, cmdline_wide.as_mut_ptr(), NULL, NULL, FALSE, CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, NULL, NULL, &si, &pi)`.
|
||||
- Close `pi.hProcess` and `pi.hThread` immediately (fire-and-forget).
|
||||
- `pub fn wait_for_parent_exit(pid: u32, timeout_ms: u32)`
|
||||
- `OpenProcess(SYNCHRONIZE, FALSE, pid)`. If it fails (parent already gone), return immediately.
|
||||
- `WaitForSingleObject(h, timeout_ms)`. Ignore return value.
|
||||
- `CloseHandle(h)`.
|
||||
- `pub fn cleanup_stale_old_exes(current_exe: &Path)` (used by phase 4; stub here, fill in phase 4)
|
||||
- Stub: returns `Ok(())`.
|
||||
|
||||
3. **Modify `src/update/mod.rs`** to add `pub mod handoff;`.
|
||||
|
||||
4. **Modify `src/main.rs`** — insert BEFORE `app::run()`:
|
||||
```rust
|
||||
let wait_pid = args.iter()
|
||||
.position(|a| a == "--wait-pid")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
if let Some(pid) = wait_pid {
|
||||
update::handoff::wait_for_parent_exit(pid, 5_000);
|
||||
}
|
||||
```
|
||||
Note: keep this AFTER `update::run_cli` so the legacy `--apply-update` path still short-circuits cleanly.
|
||||
|
||||
5. **Modify `src/app.rs::run`** — gate mutex retry on whether `--wait-pid` appeared. Cheapest implementation: re-parse `std::env::args` once at the top of `run()`. Then around line 156:
|
||||
```rust
|
||||
let retry_mutex = std::env::args().any(|a| a == "--wait-pid");
|
||||
let _mutex = acquire_singleton_mutex(retry_mutex)?; // new helper
|
||||
```
|
||||
New helper `acquire_singleton_mutex(retry: bool)`:
|
||||
- If `!retry`: today's behavior (fail immediately on `ERROR_ALREADY_EXISTS`).
|
||||
- If `retry`: loop CreateMutexW → on `ALREADY_EXISTS`, `Sleep(200)` and retry. Budget 15 iterations = ~3 seconds. Log every retry. After budget exhausted, return error and exit cleanly.
|
||||
|
||||
6. **Compile check.** `cargo build --release`. Fix any feature gaps. No behavior should change yet — `--wait-pid` arg is parsed but no caller passes it yet.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] `cargo build --release` succeeds with zero warnings beyond the existing `dead_code` allow.
|
||||
- [ ] Running the binary normally (no flags) behaves identically to today (mutex check is immediate, no retry).
|
||||
- [ ] Running the binary with `--wait-pid <pid-of-running-instance>` against a live instance: the new process waits ≤5s for old one to exit, then acquires the mutex within ~200ms of its release. Verifiable by killing the original after 2s and watching the new one continue.
|
||||
- [ ] `update::handoff::spawn_detached` smoke test: from a small one-off snippet in `main` (gated behind a never-used flag) verify CreateProcessW returns success and pid increments. Remove before commit.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Wide-char cmdline construction has off-by-one / missing NUL | Hand-test with a path containing spaces; assert `OsStringExt::encode_wide` produces expected bytes |
|
||||
| `CREATE_NO_WINDOW | DETACHED_PROCESS` combination misbehaves on GUI subsystem binaries | Documented Windows behavior: for a GUI subsystem child, both flags are effectively no-ops (no console requested), but combining them is harmless. Tested by phase 5 |
|
||||
| Mutex retry loop hangs forever if budget logic wrong | Hard cap = 15 iterations × 200ms = 3.0s. After that, exit. Add log line per retry so a stuck loop is visible in `--diagnose` |
|
||||
| `OpenProcess(SYNCHRONIZE, ...)` returns access-denied for cross-session | Fallback: `WaitForSingleObject` simply isn't called; mutex retry compensates |
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Restart path: replace restart_app with native CreateProcessW"
|
||||
status: complete
|
||||
priority: P1
|
||||
effort: "1h"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 2: Restart path: replace restart_app with native CreateProcessW
|
||||
|
||||
## Overview
|
||||
|
||||
Replace `src/app.rs::restart_app`'s `cmd.exe /c "timeout & start ..."` handoff with a direct `spawn_detached(current_exe, ["--wait-pid", our_pid])` call. The new instance handles the parent-exit wait itself using phase 1's helper; no timer needed.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional**
|
||||
- Restart triggered from the tray menu produces zero console flash.
|
||||
- New instance acquires `Global\ClaudeCodeUsageBubble` mutex successfully every time.
|
||||
- Settings still flushed to disk before exit (preserve current `snap + save` defensive write).
|
||||
- Path-with-`%` defense becomes unnecessary (no cmd.exe); the check is removed.
|
||||
|
||||
**Non-functional**
|
||||
- `restart_app` function shrinks from ~40 lines to ~25 lines.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
restart_app():
|
||||
1. settings::save(snap) (unchanged)
|
||||
2. exe = current_exe() (unchanged)
|
||||
3. our_pid = GetCurrentProcessId()
|
||||
4. handoff::spawn_detached(exe, [--wait-pid, our_pid])
|
||||
5. on success: PostQuitMessage(0)
|
||||
6. on failure: log error, do NOT quit
|
||||
```
|
||||
|
||||
Mutex release is implicit on process exit — no explicit `ReleaseMutex` needed because the `_mutex` handle in `app::run` is dropped when `run()` returns after `PostQuitMessage`. `Drop` closes the handle, which releases the mutex.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- **Modify**: `src/app.rs::restart_app` (lines ~1378-1432)
|
||||
- **Remove**: the `RESTART_CREATE_NO_WINDOW` / `RESTART_DETACHED_PROCESS` constants (now in handoff.rs)
|
||||
- **Remove**: the `%`-rejection defense (no cmd.exe to exploit)
|
||||
- **Remove**: the `replace('"', "")` quote-stripping (handoff.rs handles quoting)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Read current `restart_app`** at `src/app.rs:1378-1432` to confirm exact bounds.
|
||||
|
||||
2. **Rewrite `restart_app`** to:
|
||||
```rust
|
||||
fn restart_app() {
|
||||
// Defensive settings flush (unchanged)
|
||||
let snap = lock_state().as_ref().map(|s| s.settings.clone());
|
||||
if let Some(s) = snap {
|
||||
settings::save(&s);
|
||||
}
|
||||
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!("restart: current_exe failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let args = vec![
|
||||
OsString::from("--wait-pid"),
|
||||
OsString::from(pid.to_string()),
|
||||
];
|
||||
|
||||
match update::handoff::spawn_detached(&exe, &args) {
|
||||
Ok(()) => {
|
||||
log::info!("restart: spawned detached child, posting quit");
|
||||
unsafe { PostQuitMessage(0) };
|
||||
}
|
||||
Err(e) => log::error!("restart: spawn failed: {e}"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Drop the two `RESTART_*` const declarations** above the function — they were specific to the old cmd.exe path. Phase 1's helper has its own.
|
||||
|
||||
4. **Drop the `%`-rejection block** in the new `restart_app`. The brainstorm doc keeps the equivalent check in `install.rs` for defense-in-depth; here it's pure dead weight without cmd.exe.
|
||||
|
||||
5. **Imports**: add `use std::ffi::OsString;` and `use windows::Win32::System::Threading::GetCurrentProcessId;` if not already in scope.
|
||||
|
||||
6. **Compile check**: `cargo build --release`.
|
||||
|
||||
7. **Manual smoke test**: run the binary, click Restart in the tray menu, verify:
|
||||
- No console window flashes
|
||||
- New instance appears within ~1s
|
||||
- Old instance log shows "spawned detached child, posting quit"
|
||||
- New instance log shows mutex acquired (via Phase 1's retry path)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] `cargo build --release` clean.
|
||||
- [ ] Manual restart from menu produces ZERO visible console window across 20 consecutive triggers.
|
||||
- [ ] New instance window appears within 1500ms of menu click.
|
||||
- [ ] Settings file (`settings.json`) shows updated mtime after restart, confirming defensive save still runs.
|
||||
- [ ] `restart_app` function is ≤25 lines.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Mutex handle not yet dropped when child tries to acquire | Phase 1's `--wait-pid` + 5s `WaitForSingleObject` + 3s mutex retry covers race comfortably (8s total budget vs <1s actual parent exit) |
|
||||
| `PostQuitMessage` doesn't immediately exit; window-message pump may process more events | Phase 1 mutex retry tolerates up to 3s of overlap |
|
||||
| `current_exe()` returns a path that the child can't load (rare: deleted exe, fileshare disconnect) | Existing behavior preserved: log error, do not quit. User can manually retry |
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Update install: rename + move + native spawn"
|
||||
status: complete
|
||||
priority: P1
|
||||
effort: "2h"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 3: Update install: rename + move + native spawn
|
||||
|
||||
## Overview
|
||||
|
||||
Replace `src/update/install.rs::begin`'s `cmd.exe /c "timeout & move & start ..."` handoff with native steps: `MoveFileExW` to rename the running exe sideways, `MoveFileExW` to move the staged exe into place, then `spawn_detached` of the new binary. Removes the only remaining cmd.exe invocation in the update flow.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional**
|
||||
- Update install produces zero console flash.
|
||||
- New binary version starts after auto-update without user interaction.
|
||||
- SHA-256 verification continues to gate the swap (no swap on checksum mismatch).
|
||||
- On any failure step, the original exe must remain runnable (no half-state).
|
||||
|
||||
**Non-functional**
|
||||
- `install.rs` net change ≈ -30 lines (cmd-quoting code is gone, replaced by short Win32 calls).
|
||||
- New code paths use the windows crate; no new dependencies.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
install::begin(http, release):
|
||||
1. current = current_exe()
|
||||
2. ensure_writable(current.parent()) (unchanged)
|
||||
3. staging = stage_path()
|
||||
4. reject_unsafe_path(current) (kept for defense-in-depth; harmless now)
|
||||
5. reject_unsafe_path(staging)
|
||||
6. create_dir_all(staging.parent())
|
||||
7. download(http, asset_url, staging, sha256) (unchanged)
|
||||
8. backup = current.with_file_name(format!("{}.old.{}", filename, pid))
|
||||
9. MoveFileExW(current, backup, 0) ← NEW: rename running exe sideways
|
||||
10. MoveFileExW(staging, current, MOVEFILE_REPLACE_EXISTING) ← NEW
|
||||
11. our_pid = GetCurrentProcessId()
|
||||
12. handoff::spawn_detached(current,
|
||||
["--wait-pid", our_pid, "--updated-to", version_str]) ← NEW
|
||||
13. return Ok(())
|
||||
```
|
||||
|
||||
Caller (`app.rs` Apply action) is responsible for `PostQuitMessage` after `begin` returns Ok — same as today.
|
||||
|
||||
### Rollback semantics
|
||||
|
||||
| Step that failed | State | Recovery |
|
||||
|---|---|---|
|
||||
| 7 (download) | Original exe untouched | Existing behavior: error surfaced, user retries |
|
||||
| 9 (rename current → backup) | Original exe untouched | Surface `Error::NotWritable`; do not proceed |
|
||||
| 10 (move staging → current) | Original exe is at backup path, current path empty | Best-effort revert: rename backup back to current; surface error |
|
||||
| 10 + revert (both fail) | Original at backup path; current path empty; user has no runnable binary at the install location | **Per Validation Session 1 decision:** show a Windows `MessageBoxW` (MB_OK \| MB_ICONERROR) telling the user where the backup is, then exit. Message: "Update failed. Your original binary is saved as `{backup_path}`. Please rename it back to `{exe_name}` manually." |
|
||||
| 12 (spawn child) | New exe at correct path, but app didn't restart | Log + tray balloon "Update applied; restart manually". Rare — `CreateProcessW` on a fresh fully-written exe almost never fails |
|
||||
|
||||
<!-- Updated: Validation Session 1 - Rollback escalation MessageBox added -->
|
||||
|
||||
### Rollback escalation helper
|
||||
|
||||
Add a private `surface_rollback_failure(backup_path: &Path, target_name: &str)` helper that calls `MessageBoxW` with `MB_OK | MB_ICONERROR` and the localized message. Adds a new `LocaleStrings` field `update_rollback_failed_body` parameterized with `{backup_path}` and `{exe_name}` (Rust `format!` substitution at call site). The plain MessageBox uses the Win32 dialog, so no console can flash.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- **Modify**: `src/update/install.rs::begin`
|
||||
- **Modify**: `src/update/install.rs::spawn_handoff` → REMOVED entirely
|
||||
- **Modify**: `src/update/install.rs` imports (drop `os::windows::process::CommandExt`, `process::{Command, Stdio}`; add `MoveFileExW`, `MOVEFILE_REPLACE_EXISTING`, `GetCurrentProcessId`, `MessageBoxW`, `MB_OK`, `MB_ICONERROR`)
|
||||
- **Modify**: `src/update/mod.rs::Error` — add `Error::SwapFailed(String)` variant if MoveFileExW failures don't fit existing variants cleanly
|
||||
- **Modify**: `src/i18n/mod.rs::LocaleStrings` — add `update_rollback_failed_body: String` (also belongs to Phase 4 i18n group, but Phase 3 is the consumer)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Read current `install.rs::begin` + `spawn_handoff`** to confirm exact bounds (lines 19-36 + 99-121).
|
||||
|
||||
2. **Decide path-safety policy**: keep `reject_unsafe_path` (the `%`-check) as defense-in-depth even though no cmd.exe runs. Update the function-level comment to reflect new reality (kept for paranoia, not strict need).
|
||||
|
||||
3. **Add `swap_and_spawn` private helper** (replaces `spawn_handoff`):
|
||||
```rust
|
||||
fn swap_and_spawn(
|
||||
source: &Path,
|
||||
target: &Path,
|
||||
version: &super::release::Version,
|
||||
) -> Result<(), super::Error> {
|
||||
let backup = backup_path(target);
|
||||
move_file(target, &backup, 0)?;
|
||||
if let Err(e) = move_file(source, target, MOVEFILE_REPLACE_EXISTING) {
|
||||
// Best-effort revert
|
||||
let _ = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING);
|
||||
return Err(e);
|
||||
}
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let args = vec![
|
||||
OsString::from("--wait-pid"),
|
||||
OsString::from(pid.to_string()),
|
||||
OsString::from("--updated-to"),
|
||||
OsString::from(format!("{}.{}.{}", version.major, version.minor, version.patch)),
|
||||
];
|
||||
super::handoff::spawn_detached(target, &args)
|
||||
.map_err(super::Error::Io)
|
||||
}
|
||||
|
||||
fn move_file(src: &Path, dst: &Path, flags: MOVE_FILE_FLAGS) -> Result<(), super::Error> {
|
||||
let src_w = to_utf16_nul(src);
|
||||
let dst_w = to_utf16_nul(dst);
|
||||
let r = unsafe {
|
||||
MoveFileExW(
|
||||
PCWSTR::from_raw(src_w.as_ptr()),
|
||||
PCWSTR::from_raw(dst_w.as_ptr()),
|
||||
flags,
|
||||
)
|
||||
};
|
||||
r.ok().map_err(|e| super::Error::SwapFailed(e.to_string()))
|
||||
}
|
||||
|
||||
fn backup_path(target: &Path) -> PathBuf {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let mut p = target.to_owned();
|
||||
let fname = target.file_name().map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "exe".to_string());
|
||||
p.set_file_name(format!("{fname}.old.{pid}"));
|
||||
p
|
||||
}
|
||||
```
|
||||
Use the existing `os::to_utf16_nul` helper for wide-char conversion (already used in `app.rs::run`).
|
||||
|
||||
4. **Rewrite `begin`** to call `swap_and_spawn(&staging, ¤t, &release.version)` instead of `spawn_handoff(&staging, ¤t)`. Pass the version from the `Release` struct already in hand.
|
||||
|
||||
5. **Add `Error::SwapFailed(String)` variant** to `src/update/mod.rs` if no existing variant fits the move-failure semantics. The `#[error(...)]` message should be `"file swap failed: {0}"`.
|
||||
|
||||
6. **Remove `spawn_handoff` function** entirely. Remove now-unused imports (`std::os::windows::process::CommandExt`, `Command`, `Stdio`, `CREATE_NO_WINDOW`, `DETACHED_PROCESS` constants).
|
||||
|
||||
7. **Compile check**: `cargo build --release`. Address any feature-flag gaps (likely need `Win32_Storage_FileSystem` added to Cargo `windows` features for `MoveFileExW`).
|
||||
|
||||
8. **Test rollback path manually**: write a temp .exe to staging that is read-only or has wrong permissions to force step 10 to fail; verify backup is restored and original is still runnable.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] `cargo build --release` clean.
|
||||
- [ ] Manual auto-update from a test-tagged v0.1.99 produces ZERO visible console window across 5 consecutive runs.
|
||||
- [ ] SHA-256 mismatch still rejects the swap (verify by tampering with a downloaded asset before swap).
|
||||
- [ ] On forced step-10 failure (simulated): backup is restored, original binary still launches.
|
||||
- [ ] `spawn_handoff` function no longer exists in the codebase (`grep -r spawn_handoff src/` returns empty).
|
||||
- [ ] No `cmd.exe` string remains in `src/update/install.rs`.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `MoveFileExW` fails because AV holds a handle on the running exe | Surface `Error::SwapFailed`; user retries. Rare on Defender (which scans on read, not perpetually) |
|
||||
| Two updates triggered in quick succession leave two `.old.<pid>` files | Phase 4 cleanup at startup handles this — glob removes ALL `.old.*` siblings |
|
||||
| User's install dir is on a network share where renaming-while-open is forbidden | Existing `ensure_writable` probe catches read-only / no-write cases. Network FS oddities → surface as `NotWritable` |
|
||||
| `MoveFileExW` with `REPLACE_EXISTING` on a non-NTFS volume | Works on FAT32 (per MS docs); the renaming-while-running concern is NTFS-specific but step 9 always renames an empty (just-emptied) path in step 10 |
|
||||
| Release-build inlines + LTO breaks symbol-level rollback assumption | Functional rollback path is exercised by phase 5's manual test under release profile |
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
phase: 4
|
||||
title: "Cleanup + tray notification"
|
||||
status: complete
|
||||
priority: P2
|
||||
effort: "1.5h"
|
||||
dependencies: [1, 3]
|
||||
---
|
||||
|
||||
# Phase 4: Cleanup + tray notification
|
||||
|
||||
## Overview
|
||||
|
||||
Two additions that make the silent update visible to the user without being intrusive: (a) startup cleanup of stale `bubble.exe.old.<pid>` files from previous updates, and (b) a tray balloon "Updated to vX.Y.Z" on first launch after auto-update (driven by the `--updated-to` flag passed by phase 3).
|
||||
|
||||
<!-- Updated: Validation Session 1 - i18n approach corrected to match TOML struct-field architecture -->
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional**
|
||||
- On every startup, scan `current_exe().parent()` for files matching `<exe-stem>.exe.old.*` and remove them silently. Errors logged at debug level, never surfaced to user.
|
||||
- When `--updated-to vX.Y.Z` is passed AND the tray subsystem is initialized, show a balloon notification with localized title + body.
|
||||
- Add **3 new fields** to `LocaleStrings` (`src/i18n/mod.rs:23-71`): `update_applied_title`, `update_applied_body`, `update_rollback_failed_body` (the last one is consumed by Phase 3 but the i18n change belongs to this phase's pattern). Body strings use Rust `format!` at call site — TOML strings hold raw text (e.g. body = `"Updated to v"`, then call site does `format!("{}{}", strings.update_applied_body, version)`). Choice of suffix vs prefix vs `{}` placeholder substitution is dialect-sensitive; use literal positional substitution via `format!` because TOML doesn't support template placeholders the i18n loader recognizes.
|
||||
- Translate the 3 new strings in all **8 existing locale files**: `src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml`.
|
||||
|
||||
**Non-functional**
|
||||
- Cleanup runs in the foreground startup path (it's a few file operations — no need for a thread).
|
||||
- Balloon uses `NIIF_INFO` (blue info icon) not `NIIF_WARNING` (yellow triangle). The existing `tray::notify` (`src/tray/mod.rs:85`) hardcodes `NIIF_WARNING` and has **2 callers** (`src/app.rs:831` usage threshold, `src/app.rs:859` token expired) — both correctly semantically "warning". Rename existing `notify` → `notify_warning`; add new sibling `notify_info`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Cleanup
|
||||
|
||||
Triggered from `app::run` after tray icons register but before main message loop. Implementation lives in `update::handoff::cleanup_stale_old_exes` (stub was added in phase 1).
|
||||
|
||||
```rust
|
||||
pub fn cleanup_stale_old_exes(current_exe: &Path) {
|
||||
let Some(dir) = current_exe.parent() else { return };
|
||||
let Some(stem) = current_exe.file_name() else { return };
|
||||
let prefix = format!("{}.old.", stem.to_string_lossy());
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy().starts_with(&prefix) {
|
||||
if let Err(e) = std::fs::remove_file(entry.path()) {
|
||||
log::debug!("cleanup_stale_old_exes: remove {:?} failed: {e}", entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Balloon notification
|
||||
|
||||
Parse `--updated-to` argument early (alongside `--wait-pid` in main.rs), stash it on `AppState`. After the tray icons are registered for the first time, if the version string is present, call `tray::notify_info(hwnd, kind, title, body)`.
|
||||
|
||||
```rust
|
||||
// main.rs (after --wait-pid parse):
|
||||
let updated_to = args.iter()
|
||||
.position(|a| a == "--updated-to")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.cloned();
|
||||
// pass into app::run via existing arg-threading or a static OnceLock
|
||||
```
|
||||
|
||||
```rust
|
||||
// tray/mod.rs: split notify into two variants
|
||||
pub fn notify_info(owner: HWND, kind: IconKind, title: &str, body: &str) {
|
||||
notify_inner(owner, kind, title, body, NIIF_INFO);
|
||||
}
|
||||
pub fn notify_warning(owner: HWND, kind: IconKind, title: &str, body: &str) {
|
||||
notify_inner(owner, kind, title, body, NIIF_WARNING);
|
||||
}
|
||||
fn notify_inner(owner: HWND, kind: IconKind, title: &str, body: &str, flags: NOTIFY_ICON_INFOTIP_FLAGS) {
|
||||
// existing body, but use `flags` instead of hardcoded NIIF_WARNING
|
||||
}
|
||||
```
|
||||
|
||||
If `tray::notify` has only one caller today (which the brainstorm scout suggested), just rename it to `notify_warning` and add `notify_info`.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- **Modify**: `src/update/handoff.rs::cleanup_stale_old_exes` (fill in phase-1 stub)
|
||||
- **Modify**: `src/app.rs::run` (call cleanup; call tray::notify_info after tray registration if updated_to is set)
|
||||
- **Modify**: `src/app.rs:831,859` (rename `tray::notify` → `tray::notify_warning` at both existing call sites)
|
||||
- **Modify**: `src/main.rs` (parse `--updated-to`, stash for app)
|
||||
- **Modify**: `src/tray/mod.rs` — rename `notify` → `notify_warning`; add `notify_info`; extract shared `notify_inner(... flags: NOTIFY_ICON_INFOTIP_FLAGS)`
|
||||
- **Modify**: `src/i18n/mod.rs::LocaleStrings` — add 3 new `String` fields: `update_applied_title`, `update_applied_body`, `update_rollback_failed_body`
|
||||
- **Modify**: `src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml` — 8 files, add the 3 new keys to each. Use machine translation for non-English where idiomatic translation unavailable; flag with comment for native-speaker review later
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **i18n: add 3 new fields to `LocaleStrings`** in `src/i18n/mod.rs:23-71`. After the existing `threshold_95_body` field, add:
|
||||
```rust
|
||||
pub update_applied_title: String,
|
||||
pub update_applied_body: String,
|
||||
pub update_rollback_failed_body: String,
|
||||
```
|
||||
|
||||
2. **Translate in 8 locale files** (`src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml`). Suggested English values (mirror style of existing `token_expired_title`/`token_expired_body`):
|
||||
```toml
|
||||
update_applied_title = "Update applied"
|
||||
update_applied_body = "Updated to v" # call site appends version string
|
||||
update_rollback_failed_body = "Update failed. Your original binary is saved as " # call site appends backup path + suffix
|
||||
```
|
||||
For non-English, machine-translate body+title, leave the trailing space/prefix structure intact. Comment in PR description: "Translations machine-generated; flagged for native-speaker review".
|
||||
|
||||
3. **Split `tray::notify`** (`src/tray/mod.rs:85-94`) into:
|
||||
- rename existing fn → `notify_warning` (preserves current `NIIF_WARNING` semantics)
|
||||
- extract shared `fn notify_inner(owner, kind, title, body, flags: NOTIFY_ICON_INFOTIP_FLAGS)`
|
||||
- add new `pub fn notify_info(owner, kind, title, body)` that calls `notify_inner(..., NIIF_INFO)`
|
||||
Update both existing call sites (`src/app.rs:831,859`) to call `notify_warning` instead of `notify`.
|
||||
|
||||
4. **Fill in `cleanup_stale_old_exes`** per architecture section.
|
||||
|
||||
5. **Parse `--updated-to` in main.rs**, thread it into `app::run`. Two options:
|
||||
- Pass as a new arg to `pub fn run(updated_to: Option<String>)`.
|
||||
- Store in a `OnceLock<Option<String>>` inside `update::handoff`, set in main, read in app.
|
||||
Pick the simpler one — direct function arg is preferred unless `run`'s signature is already heavily used elsewhere.
|
||||
|
||||
6. **In `app::run`** after tray icons register and the main window message loop is about to enter:
|
||||
```rust
|
||||
if let Some(v) = updated_to.as_ref() {
|
||||
let strings = i18n.strings();
|
||||
let title = strings.update_applied_title.clone();
|
||||
let body = format!("{}{}", strings.update_applied_body, v);
|
||||
tray::notify_info(msg_hwnd, IconKind::ClaudeCode, &title, &body);
|
||||
}
|
||||
update::handoff::cleanup_stale_old_exes(&exe);
|
||||
```
|
||||
Use `ClaudeCode` IconKind because it's always present when Claude is enabled (default). If user disabled Claude and enabled only Codex, fall back to Codex kind. Cheapest: try ClaudeCode first; if `tray::notify_info` fails silently, no harm.
|
||||
|
||||
7. **Compile check**: `cargo build --release`.
|
||||
|
||||
8. **Manual test cleanup**:
|
||||
- Create a file `claude-code-usage-bubble.exe.old.1234` next to the running binary.
|
||||
- Launch the app.
|
||||
- Verify the file is gone after launch.
|
||||
|
||||
9. **Manual test notification**:
|
||||
- Launch with `--updated-to 9.9.9` flag.
|
||||
- Verify Windows notification appears with the title + version body.
|
||||
- Verify it uses the blue info icon, not yellow warning.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] `cargo build --release` clean.
|
||||
- [ ] Stale `.old.<pid>` files in install dir are removed on every startup (idempotent).
|
||||
- [ ] Launching with `--updated-to vX.Y.Z` shows a tray balloon with localized title and version body.
|
||||
- [ ] Balloon icon is blue info (NIIF_INFO), not yellow warning.
|
||||
- [ ] Launching WITHOUT `--updated-to` shows no balloon (existing behavior preserved).
|
||||
- [ ] All 8 locale TOML files (`en, nl, es, fr, de, ja, ko, zh-TW`) contain the 3 new keys (`update_applied_title`, `update_applied_body`, `update_rollback_failed_body`); `cargo build --release` would fail to deserialize otherwise.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Cleanup removes a `.old.<pid>` file that another running instance still depends on | Impossible by construction: only the spawning instance writes `.old.<pid>` and it has already exited by the time the new instance runs cleanup |
|
||||
| Glob false-positive (e.g. a user-created file matching the pattern) | Pattern requires `.old.` literal AND a numeric pid-like suffix is implied; we don't pattern-match the suffix strictly. Risk is theoretical; user-created files matching `claude-code-usage-bubble.exe.old.*` is extremely unlikely |
|
||||
| Tray balloon doesn't show because NIM_MODIFY runs before NIM_ADD completes | Defer the `notify_info` call by one tick (PostMessage to message loop) if testing shows races. Likely unnecessary because tray icons register synchronously |
|
||||
| i18n loader is struct-field based, no template substitution at the loader level | Confirmed by Validation Session 1: append/prepend the version via Rust `format!` at the call site. TOML strings are static text fragments only |
|
||||
| Translations diverge from idiomatic expression in non-English locales | Machine-translate for first cut, mark "FIXME: review by native speaker" in commit message. Subsequent crowd-sourced fixes are out of this plan's scope |
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
phase: 5
|
||||
title: "Manual end-to-end verification"
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: "1h"
|
||||
dependencies: [1, 2, 3, 4]
|
||||
---
|
||||
|
||||
# Phase 5: Manual end-to-end verification
|
||||
|
||||
## Overview
|
||||
|
||||
Functional sign-off across all changed code paths. No unit tests added (the changed surface is Win32-heavy and effectively integration territory); instead, a documented manual checklist that the maintainer runs once before committing.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional**
|
||||
- All success criteria from phases 1-4 verified on a real Windows machine (Win11 preferred, Win10 as secondary if available).
|
||||
- Build artifact runs without errors when launched plainly (no flags).
|
||||
- No regression in existing update / restart / refresh paths.
|
||||
|
||||
**Non-functional**
|
||||
- Verification log saved as a section in `phase-05` after run, with date + outcome per item.
|
||||
|
||||
## Test Matrix
|
||||
|
||||
### Group A: Restart path (phase 2)
|
||||
|
||||
| # | Step | Expected |
|
||||
|---|---|---|
|
||||
| A1 | Launch binary, open right-click menu, click "Restart" | No console flash; new instance appears within 1.5s |
|
||||
| A2 | Repeat A1 twenty times back-to-back | Zero flashes observed; settings.json mtime updates each time |
|
||||
| A3 | Launch with `--diagnose`, click Restart, inspect `%TEMP%\claude-code-usage-bubble.log` | Shows "restart: spawned detached child, posting quit" and new instance shows mutex acquired (within 200ms of the wait completing) |
|
||||
|
||||
### Group B: Update install path (phase 3)
|
||||
|
||||
Setup: tag a test release `v0.1.99` via the existing GitHub Actions workflow (per `plans/260516-1730-github-release-auto-update`). Bump local `Cargo.toml` back to `v0.1.0` before running. Build the local v0.1.0 with this plan's changes.
|
||||
|
||||
| # | Step | Expected |
|
||||
|---|---|---|
|
||||
| B1 | Launch v0.1.0 build, set update channel to "Hourly", manually trigger "Check for updates" | "Update available" shown; click "Apply" |
|
||||
| B2 | During B1 apply | No console flash; new v0.1.99 instance appears |
|
||||
| B3 | After B1/B2 | Tray balloon "Update applied — Updated to v0.1.99" appears (blue info icon) |
|
||||
| B4 | Inspect install dir after B1/B2 | NO `.old.*` files remain (cleanup removed them) |
|
||||
| B5 | Repeat B1 four more times (after re-tagging v0.1.100 etc.) | Zero flashes across 5 update cycles |
|
||||
| B6 | Tamper test: edit downloaded asset on disk before swap (would require pausing between download and swap — gate via `--diagnose` log timestamps) | SHA-256 mismatch raises `Error::ChecksumMismatch`; swap is NOT performed; original binary still runs |
|
||||
| B7 | Rollback test: make staging path read-only or simulate step-10 failure | Backup restored; original binary still runs after a manual restart |
|
||||
|
||||
### Group C: Cleanup + notification (phase 4)
|
||||
|
||||
| # | Step | Expected |
|
||||
|---|---|---|
|
||||
| C1 | Manually drop `claude-code-usage-bubble.exe.old.9999` next to the running binary; launch app | Stale file is removed within 1s of launch |
|
||||
| C2 | Launch app with `--updated-to 9.9.9` arg from a terminal | Tray balloon shows title + "Updated to v9.9.9" in current UI language |
|
||||
| C3 | Repeat C2 with each supported locale | Each locale shows correct translation |
|
||||
| C4 | Launch normally (no `--updated-to`) | No balloon appears |
|
||||
|
||||
### Group D: Regression smoke
|
||||
|
||||
| # | Step | Expected |
|
||||
|---|---|---|
|
||||
| D1 | Launch binary fresh (cold start, no flags) | Bubble appears in <2s; usage refresh fires once; tray icon registers |
|
||||
| D2 | Open settings (right-click → Language → switch), confirm restart happens | Restart works without flash (this is the same `restart_app` path) |
|
||||
| D3 | Disable auto-update ("Disabled"), wait 30s, re-enable Hourly | No state corruption; check timer resets |
|
||||
| D4 | Run with `--diagnose --apply-update <some-path> <pid>` (legacy compat) | Returns exit code 0 cleanly (per `update::install::run_cli`) — unchanged |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Build `cargo build --release`.
|
||||
2. Copy `target/release/claude-code-usage-bubble.exe` to `%LOCALAPPDATA%\ClaudeCodeUsageBubble\` (fresh test directory).
|
||||
3. Run through Test Matrix A → B → C → D in order.
|
||||
4. For each test row, record outcome (PASS/FAIL + notes) in the Verification Log section below.
|
||||
5. If any FAIL: open an issue describing the failure, do NOT mark phase complete.
|
||||
6. If all PASS: mark phase complete, commit changes following project commit conventions (no `chore:` or `docs:` per CLAUDE.md).
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All Group A tests PASS.
|
||||
- [ ] All Group B tests PASS (B6 + B7 may be skipped if test scaffolding too costly; document as such).
|
||||
- [ ] All Group C tests PASS.
|
||||
- [ ] All Group D tests PASS.
|
||||
- [ ] Verification Log filled in with date + outcomes.
|
||||
- [ ] No console flash observed across the entire test session.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Manual testing skips Group B because tagging real releases is annoying | Alternative: build two local copies (v0.1.0 + v0.1.99), set up a local HTTP server with a fake GitHub-Releases-shaped JSON, point the app at it via a debug flag. Out of scope for this plan but noted |
|
||||
| "Flash" is subjective at 60Hz | Record screen with OBS at 60fps for one test run, scrub frame-by-frame to confirm zero console window appearance |
|
||||
| Win10-only flash regression (only test on Win11) | Document Win10 testing as "best effort"; primary target is Win11. Note Win10 result in Verification Log |
|
||||
|
||||
## Verification Log
|
||||
|
||||
<!-- Fill in after running the test matrix -->
|
||||
|
||||
### Session 1 — TBD
|
||||
- Group A: TBD
|
||||
- Group B: TBD
|
||||
- Group C: TBD
|
||||
- Group D: TBD
|
||||
- Flash observed: TBD
|
||||
- Notes: TBD
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: "Silent in-app update + restart (no cmd.exe)"
|
||||
description: "Replace cmd.exe handoff in update install + app restart paths with native Win32 (MoveFileExW + CreateProcessW) so no terminal window can ever flash. Add tray-balloon notification after auto-updates."
|
||||
status: in_progress
|
||||
priority: P2
|
||||
branch: "main"
|
||||
tags: ["update", "restart", "win32", "ux"]
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: "2026-05-21T07:43:16.332Z"
|
||||
createdBy: "ck:plan"
|
||||
source: skill
|
||||
---
|
||||
|
||||
# Silent in-app update + restart (no cmd.exe)
|
||||
|
||||
## Overview
|
||||
|
||||
Two paths today spawn `cmd.exe /c "timeout ... & start ..."` for update install (`src/update/install.rs::begin`) and app restart (`src/app.rs::restart_app`). Combination of `CREATE_NO_WINDOW | DETACHED_PROCESS` + inner `start ""` can still flash a console window on some Windows configs. This plan replaces both with native `MoveFileExW` + `CreateProcessW` (the main exe is `windows_subsystem = "windows"`, so direct spawn never allocates a console). Also wires a tray balloon "Updated to vX.Y.Z" on first launch after an auto-update.
|
||||
|
||||
Brainstorm context: [`plans/reports/brainstormer-260521-1530-silent-update-no-cmd.md`](../reports/brainstormer-260521-1530-silent-update-no-cmd.md).
|
||||
|
||||
Supersedes the cmd.exe mechanism decision in [`260518-0945-menu-restart-button`](../260518-0945-menu-restart-button/plan.md) (that plan picked cmd.exe deliberately, modeled on `update::install`; this plan replaces both call sites with the native path).
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Foundation: CLI flags + native spawn helper + mutex retry](./phase-01-foundation-cli-flags-native-spawn-helper-mutex-retry.md) | Complete |
|
||||
| 2 | [Restart path: replace restart_app with native CreateProcessW](./phase-02-restart-path-replace-restart-app-with-native-createprocessw.md) | Complete |
|
||||
| 3 | [Update install: rename + move + native spawn](./phase-03-update-install-rename-move-native-spawn.md) | Complete |
|
||||
| 4 | [Cleanup + tray notification](./phase-04-cleanup-tray-notification.md) | Complete |
|
||||
| 5 | [Manual end-to-end verification](./phase-05-manual-end-to-end-verification.md) | Pending (user-driven) |
|
||||
|
||||
## Key contracts (must hold across plan)
|
||||
|
||||
| Contract | Source today | Invariant |
|
||||
|---|---|---|
|
||||
| Singleton mutex name | `app.rs` `APP_MUTEX_NAME` = `Global\ClaudeCodeUsageBubble` | New instance must wait for parent to release before acquiring |
|
||||
| Main binary subsystem | `main.rs:1` `#![windows_subsystem = "windows"]` | Direct spawn allocates no console |
|
||||
| Asset filename | `release.rs:7` `claude-code-usage-bubble.exe` | Unchanged |
|
||||
| SHA-256 verification | `install.rs:64-73` | Unchanged — still verified before swap |
|
||||
| Settings save on shutdown | `app.rs::restart_app` snap+save | Preserved in new restart helper |
|
||||
|
||||
## Dependencies
|
||||
|
||||
No cross-plan dependencies. Related (superseded mechanism): `260518-0945-menu-restart-button`.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `src/usage/refresh.rs` CLI spawns (`claude.cmd`, `codex.cmd`, `powershell.exe`, `wsl.exe`) — already use `CREATE_NO_WINDOW`; tracked for follow-up.
|
||||
- `src/creds/wsl_bridge.rs` `wsl.exe` calls — same.
|
||||
- Code signing / SmartScreen suppression — separate roadmap item.
|
||||
- Cross-platform restart/update — Windows-only by design.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
None.
|
||||
|
||||
## Validation Log
|
||||
|
||||
### Session 1 — 2026-05-21
|
||||
|
||||
#### Verification Results
|
||||
- **Tier**: Full (5 phases)
|
||||
- **Claims checked**: 8
|
||||
- **Verified**: 6 | **Failed**: 2 | **Unverified**: 0
|
||||
- Verified: `app.rs:155` mutex creation; `app.rs:1378-1432` restart_app bounds; `install.rs:42-48` `--apply-update` exit-clean handler; `install.rs:99-121` `spawn_handoff`; `os::to_utf16_nul` exists and is in use; `windows = 0.58` features in Cargo.toml include `Win32_System_Threading` but NOT `Win32_Storage_FileSystem` (phase 3 must add it).
|
||||
- Failed: (V1) phase 4 said `tray::notify` has "one current caller" — actually 2 (`app.rs:831`, `app.rs:859`); (V2) phase 4 said i18n uses key-based template lookup with `{v}` placeholder — actually uses struct-field-based `LocaleStrings` with TOML, no template engine.
|
||||
|
||||
#### Decisions
|
||||
|
||||
1. **i18n approach: add 3 fields to `LocaleStrings` + translate 8 locale TOML files; use Rust `format!` at call site for version substitution.**
|
||||
Reason: existing pattern is struct-field-based via `include_str!` of `src/i18n/locales/{en,nl,es,fr,de,ja,ko,zh-TW}.toml`. No template substitution at the loader level.
|
||||
→ Propagated to `phase-04-cleanup-tray-notification.md`: Requirements, Related Code Files, Implementation Steps, Success Criteria, Risk Assessment all updated.
|
||||
|
||||
2. **Rollback failure escalation: Windows `MessageBoxW` (MB_OK | MB_ICONERROR) when MoveFileExW step 10 AND best-effort revert both fail.**
|
||||
Reason: silent failure here would orphan the user — they'd have no exe at install path. A clear modal tells them where the backup is (`bubble.exe.old.<pid>`).
|
||||
→ Propagated to `phase-03-update-install-rename-move-native-spawn.md`: rollback table extended; imports list updated; new helper `surface_rollback_failure` added; new `LocaleStrings` field `update_rollback_failed_body` added to phase 4's i18n work.
|
||||
|
||||
3. **Stuck-parent fallback: accept 8s total budget (5s `WaitForSingleObject` + 3s mutex retry), then exit cleanly. No `TerminateProcess`.**
|
||||
Reason: forcing process termination would defeat the `settings::save` defensive flush guarantee. The 8s ceiling is generous for normal Windows scheduling; if a real hang persists, user can manually kill old process via Task Manager — acceptable failure mode.
|
||||
→ No phase file change needed. Phase 1 budget numbers (5s + 3s) already match.
|
||||
|
||||
4. **Auto-update timing: apply when check fires, no idle-window deferral.**
|
||||
Reason: matches user's brainstorm-phase choice ("Fully silent auto-update"). Idle detection adds complexity (state tracking, defer-budget, defer-never-applies edge case) for marginal UX gain — bubble flicker during ~1s restart is acceptable.
|
||||
→ No phase file change needed.
|
||||
|
||||
#### Whole-Plan Consistency Sweep
|
||||
- Files reread: `plan.md`, `phase-01-…md`, `phase-02-…md`, `phase-03-…md`, `phase-04-…md`, `phase-05-…md`
|
||||
- Decision deltas checked: 4
|
||||
- Reconciled stale references: 2
|
||||
- Phase 4 i18n section rewritten from key-based template to struct-field + Rust `format!`
|
||||
- Phase 4 `tray::notify` caller count corrected from "1" to "2" with explicit file:line citations
|
||||
- Cross-phase touchpoints verified: Phase 3 adds `update_rollback_failed_body` to `LocaleStrings`, Phase 4 owns the full i18n change (3 fields + 8 TOMLs) — both phases reference the same struct, consistent
|
||||
- Unresolved contradictions: 0
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Lock geometry"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "45m"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Lock geometry
|
||||
|
||||
## Overview
|
||||
Define the tail layout contract in `compute_bubble_layout` so both rows consume the same horizontal geometry and differ only in vertical placement and bar height.
|
||||
|
||||
## Requirements
|
||||
- Functional: percent text and countdown text appear after the bar, not inside it.
|
||||
- Functional: `tail_usage_bar_rect.left/right == tail_time_bar_rect.left/right`.
|
||||
- Functional: `tail_usage_pct_rect.left/right == tail_time_text_rect.left/right`.
|
||||
- Non-functional: preserve the 140-360 logical size behavior and the minimum bar-width guard.
|
||||
- Non-functional: no new renderer inputs from `src/app.rs`.
|
||||
|
||||
## Architecture
|
||||
- Data flow: `src/app.rs:639-655` -> `bubble::update_data` -> `PaintInputs` -> `compute_bubble_layout` -> `paint_bubble_pixmap` / `paint_bubble_text`.
|
||||
- Geometry source of truth: shared `text_w`, `text_left`, `bar_left`, and `bar_right` in `src/bubble.rs:1114-1126`.
|
||||
- Current rect assignment already fans those shared values into both tail rows at `src/bubble.rs:1141-1163`.
|
||||
|
||||
## Related Code Files
|
||||
- Modify: `src/bubble.rs`
|
||||
- Read-only check: `src/app.rs`
|
||||
|
||||
## Implementation Steps
|
||||
1. Re-verify the live mismatch before changing code; the current source already shares bar and text columns.
|
||||
2. Make the target contract explicit in `compute_bubble_layout`: one shared bar lane, one shared text lane, row-specific `top/bottom` only.
|
||||
3. Preserve `bar_min` fallback so long countdowns shrink text first, not bar width below usability.
|
||||
4. Keep bar-height asymmetry unless the user confirms that equal thickness is also required.
|
||||
|
||||
## Todo List
|
||||
- [ ] Confirm whether the bug is still reproducible on `main`.
|
||||
- [ ] Document the target geometry near `compute_bubble_layout`.
|
||||
- [ ] Ensure no later row-specific width override remains.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Both tail bars have identical `left/right` bounds.
|
||||
- [ ] Both tail texts start at the same `left` and end at the same `right`.
|
||||
- [ ] No upstream data-contract change is required.
|
||||
|
||||
## Risk Assessment
|
||||
- High: the source may already satisfy the request; unnecessary edits would add churn. Mitigation: prove the runtime mismatch first.
|
||||
- Medium: long localized countdown strings can starve bar width at minimum size. Mitigation: keep `bar_min` and shared fallback math.
|
||||
- Rollback: revert only the `compute_bubble_layout` diff.
|
||||
|
||||
## Security Considerations
|
||||
- None beyond normal memory-safety review; change is layout-only.
|
||||
|
||||
## Next Steps
|
||||
- Hand off the shared-geometry contract to Phase 2 for text painting and stale-comment cleanup.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Apply renderer change"
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: "45m"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 2: Apply renderer change
|
||||
|
||||
## Overview
|
||||
Apply the tail text-placement change in the renderer so the weekly percent lane and weekly remaining-time lane follow the same `bar -> text` behavior, without changing provider or state plumbing.
|
||||
|
||||
## Requirements
|
||||
- Functional: weekly percent text renders from `tail_usage_pct_rect`; weekly countdown renders from `tail_time_text_rect`.
|
||||
- Functional: both texts stay right-aligned after the bar using `DT_RIGHT`.
|
||||
- Functional: no tail text is drawn inside the bar fill.
|
||||
- Non-functional: preserve the existing pulse behavior for `weekly_pct >= 95`.
|
||||
- Non-functional: avoid touching `PaintInputs`, polling, or panel code unless a stale comment must be corrected.
|
||||
|
||||
## Architecture
|
||||
- Bar drawing is tiny-skia-only in `src/bubble.rs:1298-1331`.
|
||||
- Tail text drawing is a later GDI overlay in `src/bubble.rs:1643-1661`.
|
||||
- `src/app.rs:636-638` currently describes the bubble percent as inline in the bar fill; if that wording is now false, correct it in the same phase.
|
||||
|
||||
## Related Code Files
|
||||
- Modify: `src/bubble.rs`
|
||||
- Optional modify: `src/app.rs`
|
||||
|
||||
## Implementation Steps
|
||||
1. Align `paint_bubble_text` with the Phase 1 geometry contract and keep the percent/countdown text outside the bars.
|
||||
2. Remove any remaining inline-percent assumption in comments or naming if it conflicts with the final behavior.
|
||||
3. Keep the weekly percent highlight behavior and empty-countdown handling intact.
|
||||
4. Stop scope creep: no changes to provider snapshots, update timers, or panel layout.
|
||||
|
||||
## Todo List
|
||||
- [ ] Confirm `paint_bubble_text` is the only text-placement site for the tail rows.
|
||||
- [ ] Update or remove stale inline-bar comments if they become misleading.
|
||||
- [ ] Re-check placeholder and `None` states after the layout change.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Weekly percent text appears after the top tail bar.
|
||||
- [ ] Weekly countdown text appears after the bottom tail bar.
|
||||
- [ ] Tail bar widths are driven only by shared geometry from `compute_bubble_layout`.
|
||||
|
||||
## Risk Assessment
|
||||
- Medium: if the reported mismatch is only visual perception from unequal bar heights, text-placement edits alone will not fix it. Mitigation: compare runtime screenshots before and after Phase 1.
|
||||
- Low: optional comment cleanup in `src/app.rs` can drift from renderer reality. Mitigation: change comments only after the final behavior is locked.
|
||||
- Rollback: revert only the text-placement and comment diffs.
|
||||
|
||||
## Security Considerations
|
||||
- None; no auth, network, or filesystem behavior changes.
|
||||
|
||||
## Next Steps
|
||||
- Hand off to Phase 3 for compile checks and Windows visual verification.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Validate on Windows"
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: "30m"
|
||||
dependencies: [2]
|
||||
---
|
||||
|
||||
# Phase 3: Validate on Windows
|
||||
|
||||
## Overview
|
||||
Verify that the scoped renderer change compiles and that the native layered-window bubble actually presents equal-width tail bars with text after each bar across common runtime conditions.
|
||||
|
||||
## Requirements
|
||||
- Functional: both tail rows visually render as `bar -> text`.
|
||||
- Functional: both tail bars have the same visible width.
|
||||
- Non-functional: confirm no regression to head ring, head text, or tray/panel refresh behavior.
|
||||
- Non-functional: validation stays command-light and uses the existing Windows runtime.
|
||||
|
||||
## Architecture
|
||||
- Compile-time validation covers the Rust renderer path end to end.
|
||||
- Runtime validation must observe the real layered window because there are no snapshot/golden tests for `tiny-skia + GDI` composition in this repo.
|
||||
|
||||
## Related Code Files
|
||||
- Verify: `src/bubble.rs`
|
||||
- Verify if touched: `src/app.rs`
|
||||
|
||||
## Implementation Steps
|
||||
1. Run the compile/test commands below.
|
||||
2. Launch the app and verify the bubble at 140, default, and max logical sizes.
|
||||
3. Check light and dark theme, Claude and Codex bubbles, and a long countdown string if available.
|
||||
4. Capture before/after notes so a no-op or perception-only result is explicit.
|
||||
|
||||
## Validation Commands
|
||||
```powershell
|
||||
cargo check
|
||||
cargo test
|
||||
cargo run
|
||||
```
|
||||
|
||||
## Todo List
|
||||
- [ ] `cargo check` passes.
|
||||
- [ ] `cargo test` passes, or any pre-existing failures are called out separately.
|
||||
- [ ] Manual runtime check confirms equal bar widths and text-after-bar alignment.
|
||||
- [ ] No regression is seen in the head ring/text or bubble refresh path.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Compile succeeds on the current branch.
|
||||
- [ ] The top and bottom tail bars share the same left/right edges at runtime.
|
||||
- [ ] The percent and countdown texts both sit to the right of their bars at runtime.
|
||||
- [ ] Any remaining mismatch is explained with evidence, not assumption.
|
||||
|
||||
## Risk Assessment
|
||||
- High: native renderer issues are hard to prove without manual observation. Mitigation: test at minimum/default/maximum sizes and common DPI settings.
|
||||
- Medium: reproducing the original complaint may require a specific locale, DPI, or stale binary. Mitigation: record the runtime conditions used during verification.
|
||||
- Rollback: revert the renderer change if compile or visual regression appears.
|
||||
|
||||
## Security Considerations
|
||||
- None.
|
||||
|
||||
## Next Steps
|
||||
- If validation passes, implementation can be approved as a scoped `src/bubble.rs` change. If not, reopen Phase 1 with the observed runtime evidence.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: "Bubble tail bar layout alignment"
|
||||
description: "Scoped renderer-only plan to align weekly percent and remaining-time tail bar geometry."
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: 2h
|
||||
branch: "main"
|
||||
tags: [rust, renderer, bubble, layout]
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: 2026-05-23
|
||||
createdBy: "ck:plan"
|
||||
source: skill
|
||||
---
|
||||
|
||||
# Bubble tail bar layout alignment
|
||||
|
||||
## Scope
|
||||
- User-facing goal: weekly percent lane and weekly remaining-time lane both render as `bar -> text`, and both bars share identical left/right bounds.
|
||||
- Expected code scope: `src/bubble.rs`; touch `src/app.rs` only if comment cleanup is needed.
|
||||
- Backwards compatibility: no settings, storage, IPC, or provider-data changes.
|
||||
|
||||
## Verified Codebase Facts
|
||||
- Bubble data already provides `weekly_pct`, `weekly_text`, and `weekly_resets_at` through `bubble::update_data`; no new inputs are needed (`src/app.rs:639-655`).
|
||||
- `compute_bubble_layout` already derives one shared text column and one shared bar lane for the two tail rows (`src/bubble.rs:1114-1163`).
|
||||
- `paint_bubble_text` already renders weekly percent and weekly countdown as separate right-aligned texts (`src/bubble.rs:1644-1661`).
|
||||
- `paint_bubble_pixmap` paints both tail bars from rects only; text is a GDI overlay, so geometry must stay the single source of truth (`src/bubble.rs:1298-1331`).
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Lock geometry](./phase-01-lock-geometry.md) | Pending |
|
||||
| 2 | [Apply renderer change](./phase-02-apply-renderer-change.md) | Pending |
|
||||
| 3 | [Validate on Windows](./phase-03-validate-on-windows.md) | Pending |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Sequence: Phase 1 -> Phase 2 -> Phase 3.
|
||||
- File ownership: `src/bubble.rs` stays single-owner across phases; optional `src/app.rs` comment cleanup happens only in Phase 2.
|
||||
- Related existing plan: `plans/260523-ui-ux-improvement-plan/plan.md` Phase 3 overlaps in theme but does not block this scoped renderer-only change.
|
||||
|
||||
## Rollback
|
||||
- Revert layout math and text-placement changes in `src/bubble.rs`.
|
||||
- Revert optional comment cleanup in `src/app.rs`.
|
||||
- No data migration or persisted-state rollback is needed.
|
||||
|
||||
## Unresolved Questions
|
||||
- Does "same length" mean equal width only, or should the two tail bars also share the same height? Current code intentionally uses different heights (`src/bubble.rs:1105-1110`).
|
||||
- The current source already looks close to the requested behavior. If runtime still differs, is the issue in this branch, a stale binary, or perception caused by different bar heights?
|
||||
@@ -0,0 +1,91 @@
|
||||
# UI/UX Improvement Plan
|
||||
|
||||
## Context
|
||||
- Product: native Windows floating usage bubble for Claude Code/Codex.
|
||||
- Current UI stack: Win32 popup/layered windows, tiny-skia drawing, GDI text, Shell tray icons.
|
||||
- Current baseline: `cargo check` passes on 2026-05-23.
|
||||
- Primary files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, `src/tray/*`, `src/usage_color.rs`, `src/i18n/locales/*.toml`.
|
||||
|
||||
## Phase 1 - Accessibility And Status Clarity
|
||||
- Status: Partially Complete
|
||||
- Priority: High
|
||||
- Files: `src/usage_color.rs`, `src/bubble.rs`, `src/panel.rs`, `src/tray/mod.rs`, locale TOMLs.
|
||||
- Improve non-color status cues for normal/warning/critical/auth/error states.
|
||||
- Add richer tray tooltips: model, 5h percent/countdown, 7d percent/countdown, current state.
|
||||
- Add localized strings for warning/critical labels and unavailable/auth states.
|
||||
- Keep usage colors centralized in `usage_color.rs`; avoid per-surface color drift.
|
||||
- Validation: contrast check for light/dark colors, manual tray tooltip check, `cargo check`.
|
||||
- Completed 2026-05-23: richer tray tooltip now includes model, 5h, 7d, and left-click hint.
|
||||
- Completed 2026-05-23: tray tooltip uses shorter localized tray hint text to reduce truncation risk.
|
||||
|
||||
## Phase 2 - Discoverability And Native Controls
|
||||
- Status: Partially Complete
|
||||
- Priority: High
|
||||
- Files: `src/app.rs`, `src/bubble.rs`, locale TOMLs.
|
||||
- Add menu items for common hidden actions: resize smaller/larger, reset size, show details.
|
||||
- Add a short localized "Help" or "Controls" submenu listing drag, click, right-click, Ctrl+wheel.
|
||||
- Make resize available through menu commands, not only Ctrl+MouseWheel.
|
||||
- Review context-menu grouping so status/update/model/settings actions scan as separate groups.
|
||||
- Validation: keyboard-access menu traversal, menu command behavior, persisted settings.
|
||||
- Completed 2026-05-23: added localized Controls submenu with resize actions and disabled help rows.
|
||||
- Completed 2026-05-23: disabled resize commands when they would no-op and unified menu/wheel resize through shared bubble size.
|
||||
|
||||
## Phase 3 - Bubble Legibility And Interaction Robustness
|
||||
- Status: Planned
|
||||
- Priority: Medium
|
||||
- Files: `src/bubble.rs`, optional extracted bubble modules.
|
||||
- Improve layout for smallest sizes: reserve stable text bounds, handle `100%`, placeholder, `!`, and long countdowns.
|
||||
- Consider minimum size/shape copy update because code uses 140-360 logical width while README mentions 32-128 pixels.
|
||||
- Add drag threshold and click behavior review around `WM_EXITSIZEMOVE` to reduce accidental panel opens.
|
||||
- Add optional pulse reduction path if Windows animation/reduced-motion preference is available.
|
||||
- Validation: manual checks at min/default/max size, 100/125/150/200% DPI, both models enabled.
|
||||
|
||||
## Phase 4 - Expanded Panel Redesign
|
||||
- Status: Planned
|
||||
- Priority: Medium
|
||||
- Files: `src/panel.rs`, locale TOMLs, maybe `src/app.rs`.
|
||||
- Replace fixed 280x120 assumptions with measured or wider adaptive layout.
|
||||
- Make rows self-explanatory: model header, 5h and 7d labels, percent plus reset countdown.
|
||||
- Add explicit error/auth/loading state rendering instead of only symbols/placeholders.
|
||||
- Improve panel placement near screen edges and multi-monitor boundaries.
|
||||
- Consider extracting panel layout/painting into smaller modules before behavior changes.
|
||||
- Validation: all locales, long countdown text, light/dark theme, focus-loss close behavior.
|
||||
|
||||
## Phase 5 - Tray And Notification Polish
|
||||
- Status: Planned
|
||||
- Priority: Medium
|
||||
- Files: `src/tray/mod.rs`, `src/tray/badge.rs`, `src/app.rs`.
|
||||
- Make tray icon state readable without exact color distinction: tooltip carries exact data, icon bands remain coarse.
|
||||
- Review notification throttling and text for threshold crossings.
|
||||
- Ensure tray left-click/right-click behavior matches Windows notification-area conventions.
|
||||
- Add manual test matrix for one-provider and two-provider modes.
|
||||
- Validation: tray add/modify/delete, balloon messages, no stale icons after exit/restart.
|
||||
|
||||
## Phase 6 - Structure And Verification
|
||||
- Status: Planned
|
||||
- Priority: Medium
|
||||
- Files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, docs if behavior changes.
|
||||
- Split only where it reduces real risk: bubble layout/rendering/interaction and panel layout/rendering first.
|
||||
- Keep public behavior stable while extracting.
|
||||
- Add unit tests for pure functions where practical: color bands, size clamps, layout math, countdown formatting.
|
||||
- Run `cargo check`; run `cargo test` if tests are added.
|
||||
- Update README/docs after behavior changes, especially controls and size range.
|
||||
- Completed 2026-05-23: added locale schema tests covering embedded locale parsing and Controls/tray strings.
|
||||
|
||||
## Success Criteria
|
||||
- Bubble and panel remain readable at min/default/max sizes and common DPI scales.
|
||||
- Warning/critical/auth/error states are understandable without relying only on color.
|
||||
- Hidden interactions have menu alternatives or discoverable help text.
|
||||
- Panel handles all existing locales without clipping core data.
|
||||
- Tray tooltip and notifications communicate exact state.
|
||||
- Source still compiles; new pure behavior has focused tests where feasible.
|
||||
|
||||
## Risks
|
||||
- Native Win32 UI changes require manual Windows runtime verification; screenshots/tests are limited.
|
||||
- `src/bubble.rs` and `src/app.rs` are large and coupled; extract before broad changes when touching multiple concerns.
|
||||
- Adaptive text/layout can regress small-size readability if not verified at 140 logical width.
|
||||
|
||||
## Unresolved Questions
|
||||
- Should the bubble stay stadium-shaped, or should compact circular mode return as an option?
|
||||
- Should menu help be always present, or only shown on first run/first right-click?
|
||||
- Should reduced-motion preference disable only pulse, or all nonessential animation?
|
||||
+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,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,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,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.
|
||||
+359
-205
@@ -6,6 +6,7 @@
|
||||
// message-only window owned by this module.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
@@ -13,7 +14,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::System::Threading::CreateMutexW;
|
||||
use windows::Win32::System::Threading::{CreateMutexW, GetCurrentProcessId, Sleep};
|
||||
use windows::Win32::UI::HiDpi::{
|
||||
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||
};
|
||||
@@ -26,18 +27,17 @@ use crate::net;
|
||||
use crate::os;
|
||||
use crate::panel::{self, PanelData};
|
||||
use crate::settings::{self, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN};
|
||||
use crate::tray::{self, TrayAction, TrayIcon as TrayIconData};
|
||||
use crate::usage::ProviderId as TrayIconKind;
|
||||
use crate::tray::WM_APP_TRAY;
|
||||
use crate::tray::{self, TrayAction, TrayIcon as TrayIconData, WM_APP_TRAY};
|
||||
use crate::update::{self, Channel as InstallChannel, CheckOutcome};
|
||||
use crate::usage::{self, ProviderId, Registry, UsageWindows};
|
||||
|
||||
// Win32 message IDs owned by this module.
|
||||
pub const WM_APP_USAGE_UPDATED: u32 = 0x8001;
|
||||
// Posted from the update worker thread when the swap-and-restart cmd
|
||||
// handoff has been launched successfully. The UI thread responds by
|
||||
// calling PostQuitMessage(0) to release the file lock on the running
|
||||
// .exe so cmd.exe can overwrite it.
|
||||
// Posted from the update worker thread after `install::begin` has
|
||||
// already swapped the binary on disk and spawned the new detached
|
||||
// child. The UI thread responds with PostQuitMessage(0) so the old
|
||||
// instance exits cleanly and releases the singleton mutex for the
|
||||
// child waiting on `--wait-pid`.
|
||||
pub const WM_APP_UPDATE_APPLIED: u32 = 0x8002;
|
||||
|
||||
// Timer IDs used with `SetTimer(msg_hwnd, …)`.
|
||||
@@ -66,20 +66,20 @@ const IDM_FREQ_15MIN: u16 = 12;
|
||||
const IDM_FREQ_1HOUR: u16 = 13;
|
||||
const IDM_MODEL_CLAUDE: u16 = 20;
|
||||
const IDM_MODEL_CHATGPT: u16 = 21;
|
||||
const IDM_MODEL_OPENCODE_GO: u16 = 22;
|
||||
const IDM_START_WITH_WINDOWS: u16 = 30;
|
||||
const IDM_RESET_POSITION: u16 = 31;
|
||||
const IDM_VERSION_ACTION: u16 = 32;
|
||||
const IDM_LANG_SYSTEM: u16 = 40;
|
||||
const IDM_RESTART: u16 = 33;
|
||||
const IDM_SIZE_SMALLER: u16 = 34;
|
||||
const IDM_SIZE_LARGER: u16 = 35;
|
||||
const IDM_RESET_SIZE: u16 = 36;
|
||||
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
|
||||
// clear of it (and any future tray ids in the 5x band).
|
||||
const IDM_UPDATE_AUTO_OFF: u16 = 60;
|
||||
const IDM_UPDATE_AUTO_HOURLY: u16 = 61;
|
||||
const IDM_UPDATE_AUTO_DAILY: u16 = 62;
|
||||
const IDM_UPDATE_AUTO_WEEKLY: u16 = 63;
|
||||
// IMPORTANT: language ids are dynamic and start at IDM_LANG_BASE.
|
||||
// Keep IDM_LANG_BASE the highest static id so the catch-all match arm
|
||||
// stays unambiguous.
|
||||
const IDM_LANG_BASE: u16 = 100;
|
||||
|
||||
// ---------- State ----------
|
||||
|
||||
@@ -107,7 +107,7 @@ enum UpdateStatus {
|
||||
|
||||
struct AppState {
|
||||
msg_hwnd: SendHwnd,
|
||||
bubbles: HashMap<TrayIconKind, SendHwnd>,
|
||||
bubbles: HashMap<ProviderId, SendHwnd>,
|
||||
settings: Settings,
|
||||
i18n: I18n,
|
||||
is_dark: bool,
|
||||
@@ -142,33 +142,73 @@ fn lock_state() -> MutexGuard<'static, Option<AppState>> {
|
||||
state().lock().expect("app state mutex poisoned")
|
||||
}
|
||||
|
||||
/// Run `f` with mutable access to `AppState`, then snapshot `Settings` and
|
||||
/// persist it to disk. The lock is released before the disk write so the UI
|
||||
/// thread doesn't block on I/O. No-op if state hasn't been initialised yet.
|
||||
fn update_settings(f: impl FnOnce(&mut AppState)) {
|
||||
let snap = {
|
||||
let mut guard = lock_state();
|
||||
let Some(s) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
f(s);
|
||||
s.settings.clone()
|
||||
};
|
||||
settings::save(&snap);
|
||||
}
|
||||
|
||||
// ---------- Entry ----------
|
||||
|
||||
pub fn run() {
|
||||
/// Acquire the singleton mutex, optionally retrying for ~3s if the
|
||||
/// caller passed `--wait-pid` (i.e. we just spawned from an exiting
|
||||
/// parent that has not yet released its handle).
|
||||
fn acquire_singleton_mutex(retry: bool) -> Option<HANDLE> {
|
||||
let mutex_name_w = os::to_utf16_nul(APP_MUTEX_NAME);
|
||||
let max_attempts = if retry { 15 } else { 1 };
|
||||
for attempt in 0..max_attempts {
|
||||
let handle = unsafe { CreateMutexW(None, false, PCWSTR::from_raw(mutex_name_w.as_ptr())) };
|
||||
match handle {
|
||||
Ok(h) => {
|
||||
let already = unsafe { GetLastError() } == ERROR_ALREADY_EXISTS;
|
||||
if !already {
|
||||
return Some(h);
|
||||
}
|
||||
// Mutex still held by parent. Close this handle and retry.
|
||||
unsafe {
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
if attempt + 1 == max_attempts {
|
||||
log::info!("another instance already running; exiting");
|
||||
return None;
|
||||
}
|
||||
log::debug!(
|
||||
"mutex still held by parent (attempt {}/{}), waiting 200ms",
|
||||
attempt + 1,
|
||||
max_attempts
|
||||
);
|
||||
unsafe { Sleep(200) };
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("CreateMutex failed: {e}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn run(args: crate::AppArgs) {
|
||||
unsafe {
|
||||
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||
}
|
||||
|
||||
let mutex_name_w = os::to_utf16_nul(APP_MUTEX_NAME);
|
||||
let _mutex = unsafe {
|
||||
let handle = CreateMutexW(None, false, PCWSTR::from_raw(mutex_name_w.as_ptr()));
|
||||
match handle {
|
||||
Ok(h) => {
|
||||
if GetLastError() == ERROR_ALREADY_EXISTS {
|
||||
log::info!("another instance already running; exiting");
|
||||
return;
|
||||
}
|
||||
h
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("CreateMutex failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _mutex = match acquire_singleton_mutex(args.wait_pid_present) {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let settings = settings::load();
|
||||
let i18n = I18n::load(settings.language.as_deref());
|
||||
let i18n = I18n::load();
|
||||
let is_dark = os::theme::is_dark();
|
||||
let install_channel = update::current_channel();
|
||||
let http = match net::Client::new(HTTP_USER_AGENT) {
|
||||
@@ -202,9 +242,29 @@ pub fn run() {
|
||||
last_balloon_at: None,
|
||||
});
|
||||
|
||||
bubble::install_callbacks(bubble::Callbacks {
|
||||
on_click: on_bubble_click,
|
||||
on_right_click: on_bubble_right_click,
|
||||
on_moved: on_bubble_moved,
|
||||
on_resized: on_bubble_resized,
|
||||
on_menu_command,
|
||||
on_settings_changed: recheck_theme,
|
||||
});
|
||||
|
||||
create_initial_bubbles();
|
||||
refresh_tray_icons();
|
||||
|
||||
// Post-update tasks: show "Updated to vX.Y.Z" balloon (driven by
|
||||
// --updated-to passed by the previous instance) and sweep any
|
||||
// stale `<exe>.old.<pid>` siblings left by past in-place swaps.
|
||||
if let Some(v) = args.updated_to.as_ref() {
|
||||
announce_update_applied(msg_hwnd, v);
|
||||
}
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
update::handoff::cleanup_stale_old_exes(&exe_path);
|
||||
}
|
||||
update::install::cleanup_staged_update_files();
|
||||
|
||||
let poll_interval = lock_state()
|
||||
.as_ref()
|
||||
.map(|s| s.settings.poll_interval_ms)
|
||||
@@ -261,15 +321,14 @@ fn create_initial_bubbles() {
|
||||
Some(s) => (s.settings.clone(), s.is_dark),
|
||||
None => return,
|
||||
};
|
||||
if settings.show_claude_code {
|
||||
spawn_bubble(ProviderId::Claude, &settings, is_dark);
|
||||
}
|
||||
if settings.show_codex {
|
||||
spawn_bubble(ProviderId::ChatGpt, &settings, is_dark);
|
||||
for provider in ProviderId::LIVE_USAGE {
|
||||
if settings.is_provider_enabled(provider) {
|
||||
spawn_bubble(provider, &settings, is_dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_bubble(kind: TrayIconKind, settings: &Settings, is_dark: bool) {
|
||||
fn spawn_bubble(kind: ProviderId, settings: &Settings, is_dark: bool) {
|
||||
// "…" matches the in-flight/transient-error placeholder used by
|
||||
// `apply_results`, so the bubble has visible feedback during the first
|
||||
// poll rather than rendering with two empty grey tracks.
|
||||
@@ -280,8 +339,10 @@ fn spawn_bubble(kind: TrayIconKind, settings: &Settings, is_dark: bool) {
|
||||
position: settings.bubble_positions.get(kind),
|
||||
session_pct: None,
|
||||
session_text: placeholder.clone(),
|
||||
session_resets_at: None,
|
||||
weekly_pct: None,
|
||||
weekly_text: placeholder,
|
||||
weekly_resets_at: None,
|
||||
is_dark,
|
||||
});
|
||||
if hwnd != HWND::default() {
|
||||
@@ -327,40 +388,24 @@ unsafe extern "system" fn msg_wnd_proc(
|
||||
|
||||
// ---------- Bubble callbacks ----------
|
||||
|
||||
pub fn on_bubble_click(hwnd: HWND, model: TrayIconKind) {
|
||||
fn on_bubble_click(hwnd: HWND, model: ProviderId) {
|
||||
let data = build_panel_data(model);
|
||||
panel::toggle(data, hwnd);
|
||||
}
|
||||
|
||||
pub fn on_bubble_right_click(hwnd: HWND, _model: TrayIconKind, _pt: POINT) {
|
||||
fn on_bubble_right_click(hwnd: HWND, _model: ProviderId, _pt: POINT) {
|
||||
show_context_menu(hwnd);
|
||||
}
|
||||
|
||||
pub fn on_bubble_moved(model: TrayIconKind, pos: (i32, i32)) {
|
||||
let snap = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
return;
|
||||
};
|
||||
s.settings.bubble_positions.set(model, pos);
|
||||
s.settings.clone()
|
||||
};
|
||||
settings::save(&snap);
|
||||
fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) {
|
||||
update_settings(|s| s.settings.bubble_positions.set(model, pos));
|
||||
}
|
||||
|
||||
pub fn on_bubble_resized(_model: TrayIconKind, size_logical: i32) {
|
||||
let snap = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
return;
|
||||
};
|
||||
s.settings.bubble_size_logical = size_logical;
|
||||
s.settings.clone()
|
||||
};
|
||||
settings::save(&snap);
|
||||
fn on_bubble_resized(_model: ProviderId, size_logical: i32) {
|
||||
set_bubble_size(size_logical);
|
||||
}
|
||||
|
||||
pub fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
||||
fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
||||
let id = (id & 0xFFFF) as u16;
|
||||
match id {
|
||||
IDM_REFRESH => spawn_poll_thread(),
|
||||
@@ -371,22 +416,23 @@ pub fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
||||
IDM_FREQ_1HOUR => set_poll_interval(POLL_1_HOUR),
|
||||
IDM_MODEL_CLAUDE => toggle_model(ProviderId::Claude),
|
||||
IDM_MODEL_CHATGPT => toggle_model(ProviderId::ChatGpt),
|
||||
IDM_MODEL_OPENCODE_GO => {}
|
||||
IDM_START_WITH_WINDOWS => toggle_startup(),
|
||||
IDM_RESET_POSITION => reset_positions(),
|
||||
IDM_VERSION_ACTION => version_action(),
|
||||
IDM_SIZE_SMALLER => resize_bubbles(-bubble::RESIZE_STEP_LOGICAL),
|
||||
IDM_SIZE_LARGER => resize_bubbles(bubble::RESIZE_STEP_LOGICAL),
|
||||
IDM_RESET_SIZE => set_bubble_size(bubble::DEFAULT_BUBBLE_SIZE),
|
||||
IDM_UPDATE_AUTO_OFF => set_update_check_interval(None),
|
||||
IDM_UPDATE_AUTO_HOURLY => {
|
||||
set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS))
|
||||
}
|
||||
IDM_UPDATE_AUTO_DAILY => {
|
||||
set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS))
|
||||
}
|
||||
IDM_UPDATE_AUTO_DAILY => set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS)),
|
||||
IDM_UPDATE_AUTO_WEEKLY => {
|
||||
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
|
||||
}
|
||||
IDM_LANG_SYSTEM => set_language(None),
|
||||
x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize),
|
||||
tray::IDM_TOGGLE_WIDGET => toggle_widget_visibility(),
|
||||
IDM_RESTART => restart_app(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -576,8 +622,7 @@ fn propagate_to_ui() {
|
||||
};
|
||||
|
||||
for (kind, hwnd) in snap.bubbles.iter() {
|
||||
let id = kind_to_provider(*kind);
|
||||
let entry = snap.snapshots.get(&id);
|
||||
let entry = snap.snapshots.get(kind);
|
||||
let session_pct = entry.map(|s| s.windows.primary.utilization);
|
||||
let weekly_pct = entry.map(|s| s.windows.secondary.utilization);
|
||||
// The bubble paints the percent inline inside the bar fill, so it
|
||||
@@ -589,20 +634,23 @@ fn propagate_to_ui() {
|
||||
let weekly_text = entry
|
||||
.map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings))
|
||||
.unwrap_or_default();
|
||||
let session_resets_at = entry.and_then(|s| s.windows.primary.resets_at);
|
||||
let weekly_resets_at = entry.and_then(|s| s.windows.secondary.resets_at);
|
||||
bubble::update_data(
|
||||
hwnd.to_hwnd(),
|
||||
session_pct,
|
||||
session_text,
|
||||
session_resets_at,
|
||||
weekly_pct,
|
||||
weekly_text,
|
||||
weekly_resets_at,
|
||||
);
|
||||
}
|
||||
refresh_tray_icons_with(&snap);
|
||||
|
||||
if panel::is_visible() {
|
||||
if let Some(model) = panel::current_model() {
|
||||
let id = kind_to_provider(model);
|
||||
if let Some(provider_state) = snap.snapshots.get(&id) {
|
||||
if let Some(provider_state) = snap.snapshots.get(&model) {
|
||||
panel::refresh_data(build_panel_data_from(&snap, model, provider_state));
|
||||
}
|
||||
}
|
||||
@@ -612,7 +660,7 @@ fn propagate_to_ui() {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct UiSnapshot {
|
||||
bubbles: HashMap<TrayIconKind, SendHwnd>,
|
||||
bubbles: HashMap<ProviderId, SendHwnd>,
|
||||
snapshots: HashMap<ProviderId, ProviderUiState>,
|
||||
settings: Settings,
|
||||
i18n_strings: LocaleStrings,
|
||||
@@ -621,21 +669,13 @@ struct UiSnapshot {
|
||||
last_poll_ok: bool,
|
||||
}
|
||||
|
||||
fn kind_to_provider(k: TrayIconKind) -> ProviderId {
|
||||
match k {
|
||||
ProviderId::Claude => ProviderId::Claude,
|
||||
ProviderId::ChatGpt => ProviderId::ChatGpt,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_panel_data(model: TrayIconKind) -> PanelData {
|
||||
fn build_panel_data(model: ProviderId) -> PanelData {
|
||||
let s = lock_state();
|
||||
let Some(s) = s.as_ref() else {
|
||||
return placeholder_panel(model);
|
||||
};
|
||||
let id = kind_to_provider(model);
|
||||
let strings = s.i18n.strings().clone();
|
||||
let provider_state = s.snapshots.get(&id).cloned().unwrap_or_default();
|
||||
let provider_state = s.snapshots.get(&model).cloned().unwrap_or_default();
|
||||
PanelData {
|
||||
model,
|
||||
session_pct: provider_state.windows.primary.utilization,
|
||||
@@ -647,7 +687,7 @@ fn build_panel_data(model: TrayIconKind) -> PanelData {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_panel_data_from(snap: &UiSnapshot, model: TrayIconKind, p: &ProviderUiState) -> PanelData {
|
||||
fn build_panel_data_from(snap: &UiSnapshot, model: ProviderId, p: &ProviderUiState) -> PanelData {
|
||||
PanelData {
|
||||
model,
|
||||
session_pct: p.windows.primary.utilization,
|
||||
@@ -659,8 +699,8 @@ fn build_panel_data_from(snap: &UiSnapshot, model: TrayIconKind, p: &ProviderUiS
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder_panel(model: TrayIconKind) -> PanelData {
|
||||
let strings = i18n::I18n::load(None).strings().clone();
|
||||
fn placeholder_panel(model: ProviderId) -> PanelData {
|
||||
let strings = i18n::I18n::load().strings().clone();
|
||||
PanelData {
|
||||
model,
|
||||
session_pct: 0.0,
|
||||
@@ -712,47 +752,51 @@ fn refresh_tray_icons() {
|
||||
|
||||
fn refresh_tray_icons_with(snap: &UiSnapshot) {
|
||||
let mut icons = Vec::new();
|
||||
if snap.settings.show_claude_code {
|
||||
let entry = snap.snapshots.get(&ProviderId::Claude);
|
||||
for provider in ProviderId::LIVE_USAGE {
|
||||
if !snap.settings.is_provider_enabled(provider) {
|
||||
continue;
|
||||
}
|
||||
let entry = snap.snapshots.get(&provider);
|
||||
icons.push(TrayIconData {
|
||||
kind: ProviderId::Claude,
|
||||
kind: provider,
|
||||
percent: if snap.last_poll_ok {
|
||||
entry.map(|e| e.windows.primary.utilization)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tooltip: format!(
|
||||
"{} {}: {} | {}: {}",
|
||||
snap.i18n_strings.claude_label,
|
||||
snap.i18n_strings.session_window,
|
||||
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
|
||||
snap.i18n_strings.weekly_window,
|
||||
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
|
||||
),
|
||||
});
|
||||
}
|
||||
if snap.settings.show_codex {
|
||||
let entry = snap.snapshots.get(&ProviderId::ChatGpt);
|
||||
icons.push(TrayIconData {
|
||||
kind: ProviderId::ChatGpt,
|
||||
percent: if snap.last_poll_ok {
|
||||
entry.map(|e| e.windows.primary.utilization)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tooltip: format!(
|
||||
"{} {}: {} | {}: {}",
|
||||
snap.i18n_strings.chatgpt_label,
|
||||
snap.i18n_strings.session_window,
|
||||
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
|
||||
snap.i18n_strings.weekly_window,
|
||||
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
|
||||
tooltip: tray_tooltip(
|
||||
&provider_label(provider, &snap.i18n_strings),
|
||||
entry,
|
||||
&snap.i18n_strings,
|
||||
),
|
||||
});
|
||||
}
|
||||
tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
|
||||
}
|
||||
|
||||
fn tray_tooltip(label: &str, entry: Option<&ProviderUiState>, strings: &LocaleStrings) -> String {
|
||||
let session = entry
|
||||
.map(|e| e.primary_text.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("...");
|
||||
let weekly = entry
|
||||
.map(|e| e.secondary_text.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("...");
|
||||
format!(
|
||||
"{label}\n{}: {session}\n{}: {weekly}\n{}",
|
||||
strings.session_window, strings.weekly_window, strings.tray_left_click
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_label(provider: ProviderId, strings: &LocaleStrings) -> String {
|
||||
match provider {
|
||||
ProviderId::Claude => strings.claude_label.clone(),
|
||||
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
|
||||
ProviderId::OpenCodeGo => strings.opencode_go_label.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tray_action(action: TrayAction) {
|
||||
match action {
|
||||
TrayAction::None => {}
|
||||
@@ -774,7 +818,7 @@ fn handle_tray_action(action: TrayAction) {
|
||||
/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows
|
||||
/// posts that to every top-level window when the user toggles light/dark
|
||||
/// in Settings, so this naturally fires once per change.
|
||||
pub fn recheck_theme() {
|
||||
fn recheck_theme() {
|
||||
let now_dark = os::theme::is_dark();
|
||||
let changed = {
|
||||
let mut s = lock_state();
|
||||
@@ -809,10 +853,7 @@ fn show_threshold_balloon(provider: ProviderId, threshold: u8) {
|
||||
}
|
||||
s.last_balloon_at = Some(Instant::now());
|
||||
let strings = s.i18n.strings();
|
||||
let provider_label = match provider {
|
||||
ProviderId::Claude => strings.claude_label.clone(),
|
||||
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
|
||||
};
|
||||
let provider_label = provider_label(provider, strings);
|
||||
let title = format!("{provider_label} · {threshold}%");
|
||||
let body = if threshold >= 95 {
|
||||
strings.threshold_95_body.clone()
|
||||
@@ -821,7 +862,7 @@ fn show_threshold_balloon(provider: ProviderId, threshold: u8) {
|
||||
};
|
||||
(s.msg_hwnd, provider, title, body)
|
||||
};
|
||||
tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
||||
tray::notify_warning(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
||||
}
|
||||
|
||||
fn show_token_expired_balloon(failed: ProviderId) {
|
||||
@@ -846,44 +887,67 @@ fn show_token_expired_balloon(failed: ProviderId) {
|
||||
strings.chatgpt_token_expired_title.clone(),
|
||||
strings.chatgpt_token_expired_body.clone(),
|
||||
),
|
||||
ProviderId::OpenCodeGo => (
|
||||
strings.opencode_go_label.clone(),
|
||||
strings.update_failed.clone(),
|
||||
),
|
||||
};
|
||||
(s.msg_hwnd, failed, title, body)
|
||||
};
|
||||
tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
||||
tray::notify_warning(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
||||
}
|
||||
|
||||
/// Show the "Updated to vX.Y.Z" balloon on first launch after an
|
||||
/// auto-update. Picks Claude as the host icon if it's registered;
|
||||
/// otherwise falls back to Codex. If neither is registered the
|
||||
/// notification silently drops — better than crashing.
|
||||
fn announce_update_applied(_msg_hwnd: HWND, version: &str) {
|
||||
let payload = {
|
||||
let s = lock_state();
|
||||
let Some(s) = s.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let strings = s.i18n.strings();
|
||||
let title = strings.update_applied_title.clone();
|
||||
let body = format!("{}{}", strings.update_applied_body, version);
|
||||
let host = if s.settings.show_claude_code {
|
||||
ProviderId::Claude
|
||||
} else {
|
||||
ProviderId::ChatGpt
|
||||
};
|
||||
(s.msg_hwnd, host, title, body)
|
||||
};
|
||||
tray::notify_info(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
|
||||
}
|
||||
|
||||
// ---------- Context menu ----------
|
||||
|
||||
struct ContextMenuSnapshot {
|
||||
strings: LocaleStrings,
|
||||
available: Vec<(String, String)>,
|
||||
language_override: Option<String>,
|
||||
current_interval: u32,
|
||||
update_check_interval_secs: Option<u64>,
|
||||
show_claude: bool,
|
||||
show_chatgpt: bool,
|
||||
show_opencode_go: bool,
|
||||
widget_visible: bool,
|
||||
install_channel: InstallChannel,
|
||||
update_status: UpdateStatus,
|
||||
bubble_size_logical: i32,
|
||||
}
|
||||
|
||||
fn show_context_menu(owner_hwnd: HWND) {
|
||||
let snap = match lock_state().as_ref() {
|
||||
Some(s) => ContextMenuSnapshot {
|
||||
strings: s.i18n.strings().clone(),
|
||||
available: s
|
||||
.i18n
|
||||
.available()
|
||||
.map(|(c, n)| (c.to_string(), n.to_string()))
|
||||
.collect(),
|
||||
language_override: s.settings.language.clone(),
|
||||
current_interval: s.settings.poll_interval_ms,
|
||||
update_check_interval_secs: s.settings.update_check_interval_secs,
|
||||
show_claude: s.settings.show_claude_code,
|
||||
show_chatgpt: s.settings.show_codex,
|
||||
show_opencode_go: s.settings.show_opencode_go,
|
||||
widget_visible: s.settings.widget_visible,
|
||||
install_channel: s.install_channel,
|
||||
update_status: s.update_status,
|
||||
bubble_size_logical: s.settings.bubble_size_logical,
|
||||
},
|
||||
None => return,
|
||||
};
|
||||
@@ -916,24 +980,42 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
}
|
||||
append_submenu(menu, freq, &snap.strings.update_frequency);
|
||||
|
||||
let Ok(models) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(models) failed");
|
||||
let Ok(providers) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(providers) failed");
|
||||
let _ = DestroyMenu(menu);
|
||||
return;
|
||||
};
|
||||
append_item(
|
||||
models,
|
||||
providers,
|
||||
IDM_MODEL_CLAUDE,
|
||||
&snap.strings.claude_label,
|
||||
if snap.show_claude { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
||||
if snap.show_claude {
|
||||
MF_CHECKED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
append_item(
|
||||
models,
|
||||
providers,
|
||||
IDM_MODEL_CHATGPT,
|
||||
&snap.strings.chatgpt_label,
|
||||
if snap.show_chatgpt { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
||||
if snap.show_chatgpt {
|
||||
MF_CHECKED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
append_submenu(menu, models, &snap.strings.models);
|
||||
append_item(
|
||||
providers,
|
||||
IDM_MODEL_OPENCODE_GO,
|
||||
&snap.strings.opencode_go_label,
|
||||
if snap.show_opencode_go {
|
||||
MF_CHECKED | MF_GRAYED
|
||||
} else {
|
||||
MF_GRAYED
|
||||
},
|
||||
);
|
||||
append_submenu(menu, providers, &snap.strings.providers);
|
||||
|
||||
let Ok(settings_menu) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(settings_menu) failed");
|
||||
@@ -944,7 +1026,11 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
settings_menu,
|
||||
IDM_START_WITH_WINDOWS,
|
||||
&snap.strings.start_with_windows,
|
||||
if is_startup_enabled() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
||||
if is_startup_enabled() {
|
||||
MF_CHECKED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
append_item(
|
||||
settings_menu,
|
||||
@@ -953,35 +1039,6 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
MENU_ITEM_FLAGS(0),
|
||||
);
|
||||
|
||||
let Ok(lang) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(lang) failed");
|
||||
let _ = DestroyMenu(settings_menu);
|
||||
let _ = DestroyMenu(menu);
|
||||
return;
|
||||
};
|
||||
append_item(
|
||||
lang,
|
||||
IDM_LANG_SYSTEM,
|
||||
&snap.strings.system_default,
|
||||
if snap.language_override.is_none() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
||||
);
|
||||
for (i, (code, name)) in snap.available.iter().enumerate() {
|
||||
let id = IDM_LANG_BASE + i as u16;
|
||||
let flags = if snap
|
||||
.language_override
|
||||
.as_deref()
|
||||
.map(|c| c == code)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
MF_CHECKED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
};
|
||||
append_item(lang, id, name, flags);
|
||||
}
|
||||
append_submenu(settings_menu, lang, &snap.strings.language);
|
||||
let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
|
||||
let version_label = version_action_label(&snap);
|
||||
let version_flags = if matches!(
|
||||
snap.update_status,
|
||||
@@ -991,7 +1048,12 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
};
|
||||
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
|
||||
append_item(
|
||||
settings_menu,
|
||||
IDM_VERSION_ACTION,
|
||||
&version_label,
|
||||
version_flags,
|
||||
);
|
||||
|
||||
let Ok(auto_update) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(auto_update) failed");
|
||||
@@ -1027,13 +1089,61 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check);
|
||||
append_submenu(menu, settings_menu, &snap.strings.settings);
|
||||
|
||||
let Ok(controls) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(controls) failed");
|
||||
let _ = DestroyMenu(menu);
|
||||
return;
|
||||
};
|
||||
append_item(
|
||||
controls,
|
||||
IDM_SIZE_SMALLER,
|
||||
&snap.strings.size_smaller,
|
||||
if snap.bubble_size_logical <= bubble::MIN_BUBBLE_SIZE {
|
||||
MF_GRAYED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
append_item(
|
||||
controls,
|
||||
IDM_SIZE_LARGER,
|
||||
&snap.strings.size_larger,
|
||||
if snap.bubble_size_logical >= bubble::MAX_BUBBLE_SIZE {
|
||||
MF_GRAYED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
append_item(
|
||||
controls,
|
||||
IDM_RESET_SIZE,
|
||||
&snap.strings.reset_size,
|
||||
if snap.bubble_size_logical == bubble::DEFAULT_BUBBLE_SIZE {
|
||||
MF_GRAYED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
let _ = AppendMenuW(controls, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
append_item(controls, 0, &snap.strings.control_left_click, MF_GRAYED);
|
||||
append_item(controls, 0, &snap.strings.control_right_click, MF_GRAYED);
|
||||
append_item(controls, 0, &snap.strings.control_drag, MF_GRAYED);
|
||||
append_item(controls, 0, &snap.strings.control_ctrl_wheel, MF_GRAYED);
|
||||
append_item(controls, 0, &snap.strings.control_tray_click, MF_GRAYED);
|
||||
append_submenu(menu, controls, &snap.strings.controls);
|
||||
|
||||
append_item(
|
||||
menu,
|
||||
tray::IDM_TOGGLE_WIDGET,
|
||||
&snap.strings.show_widget,
|
||||
if snap.widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
||||
if snap.widget_visible {
|
||||
MF_CHECKED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
|
||||
append_item(menu, IDM_EXIT, &snap.strings.exit, MENU_ITEM_FLAGS(0));
|
||||
|
||||
let mut pt = POINT::default();
|
||||
@@ -1054,7 +1164,12 @@ fn append_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) {
|
||||
fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) {
|
||||
let w = os::to_utf16_nul(label);
|
||||
unsafe {
|
||||
let _ = AppendMenuW(menu, MF_POPUP, submenu.0 as usize, PCWSTR::from_raw(w.as_ptr()));
|
||||
let _ = AppendMenuW(
|
||||
menu,
|
||||
MF_POPUP,
|
||||
submenu.0 as usize,
|
||||
PCWSTR::from_raw(w.as_ptr()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,9 +1182,13 @@ fn version_action_label(snap: &ContextMenuSnapshot) -> String {
|
||||
UpdateStatus::Applying => snap.strings.applying_update.clone(),
|
||||
UpdateStatus::Failed => snap.strings.update_failed.clone(),
|
||||
};
|
||||
// Append the running binary's version so the user can see what
|
||||
// they are on without opening an About dialog. Using middle-dot as
|
||||
// the separator matches the bubble's countdown formatting.
|
||||
let with_version = format!("{base} \u{00b7} v{}", env!("CARGO_PKG_VERSION"));
|
||||
match snap.install_channel {
|
||||
InstallChannel::Winget => format!("{base} ({})", snap.strings.update_via_winget),
|
||||
InstallChannel::Portable => base,
|
||||
InstallChannel::Winget => format!("{with_version} ({})", snap.strings.update_via_winget),
|
||||
InstallChannel::Portable => with_version,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,31 +1210,27 @@ fn set_poll_interval(ms: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_model(model: TrayIconKind) {
|
||||
fn toggle_model(model: ProviderId) {
|
||||
let (settings, is_dark) = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match model {
|
||||
ProviderId::Claude => s.settings.show_claude_code = !s.settings.show_claude_code,
|
||||
ProviderId::ChatGpt => s.settings.show_codex = !s.settings.show_codex,
|
||||
if !model.metadata().live_usage {
|
||||
return;
|
||||
}
|
||||
if !s.settings.show_claude_code && !s.settings.show_codex {
|
||||
match model {
|
||||
ProviderId::Claude => s.settings.show_claude_code = true,
|
||||
ProviderId::ChatGpt => s.settings.show_codex = true,
|
||||
}
|
||||
s.settings.toggle_provider(model);
|
||||
if !s.settings.has_enabled_live_provider() {
|
||||
s.settings.set_provider_enabled(model, true);
|
||||
}
|
||||
(s.settings.clone(), s.is_dark)
|
||||
};
|
||||
settings::save(&settings);
|
||||
|
||||
let want = match model {
|
||||
ProviderId::Claude => settings.show_claude_code,
|
||||
ProviderId::ChatGpt => settings.show_codex,
|
||||
};
|
||||
let existing = lock_state().as_ref().and_then(|s| s.bubbles.get(&model).copied());
|
||||
let want = settings.is_provider_enabled(model);
|
||||
let existing = lock_state()
|
||||
.as_ref()
|
||||
.and_then(|s| s.bubbles.get(&model).copied());
|
||||
match (want, existing) {
|
||||
(true, None) => spawn_bubble(model, &settings, is_dark),
|
||||
(false, Some(h)) => {
|
||||
@@ -1170,37 +1285,40 @@ fn reset_positions() {
|
||||
s.bubbles.clear();
|
||||
}
|
||||
create_initial_bubbles();
|
||||
}
|
||||
|
||||
fn set_language(_dummy: Option<()>) {
|
||||
let snap = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
return;
|
||||
};
|
||||
s.i18n.set_active(None);
|
||||
s.settings.language = None;
|
||||
s.settings.clone()
|
||||
};
|
||||
settings::save(&snap);
|
||||
// The freshly-spawned bubbles boot with a "…" placeholder. Push the
|
||||
// cached snapshot so they render the last-known data immediately, and
|
||||
// kick a poll for users who used Reset Position to recover from
|
||||
// staleness.
|
||||
propagate_to_ui();
|
||||
spawn_poll_thread();
|
||||
}
|
||||
|
||||
fn set_language_by_index(idx: usize) {
|
||||
let snap = {
|
||||
fn resize_bubbles(delta: i32) {
|
||||
let current = lock_state()
|
||||
.as_ref()
|
||||
.map(|s| s.settings.bubble_size_logical)
|
||||
.unwrap_or(bubble::DEFAULT_BUBBLE_SIZE);
|
||||
set_bubble_size(current + delta);
|
||||
}
|
||||
|
||||
fn set_bubble_size(size_logical: i32) {
|
||||
let (hwnds, snap) = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string());
|
||||
if let Some(c) = code.as_deref() {
|
||||
s.i18n.set_active(Some(c));
|
||||
let new_size = size_logical.clamp(bubble::MIN_BUBBLE_SIZE, bubble::MAX_BUBBLE_SIZE);
|
||||
if new_size == s.settings.bubble_size_logical {
|
||||
return;
|
||||
}
|
||||
s.settings.language = code;
|
||||
s.settings.clone()
|
||||
s.settings.bubble_size_logical = new_size;
|
||||
let hwnds = s.bubbles.values().map(|h| h.to_hwnd()).collect::<Vec<_>>();
|
||||
(hwnds, s.settings.clone())
|
||||
};
|
||||
settings::save(&snap);
|
||||
propagate_to_ui();
|
||||
for hwnd in hwnds {
|
||||
bubble::set_size_logical(hwnd, snap.bubble_size_logical);
|
||||
}
|
||||
}
|
||||
|
||||
fn version_action() {
|
||||
@@ -1357,6 +1475,42 @@ fn set_update_check_interval(value: Option<u64>) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Restart ----------
|
||||
|
||||
/// Relaunch the running binary by spawning a detached child via
|
||||
/// `CreateProcessW`. The child waits on our PID before acquiring the
|
||||
/// singleton mutex, so no shell handoff or timer is required.
|
||||
fn restart_app() {
|
||||
// Defensive flush — bubble positions and most settings already persist
|
||||
// on change, but a final save is cheap insurance. Snapshot then drop the
|
||||
// lock before the disk write so the UI thread doesn't block on I/O.
|
||||
let snap = lock_state().as_ref().map(|s| s.settings.clone());
|
||||
if let Some(s) = snap {
|
||||
settings::save(&s);
|
||||
}
|
||||
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!("restart: current_exe failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let args = vec![
|
||||
OsString::from("--wait-pid"),
|
||||
OsString::from(pid.to_string()),
|
||||
];
|
||||
match update::handoff::spawn_detached(&exe, &args) {
|
||||
Ok(()) => {
|
||||
log::info!("restart: spawned detached child (parent pid={pid}), posting quit");
|
||||
unsafe { PostQuitMessage(0) };
|
||||
}
|
||||
Err(e) => log::error!("restart: spawn_detached failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Start-with-Windows ----------
|
||||
|
||||
fn is_startup_enabled() -> bool {
|
||||
|
||||
+1020
-487
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,45 +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."
|
||||
threshold_80_body = "5-Stunden-Limit naht."
|
||||
threshold_95_body = "Limit fast erreicht — gönn dir eine Pause."
|
||||
@@ -1,45 +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."
|
||||
threshold_80_body = "Approaching the 5-hour limit."
|
||||
threshold_95_body = "Limit is close — consider easing up."
|
||||
@@ -1,45 +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."
|
||||
threshold_80_body = "Cerca del límite de 5 horas."
|
||||
threshold_95_body = "Límite casi alcanzado — reduce el ritmo."
|
||||
@@ -1,45 +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."
|
||||
threshold_80_body = "Approche de la limite de 5 heures."
|
||||
threshold_95_body = "Limite proche — pensez à lever le pied."
|
||||
@@ -1,45 +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 = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||
threshold_80_body = "5時間の上限に近づいています。"
|
||||
threshold_95_body = "上限に近づきました — ペースを落としましょう。"
|
||||
@@ -1,45 +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 = "사용량을 계속 추적하려면 다시 로그인하세요."
|
||||
threshold_80_body = "5시간 한도에 가까워지고 있어요."
|
||||
threshold_95_body = "한도 임박 — 잠시 쉬어가세요."
|
||||
@@ -1,45 +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."
|
||||
threshold_80_body = "Je nadert de 5-uurslimiet."
|
||||
threshold_95_body = "Limiet bijna bereikt — overweeg even gas terug te nemen."
|
||||
@@ -1,45 +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 = "請重新登入以繼續追蹤使用量。"
|
||||
threshold_80_body = "接近 5 小時上限。"
|
||||
threshold_95_body = "上限將至 — 建議稍作休息。"
|
||||
+113
-136
@@ -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,
|
||||
@@ -62,137 +56,93 @@ pub struct LocaleStrings {
|
||||
pub chatgpt_token_expired_title: String,
|
||||
pub chatgpt_token_expired_body: String,
|
||||
/// Body text for "your usage just crossed 80% of the 5h limit". The
|
||||
/// title is composed from the provider label + percent so it does not
|
||||
/// need to be translated separately.
|
||||
/// 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 ----------
|
||||
@@ -254,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>,
|
||||
}
|
||||
|
||||
+36
-28
@@ -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()?;
|
||||
@@ -528,7 +540,3 @@ fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
|
||||
}
|
||||
(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
|
||||
}
|
||||
+285
-54
@@ -1,56 +1,94 @@
|
||||
// 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 crate::net::Client;
|
||||
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};
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
use crate::net::Client;
|
||||
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()?;
|
||||
// Refuse to proceed if either path contains `%`. Inside double quotes
|
||||
// cmd.exe still expands `%var%` references, so a path containing `%`
|
||||
// would let cmd substitute environment variables into the swap step.
|
||||
// Such paths are vanishingly rare on real Windows installs; failing
|
||||
// fast is safer than rolling a bespoke cmd-escape layer.
|
||||
// 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, release.asset_sha256.as_ref())?;
|
||||
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,
|
||||
to: &Path,
|
||||
expected_sha256: Option<&[u8; 32]>,
|
||||
) -> Result<(), super::Error> {
|
||||
let resp = http
|
||||
@@ -58,7 +96,9 @@ fn download(
|
||||
.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(),
|
||||
)));
|
||||
}
|
||||
let body = resp.body();
|
||||
if let Some(expected) = expected_sha256 {
|
||||
@@ -86,40 +126,146 @@ fn hex_encode(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn reject_unsafe_path(p: &std::path::Path) -> Result<(), super::Error> {
|
||||
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 '%' which cmd.exe expands as a variable: {s}"
|
||||
)));
|
||||
return Err(super::Error::UnsafePath(format!("path contains '%': {s}")));
|
||||
}
|
||||
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 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())
|
||||
@@ -130,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())
|
||||
})?;
|
||||
@@ -139,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);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-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;
|
||||
|
||||
@@ -24,6 +26,8 @@ pub enum Error {
|
||||
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};
|
||||
|
||||
+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