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