Compare commits

...
17 Commits
33 changed files with 1595 additions and 1110 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.3.6" 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.3.6" 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"
+16 -19
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,21 +33,17 @@ 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 models, update frequency, language, - Right-click for refresh, displayed providers, update frequency, startup,
startup, updates, exit updates, exit
- Optional system tray icons (one per enabled model) - 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
## 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 **Models** 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,23 +81,26 @@ corner of your primary monitor on first launch. Drag it where you want it,
release to snap to the nearest edge if you let go close to one. 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, 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
- **Tray icon** (if enabled): left-click toggles the bubble visibility, - **Tray icon** (if enabled): left-click toggles the bubble visibility,
right-click opens the same menu right-click opens the same menu
### Models ### Providers
Use the right-click **Models** menu to choose what is shown: Use the right-click **Providers** menu to choose what is shown:
- **Claude Code** is enabled by default - **Claude Code** is enabled by default
- **Codex** can be enabled alongside Claude Code or shown by itself - **Codex** can be enabled alongside Claude Code or shown by itself
- **OpenCode Go** is listed for future support; usage bars remain disabled
until OpenCode exposes a stable quota source or console integration is
explicitly enabled
When both models are shown, each gets its own bubble that you can position When multiple providers are shown, each gets its own bubble that you can position
independently. independently.
## Diagnostics ## Diagnostics
@@ -127,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`
@@ -142,9 +140,8 @@ 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 model preferences - Displayed provider preferences
What it does **not** do: send credentials to any third-party server, run a What it does **not** do: send credentials to any third-party server, run a
backend service, collect analytics, upload your project files, or write to backend service, collect analytics, upload your project files, or write to
@@ -0,0 +1,56 @@
---
phase: 1
title: Research OpenCode Go contracts
status: blocked
priority: P1
effort: 2h
dependencies: []
---
# Phase 1: Research OpenCode Go Contracts
## Overview
Prove the OpenCode Go credential, provider ID, and usage-data contracts before implementation. This is the gate that prevents guessed percentages.
Current result: blocked. Provider/model IDs and console quota math are verified, but there is no verified stable CLI/public API for this app to fetch current weekly/monthly quota usage.
## Requirements
- Functional: Identify exact local credential path(s), JSON shape, auth key name, refresh/login command, weekly/monthly usage percent, and weekly/monthly reset time.
- Non-functional: Use primary sources first: official OpenCode docs, OpenCode source, locally installed `opencode` behavior if available.
## Architecture
OpenCode Go will only become a supported provider if it can return normalized weekly/monthly data from a stable source. Official docs confirm Go limits are 5h/weekly/monthly and dollar-value based, but not a public usage endpoint. For this app, OpenCode Go must render a custom four-bar layout: weekly usage percent + weekly remaining time in the upper section, monthly usage percent + monthly remaining time in the lower section.
## Related Code Files
- Read: `src/creds/mod.rs`
- Read: `src/creds/codex_auth.rs`
- Read: `src/usage/chatgpt.rs`
- Read: `src/usage/types.rs`
- Read: `src/usage/registry.rs`
- Created: `plans/260602-1115-opencode-go-provider-support/reports/research-opencode-go-contracts.md`
## Implementation Steps
1. Check official OpenCode Go docs for provider ID, limits, endpoints, and console usage semantics.
2. Check official OpenCode CLI/source for `auth.json` storage path and schema.
3. If OpenCode is installed locally, run safe read-only commands: `opencode stats --help`, `opencode providers --help`, `opencode models --help`.
4. Inspect local `~/.local/share/opencode/auth.json` only with user approval if privacy hook blocks or if file may contain API keys.
5. Search for an official usage endpoint or CLI command that returns current weekly/monthly usage percent or used/limit values plus reset/period end times.
6. Record exact request/response shape or command output needed by Phase 3, including how to compute remaining time for weekly and monthly sections.
7. If only console-authenticated usage exists, stop and ask user before planning implementation beyond auth detection.
## Success Criteria
- [x] Exact credential docs path documented.
- [x] Exact provider key documented: `opencode-go`.
- [x] Weekly/monthly usage percent and reset-time shape verified in console source.
- [x] Stable app-callable quota source confirmed unavailable from current CLI/docs.
- [ ] User decision recorded: console integration, limited detector, or defer.
## Risk Assessment
Main risk: OpenCode docs mention tracking usage in the console but do not document a public usage API. Mitigation: make Phase 3 blocked until a stable source exists; do not infer usage from price tables.
@@ -0,0 +1,61 @@
---
phase: 2
title: Refactor provider metadata and settings
status: completed
priority: P1
effort: 4h
dependencies:
- 1
---
# Phase 2: Refactor Provider Metadata And Settings
## Overview
Remove hard-coded two-provider assumptions before adding OpenCode Go. Keep behavior identical for Claude Code and Codex.
## Requirements
- Functional: Represent enabled providers and positions by `ProviderId`, not one field per provider.
- Non-functional: Preserve old `settings.json` compatibility for `show_claude_code`, `show_codex`, and `bubble_positions.{claude,codex}`.
## Architecture
Introduce a small provider metadata layer near `usage::types` or `usage::registry`: stable ID, slug, display label key, tray icon ID, default enabled, and display mode. Existing providers use the current two-window mode. OpenCode Go uses a custom weekly/monthly four-bar mode.
Keep `ProviderId::ChatGpt` internally unless touching the code anyway. The user-facing label remains Codex. Avoid a mechanical rename that adds risk without feature value.
## Related Code Files
- Modify: `src/usage/types.rs`
- Modify: `src/usage/registry.rs`
- Modify: `src/settings.rs`
- Modify: `src/app.rs`
- Modify: `src/tray/mod.rs`
- Modify: `src/tray/badge.rs`
- Modify: `src/usage_color.rs`
- Modify: `src/bubble.rs`
- Modify: `src/panel.rs`
## Implementation Steps
1. Add `ProviderId::OpenCodeGo` and a stable `slug()` value `opencode-go`.
2. Add `ProviderId::all()` or equivalent ordered provider list: Claude, Codex, OpenCode Go.
3. Replace `Settings { show_claude_code, show_codex }` runtime logic with a provider-enabled map or compact struct that can hold `opencode_go`.
4. Preserve serde compatibility: deserialize old fields and write new fields only after migration, or keep old fields plus add `show_opencode_go` if map migration is too broad.
5. Extend `BubblePositions` to store OpenCode Go position while keeping old `claude` and `codex` JSON keys.
6. Update tray icon IDs and badge colors for third provider.
7. Add provider-specific display metadata: Claude/Codex mode=`two-window`; OpenCode Go mode=`weekly-monthly-four-bar`.
8. Update app loops to iterate providers instead of hand-writing Claude/Codex branches where this reduces match duplication.
## Success Criteria
- [ ] Existing `settings.json` with only Claude/Codex still loads.
- [ ] If all providers disabled, Claude Code is re-enabled as today.
- [ ] Three providers can have separate positions and tray icon IDs.
- [ ] OpenCode Go can select a custom four-bar renderer without changing Claude/Codex labels.
- [ ] No behavior change for existing Claude Code/Codex users.
## Risk Assessment
Settings migration is the highest regression risk. Mitigation: add serde/default tests with old two-provider JSON and new three-provider JSON.
@@ -0,0 +1,56 @@
---
phase: 3
title: "Implement OpenCode Go provider"
status: pending
priority: P1
effort: "5h"
dependencies: [1, 2]
---
# Phase 3: Implement OpenCode Go Provider
## Overview
Add OpenCode Go credential discovery and polling once Phase 1 proves the source of truth.
## Requirements
- Functional: When enabled and authenticated, OpenCode Go returns weekly/monthly usage percent and reset time, enough for four bars: usage percent + remaining time for each section.
- Non-functional: Do not store or transmit API keys outside OpenCode/OpenCode Go endpoints. Do not mutate OpenCode auth files.
## Architecture
Create an OpenCode Go credential source that reads OpenCode's auth store and a provider implementation that maps verified weekly/monthly usage percent + reset time into a provider-specific snapshot. Do not force OpenCode Go into the existing `UsageWindows.primary/secondary` shape if that would lose the grouped weekly/monthly rendering semantics.
## Related Code Files
- Create: `src/creds/opencode_auth.rs`
- Create: `src/usage/opencode_go.rs`
- Modify: `src/creds/mod.rs`
- Modify: `src/usage/mod.rs`
- Modify: `src/usage/registry.rs`
- Modify: `src/usage/refresh.rs`
- Modify: `src/app.rs`
## Implementation Steps
1. Add `LocalOpenCodeGoCreds` with path detection from Phase 1. Expected primary path: `%LOCALAPPDATA%`/XDG-equivalent for `opencode/auth.json`; verify exact Windows path before coding.
2. Parse only the OpenCode Go auth entry. Support API-key style auth if Phase 1 confirms schema.
3. Add `RefreshHint::LocalOpenCodeCli` and spawn `opencode auth login` or equivalent only if Phase 1 confirms the command.
4. Add `OpenCodeGoProvider::poll`, using the verified usage endpoint/command.
5. Add a provider-specific snapshot type if needed, for example `ProviderUsage::TwoWindow(UsageWindows)` and `ProviderUsage::WeeklyMonthly { weekly: Window, monthly: Window }`.
6. Map returned weekly/monthly source data to usage percent and reset-time windows. Existing renderer can then draw usage and remaining-time bars for each window.
7. Return `AuthRequired` on 401/403 or missing provider auth.
8. Add unit tests for credential parsing and response-to-snapshot mapping.
## Success Criteria
- [ ] Provider compiles behind the existing registry.
- [ ] Missing OpenCode auth produces `NoCredentials`, not panic.
- [ ] Expired/invalid auth produces `AuthRequired`.
- [ ] Weekly/monthly usage percent clamps to 0-100 and remaining-time bars derive from verified reset times.
- [ ] If Phase 1 cannot verify usage, this phase is not implemented.
## Risk Assessment
OpenCode Go limits are dollar-value based. If the usage source returns dollars instead of percentages, compute percent only as `used / limit * 100` from server-provided weekly/monthly values. Never estimate usage from model prices. If reset times are unavailable, stop and ask before shipping remaining-time bars with guessed periods.
@@ -0,0 +1,60 @@
---
phase: 4
title: Rename Models UI to Providers
status: in-progress
priority: P2
effort: 2h
dependencies:
- 2
---
# Phase 4: Rename Models UI To Providers
## Overview
Rename the menu and documentation wording from "Models" to "Providers" where the app is selecting quota sources.
Current result: partial. Provider wording and OpenCode Go listing are implemented. The OpenCode Go four-bar renderer is still blocked by Phase 1 because no stable quota source is available.
## Requirements
- Functional: Context menu uses "Providers" and lists Claude Code, Codex, OpenCode Go.
- Functional: OpenCode Go panel/bubble layout renders two grouped parts: Weekly on top and Monthly below, each with usage percent and remaining-time bars. Claude/Codex keep their current 5h/7d labels.
- Non-functional: Keep real model wording where it means actual LLM models, not provider selection.
## Architecture
This is mostly i18n and README copy. Keep internal `model` variable renames scoped to files touched by provider iteration. Do not churn every `model` local in renderer code just for style.
## Related Code Files
- Modify: `src/i18n/mod.rs`
- Modify: `src/i18n/locales/en.toml`
- Modify: `src/i18n/locales/ja.toml`
- Modify: `src/i18n/locales/ko.toml`
- Modify: `src/i18n/locales/vi.toml`
- Modify: `src/i18n/locales/zh-TW.toml`
- Modify: `src/app.rs`
- Modify: `README.md`
## Implementation Steps
1. Rename `LocaleStrings.models` to `providers`, or keep field name and change values if minimizing code churn is preferred.
2. Update English label to `Providers`; update other locales with best available translation.
3. Add `opencode_go_label = "OpenCode Go"` to all locale files.
4. Add localized generic labels for Weekly and Monthly group headers. Reuse existing usage-percent and remaining-time visual conventions.
5. Add or branch rendering for OpenCode Go's four-bar layout in `bubble.rs` and `panel.rs`; do not distort Claude/Codex two-bar layout.
6. Update README sections: "displayed models" -> "displayed providers"; "### Models" -> "### Providers".
7. Add locale schema tests for the new label.
## Success Criteria
- [x] Right-click menu says Providers.
- [x] README consistently describes selectable services as providers.
- [ ] OpenCode Go UI has Weekly and Monthly grouped sections, each with usage percent and remaining-time bars.
- [x] No accidental rename of OpenCode's documented `/models` command.
- [x] All embedded locale tests pass.
## Risk Assessment
Translations may be imperfect. Mitigation: keep provider product names untranslated and only translate the generic "Providers" label.
@@ -0,0 +1,56 @@
---
phase: 5
title: "Verify and document"
status: pending
priority: P1
effort: "3h"
dependencies: [3, 4]
---
# Phase 5: Verify And Document
## Overview
Prove the third-provider workflow works and document setup/privacy accurately.
## Requirements
- Functional: Claude Code, Codex, and OpenCode Go can be toggled independently; polling and tray refresh do not regress.
- Non-functional: Privacy docs must list every local file read and network endpoint called.
## Architecture
Verification must cover pure tests, build, and Windows manual smoke tests. OpenCode Go cannot be fully claimed without a real authenticated account or a captured fixture from Phase 1.
## Related Code Files
- Modify: `README.md`
- Optional Modify: `docs/release-process.md` only if release steps change
- Read: `src/app.rs`
- Read: `src/settings.rs`
- Read: `src/usage/*`
- Read: `src/creds/*`
## Implementation Steps
1. Run `cargo test`.
2. Run `cargo build --release`.
3. Run app with old settings file and confirm migration/defaults.
4. Run app with all three providers enabled; verify separate bubbles, positions, tray icons, panel labels, and menu toggles.
5. Verify missing OpenCode Go auth shows auth/no-credentials state without breaking other providers.
6. With real OpenCode Go auth, verify weekly/monthly percent and remaining-time bars match the official console/source found in Phase 1.
7. Update README privacy section with OpenCode auth file and endpoints.
## Success Criteria
- [ ] Tests pass.
- [ ] Release build passes.
- [ ] Existing two-provider settings migrate.
- [ ] Three-provider UI works on Windows.
- [ ] OpenCode Go renders four bars: weekly usage percent, weekly remaining time, monthly usage percent, monthly remaining time.
- [ ] README setup/privacy docs are accurate.
- [ ] No unresolved OpenCode Go usage-source uncertainty remains.
## Risk Assessment
Manual verification needs real OpenCode Go auth. If unavailable, ship code only behind disabled-by-default toggle and explicitly mark e2e verification deferred.
@@ -0,0 +1,72 @@
---
title: OpenCode Go provider support and providers terminology
description: >-
Add OpenCode Go as a usage provider and rename user-facing Models wording to
Providers.
status: in-progress
priority: P2
branch: main
tags:
- providers
- opencode-go
- usage
- ui-copy
blockedBy: []
blocks: []
created: '2026-06-02T04:20:10.983Z'
createdBy: 'ck:plan'
source: skill
---
# OpenCode Go Provider Support And Providers Terminology
## Overview
Add OpenCode Go as a third selectable usage provider beside Claude Code and Codex. Rename the app's user-facing "Models" menu/copy to "Providers" because the app chooses quota sources/services, not individual model IDs.
I agree with the rename. OpenCode itself still has a `/models` command, but this app's menu toggles Claude Code, Codex, and OpenCode Go providers. Internals already use `ProviderId`, so the naming is conceptually aligned.
## Codebase Findings
- Rust Win32 app; usage abstraction lives under `src/usage/*`.
- Current extension points: `UsageProvider`, `ProviderId`, `Registry`, `CredentialSource`, `RefreshHint`.
- Two-provider assumptions remain in `src/settings.rs`, `src/app.rs`, `src/tray/mod.rs`, `src/tray/badge.rs`, `src/panel.rs`, `src/usage_color.rs`, i18n TOMLs, and README.
- OpenCode official docs say Go is configured as an OpenCode provider, uses `/connect`, stores credentials in `~/.local/share/opencode/auth.json`, has 5h/weekly/monthly dollar-value limits, and current usage is visible in the console.
- User decision: OpenCode Go should display four bars in two parts: upper part is weekly usage, lower part is monthly usage. Each part has the same two concepts as current Claude/Codex: usage percent and remaining time. Keep Claude/Codex on their existing two-bar presentation unless a separate UI redesign decides otherwise.
- OpenCode docs expose model endpoints and `https://opencode.ai/zen/go/v1/models`, but do not document a stable usage endpoint. Do not fake usage percentages.
- Phase 1 found the OpenCode console computes `weeklyUsage` and `monthlyUsage` with `usagePercent` and `resetInSec`, but only through an authenticated console server query. `opencode stats` is local cost/token history and cannot drive the requested Go bars.
## Related Plans
- `plans/260516-0707-cleanroom-rewrite/plan.md`: introduced current provider abstraction; related, no blocking dependency.
- `plans/260523-ui-ux-improvement-plan/plan.md`: overlaps labels/tooltips; related, no blocking dependency.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Research OpenCode Go contracts](./phase-01-research-opencode-go-contracts.md) | Blocked |
| 2 | [Refactor provider metadata and settings](./phase-02-refactor-provider-metadata-and-settings.md) | Completed |
| 3 | [Implement OpenCode Go provider](./phase-03-implement-opencode-go-provider.md) | Pending |
| 4 | [Rename Models UI to Providers](./phase-04-rename-models-ui-to-providers.md) | In Progress |
| 5 | [Verify and document](./phase-05-verify-and-document.md) | Pending |
## Dependencies
Phase 1 gates Phase 3. If no stable OpenCode Go usage source is found, pause before Phase 3 and ask user whether to defer usage support or ship a limited connectivity/auth detector.
Phase 2 is implemented. Phase 4 provider wording is implemented; OpenCode Go remains listed but disabled until Phase 1's quota-source decision is resolved.
## Success Criteria
- OpenCode Go can be enabled/disabled independently without breaking Claude Code or Codex.
- Existing settings migrate safely; at least one provider remains enabled.
- UI and README say "Providers" where the app means selectable services.
- No guessed OpenCode Go usage. Weekly/monthly percent and reset-time data comes from verified source, or feature pauses.
- `cargo test` and `cargo build --release` pass.
## Sources
- OpenCode Go docs: https://dev.opencode.ai/docs/go/
- OpenCode CLI docs: https://opencode.ai/docs/cli/
- Current repo README: `README.md`
@@ -0,0 +1,42 @@
# Research: OpenCode Go Contracts
Date: 2026-06-02
Status: blocked
## Summary
OpenCode Go provider/model identity is verified, and the official console computes the exact quota fields we want. Implementation should not proceed yet because the verified quota source is an authenticated console server query, not a documented CLI command or public endpoint this desktop app can call safely.
## Verified Facts
- Provider/model ID format: OpenCode Go models use `opencode-go/<model-id>`, for example `opencode-go/kimi-k2.6`.
- Official limits: Go has a rolling 5-hour window, a weekly window, and a monthly window. The requested UI only needs weekly and monthly sections.
- Limits are cost based, not request-count based: weekly `$30`, monthly `$60`, and rolling 5-hour `$12`.
- CLI auth docs say credentials are stored at `~/.local/share/opencode/auth.json`; locally, `opencode providers` is an alias for auth management.
- Local `opencode stats` only aggregates local sessions from the OpenCode database and reports cost/token totals. It does not report Go quota percentages or reset times.
- Console source has the desired shape:
- `weeklyUsage: { status, resetInSec, usagePercent }`
- `monthlyUsage: { status, resetInSec, usagePercent }`
- Console source derives those from `LiteTable.weeklyUsage`, `LiteTable.monthlyUsage`, `LiteTable.timeWeeklyUpdated`, `LiteTable.timeMonthlyUpdated`, `LiteTable.timeCreated`, and `LiteData.getLimits()`.
## Sources
- OpenCode Go docs: https://dev.opencode.ai/docs/go/
- OpenCode CLI docs: https://opencode.ai/docs/cli/
- OpenCode source: `packages/opencode/src/cli/cmd/stats.ts`
- OpenCode source: `packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx`
- OpenCode source: `packages/console/core/src/subscription.ts`
- Local command: `opencode stats --help`
## Decision
Do not implement OpenCode Go four-bar usage yet. The app cannot honestly render weekly/monthly usage percent and remaining time until one of these is chosen:
1. Use an official/detected console endpoint with user-approved authentication.
2. Ship only provider/auth detection now and hide/disable Go usage bars until quota data is available.
3. Ask OpenCode upstream for a documented quota API or CLI output.
## Unresolved Questions
- Should this app integrate with the authenticated OpenCode console, or should Go support be limited to detection until OpenCode exposes a stable quota API?
- Do we still want to continue Phase 2/4 now for the provider terminology rename, while deferring Phase 3?
+56 -114
View File
@@ -66,6 +66,7 @@ const IDM_FREQ_15MIN: u16 = 12;
const IDM_FREQ_1HOUR: u16 = 13; const IDM_FREQ_1HOUR: u16 = 13;
const IDM_MODEL_CLAUDE: u16 = 20; const IDM_MODEL_CLAUDE: u16 = 20;
const IDM_MODEL_CHATGPT: u16 = 21; const IDM_MODEL_CHATGPT: u16 = 21;
const IDM_MODEL_OPENCODE_GO: u16 = 22;
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;
@@ -73,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 ----------
@@ -212,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) {
@@ -267,6 +263,7 @@ pub fn run(args: crate::AppArgs) {
if let Ok(exe_path) = std::env::current_exe() { if let Ok(exe_path) = std::env::current_exe() {
update::handoff::cleanup_stale_old_exes(&exe_path); update::handoff::cleanup_stale_old_exes(&exe_path);
} }
update::install::cleanup_staged_update_files();
let poll_interval = lock_state() let poll_interval = lock_state()
.as_ref() .as_ref()
@@ -324,11 +321,10 @@ fn create_initial_bubbles() {
Some(s) => (s.settings.clone(), s.is_dark), Some(s) => (s.settings.clone(), s.is_dark),
None => return, None => return,
}; };
if settings.show_claude_code { for provider in ProviderId::LIVE_USAGE {
spawn_bubble(ProviderId::Claude, &settings, is_dark); if settings.is_provider_enabled(provider) {
} spawn_bubble(provider, &settings, is_dark);
if settings.show_codex { }
spawn_bubble(ProviderId::ChatGpt, &settings, is_dark);
} }
} }
@@ -420,6 +416,7 @@ fn on_menu_command(id: u32, _owner_hwnd: HWND) {
IDM_FREQ_1HOUR => set_poll_interval(POLL_1_HOUR), IDM_FREQ_1HOUR => set_poll_interval(POLL_1_HOUR),
IDM_MODEL_CLAUDE => toggle_model(ProviderId::Claude), IDM_MODEL_CLAUDE => toggle_model(ProviderId::Claude),
IDM_MODEL_CHATGPT => toggle_model(ProviderId::ChatGpt), IDM_MODEL_CHATGPT => toggle_model(ProviderId::ChatGpt),
IDM_MODEL_OPENCODE_GO => {}
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(),
@@ -434,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),
_ => {} _ => {}
} }
} }
@@ -708,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,
@@ -760,28 +752,23 @@ fn refresh_tray_icons() {
fn refresh_tray_icons_with(snap: &UiSnapshot) { fn refresh_tray_icons_with(snap: &UiSnapshot) {
let mut icons = Vec::new(); let mut icons = Vec::new();
if snap.settings.show_claude_code { for provider in ProviderId::LIVE_USAGE {
let entry = snap.snapshots.get(&ProviderId::Claude); if !snap.settings.is_provider_enabled(provider) {
continue;
}
let entry = snap.snapshots.get(&provider);
icons.push(TrayIconData { icons.push(TrayIconData {
kind: ProviderId::Claude, kind: provider,
percent: if snap.last_poll_ok { percent: if snap.last_poll_ok {
entry.map(|e| e.windows.primary.utilization) entry.map(|e| e.windows.primary.utilization)
} else { } else {
None None
}, },
tooltip: tray_tooltip(&snap.i18n_strings.claude_label, entry, &snap.i18n_strings), tooltip: tray_tooltip(
}); &provider_label(provider, &snap.i18n_strings),
} entry,
if snap.settings.show_codex { &snap.i18n_strings,
let entry = snap.snapshots.get(&ProviderId::ChatGpt); ),
icons.push(TrayIconData {
kind: ProviderId::ChatGpt,
percent: if snap.last_poll_ok {
entry.map(|e| e.windows.primary.utilization)
} else {
None
},
tooltip: tray_tooltip(&snap.i18n_strings.chatgpt_label, entry, &snap.i18n_strings),
}); });
} }
tray::sync(snap.msg_hwnd.to_hwnd(), &icons); tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
@@ -802,6 +789,14 @@ fn tray_tooltip(label: &str, entry: Option<&ProviderUiState>, strings: &LocaleSt
) )
} }
fn provider_label(provider: ProviderId, strings: &LocaleStrings) -> String {
match provider {
ProviderId::Claude => strings.claude_label.clone(),
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
ProviderId::OpenCodeGo => strings.opencode_go_label.clone(),
}
}
fn handle_tray_action(action: TrayAction) { fn handle_tray_action(action: TrayAction) {
match action { match action {
TrayAction::None => {} TrayAction::None => {}
@@ -858,10 +853,7 @@ fn show_threshold_balloon(provider: ProviderId, threshold: u8) {
} }
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 provider_label = match provider { let provider_label = provider_label(provider, strings);
ProviderId::Claude => strings.claude_label.clone(),
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
};
let title = format!("{provider_label} · {threshold}%"); let title = format!("{provider_label} · {threshold}%");
let body = if threshold >= 95 { let body = if threshold >= 95 {
strings.threshold_95_body.clone() strings.threshold_95_body.clone()
@@ -895,6 +887,10 @@ fn show_token_expired_balloon(failed: ProviderId) {
strings.chatgpt_token_expired_title.clone(), strings.chatgpt_token_expired_title.clone(),
strings.chatgpt_token_expired_body.clone(), strings.chatgpt_token_expired_body.clone(),
), ),
ProviderId::OpenCodeGo => (
strings.opencode_go_label.clone(),
strings.update_failed.clone(),
),
}; };
(s.msg_hwnd, failed, title, body) (s.msg_hwnd, failed, title, body)
}; };
@@ -928,12 +924,11 @@ 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,
show_chatgpt: bool, show_chatgpt: bool,
show_opencode_go: bool,
widget_visible: bool, widget_visible: bool,
install_channel: InstallChannel, install_channel: InstallChannel,
update_status: UpdateStatus, update_status: UpdateStatus,
@@ -944,16 +939,11 @@ 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,
show_chatgpt: s.settings.show_codex, show_chatgpt: s.settings.show_codex,
show_opencode_go: s.settings.show_opencode_go,
widget_visible: s.settings.widget_visible, widget_visible: s.settings.widget_visible,
install_channel: s.install_channel, install_channel: s.install_channel,
update_status: s.update_status, update_status: s.update_status,
@@ -990,13 +980,13 @@ fn show_context_menu(owner_hwnd: HWND) {
} }
append_submenu(menu, freq, &snap.strings.update_frequency); append_submenu(menu, freq, &snap.strings.update_frequency);
let Ok(models) = CreatePopupMenu() else { let Ok(providers) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(models) failed"); log::error!("CreatePopupMenu(providers) failed");
let _ = DestroyMenu(menu); let _ = DestroyMenu(menu);
return; return;
}; };
append_item( append_item(
models, providers,
IDM_MODEL_CLAUDE, IDM_MODEL_CLAUDE,
&snap.strings.claude_label, &snap.strings.claude_label,
if snap.show_claude { if snap.show_claude {
@@ -1006,7 +996,7 @@ fn show_context_menu(owner_hwnd: HWND) {
}, },
); );
append_item( append_item(
models, providers,
IDM_MODEL_CHATGPT, IDM_MODEL_CHATGPT,
&snap.strings.chatgpt_label, &snap.strings.chatgpt_label,
if snap.show_chatgpt { if snap.show_chatgpt {
@@ -1015,7 +1005,17 @@ fn show_context_menu(owner_hwnd: HWND) {
MENU_ITEM_FLAGS(0) MENU_ITEM_FLAGS(0)
}, },
); );
append_submenu(menu, models, &snap.strings.models); append_item(
providers,
IDM_MODEL_OPENCODE_GO,
&snap.strings.opencode_go_label,
if snap.show_opencode_go {
MF_CHECKED | MF_GRAYED
} else {
MF_GRAYED
},
);
append_submenu(menu, providers, &snap.strings.providers);
let Ok(settings_menu) = CreatePopupMenu() else { let Ok(settings_menu) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(settings_menu) failed"); log::error!("CreatePopupMenu(settings_menu) failed");
@@ -1039,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,
@@ -1249,24 +1216,18 @@ fn toggle_model(model: ProviderId) {
let Some(s) = s.as_mut() else { let Some(s) = s.as_mut() else {
return; return;
}; };
match model { if !model.metadata().live_usage {
ProviderId::Claude => s.settings.show_claude_code = !s.settings.show_claude_code, return;
ProviderId::ChatGpt => s.settings.show_codex = !s.settings.show_codex,
} }
if !s.settings.show_claude_code && !s.settings.show_codex { s.settings.toggle_provider(model);
match model { if !s.settings.has_enabled_live_provider() {
ProviderId::Claude => s.settings.show_claude_code = true, s.settings.set_provider_enabled(model, true);
ProviderId::ChatGpt => s.settings.show_codex = true,
}
} }
(s.settings.clone(), s.is_dark) (s.settings.clone(), s.is_dark)
}; };
settings::save(&settings); settings::save(&settings);
let want = match model { let want = settings.is_provider_enabled(model);
ProviderId::Claude => settings.show_claude_code,
ProviderId::ChatGpt => settings.show_codex,
};
let existing = lock_state() let existing = lock_state()
.as_ref() .as_ref()
.and_then(|s| s.bubbles.get(&model).copied()); .and_then(|s| s.bubbles.get(&model).copied());
@@ -1360,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),
+276 -45
View File
@@ -19,6 +19,9 @@ use std::time::{Duration, SystemTime};
use tiny_skia::{FillRule, LineCap, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform}; use tiny_skia::{FillRule, LineCap, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform};
use windows::core::PCWSTR; use windows::core::PCWSTR;
use windows::Win32::Foundation::*; use windows::Win32::Foundation::*;
use windows::Win32::Graphics::Dwm::{
DwmGetWindowAttribute, DWMWA_CLOAKED, DWMWA_EXTENDED_FRAME_BOUNDS,
};
use windows::Win32::Graphics::Gdi::*; use windows::Win32::Graphics::Gdi::*;
use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW}; use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW};
use windows::Win32::UI::HiDpi::*; use windows::Win32::UI::HiDpi::*;
@@ -55,6 +58,7 @@ const TASKBAR_GAP_LOGICAL: i32 = 4;
const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8; const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8;
const CLASS_NAME: &str = "ClaudeCodeUsageBubble"; const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
const FULLSCREEN_POLL_MS: u32 = 1500; const FULLSCREEN_POLL_MS: u32 = 1500;
const FULLSCREEN_EDGE_TOLERANCE_PX: i32 = 2;
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60; const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60; const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
@@ -103,6 +107,8 @@ fn window_duration_secs(model: ProviderId, window: UsageWindowKind) -> u64 {
(ProviderId::Claude, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS, (ProviderId::Claude, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
(ProviderId::ChatGpt, UsageWindowKind::Primary) => FIVE_HOURS_SECS, (ProviderId::ChatGpt, UsageWindowKind::Primary) => FIVE_HOURS_SECS,
(ProviderId::ChatGpt, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS, (ProviderId::ChatGpt, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
(ProviderId::OpenCodeGo, UsageWindowKind::Primary) => SEVEN_DAYS_SECS,
(ProviderId::OpenCodeGo, UsageWindowKind::Secondary) => 30 * 24 * 60 * 60,
} }
} }
@@ -965,30 +971,7 @@ fn align_with_peer(this_hwnd: HWND, ny: &mut i32, tolerance: i32) {
fn check_fullscreen(bubble_hwnd: HWND) { fn check_fullscreen(bubble_hwnd: HWND) {
let fg = unsafe { GetForegroundWindow() }; let fg = unsafe { GetForegroundWindow() };
if fg == HWND::default() || fg == bubble_hwnd { let evaluation = evaluate_foreground_fullscreen(fg, bubble_hwnd);
return;
}
let mut fr = RECT::default();
unsafe {
if GetWindowRect(fg, &mut fr).is_err() {
return;
}
}
let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) };
if monitor.is_invalid() {
return;
}
let mut info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
let ok = unsafe { GetMonitorInfoW(monitor, &mut info).as_bool() };
if !ok {
return;
}
let mr = info.rcMonitor;
let is_fullscreen =
fr.left <= mr.left && fr.top <= mr.top && fr.right >= mr.right && fr.bottom >= mr.bottom;
let (was_hidden_by_fs, user_hidden) = { let (was_hidden_by_fs, user_hidden) = {
let bubbles = lock_bubbles(); let bubbles = lock_bubbles();
@@ -998,36 +981,273 @@ fn check_fullscreen(bubble_hwnd: HWND) {
(b.hidden_by_fullscreen, b.user_hidden) (b.hidden_by_fullscreen, b.user_hidden)
}; };
if is_fullscreen && !was_hidden_by_fs { if evaluation.is_fullscreen && !was_hidden_by_fs {
unsafe { unsafe {
let _ = ShowWindow(bubble_hwnd, SW_HIDE); let _ = ShowWindow(bubble_hwnd, SW_HIDE);
} }
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) { if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
b.hidden_by_fullscreen = true; b.hidden_by_fullscreen = true;
} }
} else if !is_fullscreen && was_hidden_by_fs { log_fullscreen_decision("hide", &evaluation, false);
if !user_hidden { } else if !evaluation.is_fullscreen && was_hidden_by_fs {
unsafe { show_after_fullscreen(bubble_hwnd, user_hidden);
let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE); log_fullscreen_decision("show", &evaluation, user_hidden);
} }
// Re-paint so the layered surface has the cached data again }
// (see comment in `set_user_visible`).
render(bubble_hwnd); struct FullscreenEvaluation {
is_fullscreen: bool,
hwnd: HWND,
class_name: String,
bounds: Option<RECT>,
bounds_source: &'static str,
monitor: Option<RECT>,
reason: &'static str,
}
fn evaluate_foreground_fullscreen(fg: HWND, bubble_hwnd: HWND) -> FullscreenEvaluation {
let mut evaluation = FullscreenEvaluation {
is_fullscreen: false,
hwnd: fg,
class_name: String::new(),
bounds: None,
bounds_source: "none",
monitor: None,
reason: "not evaluated",
};
if fg == HWND::default() {
evaluation.reason = "no foreground window";
return evaluation;
}
evaluation.class_name = window_class_name(fg);
if fg == bubble_hwnd || is_ignored_fullscreen_class(&evaluation.class_name) {
evaluation.reason = "ignored foreground class";
return evaluation;
}
if unsafe { !IsWindowVisible(fg).as_bool() || IsIconic(fg).as_bool() } {
evaluation.reason = "foreground invisible or minimized";
return evaluation;
}
if is_dwm_cloaked(fg) {
evaluation.reason = "foreground cloaked";
return evaluation;
}
let Some((bounds, source)) = visible_window_bounds(fg) else {
evaluation.reason = "foreground bounds unavailable";
return evaluation;
};
evaluation.bounds = Some(bounds);
evaluation.bounds_source = source;
let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) };
if monitor.is_invalid() {
evaluation.reason = "foreground monitor unavailable";
return evaluation;
}
let mut info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
if unsafe { !GetMonitorInfoW(monitor, &mut info).as_bool() } {
evaluation.reason = "foreground monitor info unavailable";
return evaluation;
}
evaluation.monitor = Some(info.rcMonitor);
if !rect_covers_monitor(&bounds, &info.rcMonitor) {
evaluation.reason = "visible bounds do not cover monitor";
return evaluation;
}
if !window_style_allows_fullscreen(fg) {
evaluation.reason = "standard framed window";
return evaluation;
}
evaluation.is_fullscreen = true;
evaluation.reason = "visible bounds cover monitor";
evaluation
}
fn show_after_fullscreen(bubble_hwnd: HWND, user_hidden: bool) {
if !user_hidden {
unsafe {
let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE);
} }
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) { // Re-paint so the layered surface has the cached data again
b.hidden_by_fullscreen = false; // (see comment in `set_user_visible`).
render(bubble_hwnd);
}
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
b.hidden_by_fullscreen = false;
}
}
fn visible_window_bounds(hwnd: HWND) -> Option<(RECT, &'static str)> {
let mut rect = RECT::default();
let dwm_ok = unsafe {
DwmGetWindowAttribute(
hwnd,
DWMWA_EXTENDED_FRAME_BOUNDS,
&mut rect as *mut _ as *mut c_void,
std::mem::size_of::<RECT>() as u32,
)
.is_ok()
};
if dwm_ok && !rect_is_empty(&rect) {
return Some((rect, "dwm-extended-frame"));
}
unsafe {
if GetWindowRect(hwnd, &mut rect).is_err() {
return None;
} }
} }
if rect_is_empty(&rect) {
None
} else {
Some((rect, "window-rect"))
}
}
fn is_dwm_cloaked(hwnd: HWND) -> bool {
let mut cloaked = 0u32;
unsafe {
DwmGetWindowAttribute(
hwnd,
DWMWA_CLOAKED,
&mut cloaked as *mut _ as *mut c_void,
std::mem::size_of::<u32>() as u32,
)
.is_ok()
&& cloaked != 0
}
}
fn window_class_name(hwnd: HWND) -> String {
let mut buf = [0u16; 256];
let len = unsafe { GetClassNameW(hwnd, &mut buf) };
if len <= 0 {
String::new()
} else {
String::from_utf16_lossy(&buf[..len as usize])
}
}
fn is_ignored_fullscreen_class(class_name: &str) -> bool {
["Progman", "WorkerW", "Shell_TrayWnd", CLASS_NAME]
.iter()
.any(|ignored| class_name.eq_ignore_ascii_case(ignored))
}
fn window_style_allows_fullscreen(hwnd: HWND) -> bool {
unsafe {
SetLastError(WIN32_ERROR(0));
}
let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) };
if style == 0 && unsafe { GetLastError() } != WIN32_ERROR(0) {
return false;
}
window_style_bits_allow_fullscreen(style as u32)
}
fn window_style_bits_allow_fullscreen(style: u32) -> bool {
let has_child = style & WS_CHILD.0 != 0;
let has_popup = style & WS_POPUP.0 != 0;
let has_caption = style & WS_CAPTION.0 != 0;
let has_resize_frame = style & WS_THICKFRAME.0 != 0;
!has_child && (has_popup || (!has_caption && !has_resize_frame))
}
fn rect_covers_monitor(rect: &RECT, bounds: &RECT) -> bool {
rect.left <= bounds.left + FULLSCREEN_EDGE_TOLERANCE_PX
&& rect.top <= bounds.top + FULLSCREEN_EDGE_TOLERANCE_PX
&& rect.right >= bounds.right - FULLSCREEN_EDGE_TOLERANCE_PX
&& rect.bottom >= bounds.bottom - FULLSCREEN_EDGE_TOLERANCE_PX
}
fn rect_is_empty(rect: &RECT) -> bool {
rect.right <= rect.left || rect.bottom <= rect.top
}
fn log_fullscreen_decision(action: &str, evaluation: &FullscreenEvaluation, user_hidden: bool) {
log::info!(
"bubble fullscreen {action} fg=0x{:X} class={} reason={} bounds_source={} bounds={} monitor={} user_hidden={}",
evaluation.hwnd.0 as usize,
if evaluation.class_name.is_empty() {
"<unknown>"
} else {
evaluation.class_name.as_str()
},
evaluation.reason,
evaluation.bounds_source,
format_rect(evaluation.bounds.as_ref()),
format_rect(evaluation.monitor.as_ref()),
user_hidden
);
}
fn format_rect(rect: Option<&RECT>) -> String {
match rect {
Some(r) => format!(
"({},{} {}x{})",
r.left,
r.top,
r.right - r.left,
r.bottom - r.top
),
None => String::from("<none>"),
}
}
#[cfg(test)]
mod fullscreen_tests {
use super::*;
#[test]
fn style_bits_allow_popup_or_borderless_windows_only() {
assert!(window_style_bits_allow_fullscreen(WS_POPUP.0));
assert!(window_style_bits_allow_fullscreen(0));
assert!(!window_style_bits_allow_fullscreen(
WS_CAPTION.0 | WS_THICKFRAME.0
));
assert!(!window_style_bits_allow_fullscreen(WS_CHILD.0 | WS_POPUP.0));
}
#[test]
fn rect_cover_check_allows_small_edge_differences() {
let monitor = RECT {
left: 0,
top: 0,
right: 1920,
bottom: 1080,
};
let almost_exact = RECT {
left: 1,
top: 2,
right: 1919,
bottom: 1078,
};
let inset = RECT {
left: 8,
top: 0,
right: 1920,
bottom: 1080,
};
assert!(rect_covers_monitor(&almost_exact, &monitor));
assert!(!rect_covers_monitor(&inset, &monitor));
}
} }
// ---------- 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.
/// ///
@@ -1661,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;
@@ -1702,7 +1922,12 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
} }
SetTextColor(hdc, COLORREF(color.into_colorref())); SetTextColor(hdc, COLORREF(color.into_colorref()));
let weekly_pct_text = format!("{:.0}%", pct); let weekly_pct_text = format!("{:.0}%", pct);
draw_tail_text_in_rect(hdc, &layout.tail_usage_pct_rect, &weekly_pct_text, DT_RIGHT); draw_tail_text_in_rect(
hdc,
&layout.tail_usage_pct_rect,
&weekly_pct_text,
DT_RIGHT,
);
} }
} }
@@ -1712,7 +1937,12 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
SelectObject(hdc, main_font); SelectObject(hdc, main_font);
SetTextColor(hdc, COLORREF(muted_color.into_colorref())); SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
if !inputs.weekly_text.is_empty() { if !inputs.weekly_text.is_empty() {
draw_tail_text_in_rect(hdc, &layout.tail_time_text_rect, &inputs.weekly_text, DT_RIGHT); draw_tail_text_in_rect(
hdc,
&layout.tail_time_text_rect,
&inputs.weekly_text,
DT_RIGHT,
);
} }
SelectObject(hdc, prev_font); SelectObject(hdc, prev_font);
@@ -1802,6 +2032,7 @@ fn default_position(width_px: i32, height_px: i32, model: ProviderId) -> (i32, i
let stagger = match model { let stagger = match model {
ProviderId::Claude => 0, ProviderId::Claude => 0,
ProviderId::ChatGpt => height_px + gap, ProviderId::ChatGpt => height_px + gap,
ProviderId::OpenCodeGo => 2 * (height_px + gap),
}; };
let x = wa.right - width_px - gap; let x = wa.right - width_px - gap;
let y = wa.bottom - height_px - gap - stagger; let y = wa.bottom - height_px - gap - stagger;
+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)]))
}
}
-59
View File
@@ -1,59 +0,0 @@
code = "en"
native_name = "English"
window_title = "Claude Code Usage Bubble"
refresh = "Refresh"
update_frequency = "Update frequency"
one_minute = "1 minute"
five_minutes = "5 minutes"
fifteen_minutes = "15 minutes"
one_hour = "1 hour"
models = "Models"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Settings"
start_with_windows = "Start with Windows"
reset_position = "Reset position"
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: "
-59
View File
@@ -1,59 +0,0 @@
code = "ja"
native_name = "日本語"
window_title = "Claude Code Usage Bubble"
refresh = "更新"
update_frequency = "更新間隔"
one_minute = "1分"
five_minutes = "5分"
fifteen_minutes = "15分"
one_hour = "1時間"
models = "モデル"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "設定"
start_with_windows = "Windows起動時に開始"
reset_position = "位置をリセット"
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 = "更新に失敗しました。元のバイナリは次の場所に保存されています: "
-59
View File
@@ -1,59 +0,0 @@
code = "ko"
native_name = "한국어"
window_title = "Claude Code Usage Bubble"
refresh = "새로 고침"
update_frequency = "업데이트 주기"
one_minute = "1분"
five_minutes = "5분"
fifteen_minutes = "15분"
one_hour = "1시간"
models = "모델"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "설정"
start_with_windows = "Windows 시작 시 실행"
reset_position = "위치 초기화"
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 = "업데이트 실패. 원본 바이너리는 다음 위치에 저장되었습니다: "
-59
View File
@@ -1,59 +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ờ"
models = "Mô hình"
claude_label = "Claude Code"
chatgpt_label = "Codex"
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: "
-59
View File
@@ -1,59 +0,0 @@
code = "zh-TW"
native_name = "繁體中文"
window_title = "Claude Code Usage Bubble"
refresh = "重新整理"
update_frequency = "更新頻率"
one_minute = "1 分鐘"
five_minutes = "5 分鐘"
fifteen_minutes = "15 分鐘"
one_hour = "1 小時"
models = "模型"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "設定"
start_with_windows = "隨 Windows 啟動"
reset_position = "重設位置"
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 = "更新失敗。您的原始執行檔已保存於: "
+87 -191
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,
@@ -28,9 +12,10 @@ pub struct LocaleStrings {
pub five_minutes: String, pub five_minutes: String,
pub fifteen_minutes: String, pub fifteen_minutes: String,
pub one_hour: String, pub one_hour: String,
pub models: String, pub providers: String,
pub claude_label: String, pub claude_label: String,
pub chatgpt_label: String, pub chatgpt_label: String,
pub opencode_go_label: String,
pub settings: String, pub settings: String,
pub start_with_windows: String, pub start_with_windows: String,
pub reset_position: String, pub reset_position: String,
@@ -44,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,
@@ -73,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 ----------
@@ -281,57 +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()),
("size_larger", strings.size_larger.as_str()), ("control_right_click", strings.control_right_click.as_str()),
("reset_size", strings.reset_size.as_str()), ("control_drag", strings.control_drag.as_str()),
("controls", strings.controls.as_str()), ("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()),
("control_left_click", strings.control_left_click.as_str()), ("control_tray_click", strings.control_tray_click.as_str()),
("control_right_click", strings.control_right_click.as_str()), ("tray_left_click", strings.tray_left_click.as_str()),
("control_drag", strings.control_drag.as_str()), ] {
("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()), assert!(!value.trim().is_empty(), "empty string: {name}");
("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"
);
} }
} }
+3 -5
View File
@@ -317,6 +317,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
let header = match data.model { let header = match data.model {
ProviderId::Claude => data.strings.claude_label.clone(), ProviderId::Claude => data.strings.claude_label.clone(),
ProviderId::ChatGpt => data.strings.chatgpt_label.clone(), ProviderId::ChatGpt => data.strings.chatgpt_label.clone(),
ProviderId::OpenCodeGo => data.strings.opencode_go_label.clone(),
}; };
draw_text( draw_text(
hdc, hdc,
@@ -331,11 +332,8 @@ fn paint(hwnd: HWND, hdc: HDC) {
); );
let bar_x = scaled(PADDING_LOGICAL) + scaled(LABEL_W_LOGICAL) + scaled(4); let bar_x = scaled(PADDING_LOGICAL) + scaled(LABEL_W_LOGICAL) + scaled(4);
let bar_w = rc.right let bar_w =
- bar_x rc.right - bar_x - scaled(PADDING_LOGICAL) - scaled(RIGHT_TEXT_W_LOGICAL) - scaled(4);
- scaled(PADDING_LOGICAL)
- scaled(RIGHT_TEXT_W_LOGICAL)
- scaled(4);
let row1_y = scaled(PADDING_LOGICAL) + scaled(24); let row1_y = scaled(PADDING_LOGICAL) + scaled(24);
let row2_y = row1_y + scaled(BAR_HEIGHT_LOGICAL) + scaled(ROW_GAP_LOGICAL) + scaled(8); let row2_y = row1_y + scaled(BAR_HEIGHT_LOGICAL) + scaled(ROW_GAP_LOGICAL) + scaled(8);
+120 -16
View File
@@ -31,6 +31,9 @@ fn default_show_claude() -> bool {
fn default_show_codex() -> bool { fn default_show_codex() -> bool {
false false
} }
fn default_show_opencode_go() -> bool {
false
}
fn default_widget_visible() -> bool { fn default_widget_visible() -> bool {
true true
} }
@@ -48,30 +51,35 @@ fn default_update_check_interval_secs() -> Option<u64> {
pub struct BubblePositions { pub struct BubblePositions {
pub claude: Option<(i32, i32)>, pub claude: Option<(i32, i32)>,
pub codex: Option<(i32, i32)>, pub codex: Option<(i32, i32)>,
pub opencode_go: Option<(i32, i32)>,
} }
impl BubblePositions { impl BubblePositions {
pub fn get(&self, model: ProviderId) -> Option<(i32, i32)> { pub fn get(&self, provider: ProviderId) -> Option<(i32, i32)> {
match model { match provider {
ProviderId::Claude => self.claude, ProviderId::Claude => self.claude,
ProviderId::ChatGpt => self.codex, ProviderId::ChatGpt => self.codex,
ProviderId::OpenCodeGo => self.opencode_go,
} }
} }
pub fn set(&mut self, model: ProviderId, pos: (i32, i32)) { pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) {
match model { match provider {
ProviderId::Claude => self.claude = Some(pos), ProviderId::Claude => self.claude = Some(pos),
ProviderId::ChatGpt => self.codex = Some(pos), ProviderId::ChatGpt => self.codex = Some(pos),
ProviderId::OpenCodeGo => self.opencode_go = Some(pos),
} }
} }
pub fn reset(&mut self, model: ProviderId) { pub fn reset(&mut self, provider: ProviderId) {
match model { match provider {
ProviderId::Claude => self.claude = None, ProviderId::Claude => self.claude = None,
ProviderId::ChatGpt => self.codex = None, ProviderId::ChatGpt => self.codex = None,
ProviderId::OpenCodeGo => self.opencode_go = None,
} }
} }
pub fn reset_all(&mut self) { pub fn reset_all(&mut self) {
self.claude = None; self.claude = None;
self.codex = None; self.codex = None;
self.opencode_go = None;
} }
/// Drop any saved position whose top-left no longer falls on a connected /// Drop any saved position whose top-left no longer falls on a connected
@@ -80,16 +88,26 @@ impl BubblePositions {
pub fn validate(&mut self) { pub fn validate(&mut self) {
if let Some((x, y)) = self.claude { if let Some((x, y)) = self.claude {
if !position_on_any_monitor(x, y) { if !position_on_any_monitor(x, y) {
log::warn!("bubble position claude ({x},{y}) outside all monitors; resetting to default"); log::warn!(
"bubble position claude ({x},{y}) outside all monitors; resetting to default"
);
self.claude = None; self.claude = None;
} }
} }
if let Some((x, y)) = self.codex { if let Some((x, y)) = self.codex {
if !position_on_any_monitor(x, y) { if !position_on_any_monitor(x, y) {
log::warn!("bubble position codex ({x},{y}) outside all monitors; resetting to default"); log::warn!(
"bubble position codex ({x},{y}) outside all monitors; resetting to default"
);
self.codex = None; self.codex = None;
} }
} }
if let Some((x, y)) = self.opencode_go {
if !position_on_any_monitor(x, y) {
log::warn!("bubble position opencode-go ({x},{y}) outside all monitors; resetting to default");
self.opencode_go = None;
}
}
} }
} }
@@ -112,6 +130,8 @@ pub struct Settings {
pub show_claude_code: bool, pub show_claude_code: bool,
#[serde(default = "default_show_codex")] #[serde(default = "default_show_codex")]
pub show_codex: bool, pub show_codex: bool,
#[serde(default = "default_show_opencode_go")]
pub show_opencode_go: bool,
#[serde(default)] #[serde(default)]
pub bubble_positions: BubblePositions, pub bubble_positions: BubblePositions,
#[serde(default = "default_bubble_size")] #[serde(default = "default_bubble_size")]
@@ -119,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>,
@@ -133,10 +151,10 @@ impl Default for Settings {
Self { Self {
show_claude_code: default_show_claude(), show_claude_code: default_show_claude(),
show_codex: default_show_codex(), show_codex: default_show_codex(),
show_opencode_go: default_show_opencode_go(),
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(),
@@ -144,6 +162,35 @@ impl Default for Settings {
} }
} }
impl Settings {
pub fn is_provider_enabled(&self, provider: ProviderId) -> bool {
match provider {
ProviderId::Claude => self.show_claude_code,
ProviderId::ChatGpt => self.show_codex,
ProviderId::OpenCodeGo => self.show_opencode_go,
}
}
pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) {
match provider {
ProviderId::Claude => self.show_claude_code = enabled,
ProviderId::ChatGpt => self.show_codex = enabled,
ProviderId::OpenCodeGo => self.show_opencode_go = enabled,
}
}
pub fn toggle_provider(&mut self, provider: ProviderId) {
let enabled = !self.is_provider_enabled(provider);
self.set_provider_enabled(provider, enabled);
}
pub fn has_enabled_live_provider(&self) -> bool {
ProviderId::LIVE_USAGE
.iter()
.any(|provider| self.is_provider_enabled(*provider))
}
}
pub fn settings_dir() -> Option<PathBuf> { pub fn settings_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join(APP_DIR_NAME)) dirs::config_dir().map(|d| d.join(APP_DIR_NAME))
} }
@@ -161,19 +208,76 @@ pub fn load() -> Settings {
Err(_) => return Settings::default(), Err(_) => return Settings::default(),
}; };
let mut settings: Settings = serde_json::from_str(&content).unwrap_or_default(); let mut settings: Settings = serde_json::from_str(&content).unwrap_or_default();
// At least one model must be visible. Otherwise the app has nothing to show. // At least one live provider must be visible. Otherwise the app has nothing to show.
if !settings.show_claude_code && !settings.show_codex { if !settings.has_enabled_live_provider() {
settings.show_claude_code = true; settings.show_claude_code = true;
} }
// Clamp bubble size to safe range in case settings.json was hand-edited. // Clamp bubble size to safe range in case settings.json was hand-edited.
settings.bubble_size_logical = settings settings.bubble_size_logical = settings.bubble_size_logical.clamp(
.bubble_size_logical crate::bubble::MIN_BUBBLE_SIZE,
.clamp(crate::bubble::MIN_BUBBLE_SIZE, crate::bubble::MAX_BUBBLE_SIZE); crate::bubble::MAX_BUBBLE_SIZE,
);
// Drop positions on monitors that have since been disconnected. // Drop positions on monitors that have since been disconnected.
settings.bubble_positions.validate(); settings.bubble_positions.validate();
settings settings
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn old_two_provider_settings_json_still_loads() {
let json = r#"{
"show_claude_code": false,
"show_codex": true,
"bubble_positions": {
"claude": [10, 20],
"codex": [30, 40]
}
}"#;
let settings: Settings =
serde_json::from_str(json).expect("old settings json should parse");
assert!(!settings.show_claude_code);
assert!(settings.show_codex);
assert!(!settings.show_opencode_go);
assert_eq!(
settings.bubble_positions.get(ProviderId::Claude),
Some((10, 20))
);
assert_eq!(
settings.bubble_positions.get(ProviderId::ChatGpt),
Some((30, 40))
);
assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None);
}
#[test]
fn three_provider_settings_json_loads() {
let json = r#"{
"show_claude_code": true,
"show_codex": false,
"show_opencode_go": true,
"bubble_positions": {
"opencode_go": [50, 60]
}
}"#;
let settings: Settings =
serde_json::from_str(json).expect("new settings json should parse");
assert!(settings.show_claude_code);
assert!(!settings.show_codex);
assert!(settings.show_opencode_go);
assert_eq!(
settings.bubble_positions.get(ProviderId::OpenCodeGo),
Some((50, 60))
);
}
}
pub fn save(settings: &Settings) { pub fn save(settings: &Settings) {
let path = settings_path(); let path = settings_path();
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
+9 -1
View File
@@ -43,7 +43,13 @@ fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
let mut pb = PathBuilder::new(); let mut pb = PathBuilder::new();
pb.push_circle(cx, cy, inner); pb.push_circle(cx, cy, inner);
if let Some(path) = pb.finish() { if let Some(path) = pb.finish() {
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
} }
} }
@@ -112,6 +118,8 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
ProviderId::Claude => [0x2a, 0x1f, 0x1c], ProviderId::Claude => [0x2a, 0x1f, 0x1c],
// Cool dark slate for ChatGPT/Codex. // Cool dark slate for ChatGPT/Codex.
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26], ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
// Deep green for OpenCode Go.
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
} }
} }
+3 -6
View File
@@ -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_INFO, NIIF_WARNING, Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_INFO, NIIF_WARNING, NIM_ADD,
NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS,
}; };
use windows::Win32::UI::WindowsAndMessaging::DestroyIcon; use windows::Win32::UI::WindowsAndMessaging::DestroyIcon;
@@ -130,10 +130,7 @@ fn build_data(owner: HWND, kind: IconKind) -> NOTIFYICONDATAW {
} }
fn icon_id(kind: IconKind) -> u32 { fn icon_id(kind: IconKind) -> u32 {
match kind { kind.metadata().tray_icon_id
IconKind::Claude => 1,
IconKind::ChatGpt => 2,
}
} }
fn write_utf16(dst: &mut [u16], src: &str) { fn write_utf16(dst: &mut [u16], src: &str) {
+2 -1
View File
@@ -22,6 +22,7 @@ use windows::Win32::System::Threading::{
/// so no zombie wait is required. /// so no zombie wait is required.
pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> { pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
let mut cmdline = build_command_line(exe, args); let mut cmdline = build_command_line(exe, args);
let exe_w: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
let si = STARTUPINFOW { let si = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32, cb: std::mem::size_of::<STARTUPINFOW>() as u32,
@@ -32,7 +33,7 @@ pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP; let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
let ok = unsafe { let ok = unsafe {
CreateProcessW( CreateProcessW(
PCWSTR::null(), PCWSTR::from_raw(exe_w.as_ptr()),
windows::core::PWSTR(cmdline.as_mut_ptr()), windows::core::PWSTR(cmdline.as_mut_ptr()),
None, None,
None, None,
+200 -53
View File
@@ -1,10 +1,10 @@
// Download a release asset and swap it in via native Win32 calls. // Download a release asset and swap it in through the proven `self_replace`
// handoff path.
// //
// After writing the new .exe to a staging path and verifying its // The running process cannot reliably replace its own mapped image on
// SHA-256, we `MoveFileExW` the running exe sideways (so Windows // Windows. `self_replace` handles the platform-specific rename/copy sequence
// releases the file lock on our own image), then `MoveFileExW` the // for that case. After the replacement succeeds, we start the installed path
// staged exe into place, then spawn the new binary detached via // with `--wait-pid` and let the current process exit.
// `handoff::spawn_detached`. No shell, no console allocation.
use std::ffi::OsString; use std::ffi::OsString;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -16,14 +16,22 @@ use windows::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS, MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
}; };
use windows::Win32::System::Threading::GetCurrentProcessId; use windows::Win32::System::Threading::GetCurrentProcessId;
use windows::Win32::UI::WindowsAndMessaging::{ use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
MessageBoxW, MB_ICONERROR, MB_OK,
};
use crate::net::Client; 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()?;
@@ -35,20 +43,45 @@ pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error
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, release.asset_sha256.as_ref())?; download(
swap_and_spawn(&staging, &current, &release.version)?; http,
&release.asset_url,
&staging,
release.asset_sha256.as_ref(),
)?;
replace_current_exe(&staging)?;
spawn_replaced_exe(&current, &release.version)?;
let _ = std::fs::remove_file(&staging);
Ok(()) Ok(())
} }
/// CLI entry-point compatibility for `--apply-update <target> <source> <pid>`. /// CLI entry point for `--apply-update <target> <source> <parent-pid> <version>`.
/// The native handoff already does the swap-and-restart; if this binary /// Runs from the staged new binary, waits for the old UI process to exit,
/// is invoked with the legacy flag (e.g. from an older release's helper) /// replaces the installed exe, and starts the installed copy.
/// just exit cleanly so the upgrade still completes.
pub fn run_cli(args: &[String]) -> Option<i32> { pub fn run_cli(args: &[String]) -> Option<i32> {
if args.len() >= 2 && args[1] == "--apply-update" { if args.get(1).map(String::as_str) != Some("--apply-update") {
Some(0) return None;
} else { }
None let Some(target) = args.get(2).map(PathBuf::from) else {
return Some(2);
};
let Some(source) = args.get(3).map(PathBuf::from) else {
return Some(2);
};
let Some(parent_pid) = args.get(4).and_then(|s| s.parse::<u32>().ok()) else {
return Some(2);
};
let Some(version) = args.get(5).cloned() else {
return Some(2);
};
super::handoff::wait_for_parent_exit(parent_pid, 15_000);
match replace_from_helper(&source, &target, &version) {
Ok(()) => Some(0),
Err(e) => {
log::error!("apply-update failed: {e}");
Some(1)
}
} }
} }
@@ -63,7 +96,9 @@ fn download(
.header("User-Agent", super::release::user_agent()) .header("User-Agent", super::release::user_agent())
.send()?; .send()?;
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(),
)));
} }
let body = resp.body(); let body = resp.body();
if let Some(expected) = expected_sha256 { if let Some(expected) = expected_sha256 {
@@ -94,44 +129,38 @@ fn hex_encode(bytes: &[u8]) -> String {
fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> { fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> {
let s = p.to_string_lossy(); let s = p.to_string_lossy();
if s.contains('%') { if s.contains('%') {
return Err(super::Error::UnsafePath(format!( return Err(super::Error::UnsafePath(format!("path contains '%': {s}")));
"path contains '%': {s}"
)));
} }
Ok(()) Ok(())
} }
fn swap_and_spawn( fn spawn_update_helper(
source: &Path, helper: &Path,
staging: &Path,
target: &Path, target: &Path,
version: &super::release::Version, version: &super::release::Version,
) -> Result<(), super::Error> { ) -> Result<(), super::Error> {
let backup = backup_path(target); let pid = unsafe { GetCurrentProcessId() };
// Step 1: rename running exe sideways. Windows allows renaming a let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
// file even while its image is mapped into memory; this releases let args = vec![
// the lock on the original `target` path. Same directory by OsString::from("--apply-update"),
// construction, so plain MoveFileExW with no flags is sufficient. target.as_os_str().to_os_string(),
move_file(target, &backup, MOVE_FILE_FLAGS(0))?; staging.as_os_str().to_os_string(),
OsString::from(pid.to_string()),
OsString::from(version_str),
];
super::handoff::spawn_detached(helper, &args).map_err(super::Error::Io)
}
// Step 2: move staged exe into place. Staging lives under fn replace_current_exe(staging: &Path) -> Result<(), super::Error> {
// %LOCALAPPDATA%, target lives wherever the user installed — self_replace::self_replace(staging)
// COPY_ALLOWED lets MoveFileExW fall back to copy+delete when .map_err(|e| super::Error::SwapFailed(format!("self_replace({}): {e}", staging.display())))
// 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. fn spawn_replaced_exe(
target: &Path,
version: &super::release::Version,
) -> Result<(), super::Error> {
let pid = unsafe { GetCurrentProcessId() }; let pid = unsafe { GetCurrentProcessId() };
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch); let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
let args = vec![ let args = vec![
@@ -140,15 +169,48 @@ fn swap_and_spawn(
OsString::from("--updated-to"), OsString::from("--updated-to"),
OsString::from(version_str), OsString::from(version_str),
]; ];
super::handoff::spawn_detached(target, &args).map_err(super::Error::Io)
}
fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<(), super::Error> {
let backup = backup_path(target);
// Parent has exited, so the install target is no longer mapped.
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
let swap_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
if let Err(swap_err) = move_file(source, target, swap_flags) {
// Compatibility for users updating from a release that invoked
// the downloaded binary itself as the helper. A mapped source exe
// may not be movable, but it can usually still be copied.
let copy_result = std::fs::copy(source, target);
if copy_result.is_err() {
log::error!("source move failed before copy fallback: {swap_err}");
}
if let Err(copy_err) = copy_result {
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("rollback also failed: {revert_err}; surfacing modal");
let target_name = target
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
surface_rollback_failure(&backup, &target_name);
}
return Err(super::Error::Io(copy_err));
}
}
let args = vec![OsString::from("--updated-to"), OsString::from(version)];
if let Err(spawn_err) = super::handoff::spawn_detached(target, &args) { 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"); log::error!("spawn_detached failed after swap: {spawn_err}; attempting revert");
let _ = std::fs::remove_file(target);
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) { if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("post-spawn revert failed: {revert_err}"); log::error!("post-spawn revert failed: {revert_err}");
} }
return Err(super::Error::Io(spawn_err)); return Err(super::Error::Io(spawn_err));
} }
let _ = std::fs::remove_file(source);
Ok(()) Ok(())
} }
@@ -183,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,
@@ -214,6 +276,72 @@ 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> {
let base = dirs::data_local_dir().ok_or_else(|| {
super::Error::NotWritable("no local data directory available".to_string())
})?;
let pid = unsafe { GetCurrentProcessId() };
Ok(base
.join("ClaudeCodeUsageBubble")
.join("updates")
.join(format!("updater-helper-{pid}.exe")))
}
fn prepare_update_helper(current: &Path, helper: &Path) -> Result<(), super::Error> {
if let Some(parent) = helper.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(current, helper)?;
Ok(())
}
pub fn cleanup_staged_update_files() {
let Ok(stage) = stage_path() else {
return;
};
let Some(dir) = stage.parent() else {
return;
};
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name == "update.exe" || (name.starts_with("updater-helper-") && name.ends_with(".exe")) {
if let Err(e) = std::fs::remove_file(&path) {
log::debug!("cleanup_staged_update_files: remove {:?} failed: {e}", path);
}
}
}
}
fn ensure_writable(target: &Path) -> Result<(), super::Error> { 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())
@@ -223,3 +351,22 @@ fn ensure_writable(target: &Path) -> Result<(), super::Error> {
let _ = std::fs::remove_file(&probe); let _ = std::fs::remove_file(&probe);
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stage_and_helper_paths_are_distinct_exes() {
let stage = stage_path().expect("stage path");
let helper = helper_path().expect("helper path");
assert_eq!(stage.file_name().unwrap(), "update.exe");
assert!(helper
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("updater-helper-"));
assert_ne!(stage, helper);
}
}
+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()
}
+22 -7
View File
@@ -7,7 +7,10 @@
use crate::creds::Locator; use crate::creds::Locator;
use crate::net::Client; use crate::net::Client;
use crate::settings::Settings; use crate::settings::Settings;
use crate::usage::{anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider, UsageWindows}; use crate::usage::{
anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider,
UsageWindows,
};
pub struct Registry { pub struct Registry {
claude: ClaudeProvider, claude: ClaudeProvider,
@@ -28,20 +31,32 @@ impl Registry {
settings: &Settings, settings: &Settings,
) -> Vec<(ProviderId, Result<UsageWindows, Error>)> { ) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
let mut out = Vec::new(); let mut out = Vec::new();
if settings.show_claude_code { for provider in ProviderId::LIVE_USAGE {
out.push((ProviderId::Claude, self.claude.poll(http))); if !settings.is_provider_enabled(provider) {
} continue;
if settings.show_codex { }
out.push((ProviderId::ChatGpt, self.chatgpt.poll(http))); let result = match provider {
ProviderId::Claude => self.claude.poll(http),
ProviderId::ChatGpt => self.chatgpt.poll(http),
ProviderId::OpenCodeGo => {
unreachable!("OpenCode Go has no live usage provider yet")
}
};
out.push((provider, result));
} }
out out
} }
/// Attempt to refresh the active source for one provider. /// Attempt to refresh the active source for one provider.
pub fn try_refresh(&self, id: ProviderId, orchestrator: &refresh::Orchestrator) -> refresh::Outcome { pub fn try_refresh(
&self,
id: ProviderId,
orchestrator: &refresh::Orchestrator,
) -> refresh::Outcome {
let locator = match id { let locator = match id {
ProviderId::Claude => self.claude.locator(), ProviderId::Claude => self.claude.locator(),
ProviderId::ChatGpt => self.chatgpt.locator(), ProviderId::ChatGpt => self.chatgpt.locator(),
ProviderId::OpenCodeGo => return refresh::Outcome::CliMissing,
}; };
match locator.first_available() { match locator.first_available() {
Some(src) => orchestrator.refresh(src), Some(src) => orchestrator.refresh(src),
+50 -5
View File
@@ -1,9 +1,4 @@
// Usage data shapes shared across providers. // Usage data shapes shared across providers.
//
// Every provider reports its quota as two named "windows" (short + long).
// For Claude: 5-hour and 7-day. For ChatGPT: primary and secondary. We
// normalise to `primary` + `secondary` so the UI layer doesn't care which
// provider produced the snapshot.
use std::time::SystemTime; use std::time::SystemTime;
@@ -11,15 +6,65 @@ use std::time::SystemTime;
pub enum ProviderId { pub enum ProviderId {
Claude, Claude,
ChatGpt, ChatGpt,
OpenCodeGo,
} }
impl ProviderId { impl ProviderId {
pub const ALL: [Self; 3] = [Self::Claude, Self::ChatGpt, Self::OpenCodeGo];
pub const LIVE_USAGE: [Self; 2] = [Self::Claude, Self::ChatGpt];
pub fn slug(self) -> &'static str { pub fn slug(self) -> &'static str {
match self { match self {
Self::Claude => "claude", Self::Claude => "claude",
Self::ChatGpt => "chatgpt", Self::ChatGpt => "chatgpt",
Self::OpenCodeGo => "opencode-go",
} }
} }
pub fn metadata(self) -> ProviderMetadata {
match self {
Self::Claude => ProviderMetadata {
id: self,
slug: "claude",
default_enabled: true,
tray_icon_id: 1,
display_mode: ProviderDisplayMode::TwoWindow,
live_usage: true,
},
Self::ChatGpt => ProviderMetadata {
id: self,
slug: "chatgpt",
default_enabled: false,
tray_icon_id: 2,
display_mode: ProviderDisplayMode::TwoWindow,
live_usage: true,
},
Self::OpenCodeGo => ProviderMetadata {
id: self,
slug: "opencode-go",
default_enabled: false,
tray_icon_id: 3,
display_mode: ProviderDisplayMode::WeeklyMonthlyFourBar,
live_usage: false,
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderDisplayMode {
TwoWindow,
WeeklyMonthlyFourBar,
}
#[derive(Clone, Copy, Debug)]
pub struct ProviderMetadata {
pub id: ProviderId,
pub slug: &'static str,
pub default_enabled: bool,
pub tray_icon_id: u32,
pub display_mode: ProviderDisplayMode,
pub live_usage: bool,
} }
/// One usage window: how much you've consumed (0100), and when it resets. /// One usage window: how much you've consumed (0100), and when it resets.
+16 -8
View File
@@ -5,20 +5,28 @@
use crate::os::Rgb as Color; use crate::os::Rgb as Color;
use crate::usage::ProviderId; use crate::usage::ProviderId;
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex = /// Per-provider identity color. Claude = warm orange `#D97757`. Codex tracks
/// OpenAI brand teal `#10A37F`, used consistently across dark and light /// the OpenAI Codex monochrome palette — near-white `#E5E5E5` on dark themes,
/// themes so the badge/bubble/panel never disagree on Codex identity. /// near-black `#1A1A1A` on light — so the accent stays readable against both
pub fn accent_color_for(model: ProviderId, _is_dark: bool) -> Color { /// the dark bubble surface and the `#F3F3F3` light background.
pub fn accent_color_for(model: ProviderId, is_dark: bool) -> Color {
match model { match model {
ProviderId::Claude => Color::from_hex("#D97757"), ProviderId::Claude => Color::from_hex("#D97757"),
ProviderId::ChatGpt => Color::from_hex("#10A37F"), ProviderId::ChatGpt => {
if is_dark {
Color::from_hex("#E5E5E5")
} else {
Color::from_hex("#1A1A1A")
}
}
ProviderId::OpenCodeGo => Color::from_hex("#3BAE75"),
} }
} }
/// Discrete 4-band fill color. The "safe" band uses the provider's identity /// Discrete 4-band fill color. The "safe" band uses the provider's identity
/// color so Codex bars stay white-on-dark while Claude bars stay orange; the /// color so Codex bars render monochrome (light-on-dark / dark-on-light) while
/// warning bands are theme-aware so light-mode amber stays readable against /// Claude bars stay orange; the warning bands are theme-aware so light-mode
/// the `#F3F3F3` background. /// amber stays readable against the `#F3F3F3` background.
/// ///
/// - <60% → provider accent /// - <60% → provider accent
/// - 6080% → amber (dark `#E0A040`, light `#B47A20` for WCAG AA contrast) /// - 6080% → amber (dark `#E0A040`, light `#B47A20` for WCAG AA contrast)