Compare commits

...
3 Commits
Author SHA1 Message Date
tiennm99 4022d3f2de feat: drop WSL and multi-language support 2026-06-02 16:32:21 +07:00
tiennm99 67f3b8a7ec chore: bump version to 0.4.1 2026-06-02 15:52:03 +07:00
tiennm99 c46ab57f73 fix(update): use self-replace for portable updates 2026-06-02 15:50:45 +07:00
19 changed files with 496 additions and 964 deletions
Generated
+312 -69
View File
@@ -8,6 +8,12 @@ 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,6 +32,12 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -59,18 +71,18 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "claude-code-usage-bubble" name = "claude-code-usage-bubble"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"dirs", "dirs",
"embed-resource", "embed-resource",
"log", "log",
"self-replace",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"simplelog", "simplelog",
"thiserror", "thiserror",
"tiny-skia", "tiny-skia",
"toml 0.8.23",
"windows", "windows",
] ]
@@ -151,7 +163,7 @@ dependencies = [
"cc", "cc",
"memchr", "memchr",
"rustc_version", "rustc_version",
"toml 1.1.2+spec-1.1.0", "toml",
"vswhom", "vswhom",
"winreg", "winreg",
] ]
@@ -162,6 +174,22 @@ 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"
@@ -187,6 +215,12 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@@ -208,12 +242,46 @@ 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 = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -221,7 +289,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.17.1",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -230,6 +300,12 @@ 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"
@@ -245,6 +321,12 @@ 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]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.29"
@@ -282,6 +364,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -294,7 +382,7 @@ 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", "bitflags 1.3.2",
"crc32fast", "crc32fast",
"fdeflate", "fdeflate",
"flate2", "flate2",
@@ -307,6 +395,16 @@ 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"
@@ -325,13 +423,19 @@ 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", "getrandom 0.2.17",
"libredox", "libredox",
"thiserror", "thiserror",
] ]
@@ -345,6 +449,30 @@ 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 = "self-replace"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7"
dependencies = [
"fastrand",
"tempfile",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@@ -394,15 +522,6 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@@ -463,6 +582,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[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"
@@ -551,18 +683,6 @@ dependencies = [
"strict-num", "strict-num",
] ]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "1.1.2+spec-1.1.0" version = "1.1.2+spec-1.1.0"
@@ -571,20 +691,11 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde_core", "serde_core",
"serde_spanned 1.1.1", "serde_spanned",
"toml_datetime 1.1.1+spec-1.1.0", "toml_datetime",
"toml_parser", "toml_parser",
"toml_writer", "toml_writer",
"winnow 1.0.3", "winnow",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
] ]
[[package]] [[package]]
@@ -596,35 +707,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
]
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.1.2+spec-1.1.0" version = "1.1.2+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 = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [ dependencies = [
"winnow 1.0.3", "winnow",
] ]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "toml_writer" name = "toml_writer"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@@ -643,6 +734,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -675,6 +772,58 @@ 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"
@@ -754,6 +903,15 @@ dependencies = [
"windows-targets", "windows-targets",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -836,15 +994,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.3" version = "1.0.3"
@@ -861,6 +1010,100 @@ 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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "claude-code-usage-bubble" name = "claude-code-usage-bubble"
version = "0.4.0" version = "0.5.0"
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"
@@ -14,9 +14,9 @@ dirs = "6"
log = "0.4" log = "0.4"
simplelog = "0.12" simplelog = "0.12"
thiserror = "2" thiserror = "2"
toml = "0.8"
tiny-skia = "0.11" tiny-skia = "0.11"
sha2 = "0.10" sha2 = "0.10"
self-replace = "1.5"
[dependencies.windows] [dependencies.windows]
version = "0.58" version = "0.58"
+8 -14
View File
@@ -18,7 +18,7 @@ which solves the same "how close am I to the Claude Code limit?" problem
with a horizontal taskbar widget. This project takes the UX in a different with a horizontal taskbar widget. This project takes the UX in a different
direction — a floating, draggable circular bubble that the user can place direction — a floating, draggable circular bubble that the user can place
anywhere on screen — and is a clean-room implementation: the HTTP client, anywhere on screen — and is a clean-room implementation: the HTTP client,
provider polling, credential discovery, localisation, tray rendering, and provider polling, credential discovery, tray rendering, and
self-updater are all written from scratch against the same public APIs self-updater are all written from scratch against the same public APIs
(Anthropic, ChatGPT, GitHub Releases). (Anthropic, ChatGPT, GitHub Releases).
@@ -33,8 +33,8 @@ self-updater are all written from scratch against the same public APIs
(140360 logical pixels) (140360 logical pixels)
- Left-click the bubble for an expanded panel with both **5h** and **7d** - Left-click the bubble for an expanded panel with both **5h** and **7d**
bars plus reset countdowns bars plus reset countdowns
- Right-click for refresh, displayed providers, update frequency, language, - Right-click for refresh, displayed providers, update frequency, startup,
startup, updates, exit updates, exit
- Optional system tray icons (one per enabled provider) - Optional system tray icons (one per enabled provider)
- Auto-hide when a fullscreen app is in the foreground (games, video, - Auto-hide when a fullscreen app is in the foreground (games, video,
presentations) — reappears when you leave fullscreen presentations) — reappears when you leave fullscreen
@@ -42,12 +42,8 @@ self-updater are all written from scratch against the same public APIs
## Who this is for ## Who this is for
Windows 10/11 users who already have **Claude Code (CLI or App) installed Windows 10/11 users who already have **Claude Code (CLI or App) installed
and signed in**. Codex support is optional — install and sign in to the and signed in on Windows**. Codex support is optional — install and sign in
Codex CLI, then enable Codex from the right-click **Providers** menu. to the Codex CLI, then enable Codex from the right-click **Providers** menu.
If you use Claude Code through WSL, that is supported too. The monitor
can read your Claude Code credentials from Windows or from your WSL
environment.
## Requirements ## Requirements
@@ -85,9 +81,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, providers, refresh frequency, language, "Start - **Right-click** for refresh, providers, refresh frequency, "Start with
with Windows", controls, auto-update check (Disabled / Hourly / Daily / Windows", controls, auto-update check (Disabled / Hourly / Daily / Weekly),
Weekly), manual "Check for updates", exit 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, or **Controls** in the right-click - **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click
menu, to resize it menu, to resize it
@@ -130,7 +126,6 @@ Settings are saved to:
What the app reads: What the app reads:
- Your local Claude Code OAuth credentials from `~/.claude/.credentials.json` - Your local Claude Code OAuth credentials from `~/.claude/.credentials.json`
- If needed, the same credentials file inside an installed WSL distro
- If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json` - If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json`
or `~/.codex/auth.json` or `~/.codex/auth.json`
@@ -145,7 +140,6 @@ What the app stores locally:
- Bubble position(s) per model - Bubble position(s) per model
- Bubble size - Bubble size
- Polling frequency - Polling frequency
- Language preference
- Last update check time - Last update check time
- Displayed provider preferences - Displayed provider preferences
+2 -72
View File
@@ -74,17 +74,12 @@ const IDM_RESTART: u16 = 33;
const IDM_SIZE_SMALLER: u16 = 34; const IDM_SIZE_SMALLER: u16 = 34;
const IDM_SIZE_LARGER: u16 = 35; const IDM_SIZE_LARGER: u16 = 35;
const IDM_RESET_SIZE: u16 = 36; const IDM_RESET_SIZE: u16 = 36;
const IDM_LANG_SYSTEM: u16 = 40;
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range // 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). // clear of it (and any future tray ids in the 5x band).
const IDM_UPDATE_AUTO_OFF: u16 = 60; const IDM_UPDATE_AUTO_OFF: u16 = 60;
const IDM_UPDATE_AUTO_HOURLY: u16 = 61; const IDM_UPDATE_AUTO_HOURLY: u16 = 61;
const IDM_UPDATE_AUTO_DAILY: u16 = 62; const IDM_UPDATE_AUTO_DAILY: u16 = 62;
const IDM_UPDATE_AUTO_WEEKLY: u16 = 63; 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 ----------
@@ -213,7 +208,7 @@ pub fn run(args: crate::AppArgs) {
}; };
let settings = settings::load(); let settings = settings::load();
let i18n = I18n::load(settings.language.as_deref()); let i18n = I18n::load();
let is_dark = os::theme::is_dark(); let is_dark = os::theme::is_dark();
let install_channel = update::current_channel(); let install_channel = update::current_channel();
let http = match net::Client::new(HTTP_USER_AGENT) { let http = match net::Client::new(HTTP_USER_AGENT) {
@@ -436,13 +431,8 @@ fn on_menu_command(id: u32, _owner_hwnd: HWND) {
IDM_UPDATE_AUTO_WEEKLY => { IDM_UPDATE_AUTO_WEEKLY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS)) set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
} }
IDM_LANG_SYSTEM => set_language(None),
// 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(), IDM_RESTART => restart_app(),
x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize),
_ => {} _ => {}
} }
} }
@@ -710,7 +700,7 @@ fn build_panel_data_from(snap: &UiSnapshot, model: ProviderId, p: &ProviderUiSta
} }
fn placeholder_panel(model: ProviderId) -> PanelData { fn placeholder_panel(model: ProviderId) -> PanelData {
let strings = i18n::I18n::load(None).strings().clone(); let strings = i18n::I18n::load().strings().clone();
PanelData { PanelData {
model, model,
session_pct: 0.0, session_pct: 0.0,
@@ -934,8 +924,6 @@ fn announce_update_applied(_msg_hwnd: HWND, version: &str) {
struct ContextMenuSnapshot { struct ContextMenuSnapshot {
strings: LocaleStrings, strings: LocaleStrings,
available: Vec<(String, String)>,
language_override: Option<String>,
current_interval: u32, current_interval: u32,
update_check_interval_secs: Option<u64>, update_check_interval_secs: Option<u64>,
show_claude: bool, show_claude: bool,
@@ -951,12 +939,6 @@ fn show_context_menu(owner_hwnd: HWND) {
let snap = match lock_state().as_ref() { let snap = match lock_state().as_ref() {
Some(s) => ContextMenuSnapshot { Some(s) => ContextMenuSnapshot {
strings: s.i18n.strings().clone(), strings: s.i18n.strings().clone(),
available: s
.i18n
.available()
.map(|(c, n)| (c.to_string(), n.to_string()))
.collect(),
language_override: s.settings.language.clone(),
current_interval: s.settings.poll_interval_ms, current_interval: s.settings.poll_interval_ms,
update_check_interval_secs: s.settings.update_check_interval_secs, update_check_interval_secs: s.settings.update_check_interval_secs,
show_claude: s.settings.show_claude_code, show_claude: s.settings.show_claude_code,
@@ -1057,39 +1039,6 @@ fn show_context_menu(owner_hwnd: HWND) {
MENU_ITEM_FLAGS(0), MENU_ITEM_FLAGS(0),
); );
let Ok(lang) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(lang) failed");
let _ = DestroyMenu(settings_menu);
let _ = DestroyMenu(menu);
return;
};
append_item(
lang,
IDM_LANG_SYSTEM,
&snap.strings.system_default,
if snap.language_override.is_none() {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
for (i, (code, name)) in snap.available.iter().enumerate() {
let id = IDM_LANG_BASE + i as u16;
let flags = if snap
.language_override
.as_deref()
.map(|c| c == code)
.unwrap_or(false)
{
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
};
append_item(lang, id, name, flags);
}
append_submenu(settings_menu, lang, &snap.strings.language);
let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null());
let version_label = version_action_label(&snap); let version_label = version_action_label(&snap);
let version_flags = if matches!( let version_flags = if matches!(
snap.update_status, snap.update_status,
@@ -1372,25 +1321,6 @@ fn set_bubble_size(size_logical: i32) {
} }
} }
fn set_language(_dummy: Option<()>) {
update_settings(|s| {
s.i18n.set_active(None);
s.settings.language = None;
});
propagate_to_ui();
}
fn set_language_by_index(idx: usize) {
update_settings(|s| {
let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string());
if let Some(c) = code.as_deref() {
s.i18n.set_active(Some(c));
}
s.settings.language = code;
});
propagate_to_ui();
}
fn version_action() { fn version_action() {
enum Act { enum Act {
Apply(update::Release, InstallChannel), Apply(update::Release, InstallChannel),
+4 -8
View File
@@ -1246,12 +1246,8 @@ mod fullscreen_tests {
// ---------- Painting ---------- // ---------- Painting ----------
// Sized for the widest countdown across all shipped locales. Korean // Sized for the widest English countdown text the bubble renders.
// "999시간" (3 digits + 2 CJK chars for the hour suffix) is the current const COUNTDOWN_TEMPLATE: &str = "999d";
// 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시간";
/// Geometry for the bubble's "circle head + pill tail" shape, in DPI-scaled pixels. /// Geometry for the bubble's "circle head + pill tail" shape, in DPI-scaled pixels.
/// ///
@@ -1885,8 +1881,8 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
// Head: 5h countdown text if available, otherwise the static "5h" tag. // Head: 5h countdown text if available, otherwise the static "5h" tag.
// The ring already signals "this is the 5h window", so the countdown // The ring already signals "this is the 5h window", so the countdown
// is the more useful glanceable info when we have it. Fall back to // is the more useful glanceable info when we have it. Fall back to
// "5h" when the localized countdown would overflow the rect (e.g., // "5h" when the countdown would overflow the rect at the
// wide CJK strings like "999시간" at the 140-logical minimum width) // 140-logical minimum width —
// DT_NOCLIP would otherwise leak the glyphs onto the ring stroke. // DT_NOCLIP would otherwise leak the glyphs onto the ring stroke.
SetTextColor(hdc, COLORREF(muted_color.into_colorref())); SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
let head_label_rect_w = layout.head_label_rect.right - layout.head_label_rect.left; let head_label_rect_w = layout.head_label_rect.right - layout.head_label_rect.left;
+1 -2
View File
@@ -51,8 +51,7 @@ impl super::CredentialSource for LocalClaudeCreds {
} }
} }
/// Shared between local-fs and wsl-bridge sources — both parse the same /// Parse the Claude credential JSON shape written by the official CLI.
/// JSON shape, the only difference is how they get to the file content.
pub(crate) fn parse_claude_json(content: &str) -> Result<super::Token, super::Error> { pub(crate) fn parse_claude_json(content: &str) -> Result<super::Token, super::Error> {
let value: serde_json::Value = serde_json::from_str(content)?; let value: serde_json::Value = serde_json::from_str(content)?;
let oauth = value let oauth = value
+3 -14
View File
@@ -1,13 +1,12 @@
// Pluggable credential discovery. // Pluggable credential discovery.
// //
// Each `CredentialSource` knows how to read a single OAuth token from // Each `CredentialSource` knows how to read a single OAuth token from
// somewhere (a local JSON file, a WSL filesystem, …). The `Locator` // somewhere, such as a local JSON file. The `Locator`
// holds a priority-ordered list and serves the first source that // holds a priority-ordered list and serves the first source that
// actually has a token. New sources drop in without touching the locator. // actually has a token. New sources drop in without touching the locator.
pub mod codex_auth; pub mod codex_auth;
pub mod local_fs; pub mod local_fs;
pub mod wsl_bridge;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Token { pub struct Token {
@@ -23,8 +22,6 @@ pub struct Token {
pub enum RefreshHint { pub enum RefreshHint {
/// `claude.cmd` / `claude.exe` on PATH. /// `claude.cmd` / `claude.exe` on PATH.
LocalClaudeCli, LocalClaudeCli,
/// Run `claude -p .` inside a specific WSL distro.
WslClaudeCli { distro: String },
/// `codex` / `codex.cmd` / `codex.ps1` on PATH. /// `codex` / `codex.cmd` / `codex.ps1` on PATH.
LocalCodexCli, LocalCodexCli,
} }
@@ -37,10 +34,6 @@ pub enum Error {
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("required field missing from credential JSON: {0}")] #[error("required field missing from credential JSON: {0}")]
MissingField(&'static str), MissingField(&'static str),
#[error("WSL command in {distro:?} failed: {detail}")]
WslCommand { distro: String, detail: String },
#[error("timeout while talking to WSL")]
WslTimeout,
#[error("credential source unavailable")] #[error("credential source unavailable")]
Unavailable, Unavailable,
} }
@@ -50,7 +43,7 @@ pub trait CredentialSource: Send + Sync {
/// signatures (e.g. `"local:C:\\Users\\me\\.claude\\.credentials.json"`). /// signatures (e.g. `"local:C:\\Users\\me\\.claude\\.credentials.json"`).
fn id(&self) -> &str; fn id(&self) -> &str;
/// Read the current token. May spawn subprocesses (for WSL). /// Read the current token.
fn read(&self) -> Result<Token, Error>; fn read(&self) -> Result<Token, Error>;
/// Cheap change-detection fingerprint. `None` means "source is missing". /// Cheap change-detection fingerprint. `None` means "source is missing".
@@ -70,16 +63,12 @@ impl Locator {
Self { sources } Self { sources }
} }
/// Build a Claude locator with the standard search order: Windows /// Build a Claude locator with the standard Windows credential path.
/// home directory first, then every installed WSL distro.
pub fn for_claude() -> Self { pub fn for_claude() -> Self {
let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new(); let mut sources: Vec<Box<dyn CredentialSource>> = Vec::new();
if let Some(s) = local_fs::LocalClaudeCreds::detect() { if let Some(s) = local_fs::LocalClaudeCreds::detect() {
sources.push(Box::new(s)); sources.push(Box::new(s));
} }
for distro in wsl_bridge::list_distros() {
sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro)));
}
Self { sources } Self { sources }
} }
-155
View File
@@ -1,155 +0,0 @@
// Reach into installed WSL distros to read their Claude credentials.
//
// We never mount the WSL filesystem ourselves — instead we shell out to
// `wsl.exe -d <distro> -- sh -lc '...'` and read stdout. Every call has
// a hard timeout so a hung WSL doesn't freeze the poll thread.
use std::os::windows::process::CommandExt;
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
use super::local_fs::parse_claude_json;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
/// Enumerate installed WSL distributions. Returns an empty vec if WSL is
/// not installed or the probe fails.
pub fn list_distros() -> Vec<String> {
let Some(output) = run_with_timeout(
Command::new("wsl.exe").args(["-l", "-q"]),
COMMAND_TIMEOUT,
) else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
decode_wsl_text(&output.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(ToOwned::to_owned)
.collect()
}
pub struct WslClaudeCreds {
distro: String,
id: String,
}
impl WslClaudeCreds {
pub fn new(distro: String) -> Self {
let id = format!("wsl:{distro}");
Self { distro, id }
}
pub fn distro(&self) -> &str {
&self.distro
}
}
impl super::CredentialSource for WslClaudeCreds {
fn id(&self) -> &str {
&self.id
}
fn read(&self) -> Result<super::Token, super::Error> {
let output = wsl_run(&self.distro, "cat ~/.claude/.credentials.json")?;
if !output.status.success() {
return Err(super::Error::WslCommand {
distro: self.distro.clone(),
detail: format!("cat exited {}", output.status),
});
}
let content = String::from_utf8(output.stdout).map_err(|_| super::Error::WslCommand {
distro: self.distro.clone(),
detail: "non-UTF-8 stdout".into(),
})?;
parse_claude_json(&content)
}
fn signature(&self) -> Option<String> {
let output = wsl_run(
&self.distro,
"if [ -f ~/.claude/.credentials.json ]; then \
stat -c '%s|%Y' ~/.claude/.credentials.json; \
else echo MISSING; fi",
)
.ok()?;
if !output.status.success() {
return None;
}
let body = decode_wsl_text(&output.stdout).trim().to_string();
if body == "MISSING" {
return None;
}
Some(format!("{}|{}", self.id, body))
}
fn refresh_hint(&self) -> super::RefreshHint {
super::RefreshHint::WslClaudeCli {
distro: self.distro.clone(),
}
}
}
fn wsl_run(distro: &str, script: &str) -> Result<Output, super::Error> {
run_with_timeout(
Command::new("wsl.exe")
.arg("-d")
.arg(distro)
.arg("--")
.arg("sh")
.arg("-lc")
.arg(script),
COMMAND_TIMEOUT,
)
.ok_or(super::Error::WslTimeout)
}
fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option<Output> {
let mut child = cmd
.creation_flags(CREATE_NO_WINDOW)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return child.wait_with_output().ok(),
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(80));
}
Err(_) => return None,
}
}
}
/// `wsl.exe -l -q` historically emits UTF-16LE on stdout; other commands
/// emit UTF-8. Detect by sampling high bytes and decode appropriately.
pub(crate) fn decode_wsl_text(bytes: &[u8]) -> String {
if bytes.len() >= 2 && bytes.len() % 2 == 0 {
let sample_end = bytes.len().min(256);
let mut high_nul = 0usize;
for chunk in bytes[..sample_end].chunks_exact(2) {
if chunk[1] == 0 {
high_nul += 1;
}
}
if high_nul * 2 >= sample_end / 2 {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
return String::from_utf16_lossy(&units);
}
}
String::from_utf8_lossy(bytes).into_owned()
}
-73
View File
@@ -1,73 +0,0 @@
// Discover the user's preferred Windows UI language.
//
// We try three sources in priority order and return the first non-empty
// result. Callers normalise the returned BCP-47-ish code against the
// list of locales we actually ship.
use windows::core::PWSTR;
use windows::Win32::Globalization::{
GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages,
LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME,
};
/// First non-empty locale code from the user's preferences. May be
/// `Some("en-US")` style; callers do prefix normalisation.
pub fn detect_system_locale() -> Option<String> {
preferred_ui()
.into_iter()
.next()
.or_else(default_ui_language)
.or_else(default_locale_name)
}
fn preferred_ui() -> Vec<String> {
unsafe {
let mut count: u32 = 0;
let mut buf_len: u32 = 0;
if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &mut count, PWSTR::null(), &mut buf_len)
.is_err()
|| buf_len == 0
{
return Vec::new();
}
let mut buffer = vec![0u16; buf_len as usize];
if GetUserPreferredUILanguages(
MUI_LANGUAGE_NAME,
&mut count,
PWSTR(buffer.as_mut_ptr()),
&mut buf_len,
)
.is_err()
{
return Vec::new();
}
buffer
.split(|u| *u == 0)
.filter(|s| !s.is_empty())
.map(String::from_utf16_lossy)
.collect()
}
}
fn default_ui_language() -> Option<String> {
unsafe {
let lcid = GetUserDefaultUILanguage();
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
let len = LCIDToLocaleName(lcid as u32, Some(&mut buf), LOCALE_ALLOW_NEUTRAL_NAMES);
if len <= 1 {
return None;
}
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
}
}
fn default_locale_name() -> Option<String> {
unsafe {
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
let len = GetUserDefaultLocaleName(&mut buf);
if len <= 1 {
return None;
}
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
}
}
-60
View File
@@ -1,60 +0,0 @@
code = "en"
native_name = "English"
window_title = "Claude Code Usage Bubble"
refresh = "Refresh"
update_frequency = "Update frequency"
one_minute = "1 minute"
five_minutes = "5 minutes"
fifteen_minutes = "15 minutes"
one_hour = "1 hour"
providers = "Providers"
claude_label = "Claude Code"
chatgpt_label = "Codex"
opencode_go_label = "OpenCode Go"
settings = "Settings"
start_with_windows = "Start with Windows"
reset_position = "Reset position"
size_smaller = "Make smaller"
size_larger = "Make larger"
reset_size = "Reset size"
controls = "Controls"
control_left_click = "Left-click: details"
control_right_click = "Right-click: menu"
control_drag = "Drag: move/snap"
control_ctrl_wheel = "Ctrl+Wheel: resize"
control_tray_click = "Tray click: show/hide"
tray_left_click = "Left-click: show/hide"
language = "Language"
system_default = "System default"
check_for_updates = "Check for updates"
checking_for_updates = "Checking for updates…"
up_to_date = "Up to date"
update_failed = "Update failed"
applying_update = "Applying update…"
update_available = "Update available"
update_via_winget = "via WinGet"
auto_update_check = "Auto-update check"
auto_check_disabled = "Disabled"
auto_check_hourly = "Hourly"
auto_check_daily = "Daily"
auto_check_weekly = "Weekly"
exit = "Exit"
restart = "Restart"
show_widget = "Show widget"
session_window = "5h"
weekly_window = "7d"
now = "now"
day_suffix = "d"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Claude Code session expired"
token_expired_body = "Sign in again to keep tracking your usage."
chatgpt_token_expired_title = "Codex session expired"
chatgpt_token_expired_body = "Sign in again to keep tracking your usage."
threshold_80_body = "Approaching the 5-hour limit."
threshold_95_body = "Limit is close — consider easing up."
update_applied_title = "Update applied"
update_applied_body = "Updated to v"
update_rollback_failed_body = "Update failed. Your original binary is saved at: "
-60
View File
@@ -1,60 +0,0 @@
code = "ja"
native_name = "日本語"
window_title = "Claude Code Usage Bubble"
refresh = "更新"
update_frequency = "更新間隔"
one_minute = "1分"
five_minutes = "5分"
fifteen_minutes = "15分"
one_hour = "1時間"
providers = "プロバイダー"
claude_label = "Claude Code"
chatgpt_label = "Codex"
opencode_go_label = "OpenCode Go"
settings = "設定"
start_with_windows = "Windows起動時に開始"
reset_position = "位置をリセット"
size_smaller = "小さくする"
size_larger = "大きくする"
reset_size = "サイズをリセット"
controls = "操作"
control_left_click = "左クリック: 詳細"
control_right_click = "右クリック: メニュー"
control_drag = "ドラッグ: 移動/吸着"
control_ctrl_wheel = "Ctrl+ホイール: サイズ変更"
control_tray_click = "トレイ左クリック: 表示/非表示"
tray_left_click = "左クリック: 表示/非表示"
language = "言語"
system_default = "システム既定"
check_for_updates = "更新を確認"
checking_for_updates = "確認中…"
up_to_date = "最新です"
update_failed = "更新に失敗しました"
applying_update = "更新を適用中…"
update_available = "更新あり"
update_via_winget = "WinGet経由"
auto_update_check = "更新の自動確認"
auto_check_disabled = "無効"
auto_check_hourly = "1時間ごと"
auto_check_daily = "毎日"
auto_check_weekly = "毎週"
exit = "終了"
restart = "再起動"
show_widget = "ウィジェットを表示"
session_window = "5時間"
weekly_window = "7日"
now = "今"
day_suffix = "日"
hour_suffix = "時"
minute_suffix = "分"
second_suffix = "秒"
token_expired_title = "Claude Codeのセッションが切れました"
token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
chatgpt_token_expired_title = "Codexのセッションが切れました"
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
threshold_80_body = "5時間の上限に近づいています。"
threshold_95_body = "上限に近づきました — ペースを落としましょう。"
update_applied_title = "更新を適用しました"
update_applied_body = "バージョン v"
update_rollback_failed_body = "更新に失敗しました。元のバイナリは次の場所に保存されています: "
-60
View File
@@ -1,60 +0,0 @@
code = "ko"
native_name = "한국어"
window_title = "Claude Code Usage Bubble"
refresh = "새로 고침"
update_frequency = "업데이트 주기"
one_minute = "1분"
five_minutes = "5분"
fifteen_minutes = "15분"
one_hour = "1시간"
providers = "제공자"
claude_label = "Claude Code"
chatgpt_label = "Codex"
opencode_go_label = "OpenCode Go"
settings = "설정"
start_with_windows = "Windows 시작 시 실행"
reset_position = "위치 초기화"
size_smaller = "작게"
size_larger = "크게"
reset_size = "크기 초기화"
controls = "조작"
control_left_click = "왼쪽 클릭: 상세"
control_right_click = "오른쪽 클릭: 메뉴"
control_drag = "드래그: 이동/스냅"
control_ctrl_wheel = "Ctrl+휠: 크기 조절"
control_tray_click = "트레이 클릭: 표시/숨김"
tray_left_click = "왼쪽 클릭: 표시/숨김"
language = "언어"
system_default = "시스템 기본값"
check_for_updates = "업데이트 확인"
checking_for_updates = "확인 중…"
up_to_date = "최신 상태"
update_failed = "업데이트 실패"
applying_update = "업데이트 적용 중…"
update_available = "업데이트 있음"
update_via_winget = "WinGet 사용"
auto_update_check = "업데이트 자동 확인"
auto_check_disabled = "사용 안 함"
auto_check_hourly = "매시간"
auto_check_daily = "매일"
auto_check_weekly = "매주"
exit = "종료"
restart = "다시 시작"
show_widget = "위젯 표시"
session_window = "5시간"
weekly_window = "7일"
now = "지금"
day_suffix = "일"
hour_suffix = "시간"
minute_suffix = "분"
second_suffix = "초"
token_expired_title = "Claude Code 세션 만료"
token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
chatgpt_token_expired_title = "Codex 세션 만료"
chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
threshold_80_body = "5시간 한도에 가까워지고 있어요."
threshold_95_body = "한도 임박 — 잠시 쉬어가세요."
update_applied_title = "업데이트가 적용되었습니다"
update_applied_body = "버전 v"
update_rollback_failed_body = "업데이트 실패. 원본 바이너리는 다음 위치에 저장되었습니다: "
-60
View File
@@ -1,60 +0,0 @@
code = "vi"
native_name = "Tiếng Việt"
window_title = "Claude Code Usage Bubble"
refresh = "Làm mới"
update_frequency = "Tần suất cập nhật"
one_minute = "1 phút"
five_minutes = "5 phút"
fifteen_minutes = "15 phút"
one_hour = "1 giờ"
providers = "Nhà cung cấp"
claude_label = "Claude Code"
chatgpt_label = "Codex"
opencode_go_label = "OpenCode Go"
settings = "Cài đặt"
start_with_windows = "Khởi động cùng Windows"
reset_position = "Đặt lại vị trí"
size_smaller = "Thu nhỏ"
size_larger = "Phóng to"
reset_size = "Đặt lại kích thước"
controls = "Điều khiển"
control_left_click = "Nhấp trái: chi tiết"
control_right_click = "Nhấp phải: menu"
control_drag = "Kéo: di chuyển/bám"
control_ctrl_wheel = "Ctrl+cuộn: đổi cỡ"
control_tray_click = "Khay: hiện/ẩn"
tray_left_click = "Nhấp trái: hiện/ẩn"
language = "Ngôn ngữ"
system_default = "Mặc định hệ thống"
check_for_updates = "Kiểm tra cập nhật"
checking_for_updates = "Đang kiểm tra cập nhật…"
up_to_date = "Đã là phiên bản mới nhất"
update_failed = "Cập nhật thất bại"
applying_update = "Đang áp dụng cập nhật…"
update_available = "Có bản cập nhật mới"
update_via_winget = "qua WinGet"
auto_update_check = "Tự động kiểm tra cập nhật"
auto_check_disabled = "Tắt"
auto_check_hourly = "Mỗi giờ"
auto_check_daily = "Hằng ngày"
auto_check_weekly = "Hằng tuần"
exit = "Thoát"
restart = "Khởi động lại"
show_widget = "Hiện widget"
session_window = "5g"
weekly_window = "7n"
now = "ngay"
day_suffix = "n"
hour_suffix = "g"
minute_suffix = "p"
second_suffix = "s"
token_expired_title = "Phiên Claude Code đã hết hạn"
token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
chatgpt_token_expired_title = "Phiên Codex đã hết hạn"
chatgpt_token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
threshold_80_body = "Sắp chạm giới hạn 5 giờ."
threshold_95_body = "Sắp tới giới hạn — hãy cân nhắc giảm tốc."
update_applied_title = "Đã áp dụng cập nhật"
update_applied_body = "Đã cập nhật lên v"
update_rollback_failed_body = "Cập nhật thất bại. Tệp gốc của bạn được lưu tại: "
-60
View File
@@ -1,60 +0,0 @@
code = "zh-TW"
native_name = "繁體中文"
window_title = "Claude Code Usage Bubble"
refresh = "重新整理"
update_frequency = "更新頻率"
one_minute = "1 分鐘"
five_minutes = "5 分鐘"
fifteen_minutes = "15 分鐘"
one_hour = "1 小時"
providers = "提供者"
claude_label = "Claude Code"
chatgpt_label = "Codex"
opencode_go_label = "OpenCode Go"
settings = "設定"
start_with_windows = "隨 Windows 啟動"
reset_position = "重設位置"
size_smaller = "縮小"
size_larger = "放大"
reset_size = "重設大小"
controls = "操作"
control_left_click = "左鍵: 詳細"
control_right_click = "右鍵: 選單"
control_drag = "拖曳: 移動/吸附"
control_ctrl_wheel = "Ctrl+滾輪: 調整大小"
control_tray_click = "系統匣: 顯示/隱藏"
tray_left_click = "左鍵: 顯示/隱藏"
language = "語言"
system_default = "系統預設"
check_for_updates = "檢查更新"
checking_for_updates = "檢查中…"
up_to_date = "已是最新"
update_failed = "更新失敗"
applying_update = "正在套用更新…"
update_available = "有可用更新"
update_via_winget = "透過 WinGet"
auto_update_check = "自動檢查更新"
auto_check_disabled = "停用"
auto_check_hourly = "每小時"
auto_check_daily = "每天"
auto_check_weekly = "每週"
exit = "結束"
restart = "重新啟動"
show_widget = "顯示小工具"
session_window = "5 小時"
weekly_window = "7 日"
now = "現在"
day_suffix = "日"
hour_suffix = "時"
minute_suffix = "分"
second_suffix = "秒"
token_expired_title = "Claude Code 工作階段已過期"
token_expired_body = "請重新登入以繼續追蹤使用量。"
chatgpt_token_expired_title = "Codex 工作階段已過期"
chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。"
threshold_80_body = "接近 5 小時上限。"
threshold_95_body = "上限將至 — 建議稍作休息。"
update_applied_title = "已套用更新"
update_applied_body = "已更新至 v"
update_rollback_failed_body = "更新失敗。您的原始執行檔已保存於: "
+85 -212
View File
@@ -1,25 +1,9 @@
// Embedded TOML-based localisation. // English UI strings and formatting helpers.
//
// Each supported language lives in `locales/<code>.toml`. At startup we
// `include_str!` every file, parse them with `toml`, and stash them in a
// HashMap keyed by language code. The active language defaults to whatever
// Windows reports for the user's preferred UI language; the menu lets the
// user override that.
//
// Adding a translation: copy `en.toml` to `<code>.toml`, translate the
// strings, then add one `include_str!` entry to `RAW_LOCALES` below.
use std::collections::BTreeMap;
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use serde::Deserialize; /// The strings every UI module needs.
#[derive(Clone, Debug)]
pub mod detect;
const FALLBACK_CODE: &str = "en";
/// The strings every UI module needs. Field names map 1:1 to TOML keys.
#[derive(Clone, Debug, Deserialize)]
pub struct LocaleStrings { pub struct LocaleStrings {
pub window_title: String, pub window_title: String,
pub refresh: String, pub refresh: String,
@@ -45,8 +29,6 @@ pub struct LocaleStrings {
pub control_ctrl_wheel: String, pub control_ctrl_wheel: String,
pub control_tray_click: String, pub control_tray_click: String,
pub tray_left_click: String, pub tray_left_click: String,
pub language: String,
pub system_default: String,
pub check_for_updates: String, pub check_for_updates: String,
pub checking_for_updates: String, pub checking_for_updates: String,
pub up_to_date: String, pub up_to_date: String,
@@ -74,147 +56,93 @@ pub struct LocaleStrings {
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 /// 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 /// title is composed from the provider label + percent.
/// need to be translated separately.
pub threshold_80_body: String, pub threshold_80_body: String,
/// Body text for the 95% threshold balloon. /// Body text for the 95% threshold balloon.
pub threshold_95_body: String, pub threshold_95_body: String,
/// Title for the tray balloon shown on first launch after an auto-update. /// Title for the tray balloon shown on first launch after an auto-update.
pub update_applied_title: String, pub update_applied_title: String,
/// Prefix for the tray balloon body. Call site appends the version (e.g. "0.1.10"). /// Prefix for the tray balloon body. Call site appends the version.
pub update_applied_body: String, pub update_applied_body: String,
/// Prefix for the rollback-failed MessageBox body. Call site appends /// Prefix for the rollback-failed MessageBox body. Call site appends
/// the backup path and a separator with the expected target filename. /// the backup path and a separator with the expected target filename.
pub update_rollback_failed_body: String, pub update_rollback_failed_body: String,
} }
#[derive(Deserialize)] pub struct I18n {
struct LocaleFile {
code: String,
native_name: String,
#[serde(flatten)]
strings: LocaleStrings, strings: LocaleStrings,
} }
const RAW_LOCALES: &[(&str, &str)] = &[
("en", include_str!("locales/en.toml")),
("ja", include_str!("locales/ja.toml")),
("ko", include_str!("locales/ko.toml")),
("vi", include_str!("locales/vi.toml")),
("zh-TW", include_str!("locales/zh-TW.toml")),
];
pub struct I18n {
/// Sorted by code so menus list languages deterministically.
available: BTreeMap<String, (String, LocaleStrings)>,
active: String,
}
impl I18n { impl I18n {
/// Load all embedded TOML files and pick an active language. pub fn load() -> Self {
/// Self {
/// `requested` overrides system detection. `None` means "ask Windows". strings: english_strings(),
pub fn load(requested: Option<&str>) -> Self {
let mut available = BTreeMap::new();
for (code, body) in RAW_LOCALES {
match toml::from_str::<LocaleFile>(body) {
Ok(file) => {
available.insert(file.code.clone(), (file.native_name, file.strings));
}
Err(e) => {
log::error!("failed to parse locale {code}: {e}");
}
}
} }
if !available.contains_key(FALLBACK_CODE) {
// Embedded TOMLs are validated by tests; this should never
// happen in practice. Fall through with whatever we have.
log::error!("fallback locale '{FALLBACK_CODE}' missing");
}
let active = requested
.and_then(|c| normalise(c, &available))
.or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &available)))
.unwrap_or_else(|| FALLBACK_CODE.to_string());
Self { available, active }
} }
pub fn strings(&self) -> &LocaleStrings { pub fn strings(&self) -> &LocaleStrings {
self.available &self.strings
.get(&self.active)
.map(|(_, s)| s)
.unwrap_or_else(|| {
// Defensive: if `active` was set to something unavailable
// (shouldn't happen given `load` validates) — fall back.
&self
.available
.get(FALLBACK_CODE)
.expect("fallback locale must exist")
.1
})
}
pub fn active_code(&self) -> &str {
&self.active
}
/// Iterate `(code, native_name)` pairs in stable order.
pub fn available(&self) -> impl Iterator<Item = (&str, &str)> {
self.available
.iter()
.map(|(code, (name, _))| (code.as_str(), name.as_str()))
}
pub fn set_active(&mut self, requested: Option<&str>) {
let new_active = requested
.and_then(|c| normalise(c, &self.available))
.or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &self.available)))
.unwrap_or_else(|| FALLBACK_CODE.to_string());
self.active = new_active;
} }
} }
/// Resolve a user-supplied or system-supplied locale code to one we have. fn english_strings() -> LocaleStrings {
/// LocaleStrings {
/// Handles `en_US`, `en-US`, `EN`, `zh-Hant-TW`, etc. by progressive window_title: "Claude Code Usage Bubble".into(),
/// fallback: exact → ASCII-lower exact → prefix match. refresh: "Refresh".into(),
fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>) -> Option<String> { update_frequency: "Update frequency".into(),
let cleaned = input.trim().replace('_', "-"); one_minute: "1 minute".into(),
if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("system") { five_minutes: "5 minutes".into(),
return None; fifteen_minutes: "15 minutes".into(),
one_hour: "1 hour".into(),
providers: "Providers".into(),
claude_label: "Claude Code".into(),
chatgpt_label: "Codex".into(),
opencode_go_label: "OpenCode Go".into(),
settings: "Settings".into(),
start_with_windows: "Start with Windows".into(),
reset_position: "Reset position".into(),
size_smaller: "Make smaller".into(),
size_larger: "Make larger".into(),
reset_size: "Reset size".into(),
controls: "Controls".into(),
control_left_click: "Left-click: details".into(),
control_right_click: "Right-click: menu".into(),
control_drag: "Drag: move/snap".into(),
control_ctrl_wheel: "Ctrl+Wheel: resize".into(),
control_tray_click: "Tray click: show/hide".into(),
tray_left_click: "Left-click: show/hide".into(),
check_for_updates: "Check for updates".into(),
checking_for_updates: "Checking for updates...".into(),
up_to_date: "Up to date".into(),
update_failed: "Update failed".into(),
applying_update: "Applying update...".into(),
update_available: "Update available".into(),
update_via_winget: "via WinGet".into(),
auto_update_check: "Auto-update check".into(),
auto_check_disabled: "Disabled".into(),
auto_check_hourly: "Hourly".into(),
auto_check_daily: "Daily".into(),
auto_check_weekly: "Weekly".into(),
exit: "Exit".into(),
restart: "Restart".into(),
show_widget: "Show widget".into(),
session_window: "5h".into(),
weekly_window: "7d".into(),
now: "now".into(),
day_suffix: "d".into(),
hour_suffix: "h".into(),
minute_suffix: "m".into(),
second_suffix: "s".into(),
token_expired_title: "Claude Code session expired".into(),
token_expired_body: "Sign in again to keep tracking your usage.".into(),
chatgpt_token_expired_title: "Codex session expired".into(),
chatgpt_token_expired_body: "Sign in again to keep tracking your usage.".into(),
threshold_80_body: "Approaching the 5-hour limit.".into(),
threshold_95_body: "Limit is close - consider easing up.".into(),
update_applied_title: "Update applied".into(),
update_applied_body: "Updated to v".into(),
update_rollback_failed_body: "Update failed. Your original binary is saved at: ".into(),
} }
// Exact (case-insensitive)
for key in available.keys() {
if key.eq_ignore_ascii_case(&cleaned) {
return Some(key.clone());
}
}
// Special-case: Traditional Chinese variants → zh-TW
let lower = cleaned.to_ascii_lowercase();
if lower.starts_with("zh")
&& (lower.contains("tw") || lower.contains("hk") || lower.contains("hant"))
{
if available.contains_key("zh-TW") {
return Some("zh-TW".to_string());
}
}
// Prefix fallback (e.g. "en-US" → "en")
let prefix = lower.split('-').next().unwrap_or("");
if !prefix.is_empty() {
for key in available.keys() {
if key
.split('-')
.next()
.map(str::to_ascii_lowercase)
.as_deref()
== Some(prefix)
{
return Some(key.clone());
}
}
}
None
} }
// ---------- Free-function helpers ---------- // ---------- Free-function helpers ----------
@@ -282,79 +210,24 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn embedded_locales_parse_and_include_required_control_strings() { fn english_strings_include_required_menu_labels() {
let mut has_fallback = false; let strings = english_strings();
for (expected_code, body) in RAW_LOCALES { for (name, value) in [
let file = toml::from_str::<LocaleFile>(body) ("refresh", strings.refresh.as_str()),
.unwrap_or_else(|e| panic!("locale {expected_code} failed to parse: {e}")); ("providers", strings.providers.as_str()),
assert_eq!(file.code, *expected_code); ("settings", strings.settings.as_str()),
has_fallback |= file.code == FALLBACK_CODE; ("size_smaller", strings.size_smaller.as_str()),
("size_larger", strings.size_larger.as_str()),
let strings = file.strings; ("reset_size", strings.reset_size.as_str()),
for (name, value) in [ ("controls", strings.controls.as_str()),
("size_smaller", strings.size_smaller.as_str()), ("control_left_click", strings.control_left_click.as_str()),
("providers", strings.providers.as_str()), ("control_right_click", strings.control_right_click.as_str()),
("size_larger", strings.size_larger.as_str()), ("control_drag", strings.control_drag.as_str()),
("opencode_go_label", strings.opencode_go_label.as_str()), ("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()),
("reset_size", strings.reset_size.as_str()), ("control_tray_click", strings.control_tray_click.as_str()),
("controls", strings.controls.as_str()), ("tray_left_click", strings.tray_left_click.as_str()),
("control_left_click", strings.control_left_click.as_str()), ] {
("control_right_click", strings.control_right_click.as_str()), assert!(!value.trim().is_empty(), "empty string: {name}");
("control_drag", strings.control_drag.as_str()),
("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()),
("control_tray_click", strings.control_tray_click.as_str()),
("tray_left_click", strings.tray_left_click.as_str()),
] {
assert!(
!value.trim().is_empty(),
"locale {expected_code} has empty {name}"
);
}
} }
assert!(has_fallback, "fallback locale {FALLBACK_CODE} missing");
}
#[test]
fn locale_schema_rejects_missing_or_malformed_control_strings() {
let (_, fallback_body) = RAW_LOCALES
.iter()
.find(|(code, _)| *code == FALLBACK_CODE)
.expect("fallback locale fixture must exist");
let missing_control =
fallback_body.replace("tray_left_click = \"Left-click: show/hide\"\n", "");
assert!(
toml::from_str::<LocaleFile>(&missing_control).is_err(),
"missing tray_left_click should fail locale deserialization"
);
let malformed_control = fallback_body.replace(
"control_tray_click = \"Tray click: show/hide\"",
"control_tray_click = [\"Tray click: show/hide\"]",
);
assert!(
toml::from_str::<LocaleFile>(&malformed_control).is_err(),
"malformed control_tray_click should fail locale deserialization"
);
}
#[test]
fn locale_schema_rejects_missing_provider_strings() {
let (_, fallback_body) = RAW_LOCALES
.iter()
.find(|(code, _)| *code == FALLBACK_CODE)
.expect("fallback locale fixture must exist");
let missing_providers = fallback_body.replace("providers = \"Providers\"\n", "");
assert!(
toml::from_str::<LocaleFile>(&missing_providers).is_err(),
"missing providers should fail locale deserialization"
);
let missing_opencode = fallback_body.replace("opencode_go_label = \"OpenCode Go\"\n", "");
assert!(
toml::from_str::<LocaleFile>(&missing_opencode).is_err(),
"missing opencode_go_label should fail locale deserialization"
);
} }
} }
-3
View File
@@ -139,8 +139,6 @@ pub struct Settings {
#[serde(default = "default_poll_interval_ms")] #[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u32, pub poll_interval_ms: u32,
#[serde(default)] #[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub last_update_check_unix: Option<u64>, pub last_update_check_unix: Option<u64>,
#[serde(default = "default_update_check_interval_secs")] #[serde(default = "default_update_check_interval_secs")]
pub update_check_interval_secs: Option<u64>, pub update_check_interval_secs: Option<u64>,
@@ -157,7 +155,6 @@ impl Default for Settings {
bubble_positions: BubblePositions::default(), bubble_positions: BubblePositions::default(),
bubble_size_logical: default_bubble_size(), bubble_size_logical: default_bubble_size(),
poll_interval_ms: default_poll_interval_ms(), poll_interval_ms: default_poll_interval_ms(),
language: None,
last_update_check_unix: None, last_update_check_unix: None,
update_check_interval_secs: default_update_check_interval_secs(), update_check_interval_secs: default_update_check_interval_secs(),
widget_visible: default_widget_visible(), widget_visible: default_widget_visible(),
+65 -11
View File
@@ -1,10 +1,10 @@
// Download a release asset and swap it in via a detached helper process. // Download a release asset and swap it in through the proven `self_replace`
// handoff path.
// //
// The running process cannot reliably replace its own mapped image on // The running process cannot reliably replace its own mapped image on
// Windows. Instead, it writes the new .exe to a staging path, copies the // Windows. `self_replace` handles the platform-specific rename/copy sequence
// current executable to a helper path, starts that helper with // for that case. After the replacement succeeds, we start the installed path
// `--apply-update`, and then exits. The helper waits for the parent process // with `--wait-pid` and let the current process exit.
// to exit before replacing the install target with the staged new binary.
use std::ffi::OsString; use std::ffi::OsString;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -22,6 +22,16 @@ use crate::net::Client;
use crate::os::to_utf16_nul; use crate::os::to_utf16_nul;
pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error> { pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error> {
match begin_inner(http, release) {
Ok(()) => Ok(()),
Err(e) => {
write_update_error(&e);
Err(e)
}
}
}
fn begin_inner(http: &Client, release: &super::Release) -> Result<(), super::Error> {
let current = std::env::current_exe()?; let current = std::env::current_exe()?;
ensure_writable(&current)?; ensure_writable(&current)?;
let staging = stage_path()?; let staging = stage_path()?;
@@ -39,10 +49,9 @@ pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error
&staging, &staging,
release.asset_sha256.as_ref(), release.asset_sha256.as_ref(),
)?; )?;
let helper = helper_path()?; replace_current_exe(&staging)?;
reject_unsafe_path(&helper)?; spawn_replaced_exe(&current, &release.version)?;
prepare_update_helper(&current, &helper)?; let _ = std::fs::remove_file(&staging);
spawn_update_helper(&helper, &staging, &current, &release.version)?;
Ok(()) Ok(())
} }
@@ -143,6 +152,26 @@ fn spawn_update_helper(
super::handoff::spawn_detached(helper, &args).map_err(super::Error::Io) super::handoff::spawn_detached(helper, &args).map_err(super::Error::Io)
} }
fn replace_current_exe(staging: &Path) -> Result<(), super::Error> {
self_replace::self_replace(staging)
.map_err(|e| super::Error::SwapFailed(format!("self_replace({}): {e}", staging.display())))
}
fn spawn_replaced_exe(
target: &Path,
version: &super::release::Version,
) -> Result<(), super::Error> {
let pid = unsafe { GetCurrentProcessId() };
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
let args = vec![
OsString::from("--wait-pid"),
OsString::from(pid.to_string()),
OsString::from("--updated-to"),
OsString::from(version_str),
];
super::handoff::spawn_detached(target, &args).map_err(super::Error::Io)
}
fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<(), super::Error> { fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<(), super::Error> {
let backup = backup_path(target); let backup = backup_path(target);
// Parent has exited, so the install target is no longer mapped. // Parent has exited, so the install target is no longer mapped.
@@ -216,9 +245,9 @@ fn backup_path(target: &Path) -> PathBuf {
} }
fn surface_rollback_failure(backup: &Path, target_name: &str) { fn surface_rollback_failure(backup: &Path, target_name: &str) {
// Pull the localized body from i18n; the caller passes the // Pull the UI body text from i18n; the caller passes the
// user-meaningful filename so we can format it in-place. // user-meaningful filename so we can format it in-place.
let strings = crate::i18n::I18n::load(None).strings().clone(); let strings = crate::i18n::I18n::load().strings().clone();
let body = format!( let body = format!(
"{}{}\n\n{}", "{}{}\n\n{}",
strings.update_rollback_failed_body, strings.update_rollback_failed_body,
@@ -247,6 +276,31 @@ fn stage_path() -> Result<PathBuf, super::Error> {
.join("update.exe")) .join("update.exe"))
} }
fn update_error_log_path() -> Result<PathBuf, super::Error> {
let base = dirs::data_local_dir().ok_or_else(|| {
super::Error::NotWritable("no local data directory available".to_string())
})?;
Ok(base
.join("ClaudeCodeUsageBubble")
.join("updates")
.join("update-error.log"))
}
fn write_update_error(error: &super::Error) {
let Ok(path) = update_error_log_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
let body = format!("unix_time={ts}\nerror={error}\n");
let _ = std::fs::write(path, body);
}
fn helper_path() -> Result<PathBuf, super::Error> { fn helper_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())
+3 -3
View File
@@ -1,9 +1,9 @@
// 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, swaps it in via native // build; `install::begin` downloads the .exe, swaps it in via `self_replace`,
// `MoveFileExW`, then spawns the new binary detached via // then spawns the new binary detached via `CreateProcessW`. No shell handoff —
// `CreateProcessW`. No shell handoff — nothing can flash a console. // nothing can flash a console.
pub mod channel; pub mod channel;
pub mod handoff; pub mod handoff;
+11 -26
View File
@@ -56,11 +56,13 @@ impl Orchestrator {
fn spawn_cli(hint: &RefreshHint) -> bool { fn spawn_cli(hint: &RefreshHint) -> bool {
match hint { match hint {
RefreshHint::LocalClaudeCli => spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."]), RefreshHint::LocalClaudeCli => {
RefreshHint::WslClaudeCli { distro } => spawn_wsl(distro), spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."])
RefreshHint::LocalCodexCli => {
spawn_local(&["codex.cmd", "codex.ps1", "codex.exe", "codex"], &["exec", "."])
} }
RefreshHint::LocalCodexCli => spawn_local(
&["codex.cmd", "codex.ps1", "codex.exe", "codex"],
&["exec", "."],
),
} }
} }
@@ -69,7 +71,11 @@ fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
let lower = name.to_ascii_lowercase(); let lower = name.to_ascii_lowercase();
let mut cmd = if lower.ends_with(".ps1") { let mut cmd = if lower.ends_with(".ps1") {
let mut c = Command::new("powershell.exe"); let mut c = Command::new("powershell.exe");
c.arg("-NoProfile").arg("-ExecutionPolicy").arg("Bypass").arg("-File").arg(name); c.arg("-NoProfile")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-File")
.arg(name);
for a in args { for a in args {
c.arg(a); c.arg(a);
} }
@@ -100,24 +106,3 @@ fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
} }
false false
} }
fn spawn_wsl(distro: &str) -> bool {
let script = "if command -v claude >/dev/null 2>&1; then claude -p .; \
elif [ -x \"$HOME/.local/bin/claude\" ]; then \"$HOME/.local/bin/claude\" -p .; \
else exit 127; fi";
Command::new("wsl.exe")
.arg("-d")
.arg(distro)
.arg("--")
.arg("bash")
.arg("-lic")
.arg(script)
.env_remove("CLAUDECODE")
.env_remove("CLAUDE_CODE_ENTRYPOINT")
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.is_ok()
}