feat: initial port of claude-code-usage-monitor as a floating bubble

Windows-only floating, draggable circular bubble showing Claude Code
and Codex usage. Derivative of CodeZeno/Claude-Code-Usage-Monitor (MIT),
relicensed under Apache 2.0 with upstream attribution in NOTICE.

- Ported verbatim: poller, updater, tray_icon, theme, localization,
  diagnose, models (~2,700 LOC)
- Original: bubble (circular layered window, drag-anywhere via
  WM_NCHITTEST+HTCAPTION, snap-to-edge, Ctrl+Wheel resize, auto-hide
  on fullscreen), panel (expanded 5h/7d view), app (orchestrator,
  single-instance mutex, polling thread, context menu, dual-bubble
  lifecycle), settings (settings.json persistence)
- Cargo.toml features cover Win32 GDI, HiDpi, Registry, Threading,
  Shell, WindowsAndMessaging, and KeyboardAndMouse
This commit is contained in:
2026-05-15 21:27:31 +07:00
commit c0f3e3f860
42 changed files with 7046 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "claude-code-usage-bubble"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows"
homepage = "https://github.com/tiennm99/claude-code-usage-bubble"
repository = "https://github.com/tiennm99/claude-code-usage-bubble"
[package.metadata.winres]
ProductName = "Claude Code Usage Bubble"
FileDescription = "Claude Code Usage Bubble"
OriginalFilename = "claude-code-usage-bubble.exe"
InternalName = "ClaudeCodeUsageBubble"
LegalCopyright = "Copyright (C) 2026"
Comments = "Floating bubble showing Claude Code and Codex usage on Windows"
[dependencies]
ureq = { version = "2", default-features = false, features = ["native-tls", "json", "proxy-from-env"] }
native-tls = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6"
[dependencies.windows]
version = "0.58"
features = [
"Win32_Foundation",
"Win32_Globalization",
"Win32_Graphics_Gdi",
"Win32_System_LibraryLoader",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_Security",
"Win32_UI_HiDpi",
"Win32_UI_Input_KeyboardAndMouse",
]
[build-dependencies]
winres = "0.1"
[profile.release]
opt-level = "z"
lto = true
strip = true
codegen-units = 1
panic = "abort"
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 tiennm99
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing permissions and
limitations under the License.
+48
View File
@@ -0,0 +1,48 @@
Claude Code Usage Bubble
Copyright 2026 tiennm99
This product includes software developed as a derivative work of
"Claude Code Usage Monitor" (https://github.com/CodeZeno/Claude-Code-Usage-Monitor),
Copyright (c) 2026 Code Zeno Pty Ltd, originally licensed under the MIT License.
The following modules are ported with minor adaptations from that
upstream project:
src/models.rs
src/diagnose.rs
src/theme.rs
src/poller.rs
src/updater.rs
src/tray_icon.rs
src/localization/*
The floating-bubble UI (src/bubble.rs), expanded panel (src/panel.rs),
settings persistence (src/settings.rs), and orchestrator (src/app.rs)
are original to this project.
The original upstream MIT license text is reproduced below for the
ported portions:
MIT License
Copyright (c) 2026 Code Zeno Pty Ltd
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+147
View File
@@ -0,0 +1,147 @@
![Windows](https://img.shields.io/badge/platform-Windows-blue)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
# Claude Code Usage Bubble
A floating, draggable circular bubble that shows your Claude Code and/or
Codex usage on Windows — inspired by the floating "memory boost ball" UX
of 360 Security and IObit Advanced SystemCare.
Drop it anywhere on screen, drag it around, snap it to a monitor edge,
left-click for a panel with both your 5-hour and 7-day windows, right-click
for the menu.
## Differences vs upstream
This project is a derivative of
[CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor)
(MIT, © 2026 Code Zeno Pty Ltd). The usage-polling, updater, tray-icon,
localization, theme-detection, and diagnostic modules are ported from that
codebase with minor adaptations.
The original app embeds a horizontal widget directly into the Windows
taskbar. This fork replaces that UI with a **floating circular bubble that
the user can drag anywhere on screen**, plus an on-demand expanded panel.
Everything else (credential reading, OAuth refresh via the Claude/Codex
CLI, WSL credential support, GitHub self-update, eight languages) behaves
the same.
## What you get
- A circular floating bubble showing your current 5-hour Claude Code or
Codex usage as a percentage and a colored progress ring
- Drag anywhere — the bubble snaps to monitor work-area edges when
released
- Resize with `Ctrl + MouseWheel` on the bubble (32128 pixels)
- Left-click the bubble for an expanded panel with both **5h** and **7d**
bars plus reset countdowns
- Right-click for refresh, displayed models, update frequency, language,
startup, updates, exit
- Optional system tray icons (one per enabled model)
- Auto-hide when a fullscreen app is in the foreground (games, video,
presentations) — reappears when you leave fullscreen
## Who this is for
Windows 10/11 users who already have **Claude Code (CLI or App) installed
and signed in**. Codex support is optional — install and sign in to the
Codex CLI, then enable Codex from the right-click **Models** menu.
If you use Claude Code through WSL, that is supported too. The monitor
can read your Claude Code credentials from Windows or from your WSL
environment.
## Requirements
- Windows 10 or Windows 11
- Claude Code (CLI or App) installed and authenticated
- Optional: Codex CLI installed and authenticated, if you want Codex usage
## Install
Until packaged binaries are published, build from source:
```powershell
git clone https://github.com/<your-fork>/claude-code-usage-bubble
cd claude-code-usage-bubble
cargo build --release
```
The binary lands at `target/release/claude-code-usage-bubble.exe`.
## Use
Run `claude-code-usage-bubble.exe`. The bubble appears near the bottom-right
corner of your primary monitor on first launch. Drag it where you want it,
release to snap to the nearest edge if you let go close to one.
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
- **Right-click** for refresh, models, update frequency, language, "Start
with Windows", updates, exit
- **Drag** anywhere — it floats on top of all other windows
- **Ctrl + MouseWheel** on the bubble to resize it
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
right-click opens the same menu
### Models
Use the right-click **Models** menu to choose what is shown:
- **Claude Code** is enabled by default
- **Codex** can be enabled alongside Claude Code or shown by itself
When both models are shown, each gets its own bubble that you can position
independently.
## Diagnostics
```powershell
claude-code-usage-bubble.exe --diagnose
```
This writes a log file to:
```text
%TEMP%\claude-code-usage-bubble.log
```
Settings are saved to:
```text
%APPDATA%\ClaudeCodeUsageBubble\settings.json
```
## Privacy and security
What the app reads:
- Your local Claude Code OAuth credentials from `~/.claude/.credentials.json`
- If needed, the same credentials file inside an installed WSL distro
- If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json`
or `~/.codex/auth.json`
What the app sends over the network:
- Requests to Anthropic's Claude endpoints to read your usage
- Requests to ChatGPT's Codex usage endpoint, if Codex is enabled
- Requests to GitHub only if you use the app's update-check feature
What the app stores locally:
- Bubble position(s) per model
- Bubble size
- Polling frequency
- Language preference
- Last update check time
- Displayed model preferences
What it does **not** do: send credentials to any third-party server, run a
backend service, collect analytics, upload your project files, or write to
your Codex `auth.json` directly.
## License
Apache License 2.0 — see [LICENSE](LICENSE). This project is a derivative of
[CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor)
(MIT). Upstream attribution and the original MIT terms for the ported portions
are recorded in [NOTICE](NOTICE).
+24
View File
@@ -0,0 +1,24 @@
use winres::{VersionInfo, WindowsResource};
fn main() {
let version = env!("CARGO_PKG_VERSION");
let mut res = WindowsResource::new();
let numeric_version = pack_version(version);
res.set_icon("src/icons/icon.ico")
.set("FileVersion", version)
.set("ProductVersion", version)
.set_version_info(VersionInfo::FILEVERSION, numeric_version)
.set_version_info(VersionInfo::PRODUCTVERSION, numeric_version);
res.compile().expect("Failed to compile Windows resources");
}
fn pack_version(version: &str) -> u64 {
let core = version.split('-').next().unwrap_or(version);
let mut parts = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
let major = parts.next().unwrap_or(0).min(u16::MAX as u64);
let minor = parts.next().unwrap_or(0).min(u16::MAX as u64);
let patch = parts.next().unwrap_or(0).min(u16::MAX as u64);
(major << 48) | (minor << 32) | (patch << 16)
}
@@ -0,0 +1,121 @@
# Phase 1: Bootstrap Repo
## Context Links
- Source `Cargo.toml`: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/Cargo.toml`
- Source `build.rs`: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/build.rs`
- Source icons: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/icons/`
## Overview
- **Priority:** Must-first (every other phase depends on this)
- **Status:** pending
- **Description:** Create the new repo's foundation: `Cargo.toml` with the right windows-rs features, `build.rs` for icon embedding, LICENSE (MIT, dual attribution), README with attribution to source, fresh icon assets, project layout.
## Key Insights
- Source uses `windows = 0.58` with 12 feature flags. New repo can **drop** `Win32_UI_Accessibility` (no more `SetWinEventHook` on TrayNotifyWnd) and **drop** `Win32_UI_Input_KeyboardAndMouse` if drag is handled via `WM_NCHITTEST` + `HTCAPTION` instead of manual `SetCapture`.
- Source uses `ureq` + `native-tls` + `serde` + `dirs` — all keep verbatim.
- Source release profile: `opt-level="z"`, `lto=true`, `strip=true`, `codegen-units=1`, `panic="abort"` — keep all (produces ~2MB binary).
- Package name change: `claude-code-usage-bubble` (binary name `claude-code-usage-bubble.exe`).
## Requirements
### Functional
- `cargo build --release` produces a single-file `.exe`
- `winres` embeds icon resource so the .exe has a proper Windows icon
- README clearly attributes original repo + MIT license
- Project compiles with no implementation yet (empty `main.rs` returning `()`)
### Non-functional
- Binary size target: < 3 MB stripped
- No Linux/Mac build (Windows-only `#![windows_subsystem]` in main)
## Architecture
```
claude-code-usage-bubble/
├── Cargo.toml
├── build.rs
├── LICENSE # MIT, with attribution clause
├── README.md # Attribution + usage
├── src/
│ ├── main.rs # entry, mod declarations only
│ └── icons/
│ ├── icon.ico
│ ├── 16x16.png, 32x32.png, 48x48.png, 256x256.png
│ └── *.svg sources
└── plans/ # this directory
```
## Related Code Files
**To create:**
- `Cargo.toml`
- `build.rs`
- `LICENSE`
- `README.md`
- `src/main.rs` (stub)
- `src/icons/*` (placeholder copies from source; can be replaced with bubble-specific art later)
- `.gitignore`
**To modify:** none (greenfield repo)
## Implementation Steps
1. **Create `Cargo.toml`** with package metadata, the same `windows-rs` features minus accessibility + keyboard/mouse:
```toml
[package]
name = "claude-code-usage-bubble"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Floating bubble showing Claude Code / Codex usage on Windows"
repository = "<set this>"
```
Features to include: `Win32_Foundation`, `Win32_Globalization`, `Win32_Graphics_Gdi`, `Win32_System_LibraryLoader`, `Win32_UI_Shell`, `Win32_UI_WindowsAndMessaging`, `Win32_System_Registry`, `Win32_System_Threading`, `Win32_Security`, `Win32_UI_HiDpi`.
2. **Create `build.rs`** mirroring source `build.rs`; embed `src/icons/icon.ico` via `winres`.
3. **Copy `src/icons/*`** from source verbatim (placeholder; designer can replace).
4. **Write `LICENSE`** — MIT text with a header line crediting CodeZeno/Claude-Code-Usage-Monitor.
5. **Write `README.md`** — short, includes:
- One-paragraph what-it-is
- Attribution: "This project ports usage-polling, updater, and tray-icon code from [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) (MIT)."
- Install/run section (placeholder)
6. **Stub `src/main.rs`**:
```rust
#![windows_subsystem = "windows"]
fn main() {}
```
7. **Run `cargo build`** — must compile clean.
8. **Run `cargo build --release`** — confirm binary produced, check size.
## Todo List
- [ ] Cargo.toml with correct features
- [ ] build.rs with winres
- [ ] Icons copied from source
- [ ] LICENSE with attribution
- [ ] README.md with attribution
- [ ] src/main.rs stub
- [ ] .gitignore (target/, Cargo.lock for libs only — keep Cargo.lock for binaries)
- [ ] `cargo build` succeeds
- [ ] `cargo build --release` succeeds, binary < 3 MB
## Success Criteria
- `cargo build --release` on Windows produces a runnable .exe with embedded icon
- README links source repo
- LICENSE includes attribution clause
## Risk Assessment
- **Low.** Pure configuration; no logic.
- Cross-compile note: if developer is on Linux/Mac, will need MinGW or Windows machine for `winres` step. Document this in README.
## Security Considerations
- N/A in this phase.
## Next Steps
→ Phase 2: port portable modules into `src/`
@@ -0,0 +1,124 @@
# Phase 2: Port Portable Modules
## Context Links
- Source: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/`
- Modules to port verbatim (or near-verbatim): `models.rs`, `diagnose.rs`, `theme.rs`, `poller.rs`, `updater.rs`, `tray_icon.rs`, `localization/*`
- Module to trim: `native_interop.rs`
## Overview
- **Priority:** High (must precede phases 3-4)
- **Status:** pending
- **Description:** Bring over the portable subsystems from the source repo with minimal changes. These represent ~2,700 lines of working, tested code — the goal is to keep them intact and only edit what's required for the new project name and the simplified Win32 surface.
## Key Insights
- `poller.rs` (1099 lines) is fully self-contained — depends only on `serde`, `ureq`, `native-tls`, `dirs`, plus `diagnose` and `models` from the same crate.
- `updater.rs` (510 lines) embeds `env!("CARGO_PKG_REPOSITORY")` to resolve GitHub owner/repo automatically — no hardcoded references.
- `tray_icon.rs` uses `WM_APP_TRAY` (`WM_APP + 3`) and `IDM_TOGGLE_WIDGET = 50`. Keep the constants; `app.rs` (phase 4) will own dispatch.
- `native_interop.rs` is the trimming target: drop `find_taskbar`, `find_child_window`, `get_taskbar_rect`, `embed_in_taskbar`, `set_tray_event_hook`, `get_window_thread_id`, `unhook_win_event`. Keep `wide_str`, `colorref`, `Color`, timer-ID constants, custom-message constants.
## Requirements
### Functional
- `mod models; mod diagnose; mod theme; mod poller; mod updater; mod tray_icon; mod localization; mod native_interop;` all compile against current `main.rs`
- `cargo check` passes with zero warnings beyond unused-symbol warnings (which will resolve in phases 3-4)
- All `pub` symbols documented above remain reachable
### Non-functional
- No behavioral changes vs source — diffs limited to module boundaries
## Architecture
```
src/
├── main.rs (stub from phase 1)
├── models.rs COPIED
├── diagnose.rs COPIED
├── theme.rs COPIED
├── poller.rs COPIED
├── updater.rs COPIED + 1-line stub for current_install_channel
├── tray_icon.rs COPIED
├── native_interop.rs TRIMMED (~80 lines vs source 179)
└── localization/
├── mod.rs COPIED
├── english.rs COPIED
├── dutch.rs COPIED
├── french.rs COPIED
├── german.rs COPIED
├── japanese.rs COPIED
├── korean.rs COPIED
├── spanish.rs COPIED
└── traditional_chinese.rs COPIED
```
## Related Code Files
**To create (copy from source):**
- All files listed in Architecture section above.
**To modify after copying:**
- `src/updater.rs` — replace `current_install_channel()` body with `InstallChannel::Portable` while keeping the rest of the function intact (preserves code for future winget enablement).
- `src/native_interop.rs` — delete taskbar/WinEvent functions and their imports.
- `src/main.rs` — declare modules; do NOT yet call `window::run` (window module doesn't exist yet).
**To delete:** none.
## Implementation Steps
1. **Copy modules verbatim:**
```
cp -r ../Claude-Code-Usage-Monitor/src/{models,diagnose,theme,poller,updater,tray_icon}.rs src/
cp -r ../Claude-Code-Usage-Monitor/src/localization src/
```
2. **Copy & trim `native_interop.rs`:**
- Keep: `wide_str`, `colorref`, `Color`, `TIMER_*` constants, `WM_APP_*` constants, `get_window_rect_safe`, `move_window`
- Delete: `WS_POPUP_STYLE`, `WS_CHILD_STYLE`, `WS_CLIPSIBLINGS_STYLE` (bubble uses standard windows-rs constants), `EVENT_OBJECT_LOCATIONCHANGE`, `WINEVENT_OUTOFCONTEXT`, `find_taskbar`, `find_child_window`, `get_taskbar_rect`, `embed_in_taskbar`, `set_tray_event_hook`, `get_window_thread_id`, `unhook_win_event`
- Drop imports for `Accessibility`, `Shell::SHAppBarMessage`/`APPBARDATA`, `Foundation::RECT` (if no longer used after trim)
3. **Stub winget detection in `updater.rs`:**
```rust
pub fn current_install_channel() -> InstallChannel {
// Bubble repo is not yet published to winget; once it is, restore the
// is_winget_install_path probe by reading the source repo's logic.
InstallChannel::Portable
}
```
Keep `is_winget_install_path` + `winget_install_roots` + `normalize_path` as `#[allow(dead_code)]` to preserve the code path.
4. **Update `src/main.rs`** to declare the modules:
```rust
#![windows_subsystem = "windows"]
mod diagnose; mod localization; mod models; mod native_interop;
mod poller; mod theme; mod tray_icon; mod updater;
fn main() { /* phase-04 will wire this */ }
```
5. **Run `cargo check`** — expect warnings about unused public items; should be zero errors.
## Todo List
- [ ] Copy 7 source modules + localization directory
- [ ] Trim `native_interop.rs` to ~80 lines (drop taskbar/WinEvent helpers)
- [ ] Stub `updater::current_install_channel`
- [ ] Wire `mod` declarations in `main.rs`
- [ ] `cargo check` clean (only dead-code warnings)
- [ ] `cargo build --release` still produces a binary
## Success Criteria
- All ported modules compile in the new crate without modification beyond what is listed above
- No warnings about missing imports
- Source code license headers (if any) are preserved
## Risk Assessment
- **Low.** Copy-with-rename operation; the trimming of `native_interop.rs` is the only judgment call.
- Edge case: `tray_icon.rs` imports `crate::native_interop::WM_APP_TRAY` — verify constant survives the trim.
## Security Considerations
- `poller.rs` reads OAuth credentials from `~/.claude/.credentials.json` and (optionally) WSL distros. No new attack surface vs source.
- `updater.rs` downloads .exe from GitHub. Same trust model as source. Until the new repo has releases published, this code is dormant.
## Next Steps
→ Phase 3: build the bubble window (replaces 2847 lines of `window.rs`)
@@ -0,0 +1,166 @@
# Phase 3: Build Floating Bubble Window
## Context Links
- Source `window.rs` painting + drag logic: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/window.rs` (lines around `UpdateLayeredWindow`, `WM_LBUTTONDOWN`, `SetCapture`)
- Reference UX: 360 Security floating ball, IObit Advanced SystemCare RAM-boost ball — both are circular, top-most, draggable-anywhere with edge snap.
## Overview
- **Priority:** Critical — this is the heart of the new UX
- **Status:** pending
- **Description:** Build a circular floating bubble window that floats on top of everything, can be dragged anywhere, snaps to monitor edges, and shows usage percentage in the center over a colored progress ring. Replaces the 2847-line `window.rs` taskbar embedding code.
## Key Insights
- Use `WS_POPUP | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW``TOOLWINDOW` keeps it out of Alt+Tab; `NOACTIVATE` prevents focus theft.
- Drag-anywhere = handle `WM_NCHITTEST`, return `HTCAPTION` for the entire bubble area. The OS handles drag automatically, including with proper cursor and Win+drag behavior. No need for `SetCapture`.
- Circular alpha mask: render to a DIB section with per-pixel alpha. Pixels outside the circle = `0x00000000` (fully transparent). Then `UpdateLayeredWindow` with `ULW_ALPHA`. Click-through outside the circle happens automatically because alpha=0 doesn't hit-test (default behavior of layered windows with `WS_EX_LAYERED` + per-pixel alpha; verify via test).
- Snap to edge: in `WM_EXITSIZEMOVE`, query current position via `GetWindowRect`, find nearest monitor via `MonitorFromPoint`, get its work area via `GetMonitorInfo`. If center is within 12px of any work-area edge, snap that edge.
- HiDPI: use `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` early in `main`. Query `GetDpiForWindow` on `WM_DPICHANGED` and rescale bubble size + font.
- GDI ring drawing: parametric — sweep angle proportional to percentage. Use `Polygon` filled with brush, or `AngleArc` with thick pen. For clean anti-aliased look on layered window, draw into DIB section manually with alpha-weighted line algorithm. Simpler path: use GDI+ via `gdiplus` crate, or accept GDI-aliased look for v1.
- Bubble size default: **56×56 px** at 100% DPI (matches reference apps). Allow user to tweak in `Cargo.toml` constant for v1.
## Requirements
### Functional
- Window appears as a circular bubble, can be dragged anywhere on any monitor
- Bubble shows percentage text (e.g. "73%") in center
- Colored progress ring around the percentage; color matches the source app's color stops (orange → red gradient from 50% to 100%)
- Top-most: stays visible over other windows
- No taskbar entry, no Alt-Tab entry
- Left-click → posts `WM_APP_PANEL_TOGGLE` to self (panel implementation in phase 4)
- Right-click → context menu (phase 4 owns menu items; bubble owns the right-click detection)
- Drag releases → snap to nearest monitor edge if within 12 px of it
- Repaints when percentage / theme / DPI changes
### Non-functional
- 60 FPS not required; redraws on data update only (every 60s poll cycle plus countdown ticks)
- Per-monitor DPI aware
- Visible on dark and light Windows themes (use `theme::is_dark_mode` for ring background tint)
## Architecture
```
src/
├── bubble.rs NEW — Window class, message loop owner, GDI painting,
│ drag + snap, DPI, hit-testing
└── (other modules unchanged from phase 2)
```
Bubble owns:
- HWND lifecycle (`RegisterClassExW` + `CreateWindowExW`)
- DIB section + `UpdateLayeredWindow` call
- Percentage state (`Option<f64>` for each enabled model)
- Drag state (managed by OS via `HTCAPTION`)
- Snap math
- DPI scale factor cache
Bubble delegates:
- Polling → `poller::poll` (phase 4 wires the background thread)
- Panel toggle → `app::on_panel_toggle` (phase 4)
- Right-click menu → `app::on_show_context_menu` (phase 4)
- Settings → `settings` module (phase 4)
## Related Code Files
**To create:**
- `src/bubble.rs` (target: 400700 lines)
**To modify:**
- `src/main.rs` — eventually call `bubble::run()` (wired in phase 4)
- `src/native_interop.rs` — may add helpers if hit-testing geometry math gets gnarly
**To delete:** none.
## Implementation Steps
1. **Window class registration:**
- Class name: `ClaudeCodeUsageBubble`
- Style: `CS_DBLCLKS` (allow `WM_LBUTTONDBLCLK` if we want double-click later)
- WndProc: `bubble_wnd_proc`
2. **Window creation:**
- `WS_POPUP`, ext `WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW`
- Initial position: load from settings (phase 4); fall back to "near bottom-right corner of primary monitor"
- Size: 56×56 logical px scaled by current DPI
3. **DPI awareness:**
- In `bubble::run`, call `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)`.
- On `WM_CREATE`, cache DPI via `GetDpiForWindow`.
- On `WM_DPICHANGED`, update scale and resize.
4. **Painting (the hard part):**
- On every state change (percentage, DPI, theme), call `redraw()`.
- `redraw()`:
- Create DIB section sized to bubble pixel dimensions (`CreateDIBSection` with `BI_RGB` and 32bpp).
- Clear to fully transparent (`0x00000000`).
- For each pixel inside the circle radius, write background fill (theme-adjusted: dark theme → semi-opaque dark with high alpha; light theme → semi-opaque white).
- Stroke progress ring: for the sweep angle proportional to current percentage, draw a thick arc using either GDI `AngleArc` with rounded `Pen`, or manual pixel writes (4 px ring thickness at 100% DPI).
- Draw percentage text in center via `DrawTextW` with `DT_CENTER | DT_VCENTER | DT_SINGLELINE`. Font: bold 14 pt at 100% DPI, scaled by DPI factor.
- Call `UpdateLayeredWindow` with `ULW_ALPHA` and the DIB.
5. **Drag-anywhere via `WM_NCHITTEST`:**
```rust
WM_NCHITTEST => {
// Convert lparam (screen coords) to client coords
let p = screen_to_client(hwnd, lparam);
if inside_circle(p, radius) { LRESULT(HTCAPTION as isize) }
else { LRESULT(HTTRANSPARENT as isize) }
}
```
OS handles drag + cursor. `HTTRANSPARENT` outside the circle ensures clicks pass through.
6. **Snap on drag release:**
- `WM_EXITSIZEMOVE` → snap logic.
- `MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)` → `GetMonitorInfo` → work area rect.
- Compare bubble's center to each edge of work area. If distance < 12 logical px (scaled by DPI), adjust window position to snap.
- Persist new position via `app::on_bubble_moved(model, x, y)` (phase 4).
7. **Click handling:**
- `WM_LBUTTONUP` → if no drag occurred (compare with `WM_LBUTTONDOWN` position), `PostMessageW(WM_APP_PANEL_TOGGLE)`.
- `WM_RBUTTONUP` → call into `app::show_context_menu(hwnd, screen_pos)` (phase 4 implements).
8. **Public API:**
```rust
pub fn run(initial: BubbleConfig) -> ! { /* never returns; spins message loop */ }
pub struct BubbleConfig {
pub model: TrayIconKind, // Claude or Codex
pub initial_position: Option<(i32, i32)>,
pub initial_percentage: Option<f64>,
}
pub fn update_percentage(hwnd: HWND, percentage: Option<f64>); // called from poll thread via PostMessage
```
For dual-bubble mode, phase 4 spawns one `bubble::run` per enabled model on separate threads (each with its own message loop) — simpler than juggling two HWNDs in one thread.
## Todo List
- [ ] Window class + creation with correct styles
- [ ] Per-monitor DPI awareness on entry
- [ ] DIB section + layered window painting pipeline
- [ ] Circle fill with theme-aware background
- [ ] Progress ring painted at correct sweep angle, correct color stop
- [ ] Percentage text drawn centered
- [ ] `WM_NCHITTEST` returns `HTCAPTION` inside circle, `HTTRANSPARENT` outside
- [ ] Drag works smoothly across monitors
- [ ] Snap on release within 12px of work-area edge
- [ ] Left-click (no drag) posts panel-toggle message
- [ ] Right-click posts context-menu request
- [ ] Public API `run`, `update_percentage`
- [ ] Manual test: bubble visible, draggable, snaps, percentage updates
## Success Criteria
- Bubble appears on Windows 10/11 with a Visual Studio-clean cargo build
- Drag works smoothly with no flicker
- Edge snap engages reliably from 12 px
- Bubble survives display reconnection (laptop → external monitor → unplug)
- Percentage text remains crisp on 100%, 125%, 150%, 175% DPI
## Risk Assessment
- **High** — this is novel code with no exact analog in source.
- Risk: ClearType sub-pixel text rendering on a per-pixel-alpha layered window looks bad. Source repo's `window.rs` solved this with a black background-pixel hack (`alpha = 0x01` so it's nearly transparent but still gets ClearType). Apply the same trick for the circle's interior fill region.
- Risk: GDI `AngleArc` doesn't anti-alias. Mitigation: either accept aliased v1, or render to a 2x supersampled DIB and downsample.
- Risk: Snap math wrong on rotated taskbar or unusual DPI configurations. Mitigation: clamp to monitor work area only, ignore taskbar position.
## Security Considerations
- N/A in this phase; bubble does not handle user input beyond mouse position and clicks.
## Next Steps
→ Phase 4: expanded panel, settings persistence, polling thread, orchestration
@@ -0,0 +1,190 @@
# Phase 4: Expanded Panel + Settings Persistence + Orchestration
## Context Links
- Source `window.rs` — borrow message-loop dispatch, polling-thread orchestration, settings persistence pattern
- Source `poller.rs``poll`, `credential_watch_snapshot`, `format_line`, `time_until_display_change`
- Source `tray_icon.rs``add/update/remove/sync`, `handle_message`
## Overview
- **Priority:** High
- **Status:** pending
- **Description:** Build (a) the expanded panel that appears on bubble click and shows both 5h and 7d bars with countdowns, (b) settings persistence to `%APPDATA%\ClaudeCodeUsageBubble\settings.json`, (c) the orchestrating `app.rs` module that owns polling, message routing, context menus, and dual-bubble lifecycle.
## Key Insights
- Panel is a separate window: `WS_POPUP | WS_EX_LAYERED | WS_EX_TOPMOST`, opaque background, shown adjacent to bubble. Source's draw code for the horizontal bars can be ported almost directly (it already draws progress bars + countdown text via GDI).
- Settings file location matches source pattern: `%APPDATA%\ClaudeCodeUsageBubble\settings.json` (renamed dir). Use `dirs::config_dir()`.
- Polling: background `std::thread` spawned in `app::run`. Posts `WM_APP_USAGE_UPDATED` to each bubble window when data refreshes. Source's poll-loop logic is copy-friendly.
- Context menu: built via `CreatePopupMenu` + `AppendMenuW` + `TrackPopupMenu`. Source has the full menu structure — port it but remove "Reset position" → rename to "Reset bubble position" (per model).
- Dual-bubble: each enabled model gets its own HWND + tray icon + bubble window. Settings stores `bubble_positions: { claude: {x, y}, codex: {x, y} }`.
## Requirements
### Functional
- Settings persist across restarts: window positions, polling frequency, enabled models, language, "Start with Windows" state, last update check
- Expanded panel: shows session bar + weekly bar + countdowns + reset times for the model whose bubble was clicked
- Panel auto-closes on focus loss or after a brief timeout (optional)
- Right-click menu mirrors source's menu structure: Refresh, Models, Update frequency, Language, Start with Windows, Reset position, Updates, Exit
- Single-instance enforced via named mutex `Global\ClaudeCodeUsageBubble`
- Polling runs in background, posts updates via `PostMessageW(WM_APP_USAGE_UPDATED, ...)`
- Countdown timer adapts to display granularity (`time_until_display_change`)
### Non-functional
- Settings file is atomically written (write to `.tmp`, rename)
- Polling thread cannot block UI thread
- Mutex released on clean shutdown
## Architecture
```
src/
├── app.rs NEW — orchestrator: spawns bubbles, polls, routes messages,
│ owns tray icons, owns context menu builder
├── panel.rs NEW — expanded panel window (one per model on demand)
├── settings.rs NEW — load/save settings.json, schema
└── main.rs modified — calls app::run
```
Message flow:
```
poll thread ────PostMessage(WM_APP_USAGE_UPDATED)──▶ bubble HWND
└─▶ updates percentage, redraws
bubble click ──PostMessage(WM_APP_PANEL_TOGGLE)─▶ app handler (in bubble wndproc)
└─▶ panel::show_for(model)
right-click ──app::show_context_menu(hwnd)──▶ TrackPopupMenu ─▶ WM_COMMAND
└─▶ menu action dispatch
tray icon ────WM_APP_TRAY───────────▶ tray_icon::handle_message ─▶ TrayAction
└─▶ toggle/ shutdown / refresh
```
## Related Code Files
**To create:**
- `src/app.rs` (target: 500800 lines)
- `src/panel.rs` (target: 300500 lines)
- `src/settings.rs` (target: 150250 lines)
**To modify:**
- `src/main.rs`:
```rust
#![windows_subsystem = "windows"]
mod app; mod bubble; mod diagnose; mod localization; mod models;
mod native_interop; mod panel; mod poller; mod settings; mod theme;
mod tray_icon; mod updater;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--diagnose") {
if let Ok(path) = diagnose::init() {
diagnose::log(format!("startup args={args:?} log_path={}", path.display()));
}
}
if let Some(exit_code) = updater::handle_cli_mode(&args) {
std::process::exit(exit_code);
}
app::run();
}
```
**To delete:** none.
## Implementation Steps
1. **`settings.rs`** — define schema:
```rust
#[derive(Serialize, Deserialize, Default)]
pub struct Settings {
pub show_claude_code: bool, // default true
pub show_codex: bool, // default false
pub bubble_positions: BubblePositions,
pub poll_minutes: u32, // default 5
pub language: Option<String>, // None = system
pub start_with_windows: bool,
pub last_update_check_unix: Option<i64>,
}
#[derive(Serialize, Deserialize, Default)]
pub struct BubblePositions {
pub claude: Option<(i32, i32)>,
pub codex: Option<(i32, i32)>,
}
pub fn load() -> Settings { /* dirs::config_dir + read + serde + atomic */ }
pub fn save(s: &Settings) { /* write .tmp + rename */ }
```
2. **`panel.rs`** — port the existing horizontal-bar painting code from source `window.rs`:
- Window class `ClaudeCodeUsageBubblePanel`
- On `WM_LBUTTONDOWN` outside → close
- On `WM_KILLFOCUS` → close (with debounce so it doesn't close instantly when bubble is clicked again to toggle off)
- Paints two rows (5h, 7d) for the model that was clicked; uses `poller::format_line` for the countdown text
- Position: anchor next to bubble; flip side if would go off-screen
3. **`app.rs`** — orchestrator:
- `pub fn run() -> !`:
1. Acquire single-instance mutex; if already running, exit.
2. `SetProcessDpiAwarenessContext`.
3. Load settings.
4. Resolve language (`localization::resolve_language`).
5. Start polling thread.
6. For each enabled model, spawn a bubble window thread.
7. Run main message loop on UI thread (the bubble windows can be on the same thread — easier than multi-thread message pumps).
- Polling thread:
```rust
loop {
match poller::poll(show_claude, show_codex) {
Ok(data) => post_update_to_bubbles(data),
Err(e) => post_error_to_app(e),
}
sleep(poll_interval);
}
```
- Context menu builder: replicate source's menu structure verbatim; localized strings via `Strings`. Map `WM_COMMAND` IDs to handlers (refresh, toggle model, change interval, etc.).
- "Start with Windows": registry write to `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run\ClaudeCodeUsageBubble`.
- Token-expired flow: if `PollError::TokenExpired`, show tray balloon via `tray_icon::notify_balloon` with localized title/body.
4. **Wire `bubble.rs` events back to `app.rs`:**
- `WM_APP_PANEL_TOGGLE` handler in bubble wndproc → call `panel::show_for_model(model)` (registered via callback or via `app::handle_panel_toggle`).
- `WM_RBUTTONUP` → call `app::show_context_menu_at(point, model)`.
- `WM_EXITSIZEMOVE` → call `app::on_bubble_moved(model, x, y)` which updates settings.
5. **Reuse `tray_icon.rs`** as the source-app's secondary indicator:
- In `app::run`, after creating bubbles, call `tray_icon::sync(hwnd, &[TrayIconData{kind: Claude, percent: …, tooltip: …}, …])`.
- Tray icon clicked → `tray_icon::handle_message` → if `ToggleWidget`, toggle bubble visibility (set `WS_VISIBLE` style); if `ShowContextMenu`, dispatch to `app::show_context_menu_at`.
## Todo List
- [ ] `settings.rs` with atomic save
- [ ] `panel.rs` with bar painting + auto-close
- [ ] `app.rs` orchestrator
- [ ] Single-instance mutex acquisition + release
- [ ] Polling thread spawning + `PostMessage` updates
- [ ] Context menu localized + dispatchable
- [ ] Start-with-Windows registry roundtrip
- [ ] Tray icons synced from polling updates
- [ ] Bubble-to-app event callbacks wired
- [ ] Dual-bubble mode tested (both Claude + Codex enabled)
- [ ] `cargo build --release` → working binary
## Success Criteria
- Launching the app twice: second launch exits silently
- Bubble + panel + tray icon all show correct usage after first poll
- Right-click menu functional for all items
- Toggle Claude/Codex via menu: bubbles appear/disappear; settings persist
- Restart app: bubble reappears at last saved position
- Token-expired triggers tray balloon
- Poll frequency change takes effect within one poll cycle
## Risk Assessment
- **Medium.** Most logic is structural — message routing and state management. Source repo has all the patterns.
- Risk: dual-bubble on same UI thread with two HWNDs — should work, but verify message routing keys off `hwnd` parameter.
- Risk: panel auto-close races with bubble re-click. Mitigation: 200 ms debounce on `WM_KILLFOCUS` before destroying panel; if bubble clicked within that window, cancel close.
## Security Considerations
- Settings file written to `%APPDATA%`, user-scoped, no privileged ops.
- Single-instance mutex name `Global\ClaudeCodeUsageBubble` — distinct from source app to allow coexistence.
## Next Steps
→ Phase 5: polish (HiDPI testing, multi-monitor, README, attribution, etc.)
@@ -0,0 +1,120 @@
# Phase 5: Polish & Finishing
## Context Links
- All prior phases
- Source README for attribution / language list
## Overview
- **Priority:** Required before any release
- **Status:** pending
- **Description:** Multi-monitor and HiDPI verification, accessibility checks, README, license attribution, version 0.1.0 tag, optional CI workflow.
## Requirements
### Functional
- Bubble renders crisply at 100% / 125% / 150% / 175% / 200% DPI
- Bubble survives display add/remove (laptop dock/undock)
- Bubble respects monitor work area when snapping (does not overlap taskbar)
- README has install + run + uninstall sections
- LICENSE has CodeZeno attribution paragraph
- `--diagnose` flag works (writes `%TEMP%\claude-code-usage-bubble.log`)
### Non-functional
- Cargo build is fully reproducible
- No clippy warnings on `cargo clippy -- -D warnings` (or document the ones you keep)
## Architecture
No new modules.
## Related Code Files
**To modify:**
- `README.md` — fill out final content
- `LICENSE` — final attribution paragraph
- Maybe `.github/workflows/build.yml` for CI Windows build
**To delete:** none.
## Implementation Steps
1. **HiDPI manual test matrix:**
- Windows 10 at 100% DPI: bubble visible, text readable, ring smooth
- Windows 11 at 150%: same
- 4K monitor at 200%: same
- Mixed-DPI dual monitor: drag bubble between monitors → verify rescale on `WM_DPICHANGED`
2. **Multi-monitor edge tests:**
- Snap bubble to right edge of secondary monitor → settings saved with correct coords
- Disconnect monitor → bubble should reposition to primary monitor's work area on next start
- Test with taskbar on top / left / right (not just default bottom)
3. **README content checklist:**
- One-paragraph what-it-is + screenshot/gif placeholder
- **Attribution section** (required by source MIT license):
> This project is a derivative of [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) (MIT, © 2026 Code Zeno Pty Ltd). The usage-polling, updater, tray-icon, and localization modules are ported from that codebase with minor adaptations; the floating-bubble UI is original to this project.
- Install: cargo build instructions; future winget block
- Use: bubble + panel + tray icon described
- Models: same content as source
- Diagnostics: `--diagnose` flag, log path
- Privacy: same content as source (credentials read locally, GitHub for updates)
- License: MIT
4. **LICENSE file** — include both:
```
MIT License
Copyright (c) 2026 <your name>
Portions of this software are derived from Claude Code Usage Monitor,
Copyright (c) 2026 Code Zeno Pty Ltd, licensed under the MIT License.
<rest of MIT license text>
```
5. **Optional CI** (`.github/workflows/build.yml`):
- Runs `cargo fmt --check`, `cargo clippy`, `cargo build --release` on `windows-latest`
- Uploads artifact on tag
6. **Smoke test before tagging:**
- Run `claude-code-usage-bubble.exe`
- Verify bubble appears with placeholder data (or real data if Claude CLI signed in)
- Drag, snap, expand, menu, exit — all work
- Re-launch → second instance exits silently
- `claude-code-usage-bubble.exe --diagnose` → log file populated
7. **Tag v0.1.0** (only after the above passes):
- `git tag v0.1.0`
- Push to GitHub
- Create release with the .exe artifact attached (so `updater.rs` works for future versions)
## Todo List
- [ ] HiDPI matrix tested
- [ ] Multi-monitor edge tests done
- [ ] README.md final
- [ ] LICENSE attribution finalized
- [ ] Diagnostic log verified
- [ ] Clippy clean
- [ ] CI workflow (optional)
- [ ] Smoke test green
- [ ] v0.1.0 tagged
## Success Criteria
- App can be downloaded fresh, built once, and used end-to-end
- Source repo attribution is unambiguous
- No regressions vs phases 1-4
## Risk Assessment
- **Low.** Polish phase.
- Possible regression: HiDPI bug discovered late — fix in `bubble.rs` painting code.
## Security Considerations
- Verify `updater.rs` `current_install_channel()` still returns `Portable`. Re-enabling winget detection is a future task — not part of v0.1.0.
## Next Steps
→ v0.1.0 release; future tasks (out of scope for this port):
- Winget package submission (when ready)
- Custom bubble art per model (Claude orange, Codex green) replacing inherited icons
- Optional bubble-size setting (S/M/L) in right-click menu
- Auto-hide when fullscreen apps active
@@ -0,0 +1,83 @@
# Plan: claude-code-usage-bubble — port from CodeZeno/Claude-Code-Usage-Monitor
**Mode:** `/ck:xia --port`
**Source repo:** `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor` (Rust ~5.8k LOC, MIT)
**Target repo:** `/config/workspace/CodeZeno/claude-code-usage-bubble` (new)
**Date:** 2026-05-15
## Source Manifest
- Path: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor`
- Branch: `main` @ `b5f038d` (v1.4.1)
- License: MIT (attribution required in new repo README)
- Scope: portable subsystems only — see `phase-02-port-portable-modules.md`
## Decision Matrix (Approved)
| Decision | Choice |
|---|---|
| Platform | Windows-only (Win32 GDI + layered window) |
| WSL credential reading | Keep |
| Snap-to-edge | On, 12px zone, monitor work area |
| Click behavior | Left-click = toggle panel, right-click = menu |
| Dual-model layout | Two independent bubbles, positions persisted per model |
| Winget channel | Code kept, `current_install_channel` stubbed to Portable |
| Single-instance mutex | `Global\ClaudeCodeUsageBubble` |
| Auto-hide when fullscreen | **Yes** (added to phase 3) — detect via `SHQueryUserNotificationState` or `MonitorFromWindow + window-rect == monitor-rect` against foreground HWND |
| Bubble size customization | **Yes**, free range 32128 px persisted in settings.json; resize via Ctrl+MouseWheel on bubble (no S/M/L menu) |
| Per-model bubble art | **No** — both models share the same bubble look; differentiation only via usage-percentage ring color |
## Dependency Matrix (Source → New)
| Source file | LOC | Action | Target file |
|---|---|---|---|
| `src/models.rs` | 19 | COPY | `src/models.rs` |
| `src/diagnose.rs` | 52 | COPY | `src/diagnose.rs` |
| `src/theme.rs` | 52 | COPY | `src/theme.rs` |
| `src/poller.rs` | 1099 | COPY | `src/poller.rs` |
| `src/updater.rs` | 510 | COPY + stub channel | `src/updater.rs` |
| `src/tray_icon.rs` | 441 | COPY | `src/tray_icon.rs` |
| `src/localization/*` | ~620 | COPY | `src/localization/*` |
| `src/native_interop.rs` | 179 | ADAPT (drop taskbar/WinEvent helpers) | `src/native_interop.rs` |
| `src/main.rs` | 40 | ADAPT (call `bubble::run` instead of `window::run`, rename single-instance mutex) | `src/main.rs` |
| `src/window.rs` | 2847 | **REWRITE** as `bubble.rs` + `panel.rs` + `settings.rs` + `app.rs` | NEW |
| `build.rs`, `Cargo.toml`, `src/icons/*` | — | ADAPT | NEW |
## Phases
| # | Phase | Status | File |
|---|---|---|---|
| 1 | Bootstrap repo (Cargo.toml, build.rs, LICENSE, README, icons) | pending | `phase-01-bootstrap-repo.md` |
| 2 | Port portable modules verbatim | pending | `phase-02-port-portable-modules.md` |
| 3 | Build floating bubble window (layered alpha, GDI ring, drag-anywhere, snap) | pending | `phase-03-build-bubble-window.md` |
| 4 | Build expanded panel + settings persistence + orchestration | pending | `phase-04-panel-and-orchestration.md` |
| 5 | Polish: HiDPI, multi-monitor, startup registry, mutex, tray icon wiring, README | pending | `phase-05-polish-and-finishing.md` |
## Risk Score
**Medium.** Highest-risk surface is **phase 3** — circular layered window with HiDPI-aware GDI ring drawing. Source codebase has no precedent for that exact pattern; needs fresh implementation. All other phases are straightforward ports or thin orchestration.
| Risk | Severity | Mitigation |
|---|---|---|
| GDI ring + ClearType text on layered alpha window | High | Reference `window.rs` UpdateLayeredWindow + DIB section pattern (lines around layered painting); keep ring math simple (parametric arc) |
| Drag + snap interaction on multi-monitor | Medium | Use `MonitorFromPoint` per move; clamp to nearest monitor work area |
| Two-bubble position state | Low | Independent `BubbleState` structs in settings.json |
| WSL credential read regressions | Low | Verbatim port; no behavioral changes |
## Estimated Effort
- Phase 1: 12h
- Phase 2: 1h (mostly file copies + import path fixes)
- Phase 3: 610h (the heavy lift)
- Phase 4: 35h
- Phase 5: 24h
**Total:** ~1522h of focused implementation.
## Rollback Strategy
The new repo is greenfield — rollback means `rm -rf /config/workspace/CodeZeno/claude-code-usage-bubble`. No source-repo changes; this plan does not modify the source app.
## Open Questions
- None. All three formerly-deferred items resolved by user on 2026-05-15 (see Decision Matrix rows 810).
+1354
View File
File diff suppressed because it is too large Load Diff
+817
View File
@@ -0,0 +1,817 @@
// Floating circular bubble window.
//
// Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE.
// Shape is achieved via per-pixel alpha (alpha=0 outside the circle) and confirmed
// via WM_NCHITTEST returning HTCAPTION inside the circle, HTTRANSPARENT outside.
// The OS handles drag automatically because HTCAPTION inside the circle puts the
// click into the system move loop.
use std::collections::HashMap;
use std::ffi::c_void;
use std::sync::{Mutex, MutexGuard, OnceLock};
use windows::core::PCWSTR;
use windows::Win32::Foundation::*;
use windows::Win32::Graphics::Gdi::*;
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::HiDpi::*;
use windows::Win32::UI::Shell::ExtractIconExW;
use windows::Win32::UI::WindowsAndMessaging::*;
use crate::diagnose;
use crate::native_interop::{wide_str, Color, TIMER_FULLSCREEN_CHECK};
use crate::tray_icon::TrayIconKind;
// ---------- Public types & API ----------
pub const MIN_BUBBLE_SIZE: i32 = 32;
pub const MAX_BUBBLE_SIZE: i32 = 128;
pub const DEFAULT_BUBBLE_SIZE: i32 = 56;
const RESIZE_STEP: i32 = 8;
const SNAP_ZONE_LOGICAL: i32 = 12;
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
const FULLSCREEN_POLL_MS: u32 = 1500;
pub struct BubbleConfig {
pub model: TrayIconKind,
pub size_logical: i32,
pub position: Option<(i32, i32)>,
pub percent: Option<f64>,
pub is_dark: bool,
}
/// Register the bubble window class. Idempotent; safe to call before the first
/// `create()` from the UI thread.
pub fn register_class() {
static REGISTERED: OnceLock<()> = OnceLock::new();
REGISTERED.get_or_init(|| unsafe {
let class_w = wide_str(CLASS_NAME);
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(wnd_proc),
hInstance: HINSTANCE(hinstance.0),
hCursor: LoadCursorW(HINSTANCE::default(), IDC_SIZEALL).unwrap_or_default(),
hbrBackground: HBRUSH(std::ptr::null_mut()),
lpszClassName: PCWSTR::from_raw(class_w.as_ptr()),
..Default::default()
};
if RegisterClassExW(&wc) == 0 {
diagnose::log("bubble RegisterClassExW returned 0");
}
});
}
/// Create a bubble window. Returns the HWND. The caller (app::run) owns the
/// message-loop dispatch.
pub fn create(config: BubbleConfig) -> HWND {
register_class();
let initial_size_logical = config
.size_logical
.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
let hwnd = unsafe {
let class_w = wide_str(CLASS_NAME);
let title_w = wide_str("Claude Code Usage Bubble");
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
let dpi = primary_dpi();
let size_px = scale_to_dpi(initial_size_logical, dpi);
let (x, y) =
config
.position
.unwrap_or_else(|| default_position(size_px, config.model));
CreateWindowExW(
WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE,
PCWSTR::from_raw(class_w.as_ptr()),
PCWSTR::from_raw(title_w.as_ptr()),
WS_POPUP,
x,
y,
size_px,
size_px,
HWND::default(),
HMENU::default(),
hinstance,
None,
)
.unwrap_or_default()
};
if hwnd == HWND::default() {
diagnose::log("bubble CreateWindowExW failed");
return hwnd;
}
// Embed app icon in window non-client (mostly cosmetic; toolwindows
// don't show captions but the icon helps in dev tooling).
unsafe {
let mut large_icon = HICON::default();
let mut small_icon = HICON::default();
let mut exe = [0u16; 260];
GetModuleFileNameW(HMODULE::default(), &mut exe);
let _ = ExtractIconExW(
PCWSTR::from_raw(exe.as_ptr()),
0,
Some(&mut large_icon),
Some(&mut small_icon),
1,
);
if !large_icon.is_invalid() {
let _ = SendMessageW(
hwnd,
WM_SETICON,
WPARAM(ICON_BIG as usize),
LPARAM(large_icon.0 as isize),
);
}
if !small_icon.is_invalid() {
let _ = SendMessageW(
hwnd,
WM_SETICON,
WPARAM(ICON_SMALL as usize),
LPARAM(small_icon.0 as isize),
);
}
}
let dpi = unsafe { GetDpiForWindow(hwnd).max(96) };
lock_bubbles().insert(
hwnd.0 as isize,
BubbleState {
model: config.model,
size_logical: initial_size_logical,
dpi,
percent: config.percent,
is_dark: config.is_dark,
drag_start_pos: None,
hidden_by_fullscreen: false,
user_hidden: false,
},
);
render(hwnd);
unsafe {
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
// Periodic fullscreen-foreground check.
SetTimer(hwnd, TIMER_FULLSCREEN_CHECK, FULLSCREEN_POLL_MS, None);
}
hwnd
}
pub fn destroy(hwnd: HWND) {
unsafe {
let _ = KillTimer(hwnd, TIMER_FULLSCREEN_CHECK);
let _ = DestroyWindow(hwnd);
}
}
pub fn update_percentage(hwnd: HWND, percent: Option<f64>) {
{
let mut bubbles = lock_bubbles();
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
return;
};
b.percent = percent;
}
render(hwnd);
}
pub fn update_dark_mode(hwnd: HWND, is_dark: bool) {
{
let mut bubbles = lock_bubbles();
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
return;
};
b.is_dark = is_dark;
}
render(hwnd);
}
pub fn set_user_visible(hwnd: HWND, visible: bool) {
{
let mut bubbles = lock_bubbles();
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
return;
};
b.user_hidden = !visible;
}
unsafe {
let cmd = if visible { SW_SHOWNOACTIVATE } else { SW_HIDE };
let _ = ShowWindow(hwnd, cmd);
}
}
pub fn position(hwnd: HWND) -> Option<(i32, i32)> {
let mut r = RECT::default();
unsafe {
if GetWindowRect(hwnd, &mut r).is_err() {
return None;
}
}
Some((r.left, r.top))
}
pub fn model(hwnd: HWND) -> Option<TrayIconKind> {
lock_bubbles()
.get(&(hwnd.0 as isize))
.map(|b| b.model)
}
pub fn size_logical(hwnd: HWND) -> Option<i32> {
lock_bubbles()
.get(&(hwnd.0 as isize))
.map(|b| b.size_logical)
}
// ---------- State ----------
struct BubbleState {
model: TrayIconKind,
size_logical: i32,
dpi: u32,
percent: Option<f64>,
is_dark: bool,
drag_start_pos: Option<(i32, i32)>,
hidden_by_fullscreen: bool,
user_hidden: bool,
}
fn bubbles() -> &'static Mutex<HashMap<isize, BubbleState>> {
static BUBBLES: OnceLock<Mutex<HashMap<isize, BubbleState>>> = OnceLock::new();
BUBBLES.get_or_init(|| Mutex::new(HashMap::new()))
}
fn lock_bubbles() -> MutexGuard<'static, HashMap<isize, BubbleState>> {
bubbles().lock().expect("bubble state mutex poisoned")
}
// ---------- Window proc ----------
unsafe extern "system" fn wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
WM_NCHITTEST => hit_test(hwnd, lparam),
WM_ENTERSIZEMOVE => {
let mut r = RECT::default();
let _ = GetWindowRect(hwnd, &mut r);
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
b.drag_start_pos = Some((r.left, r.top));
}
LRESULT(0)
}
WM_EXITSIZEMOVE => {
// WM_NCLBUTTONUP isn't reliably delivered for HTCAPTION drags; instead
// we infer click-vs-drag from whether the window actually moved.
let start = {
let mut bubbles = lock_bubbles();
let start = bubbles
.get(&(hwnd.0 as isize))
.and_then(|b| b.drag_start_pos);
if let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) {
b.drag_start_pos = None;
}
start
};
let mut current = RECT::default();
let _ = GetWindowRect(hwnd, &mut current);
let moved = match start {
Some((sx, sy)) => (current.left - sx).abs() >= 3 || (current.top - sy).abs() >= 3,
None => false,
};
if moved {
snap_to_edge(hwnd);
if let Some(model) = model(hwnd) {
if let Some(pos) = position(hwnd) {
crate::app::on_bubble_moved(model, pos);
}
}
} else if let Some(model) = model(hwnd) {
crate::app::on_bubble_click(hwnd, model);
}
LRESULT(0)
}
WM_NCRBUTTONUP => {
if let Some(model) = model(hwnd) {
let pt = lparam_to_point(lparam);
crate::app::on_bubble_right_click(hwnd, model, pt);
}
LRESULT(0)
}
WM_MOUSEWHEEL => {
let modifiers = (wparam.0 & 0xFFFF) as u32;
const MK_CONTROL: u32 = 0x0008;
if modifiers & MK_CONTROL != 0 {
let delta = ((wparam.0 >> 16) & 0xFFFF) as i16;
let step = if delta > 0 { RESIZE_STEP } else { -RESIZE_STEP };
resize_step(hwnd, step);
LRESULT(0)
} else {
DefWindowProcW(hwnd, msg, wparam, lparam)
}
}
WM_DPICHANGED => {
let new_dpi = ((wparam.0 >> 16) & 0xFFFF) as u32;
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
b.dpi = new_dpi;
}
let rect_ptr = lparam.0 as *const RECT;
if !rect_ptr.is_null() {
let r = *rect_ptr;
let _ = SetWindowPos(
hwnd,
HWND::default(),
r.left,
r.top,
r.right - r.left,
r.bottom - r.top,
SWP_NOZORDER | SWP_NOACTIVATE,
);
}
render(hwnd);
LRESULT(0)
}
WM_TIMER => {
if wparam.0 == TIMER_FULLSCREEN_CHECK {
check_fullscreen(hwnd);
}
LRESULT(0)
}
WM_COMMAND => {
crate::app::on_menu_command(wparam.0 as u32, hwnd);
LRESULT(0)
}
WM_DESTROY => {
lock_bubbles().remove(&(hwnd.0 as isize));
LRESULT(0)
}
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}
fn hit_test(hwnd: HWND, lparam: LPARAM) -> LRESULT {
let pt = lparam_to_point(lparam);
let mut r = RECT::default();
unsafe {
if GetWindowRect(hwnd, &mut r).is_err() {
return LRESULT(HTNOWHERE as isize);
}
}
let cx = (r.left + r.right) / 2;
let cy = (r.top + r.bottom) / 2;
let radius = ((r.right - r.left) / 2).max(1);
let dx = pt.x - cx;
let dy = pt.y - cy;
if dx * dx + dy * dy <= radius * radius {
LRESULT(HTCAPTION as isize)
} else {
LRESULT(HTTRANSPARENT as isize)
}
}
fn lparam_to_point(lparam: LPARAM) -> POINT {
let lo = (lparam.0 & 0xFFFF) as i16 as i32;
let hi = ((lparam.0 >> 16) & 0xFFFF) as i16 as i32;
POINT { x: lo, y: hi }
}
// ---------- Resize / snap ----------
fn resize_step(hwnd: HWND, delta: i32) {
let (new_logical, dpi) = {
let mut bubbles = lock_bubbles();
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
return;
};
let new_logical = (b.size_logical + delta).clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
if new_logical == b.size_logical {
return;
}
b.size_logical = new_logical;
(new_logical, b.dpi)
};
let size_px = scale_to_dpi(new_logical, dpi);
let mut r = RECT::default();
unsafe {
let _ = GetWindowRect(hwnd, &mut r);
// Resize centered on existing center.
let cx = (r.left + r.right) / 2;
let cy = (r.top + r.bottom) / 2;
let new_x = cx - size_px / 2;
let new_y = cy - size_px / 2;
let _ = SetWindowPos(
hwnd,
HWND::default(),
new_x,
new_y,
size_px,
size_px,
SWP_NOZORDER | SWP_NOACTIVATE,
);
}
render(hwnd);
if let Some(m) = model(hwnd) {
crate::app::on_bubble_resized(m, new_logical);
}
}
fn snap_to_edge(hwnd: HWND) {
let dpi = lock_bubbles()
.get(&(hwnd.0 as isize))
.map(|b| b.dpi)
.unwrap_or(96);
let snap_zone = scale_to_dpi(SNAP_ZONE_LOGICAL, dpi);
let mut r = RECT::default();
let monitor;
unsafe {
if GetWindowRect(hwnd, &mut r).is_err() {
return;
}
monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
}
if monitor.is_invalid() {
return;
}
let mut info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
unsafe {
if !GetMonitorInfoW(monitor, &mut info).as_bool() {
return;
}
}
let wa = info.rcWork;
let w = r.right - r.left;
let h = r.bottom - r.top;
let mut nx = r.left;
let mut ny = r.top;
if (nx - wa.left).abs() < snap_zone {
nx = wa.left;
} else if (wa.right - (nx + w)).abs() < snap_zone {
nx = wa.right - w;
}
if (ny - wa.top).abs() < snap_zone {
ny = wa.top;
} else if (wa.bottom - (ny + h)).abs() < snap_zone {
ny = wa.bottom - h;
}
// Clamp into the work area in any case (so the bubble can't be lost off-screen).
nx = nx.clamp(wa.left, wa.right - w);
ny = ny.clamp(wa.top, wa.bottom - h);
if nx != r.left || ny != r.top {
unsafe {
let _ = SetWindowPos(
hwnd,
HWND::default(),
nx,
ny,
0,
0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE,
);
}
}
}
// ---------- Fullscreen detection ----------
fn check_fullscreen(bubble_hwnd: HWND) {
let fg = unsafe { GetForegroundWindow() };
if fg == HWND::default() || 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 bubbles = lock_bubbles();
let Some(b) = bubbles.get(&(bubble_hwnd.0 as isize)) else {
return;
};
(b.hidden_by_fullscreen, b.user_hidden)
};
if is_fullscreen && !was_hidden_by_fs {
unsafe {
let _ = ShowWindow(bubble_hwnd, SW_HIDE);
}
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
b.hidden_by_fullscreen = true;
}
} else if !is_fullscreen && was_hidden_by_fs {
if !user_hidden {
unsafe {
let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE);
}
}
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
b.hidden_by_fullscreen = false;
}
}
}
// ---------- Painting ----------
fn render(hwnd: HWND) {
let (size_logical, dpi, percent, is_dark) = {
let bubbles = lock_bubbles();
let Some(b) = bubbles.get(&(hwnd.0 as isize)) else {
return;
};
(b.size_logical, b.dpi, b.percent, b.is_dark)
};
let size_px = scale_to_dpi(size_logical, dpi);
unsafe {
let screen_dc = GetDC(hwnd);
let mem_dc = CreateCompatibleDC(screen_dc);
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: size_px,
biHeight: -size_px,
biPlanes: 1,
biBitCount: 32,
biCompression: 0,
..Default::default()
},
..Default::default()
};
let mut bits: *mut c_void = std::ptr::null_mut();
let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
.unwrap_or_default();
if dib.is_invalid() || bits.is_null() {
let _ = DeleteDC(mem_dc);
ReleaseDC(hwnd, screen_dc);
return;
}
let old_bmp = SelectObject(mem_dc, dib);
let pixel_count = (size_px * size_px) as usize;
let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
paint_background(pixels, size_px, is_dark);
paint_ring(pixels, size_px, percent.unwrap_or(0.0), is_dark);
paint_text(mem_dc, size_px, percent, is_dark, dpi);
// Final alpha pass: set alpha=255 inside circle, 0 outside.
let cx = (size_px - 1) as f64 / 2.0;
let cy = cx;
let radius = (size_px / 2) as f64 - 1.0;
let r_sq = radius * radius;
for y in 0..size_px {
for x in 0..size_px {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
let idx = (y * size_px + x) as usize;
if dx * dx + dy * dy <= r_sq {
pixels[idx] |= 0xFF000000;
} else {
pixels[idx] = 0;
}
}
}
let mut wr = RECT::default();
let _ = GetWindowRect(hwnd, &mut wr);
let pt_dst = POINT {
x: wr.left,
y: wr.top,
};
let pt_src = POINT { x: 0, y: 0 };
let sz = SIZE {
cx: size_px,
cy: size_px,
};
let blend = BLENDFUNCTION {
BlendOp: 0,
BlendFlags: 0,
SourceConstantAlpha: 255,
AlphaFormat: 1, // AC_SRC_ALPHA
};
let _ = UpdateLayeredWindow(
hwnd,
screen_dc,
Some(&pt_dst),
Some(&sz),
mem_dc,
Some(&pt_src),
COLORREF(0),
Some(&blend),
ULW_ALPHA,
);
SelectObject(mem_dc, old_bmp);
let _ = DeleteObject(dib);
let _ = DeleteDC(mem_dc);
ReleaseDC(hwnd, screen_dc);
}
}
fn paint_background(pixels: &mut [u32], size_px: i32, is_dark: bool) {
let bg = if is_dark {
Color::from_hex("#1F1F1F")
} else {
Color::from_hex("#F3F3F3")
};
let bg_bgr = bg.to_colorref();
let cx = (size_px - 1) as f64 / 2.0;
let cy = cx;
let radius = (size_px / 2) as f64 - 1.0;
let r_sq = radius * radius;
for y in 0..size_px {
for x in 0..size_px {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
let idx = (y * size_px + x) as usize;
if dx * dx + dy * dy <= r_sq {
pixels[idx] = bg_bgr;
} else {
pixels[idx] = 0;
}
}
}
}
fn paint_ring(pixels: &mut [u32], size_px: i32, percent: f64, is_dark: bool) {
let ring = ring_color_for_percent(percent).to_colorref();
let track = if is_dark {
Color::from_hex("#3A3A3A").to_colorref()
} else {
Color::from_hex("#D6D6D6").to_colorref()
};
let cx = (size_px - 1) as f64 / 2.0;
let cy = cx;
let outer = (size_px / 2) as f64 - 1.0;
let thickness = ((size_px as f64) * 0.12).max(3.0);
let inner = outer - thickness;
let inner_sq = inner * inner;
let outer_sq = outer * outer;
let sweep = (percent.clamp(0.0, 100.0) / 100.0) * 2.0 * std::f64::consts::PI;
for y in 0..size_px {
for x in 0..size_px {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
let d_sq = dx * dx + dy * dy;
if d_sq <= outer_sq && d_sq >= inner_sq {
// Angle from 12 o'clock, clockwise.
let mut a = dx.atan2(-dy);
if a < 0.0 {
a += 2.0 * std::f64::consts::PI;
}
let idx = (y * size_px + x) as usize;
pixels[idx] = if a <= sweep { ring } else { track };
}
}
}
}
fn paint_text(hdc: HDC, size_px: i32, percent: Option<f64>, is_dark: bool, _dpi: u32) {
let text = match percent {
Some(p) => format!("{:.0}%", p),
None => "".to_string(),
};
let mut text_w = wide_str(&text);
let text_color = if is_dark {
Color::from_hex("#F5F5F5")
} else {
Color::from_hex("#1F1F1F")
};
let font_height = -(size_px / 4).max(8);
let font_name = wide_str("Segoe UI");
unsafe {
let font = CreateFontW(
font_height,
0,
0,
0,
FW_SEMIBOLD.0 as i32,
0,
0,
0,
DEFAULT_CHARSET.0 as u32,
OUT_DEFAULT_PRECIS.0 as u32,
CLIP_DEFAULT_PRECIS.0 as u32,
CLEARTYPE_QUALITY.0 as u32,
(FF_SWISS.0 | DEFAULT_PITCH.0) as u32,
PCWSTR::from_raw(font_name.as_ptr()),
);
let old_font = SelectObject(hdc, font);
SetTextColor(hdc, COLORREF(text_color.to_colorref()));
SetBkMode(hdc, TRANSPARENT);
let mut rect = RECT {
left: 0,
top: 0,
right: size_px,
bottom: size_px,
};
// Trim the trailing NUL — DrawTextW reads slice length as count.
let len_no_nul = text_w.len().saturating_sub(1);
let _ = DrawTextW(
hdc,
&mut text_w[..len_no_nul],
&mut rect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP,
);
SelectObject(hdc, old_font);
let _ = DeleteObject(font);
}
}
pub fn ring_color_for_percent(percent: f64) -> Color {
if percent <= 50.0 {
return Color::from_hex("#D97757");
}
let stops: [(f64, Color); 5] = [
(50.0, Color::from_hex("#D97757")),
(70.0, Color::from_hex("#D08540")),
(85.0, Color::from_hex("#CC8C20")),
(95.0, Color::from_hex("#C45020")),
(100.0, Color::from_hex("#B82020")),
];
for pair in stops.windows(2) {
let (sp, sc) = pair[0];
let (ep, ec) = pair[1];
if percent <= ep {
let span = (ep - sp).max(f64::EPSILON);
let t = ((percent - sp) / span).clamp(0.0, 1.0);
return Color::new(
lerp_u8(sc.r, ec.r, t),
lerp_u8(sc.g, ec.g, t),
lerp_u8(sc.b, ec.b, t),
);
}
}
Color::from_hex("#B82020")
}
fn lerp_u8(a: u8, b: u8, t: f64) -> u8 {
(a as f64 + (b as f64 - a as f64) * t).round() as u8
}
// ---------- Helpers ----------
fn primary_dpi() -> u32 {
unsafe { GetDpiForSystem().max(96) }
}
fn scale_to_dpi(logical: i32, dpi: u32) -> i32 {
((logical as i64) * (dpi as i64) / 96) as i32
}
fn default_position(size_px: i32, model: TrayIconKind) -> (i32, i32) {
// Bottom-right of primary work area, with a 24-pixel gap from the edges.
// Stagger the Codex bubble above the Claude one if both are enabled.
unsafe {
let monitor = MonitorFromPoint(POINT { x: 0, y: 0 }, MONITOR_DEFAULTTOPRIMARY);
let mut info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
let wa = if GetMonitorInfoW(monitor, &mut info).as_bool() {
info.rcWork
} else {
RECT {
left: 0,
top: 0,
right: 1920,
bottom: 1080,
}
};
let gap = 24;
let stagger = match model {
TrayIconKind::Claude => 0,
TrayIconKind::Codex => size_px + gap,
};
let x = wa.right - size_px - gap;
let y = wa.bottom - size_px - gap - stagger;
(x, y)
}
}
+52
View File
@@ -0,0 +1,52 @@
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
struct DiagnoseState {
file: Mutex<File>,
}
static DIAGNOSE_STATE: OnceLock<DiagnoseState> = OnceLock::new();
pub fn init() -> Result<PathBuf, String> {
let path = std::env::temp_dir().join("claude-code-usage-bubble.log");
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)
.map_err(|e| format!("Unable to open diagnostic log file {}: {e}", path.display()))?;
let _ = DIAGNOSE_STATE.set(DiagnoseState {
file: Mutex::new(file),
});
log("diagnostic logging enabled");
Ok(path)
}
pub fn is_enabled() -> bool {
DIAGNOSE_STATE.get().is_some()
}
pub fn log(message: impl AsRef<str>) {
let Some(state) = DIAGNOSE_STATE.get() else {
return;
};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
if let Ok(mut file) = state.file.lock() {
let _ = writeln!(file, "[{timestamp}] {}", message.as_ref());
let _ = file.flush();
}
}
pub fn log_error(context: &str, error: impl std::fmt::Display) {
log(format!("{context}: {error}"));
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<rect x="0.5" y="0.5" width="15" height="15" rx="1" stroke="#D97757" stroke-width="1"></rect>
<rect x="2" y="2" width="5" height="5" rx="1" fill="#D97757"></rect>
<rect x="9" y="2" width="5" height="5" rx="1" fill="#F3D6CC"></rect>
<rect x="2" y="9" width="5" height="5" rx="1" fill="#D97757"></rect>
<rect x="9" y="9" width="5" height="5" rx="1" fill="#F3D6CC"></rect>
<path d="M 10 2 L 11 2 L 11 7 L 10 7 C 9.448 7 9 6.552 9 6 L 9 3 C 9 2.448 9.448 2 10 2 Z" fill="#D97757"></path>
</svg>

After

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none">
<rect x="4" y="36" width="248" height="184" rx="16" stroke="#D97757" stroke-width="8"></rect>
<rect x="15" y="72" width="19" height="48" rx="4" fill="#D97757"></rect>
<rect x="38" y="72" width="19" height="48" rx="4" fill="#D97757"></rect>
<rect x="61" y="72" width="19" height="48" rx="4" fill="#D97757"></rect>
<rect x="84" y="72" width="19" height="48" rx="4" fill="#D97757"></rect>
<rect x="107" y="72" width="19" height="48" rx="4" fill="#D97757"></rect>
<rect x="130" y="72" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<rect x="153" y="72" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<rect x="176" y="72" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<rect x="199" y="72" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<rect x="222" y="72" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<rect x="15" y="136" width="19" height="48" rx="4" fill="#D97757"></rect>
<rect x="38" y="136" width="19" height="48" rx="4" fill="#D97757"></rect>
<rect x="61" y="136" width="19" height="48" rx="4" fill="#D97757"></rect>
<rect x="84" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<rect x="107" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<rect x="130" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<rect x="153" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<rect x="176" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<rect x="199" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<rect x="222" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
<path d="M 134 72 L 143 72 L 143 120 L 134 120 C 131.791 120 130 118.209 130 116 L 130 76 C 130 73.791 131.791 72 134 72 Z" fill="#D97757"></path>
<path d="M 88 136 L 89 136 L 89 184 L 88 184 C 85.791 184 84 182.209 84 180 L 84 140 C 84 137.791 85.791 136 88 136 Z" fill="#D97757"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect x="1" y="1" width="30" height="30" rx="4" stroke="#D97757" stroke-width="2"></rect>
<rect x="3" y="4" width="8" height="11" rx="2" fill="#D97757"></rect>
<rect x="12" y="4" width="8" height="11" rx="2" fill="#D97757"></rect>
<rect x="21" y="4" width="8" height="11" rx="2" fill="#F3D6CC"></rect>
<path d="M 23 4 L 25 4 L 25 15 L 23 15 C 21.895 15 21 14.18 21 13.167 L 21 5.833 C 21 4.82 21.895 4 23 4 Z" fill="#D97757"></path>
<rect x="3" y="17" width="8" height="11" rx="2" fill="#D97757"></rect>
<rect x="12" y="17" width="8" height="11" rx="2" fill="#D97757"></rect>
<rect x="21" y="17" width="8" height="11" rx="2" fill="#F3D6CC"></rect>
</svg>

After

Width:  |  Height:  |  Size: 743 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<rect x="2" y="2" width="44" height="44" rx="6" stroke="#D97757" stroke-width="4"></rect>
<rect x="7" y="10" width="6" height="12" rx="2" fill="#D97757"></rect>
<rect x="14" y="10" width="6" height="12" rx="2" fill="#D97757"></rect>
<rect x="21" y="10" width="6" height="12" rx="2" fill="#D97757"></rect>
<rect x="28" y="10" width="6" height="12" rx="2" fill="#F3D6CC"></rect>
<path d="M 30 10 L 31 10 L 31 22 L 30 22 C 28.895 22 28 21.105 28 20 L 28 12 C 28 10.895 28.895 10 30 10 Z" fill="#D97757"></path>
<rect x="35" y="10" width="6" height="12" rx="2" fill="#F3D6CC"></rect>
<rect x="7" y="26" width="6" height="12" rx="2" fill="#D97757"></rect>
<rect x="14" y="26" width="6" height="12" rx="2" fill="#D97757"></rect>
<rect x="21" y="26" width="6" height="12" rx="2" fill="#F3D6CC"></rect>
<rect x="28" y="26" width="6" height="12" rx="2" fill="#F3D6CC"></rect>
<rect x="35" y="26" width="6" height="12" rx="2" fill="#F3D6CC"></rect>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+46
View File
@@ -0,0 +1,46 @@
use super::Strings;
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Bijwerken via WinGet";
pub(super) const STRINGS: Strings = Strings {
window_title: "Claude Code Gebruiksmonitor",
refresh: "Vernieuwen",
update_frequency: "Updatefrequentie",
one_minute: "1 minuut",
five_minutes: "5 minuten",
fifteen_minutes: "15 minuten",
one_hour: "1 uur",
models: "Modellen",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "Instellingen",
start_with_windows: "Opstarten met Windows",
reset_position: "Positie herstellen",
language: "Taal",
system_default: "Systeemstandaard",
check_for_updates: "Controleren op updates",
checking_for_updates: "Controleren op updates...",
updates: "Updates",
update_in_progress: "Er is al een updatecontrole bezig.",
up_to_date: "Je gebruikt al de nieuwste versie.",
up_to_date_short: "Up-to-date",
update_failed: "Automatisch bijwerken mislukt",
applying_update: "Update wordt toegepast...",
update_to: "Bijwerken naar",
update_available: "Update beschikbaar",
update_prompt_now: "Versie {version} is beschikbaar. Wil je nu bijwerken?",
exit: "Afsluiten",
show_widget: "Widget tonen",
session_window: "5u",
weekly_window: "7d",
now: "nu",
day_suffix: "d",
hour_suffix: "u",
minute_suffix: "m",
token_expired_title: "Claude Code-authenticatiefout",
token_expired_body: "Voer 'claude' uit in een terminal, gebruik daarna '/login' en volg de stappen. Ververs of herstart de app daarna.",
codex_token_expired_title: "Codex-authenticatiefout",
codex_token_expired_body: "Voer 'codex' uit in een terminal en volg de aanmeldstappen. Ververs of herstart de app daarna.",
codex_window_title: "Codex-gebruiksmonitor",
second_suffix: "s",
};
+46
View File
@@ -0,0 +1,46 @@
use super::Strings;
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Update via WinGet";
pub(super) const STRINGS: Strings = Strings {
window_title: "Claude Code Usage Monitor",
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_code_model: "Claude Code",
codex_model: "Codex",
settings: "Settings",
start_with_windows: "Start with Windows",
reset_position: "Reset Position",
language: "Language",
system_default: "System Default",
check_for_updates: "Check for Updates",
checking_for_updates: "Checking for Updates...",
updates: "Updates",
update_in_progress: "An update check is already in progress.",
up_to_date: "You already have the latest version.",
up_to_date_short: "Up to date",
update_failed: "Unable to update automatically",
applying_update: "Applying update...",
update_to: "Update to",
update_available: "Update available",
update_prompt_now: "Version {version} is available. Do you want to update now?",
exit: "Exit",
show_widget: "Show Widget",
session_window: "5h",
weekly_window: "7d",
now: "now",
day_suffix: "d",
hour_suffix: "h",
minute_suffix: "m",
token_expired_title: "Claude Code Auth Error",
token_expired_body: "Run 'claude' in a terminal, then use '/login' and follow the prompts. After that, refresh or restart this app.",
codex_token_expired_title: "Codex Auth Error",
codex_token_expired_body: "Run 'codex' in a terminal and follow the sign-in prompts. After that, refresh or restart this app.",
codex_window_title: "Codex Usage Monitor",
second_suffix: "s",
};
+46
View File
@@ -0,0 +1,46 @@
use super::Strings;
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Mettre à jour avec WinGet";
pub(super) const STRINGS: Strings = Strings {
window_title: "Moniteur d'utilisation Claude Code",
refresh: "Actualiser",
update_frequency: "Fréquence de mise à jour",
one_minute: "1 minute",
five_minutes: "5 minutes",
fifteen_minutes: "15 minutes",
one_hour: "1 heure",
models: "Modeles",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "Paramètres",
start_with_windows: "Démarrer avec Windows",
reset_position: "Réinitialiser la position",
language: "Langue",
system_default: "Par défaut du système",
check_for_updates: "Vérifier les mises à jour",
checking_for_updates: "Vérification des mises à jour...",
updates: "Mises à jour",
update_in_progress: "Une vérification de mise à jour est déjà en cours.",
up_to_date: "Vous utilisez déjà la version la plus récente.",
up_to_date_short: "À jour",
update_failed: "Impossible d'effectuer la mise à jour automatiquement",
applying_update: "Application de la mise à jour...",
update_to: "Mettre à jour vers",
update_available: "Mise à jour disponible",
update_prompt_now: "La version {version} est disponible. Voulez-vous mettre à jour maintenant ?",
exit: "Quitter",
show_widget: "Afficher le widget",
session_window: "5h",
weekly_window: "7d",
now: "maintenant",
day_suffix: "j",
hour_suffix: "h",
minute_suffix: "m",
token_expired_title: "Erreur d'authentification",
token_expired_body: "Exécutez 'claude' dans un terminal, puis utilisez '/login' et suivez les instructions. Ensuite, actualisez ou redémarrez cette application.",
codex_token_expired_title: "Erreur d'authentification Codex",
codex_token_expired_body: "Executez 'codex' dans un terminal et suivez les instructions de connexion. Ensuite, actualisez ou redemarrez cette application.",
codex_window_title: "Moniteur d'utilisation Codex",
second_suffix: "s",
};
+46
View File
@@ -0,0 +1,46 @@
use super::Strings;
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Mit WinGet aktualisieren";
pub(super) const STRINGS: Strings = Strings {
window_title: "Claude Code Nutzungsmonitor",
refresh: "Aktualisieren",
update_frequency: "Aktualisierungsintervall",
one_minute: "1 Minute",
five_minutes: "5 Minuten",
fifteen_minutes: "15 Minuten",
one_hour: "1 Stunde",
models: "Modelle",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "Einstellungen",
start_with_windows: "Mit Windows starten",
reset_position: "Position zurücksetzen",
language: "Sprache",
system_default: "Systemstandard",
check_for_updates: "Nach Updates suchen",
checking_for_updates: "Suche nach Updates...",
updates: "Updates",
update_in_progress: "Eine Update-Prüfung läuft bereits.",
up_to_date: "Sie verwenden bereits die neueste Version.",
up_to_date_short: "Aktuell",
update_failed: "Automatisches Update war nicht möglich",
applying_update: "Update wird installiert...",
update_to: "Aktualisieren auf",
update_available: "Update verfügbar",
update_prompt_now: "Version {version} ist verfügbar. Möchten Sie jetzt aktualisieren?",
exit: "Beenden",
show_widget: "Widget anzeigen",
session_window: "5h",
weekly_window: "7d",
now: "jetzt",
day_suffix: "T",
hour_suffix: "h",
minute_suffix: "m",
token_expired_title: "Authentifizierungsfehler",
token_expired_body: "Führen Sie 'claude' in einem Terminal aus, verwenden Sie dann '/login' und folgen Sie den Anweisungen. Aktualisieren oder starten Sie diese App anschließend neu.",
codex_token_expired_title: "Codex-Authentifizierungsfehler",
codex_token_expired_body: "Fuhren Sie 'codex' in einem Terminal aus und folgen Sie den Anmeldeanweisungen. Aktualisieren oder starten Sie diese App anschliessend neu.",
codex_window_title: "Codex-Nutzungsmonitor",
second_suffix: "s",
};
+46
View File
@@ -0,0 +1,46 @@
use super::Strings;
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "WinGet で更新";
pub(super) const STRINGS: Strings = Strings {
window_title: "Claude Code 使用量モニター",
refresh: "更新",
update_frequency: "更新間隔",
one_minute: "1分",
five_minutes: "5分",
fifteen_minutes: "15分",
one_hour: "1時間",
models: "モデル",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "設定",
start_with_windows: "Windows と同時に開始",
reset_position: "位置をリセット",
language: "言語",
system_default: "システム既定",
check_for_updates: "更新を確認",
checking_for_updates: "更新を確認しています...",
updates: "更新",
update_in_progress: "更新確認は既に実行中です。",
up_to_date: "既に最新バージョンです。",
up_to_date_short: "最新です",
update_failed: "自動更新を完了できませんでした",
applying_update: "更新を適用しています...",
update_to: "更新先",
update_available: "更新が利用可能です",
update_prompt_now: "バージョン {version} が利用可能です。今すぐ更新しますか?",
exit: "終了",
show_widget: "ウィジェットを表示",
session_window: "5h",
weekly_window: "7d",
now: "",
day_suffix: "",
hour_suffix: "時間",
minute_suffix: "",
token_expired_title: "認証エラー",
token_expired_body: "ターミナルで 'claude' を実行し、'/login' を使って案内に従ってください。その後、このアプリを更新するか再起動してください。",
codex_token_expired_title: "Codex 認証エラー",
codex_token_expired_body: "ターミナルで 'codex' を実行し、サインインの案内に従ってください。その後、このアプリを更新または再起動してください。",
codex_window_title: "Codex 使用量モニター",
second_suffix: "",
};
+46
View File
@@ -0,0 +1,46 @@
use super::Strings;
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "WinGet으로 업데이트";
pub(super) const STRINGS: Strings = Strings {
window_title: "Claude Code 사용량 모니터",
refresh: "새로고침",
update_frequency: "업데이트 주기",
one_minute: "1분",
five_minutes: "5분",
fifteen_minutes: "15분",
one_hour: "1시간",
models: "모델",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "설정",
start_with_windows: "Windows 시작 시 자동 실행",
reset_position: "위치 초기화",
language: "언어",
system_default: "시스템 기본값",
check_for_updates: "업데이트 확인",
checking_for_updates: "업데이트 확인 중...",
updates: "업데이트",
update_in_progress: "이미 업데이트 확인이 진행 중입니다.",
up_to_date: "이미 최신 버전입니다.",
up_to_date_short: "최신",
update_failed: "자동 업데이트를 완료할 수 없습니다",
applying_update: "업데이트 적용 중...",
update_to: "업데이트 대상",
update_available: "업데이트 사용 가능",
update_prompt_now: "버전 {version}을 사용할 수 있습니다. 지금 업데이트하시겠습니까?",
exit: "종료",
show_widget: "위젯 표시",
session_window: "5시간",
weekly_window: "7일",
now: "지금",
day_suffix: "",
hour_suffix: "시간",
minute_suffix: "",
token_expired_title: "인증 오류",
token_expired_body: "터미널에서 'claude'를 실행한 다음 '/login'을 사용하고 안내에 따라 진행하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.",
codex_token_expired_title: "Codex 인증 오류",
codex_token_expired_body: "터미널에서 'codex'를 실행하고 로그인 안내를 따르세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.",
codex_window_title: "Codex 사용량 모니터",
second_suffix: "",
};
+246
View File
@@ -0,0 +1,246 @@
mod dutch;
mod english;
mod french;
mod german;
mod japanese;
mod korean;
mod spanish;
mod traditional_chinese;
use windows::core::PWSTR;
use windows::Win32::Globalization::{
GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages,
LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LanguageId {
English,
Dutch,
Spanish,
French,
German,
Japanese,
Korean,
TraditionalChinese,
}
impl LanguageId {
pub const ALL: [LanguageId; 8] = [
LanguageId::English,
LanguageId::Dutch,
LanguageId::Spanish,
LanguageId::French,
LanguageId::German,
LanguageId::Japanese,
LanguageId::Korean,
LanguageId::TraditionalChinese,
];
pub fn code(self) -> &'static str {
match self {
Self::English => "en",
Self::Dutch => "nl",
Self::Spanish => "es",
Self::French => "fr",
Self::German => "de",
Self::Japanese => "ja",
Self::Korean => "ko",
Self::TraditionalChinese => "zh-TW",
}
}
pub fn native_name(self) -> &'static str {
match self {
Self::English => "English",
Self::Dutch => "Nederlands",
Self::Spanish => "Español",
Self::French => "Français",
Self::German => "Deutsch",
Self::Japanese => "日本語",
Self::Korean => "한국어",
Self::TraditionalChinese => "繁體中文",
}
}
pub fn strings(self) -> Strings {
match self {
Self::English => english::STRINGS,
Self::Dutch => dutch::STRINGS,
Self::Spanish => spanish::STRINGS,
Self::French => french::STRINGS,
Self::German => german::STRINGS,
Self::Japanese => japanese::STRINGS,
Self::Korean => korean::STRINGS,
Self::TraditionalChinese => traditional_chinese::STRINGS,
}
}
pub fn update_via_winget_label(self) -> &'static str {
match self {
Self::English => english::UPDATE_VIA_WINGET_LABEL,
Self::Dutch => dutch::UPDATE_VIA_WINGET_LABEL,
Self::Spanish => spanish::UPDATE_VIA_WINGET_LABEL,
Self::French => french::UPDATE_VIA_WINGET_LABEL,
Self::German => german::UPDATE_VIA_WINGET_LABEL,
Self::Japanese => japanese::UPDATE_VIA_WINGET_LABEL,
Self::Korean => korean::UPDATE_VIA_WINGET_LABEL,
Self::TraditionalChinese => traditional_chinese::UPDATE_VIA_WINGET_LABEL,
}
}
pub fn from_code(code: &str) -> Option<Self> {
let normalized = code.trim().replace('_', "-").to_ascii_lowercase();
if normalized.is_empty() || normalized == "system" {
return None;
}
let prefix = normalized.split('-').next().unwrap_or_default();
match prefix {
"en" => Some(Self::English),
"nl" => Some(Self::Dutch),
"es" => Some(Self::Spanish),
"fr" => Some(Self::French),
"de" => Some(Self::German),
"ja" => Some(Self::Japanese),
"ko" => Some(Self::Korean),
"zh" => {
if normalized.contains("tw")
|| normalized.contains("hk")
|| normalized.contains("hant")
{
Some(Self::TraditionalChinese)
} else {
None
}
}
_ => None,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Strings {
pub window_title: &'static str,
pub refresh: &'static str,
pub update_frequency: &'static str,
pub one_minute: &'static str,
pub five_minutes: &'static str,
pub fifteen_minutes: &'static str,
pub one_hour: &'static str,
pub models: &'static str,
pub claude_code_model: &'static str,
pub codex_model: &'static str,
pub settings: &'static str,
pub start_with_windows: &'static str,
pub reset_position: &'static str,
pub language: &'static str,
pub system_default: &'static str,
pub check_for_updates: &'static str,
pub checking_for_updates: &'static str,
pub updates: &'static str,
pub update_in_progress: &'static str,
pub up_to_date: &'static str,
pub up_to_date_short: &'static str,
pub update_failed: &'static str,
pub applying_update: &'static str,
pub update_to: &'static str,
pub update_available: &'static str,
pub update_prompt_now: &'static str,
pub exit: &'static str,
pub show_widget: &'static str,
pub session_window: &'static str,
pub weekly_window: &'static str,
pub now: &'static str,
pub day_suffix: &'static str,
pub hour_suffix: &'static str,
pub minute_suffix: &'static str,
pub second_suffix: &'static str,
pub token_expired_title: &'static str,
pub token_expired_body: &'static str,
pub codex_token_expired_title: &'static str,
pub codex_token_expired_body: &'static str,
pub codex_window_title: &'static str,
}
pub fn resolve_language(language_override: Option<LanguageId>) -> LanguageId {
language_override.unwrap_or_else(detect_system_language)
}
pub fn detect_system_language() -> LanguageId {
preferred_ui_languages()
.into_iter()
.find_map(|locale| LanguageId::from_code(&locale))
.or_else(default_ui_locale)
.or_else(default_locale_name)
.unwrap_or(LanguageId::English)
}
pub fn update_via_winget(language: LanguageId) -> &'static str {
language.update_via_winget_label()
}
fn preferred_ui_languages() -> Vec<String> {
unsafe {
let mut num_languages = 0u32;
let mut buffer_len = 0u32;
if GetUserPreferredUILanguages(
MUI_LANGUAGE_NAME,
&mut num_languages,
PWSTR::null(),
&mut buffer_len,
)
.is_err()
|| buffer_len == 0
{
return Vec::new();
}
let mut buffer = vec![0u16; buffer_len as usize];
if GetUserPreferredUILanguages(
MUI_LANGUAGE_NAME,
&mut num_languages,
PWSTR(buffer.as_mut_ptr()),
&mut buffer_len,
)
.is_err()
{
return Vec::new();
}
buffer
.split(|unit| *unit == 0)
.filter(|part| !part.is_empty())
.map(String::from_utf16_lossy)
.collect()
}
}
fn default_ui_locale() -> Option<LanguageId> {
unsafe {
let lang_id = GetUserDefaultUILanguage();
let mut buffer = [0u16; MAX_LOCALE_NAME as usize];
let len = LCIDToLocaleName(
lang_id as u32,
Some(&mut buffer),
LOCALE_ALLOW_NEUTRAL_NAMES,
);
if len <= 1 {
return None;
}
let locale = String::from_utf16_lossy(&buffer[..(len as usize - 1)]);
LanguageId::from_code(&locale)
}
}
fn default_locale_name() -> Option<LanguageId> {
unsafe {
let mut buffer = [0u16; MAX_LOCALE_NAME as usize];
let len = GetUserDefaultLocaleName(&mut buffer);
if len <= 1 {
return None;
}
let locale = String::from_utf16_lossy(&buffer[..(len as usize - 1)]);
LanguageId::from_code(&locale)
}
}
+46
View File
@@ -0,0 +1,46 @@
use super::Strings;
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Actualizar con WinGet";
pub(super) const STRINGS: Strings = Strings {
window_title: "Monitor de uso de Claude Code",
refresh: "Actualizar",
update_frequency: "Frecuencia de actualización",
one_minute: "1 minuto",
five_minutes: "5 minutos",
fifteen_minutes: "15 minutos",
one_hour: "1 hora",
models: "Modelos",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "Configuración",
start_with_windows: "Iniciar con Windows",
reset_position: "Restablecer posición",
language: "Idioma",
system_default: "Predeterminado del sistema",
check_for_updates: "Buscar actualizaciones",
checking_for_updates: "Buscando actualizaciones...",
updates: "Actualizaciones",
update_in_progress: "Ya hay una comprobación de actualización en curso.",
up_to_date: "Ya tienes la versión más reciente.",
up_to_date_short: "Actualizado",
update_failed: "No se pudo actualizar automáticamente",
applying_update: "Aplicando actualización...",
update_to: "Actualizar a",
update_available: "Actualización disponible",
update_prompt_now: "La versión {version} está disponible. ¿Quieres actualizar ahora?",
exit: "Salir",
show_widget: "Mostrar widget",
session_window: "5h",
weekly_window: "7d",
now: "ahora",
day_suffix: "d",
hour_suffix: "h",
minute_suffix: "m",
token_expired_title: "Error de autenticación",
token_expired_body: "Ejecuta 'claude' en una terminal, luego usa '/login' y sigue las indicaciones. Después, actualiza o reinicia esta aplicación.",
codex_token_expired_title: "Error de autenticacion de Codex",
codex_token_expired_body: "Ejecuta 'codex' en una terminal y sigue las indicaciones de inicio de sesion. Despues, actualiza o reinicia esta aplicacion.",
codex_window_title: "Monitor de uso de Codex",
second_suffix: "s",
};
+46
View File
@@ -0,0 +1,46 @@
use super::Strings;
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "透過 WinGet 更新";
pub(super) const STRINGS: Strings = Strings {
window_title: "Claude Code 使用量監控",
refresh: "重新整理",
update_frequency: "更新頻率",
one_minute: "1 分鐘",
five_minutes: "5 分鐘",
fifteen_minutes: "15 分鐘",
one_hour: "1 小時",
models: "模型",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "設定",
start_with_windows: "開機時啟動",
reset_position: "重置位置",
language: "語言",
system_default: "系統預設",
check_for_updates: "檢查更新",
checking_for_updates: "正在檢查更新...",
updates: "更新",
update_in_progress: "已有更新檢查正在進行中。",
up_to_date: "您已使用最新版本。",
up_to_date_short: "已是最新",
update_failed: "無法自動更新",
applying_update: "正在套用更新...",
update_to: "更新至",
update_available: "有可用更新",
update_prompt_now: "版本 {version} 已可用。是否立即更新?",
exit: "結束",
show_widget: "顯示小工具",
session_window: "5h",
weekly_window: "7d",
now: "現在",
day_suffix: "",
hour_suffix: "",
minute_suffix: "",
token_expired_title: "驗證錯誤",
token_expired_body: "請在終端機中執行 'claude',然後使用 '/login' 並依照提示操作。完成後,請重新整理或重新啟動此應用程式。",
codex_token_expired_title: "Codex 驗證錯誤",
codex_token_expired_body: "請在終端機中執行 'codex',並依照登入提示操作。完成後,請重新整理或重新啟動此應用程式。",
codex_window_title: "Codex 使用量監控",
second_suffix: "",
};
+39
View File
@@ -0,0 +1,39 @@
#![windows_subsystem = "windows"]
mod app;
mod bubble;
mod diagnose;
mod localization;
mod models;
mod native_interop;
mod panel;
mod poller;
mod settings;
mod theme;
mod tray_icon;
mod updater;
fn main() {
let args: Vec<String> = std::env::args().collect();
let diagnose_enabled = args.iter().any(|a| a == "--diagnose");
if diagnose_enabled {
if let Ok(path) = diagnose::init() {
diagnose::log(format!(
"startup args={args:?} log_path={}",
path.display()
));
}
}
if let Some(exit_code) = updater::handle_cli_mode(&args) {
if diagnose_enabled {
diagnose::log(format!("cli mode exited with code {exit_code}"));
}
std::process::exit(exit_code);
}
if diagnose_enabled {
diagnose::log("entering app::run");
}
app::run();
}
+19
View File
@@ -0,0 +1,19 @@
use std::time::SystemTime;
#[derive(Clone, Debug, Default)]
pub struct UsageSection {
pub percentage: f64,
pub resets_at: Option<SystemTime>,
}
#[derive(Clone, Debug, Default)]
pub struct UsageData {
pub session: UsageSection,
pub weekly: UsageSection,
}
#[derive(Clone, Debug, Default)]
pub struct AppUsageData {
pub claude_code: Option<UsageData>,
pub codex: Option<UsageData>,
}
+71
View File
@@ -0,0 +1,71 @@
use windows::Win32::Foundation::{HWND, RECT};
use windows::Win32::UI::WindowsAndMessaging::{GetWindowRect, MoveWindow};
// Timer IDs (used by SetTimer / KillTimer with the bubble HWND)
pub const TIMER_POLL: usize = 1;
pub const TIMER_COUNTDOWN: usize = 2;
pub const TIMER_RESET_POLL: usize = 3;
pub const TIMER_UPDATE_CHECK: usize = 4;
pub const TIMER_FULLSCREEN_CHECK: usize = 5;
// Custom messages
pub const WM_APP: u32 = 0x8000;
pub const WM_APP_USAGE_UPDATED: u32 = WM_APP + 1;
pub const WM_APP_PANEL_TOGGLE: u32 = WM_APP + 2;
pub const WM_APP_TRAY: u32 = WM_APP + 3;
pub const WM_APP_PANEL_CLOSE: u32 = WM_APP + 4;
/// Get the bounding rectangle of a window in screen coordinates.
pub fn get_window_rect_safe(hwnd: HWND) -> Option<RECT> {
unsafe {
let mut rect = RECT::default();
if GetWindowRect(hwnd, &mut rect).is_ok() {
Some(rect)
} else {
None
}
}
}
/// Move and resize a window (top-level coordinates).
pub fn move_window(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) {
unsafe {
let _ = MoveWindow(hwnd, x, y, w, h, true);
}
}
/// Convert a Rust string to a null-terminated UTF-16 vector suitable for
/// passing as `PCWSTR`.
pub fn wide_str(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// COLORREF byte order: 0x00BBGGRR.
pub fn colorref(r: u8, g: u8, b: u8) -> u32 {
r as u32 | (g as u32) << 8 | (b as u32) << 16
}
#[derive(Clone, Copy, Debug)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Color {
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
pub fn from_hex(hex: &str) -> Self {
let hex = hex.trim_start_matches('#');
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
Self { r, g, b }
}
pub fn to_colorref(self) -> u32 {
colorref(self.r, self.g, self.b)
}
}
+506
View File
@@ -0,0 +1,506 @@
// Expanded panel shown when a bubble is left-clicked.
//
// Plain opaque top-most popup with two horizontal usage bars (session/5h and
// weekly/7d) plus countdown text. Closes on focus loss.
use std::sync::{Mutex, MutexGuard, OnceLock};
use windows::core::PCWSTR;
use windows::Win32::Foundation::*;
use windows::Win32::Graphics::Gdi::*;
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::HiDpi::GetDpiForWindow;
use windows::Win32::UI::WindowsAndMessaging::*;
use crate::diagnose;
use crate::localization::Strings;
use crate::native_interop::{wide_str, Color};
use crate::tray_icon::TrayIconKind;
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
const PANEL_W_LOGICAL: i32 = 280;
const PANEL_H_LOGICAL: i32 = 120;
const PADDING_LOGICAL: i32 = 14;
const ROW_GAP_LOGICAL: i32 = 8;
const LABEL_W_LOGICAL: i32 = 28;
const RIGHT_TEXT_W_LOGICAL: i32 = 96;
const BAR_HEIGHT_LOGICAL: i32 = 14;
pub struct PanelData {
pub model: TrayIconKind,
pub session_pct: f64,
pub session_text: String,
pub weekly_pct: f64,
pub weekly_text: String,
pub is_dark: bool,
pub strings: Strings,
pub claude_label: String,
pub codex_label: String,
}
struct PanelState {
hwnd: HWND,
data: PanelData,
}
fn state() -> &'static Mutex<Option<PanelState>> {
static S: OnceLock<Mutex<Option<PanelState>>> = OnceLock::new();
S.get_or_init(|| Mutex::new(None))
}
fn lock_state() -> MutexGuard<'static, Option<PanelState>> {
state().lock().expect("panel state mutex poisoned")
}
pub fn register_class() {
static REGISTERED: OnceLock<()> = OnceLock::new();
REGISTERED.get_or_init(|| unsafe {
let class_w = wide_str(CLASS_NAME);
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(wnd_proc),
hInstance: HINSTANCE(hinstance.0),
hCursor: LoadCursorW(HINSTANCE::default(), IDC_ARROW).unwrap_or_default(),
hbrBackground: HBRUSH(std::ptr::null_mut()),
lpszClassName: PCWSTR::from_raw(class_w.as_ptr()),
..Default::default()
};
if RegisterClassExW(&wc) == 0 {
diagnose::log("panel RegisterClassExW returned 0");
}
});
}
pub fn is_visible() -> bool {
let s = lock_state();
s.as_ref()
.map(|p| unsafe { IsWindowVisible(p.hwnd).as_bool() })
.unwrap_or(false)
}
pub fn current_model() -> Option<TrayIconKind> {
lock_state().as_ref().map(|p| p.data.model)
}
/// Show the panel for the given model, anchored near the supplied bubble HWND.
/// If a panel is already visible, hide it instead (toggle).
pub fn toggle(data: PanelData, anchor_hwnd: HWND) {
if is_visible() && current_model() == Some(data.model) {
hide();
return;
}
show(data, anchor_hwnd);
}
pub fn show(data: PanelData, anchor_hwnd: HWND) {
register_class();
let mut anchor_rect = RECT::default();
unsafe {
let _ = GetWindowRect(anchor_hwnd, &mut anchor_rect);
}
let dpi = unsafe { GetDpiForWindow(anchor_hwnd).max(96) };
let panel_w = scale_to_dpi(PANEL_W_LOGICAL, dpi);
let panel_h = scale_to_dpi(PANEL_H_LOGICAL, dpi);
let (x, y) = place_near(anchor_rect, panel_w, panel_h);
let existing_hwnd = lock_state().as_ref().map(|p| p.hwnd);
let hwnd = match existing_hwnd {
Some(h) => h,
None => match create_panel_window(x, y, panel_w, panel_h) {
Some(h) => h,
None => return,
},
};
{
let mut guard = lock_state();
if let Some(p) = guard.as_mut() {
p.data = data;
} else {
*guard = Some(PanelState { hwnd, data });
}
}
unsafe {
let _ = SetWindowPos(
hwnd,
HWND::default(),
x,
y,
panel_w,
panel_h,
SWP_NOZORDER | SWP_NOACTIVATE,
);
let _ = InvalidateRect(hwnd, None, true);
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
let _ = SetForegroundWindow(hwnd);
}
}
fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
let hwnd = unsafe {
let class_w = wide_str(CLASS_NAME);
let title_w = wide_str("Usage panel");
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
CreateWindowExW(
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
PCWSTR::from_raw(class_w.as_ptr()),
PCWSTR::from_raw(title_w.as_ptr()),
WS_POPUP | WS_BORDER,
x,
y,
w,
h,
HWND::default(),
HMENU::default(),
hinstance,
None,
)
.unwrap_or_default()
};
if hwnd == HWND::default() {
diagnose::log("panel CreateWindowExW failed");
None
} else {
Some(hwnd)
}
}
pub fn hide() {
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
if let Some(hwnd) = hwnd_opt {
unsafe {
let _ = ShowWindow(hwnd, SW_HIDE);
}
}
}
pub fn destroy() {
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
if let Some(hwnd) = hwnd_opt {
unsafe {
let _ = DestroyWindow(hwnd);
}
}
*lock_state() = None;
}
/// Refresh the panel's data (called from app when poll cycle completes).
pub fn refresh_data(data: PanelData) {
let hwnd_opt = {
let mut guard = lock_state();
let Some(p) = guard.as_mut() else {
return;
};
if p.data.model != data.model {
// Showing a different model right now — caller can decide whether to swap.
return;
}
p.data = data;
Some(p.hwnd)
};
if let Some(hwnd) = hwnd_opt {
unsafe {
let _ = InvalidateRect(hwnd, None, false);
}
}
}
// ---------- Window proc & painting ----------
unsafe extern "system" fn wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
WM_PAINT => {
let mut ps = PAINTSTRUCT::default();
let hdc = BeginPaint(hwnd, &mut ps);
paint(hwnd, hdc);
let _ = EndPaint(hwnd, &ps);
LRESULT(0)
}
WM_ERASEBKGND => LRESULT(1),
WM_KILLFOCUS => {
// Close the panel when it loses focus. Use ShowWindow rather than
// DestroyWindow so we can re-show it next click without re-creating.
let _ = ShowWindow(hwnd, SW_HIDE);
LRESULT(0)
}
WM_DESTROY => {
*lock_state() = None;
LRESULT(0)
}
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}
fn paint(hwnd: HWND, hdc: HDC) {
let Some(data) = clone_data() else {
return;
};
let mut rc = RECT::default();
unsafe {
let _ = GetClientRect(hwnd, &mut rc);
}
let dpi = unsafe { GetDpiForWindow(hwnd).max(96) };
let scaled = |v: i32| scale_to_dpi(v, dpi);
let bg = if data.is_dark {
Color::from_hex("#1F1F1F")
} else {
Color::from_hex("#FAFAFA")
};
let text_color = if data.is_dark {
Color::from_hex("#EAEAEA")
} else {
Color::from_hex("#1F1F1F")
};
let track = if data.is_dark {
Color::from_hex("#3A3A3A")
} else {
Color::from_hex("#D6D6D6")
};
let accent = bar_color_for(data.session_pct.max(data.weekly_pct), data.is_dark);
unsafe {
let bg_brush = CreateSolidBrush(COLORREF(bg.to_colorref()));
FillRect(hdc, &rc, bg_brush);
let _ = DeleteObject(bg_brush);
// Header row: model label
let header = match data.model {
TrayIconKind::Claude => data.claude_label.clone(),
TrayIconKind::Codex => data.codex_label.clone(),
};
draw_text(
hdc,
&header,
text_color,
scaled(PADDING_LOGICAL),
scaled(PADDING_LOGICAL),
rc.right - 2 * scaled(PADDING_LOGICAL),
scaled(18),
true,
dpi,
);
let bar_x = scaled(PADDING_LOGICAL) + scaled(LABEL_W_LOGICAL) + scaled(4);
let bar_w = rc.right
- bar_x
- scaled(PADDING_LOGICAL)
- scaled(RIGHT_TEXT_W_LOGICAL)
- scaled(4);
let row1_y = scaled(PADDING_LOGICAL) + scaled(24);
let row2_y = row1_y + scaled(BAR_HEIGHT_LOGICAL) + scaled(ROW_GAP_LOGICAL) + scaled(8);
draw_row(
hdc,
data.strings.session_window,
scaled(PADDING_LOGICAL),
row1_y,
bar_x,
bar_w,
scaled(BAR_HEIGHT_LOGICAL),
data.session_pct,
&data.session_text,
text_color,
track,
accent,
dpi,
);
draw_row(
hdc,
data.strings.weekly_window,
scaled(PADDING_LOGICAL),
row2_y,
bar_x,
bar_w,
scaled(BAR_HEIGHT_LOGICAL),
data.weekly_pct,
&data.weekly_text,
text_color,
track,
accent,
dpi,
);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_row(
hdc: HDC,
label: &str,
label_x: i32,
row_y: i32,
bar_x: i32,
bar_w: i32,
bar_h: i32,
pct: f64,
right_text: &str,
text_color: Color,
track: Color,
accent: Color,
dpi: u32,
) {
let scaled = |v: i32| scale_to_dpi(v, dpi);
let label_w = scaled(LABEL_W_LOGICAL);
draw_text(
hdc,
label,
text_color,
label_x,
row_y - scaled(2),
label_w,
bar_h + scaled(4),
false,
dpi,
);
unsafe {
let track_brush = CreateSolidBrush(COLORREF(track.to_colorref()));
let bar_rect = RECT {
left: bar_x,
top: row_y,
right: bar_x + bar_w,
bottom: row_y + bar_h,
};
FillRect(hdc, &bar_rect, track_brush);
let _ = DeleteObject(track_brush);
let fill_w = ((pct.clamp(0.0, 100.0) / 100.0) * bar_w as f64).round() as i32;
if fill_w > 0 {
let accent_brush = CreateSolidBrush(COLORREF(accent.to_colorref()));
let fill_rect = RECT {
left: bar_x,
top: row_y,
right: bar_x + fill_w,
bottom: row_y + bar_h,
};
FillRect(hdc, &fill_rect, accent_brush);
let _ = DeleteObject(accent_brush);
}
}
let right_text_x = bar_x + bar_w + scaled(8);
let right_text_w = scaled(RIGHT_TEXT_W_LOGICAL);
draw_text(
hdc,
right_text,
text_color,
right_text_x,
row_y - scaled(2),
right_text_w,
bar_h + scaled(4),
false,
dpi,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_text(
hdc: HDC,
text: &str,
color: Color,
x: i32,
y: i32,
w: i32,
h: i32,
bold: bool,
dpi: u32,
) {
let mut text_w: Vec<u16> = text.encode_utf16().collect();
let font_size = if bold {
scale_to_dpi(13, dpi)
} else {
scale_to_dpi(11, dpi)
};
let font_name = wide_str("Segoe UI");
unsafe {
let weight = if bold { FW_SEMIBOLD.0 } else { FW_NORMAL.0 } as i32;
let font = CreateFontW(
-font_size,
0,
0,
0,
weight,
0,
0,
0,
DEFAULT_CHARSET.0 as u32,
OUT_DEFAULT_PRECIS.0 as u32,
CLIP_DEFAULT_PRECIS.0 as u32,
CLEARTYPE_QUALITY.0 as u32,
(FF_SWISS.0 | DEFAULT_PITCH.0) as u32,
PCWSTR::from_raw(font_name.as_ptr()),
);
let old_font = SelectObject(hdc, font);
SetTextColor(hdc, COLORREF(color.to_colorref()));
SetBkMode(hdc, TRANSPARENT);
let mut rect = RECT {
left: x,
top: y,
right: x + w,
bottom: y + h,
};
let _ = DrawTextW(
hdc,
&mut text_w,
&mut rect,
DT_LEFT | DT_VCENTER | DT_SINGLELINE,
);
SelectObject(hdc, old_font);
let _ = DeleteObject(font);
}
}
fn bar_color_for(percent: f64, _is_dark: bool) -> Color {
crate::bubble::ring_color_for_percent(percent)
}
fn clone_data() -> Option<PanelData> {
let guard = lock_state();
let p = guard.as_ref()?;
Some(PanelData {
model: p.data.model,
session_pct: p.data.session_pct,
session_text: p.data.session_text.clone(),
weekly_pct: p.data.weekly_pct,
weekly_text: p.data.weekly_text.clone(),
is_dark: p.data.is_dark,
strings: p.data.strings,
claude_label: p.data.claude_label.clone(),
codex_label: p.data.codex_label.clone(),
})
}
fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
// Anchor below the bubble by default; flip above if it would clip.
let mut x = anchor.left;
let mut y = anchor.bottom + 8;
let virtual_screen_h = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
let virtual_screen_w = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
if y + panel_h > virtual_screen_h {
y = anchor.top - panel_h - 8;
}
if y < 0 {
y = anchor.top;
}
if x + panel_w > virtual_screen_w {
x = virtual_screen_w - panel_w - 8;
}
if x < 0 {
x = 8;
}
(x, y)
}
fn scale_to_dpi(logical: i32, dpi: u32) -> i32 {
((logical as i64) * (dpi as i64) / 96) as i32
}
+1099
View File
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::bubble::DEFAULT_BUBBLE_SIZE;
use crate::tray_icon::TrayIconKind;
const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble";
const SETTINGS_FILE: &str = "settings.json";
pub const POLL_1_MIN: u32 = 60_000;
pub const POLL_5_MIN: u32 = 5 * 60_000;
pub const POLL_15_MIN: u32 = 15 * 60_000;
pub const POLL_1_HOUR: u32 = 60 * 60_000;
fn default_show_claude() -> bool {
true
}
fn default_show_codex() -> bool {
false
}
fn default_widget_visible() -> bool {
true
}
fn default_bubble_size() -> i32 {
DEFAULT_BUBBLE_SIZE
}
fn default_poll_interval_ms() -> u32 {
POLL_5_MIN
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BubblePositions {
pub claude: Option<(i32, i32)>,
pub codex: Option<(i32, i32)>,
}
impl BubblePositions {
pub fn get(&self, model: TrayIconKind) -> Option<(i32, i32)> {
match model {
TrayIconKind::Claude => self.claude,
TrayIconKind::Codex => self.codex,
}
}
pub fn set(&mut self, model: TrayIconKind, pos: (i32, i32)) {
match model {
TrayIconKind::Claude => self.claude = Some(pos),
TrayIconKind::Codex => self.codex = Some(pos),
}
}
pub fn reset(&mut self, model: TrayIconKind) {
match model {
TrayIconKind::Claude => self.claude = None,
TrayIconKind::Codex => self.codex = None,
}
}
pub fn reset_all(&mut self) {
self.claude = None;
self.codex = None;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "default_show_claude")]
pub show_claude_code: bool,
#[serde(default = "default_show_codex")]
pub show_codex: bool,
#[serde(default)]
pub bubble_positions: BubblePositions,
#[serde(default = "default_bubble_size")]
pub bubble_size_logical: i32,
#[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u32,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub last_update_check_unix: Option<u64>,
#[serde(default = "default_widget_visible")]
pub widget_visible: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
show_claude_code: default_show_claude(),
show_codex: default_show_codex(),
bubble_positions: BubblePositions::default(),
bubble_size_logical: default_bubble_size(),
poll_interval_ms: default_poll_interval_ms(),
language: None,
last_update_check_unix: None,
widget_visible: default_widget_visible(),
}
}
}
pub fn settings_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join(APP_DIR_NAME))
}
pub fn settings_path() -> PathBuf {
settings_dir()
.unwrap_or_else(|| std::env::temp_dir().join(APP_DIR_NAME))
.join(SETTINGS_FILE)
}
pub fn load() -> Settings {
let path = settings_path();
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return Settings::default(),
};
let mut settings: Settings = serde_json::from_str(&content).unwrap_or_default();
// At least one model must be visible. Otherwise the app has nothing to show.
if !settings.show_claude_code && !settings.show_codex {
settings.show_claude_code = true;
}
// Clamp bubble size to safe range in case settings.json was hand-edited.
settings.bubble_size_logical = settings
.bubble_size_logical
.clamp(crate::bubble::MIN_BUBBLE_SIZE, crate::bubble::MAX_BUBBLE_SIZE);
settings
}
pub fn save(settings: &Settings) {
let path = settings_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let Ok(json) = serde_json::to_string_pretty(settings) else {
return;
};
// Atomic write: tmp then rename. Falls back to direct write on rename failure.
let tmp_path = path.with_extension("json.tmp");
if std::fs::write(&tmp_path, &json).is_ok() && std::fs::rename(&tmp_path, &path).is_ok() {
return;
}
let _ = std::fs::write(&path, json);
}
+51
View File
@@ -0,0 +1,51 @@
use windows::core::PCWSTR;
use windows::Win32::System::Registry::*;
use crate::native_interop::wide_str;
const REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
const REGISTRY_KEY: &str = "SystemUsesLightTheme";
/// Check if the system is in dark mode by reading the registry
pub fn is_dark_mode() -> bool {
!is_light_theme()
}
fn is_light_theme() -> bool {
unsafe {
let path = wide_str(REGISTRY_PATH);
let key_name = wide_str(REGISTRY_KEY);
let mut hkey = HKEY::default();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR::from_raw(path.as_ptr()),
0,
KEY_READ,
&mut hkey,
);
if result.is_err() {
return false; // Default to dark mode
}
let mut data: u32 = 0;
let mut data_size: u32 = std::mem::size_of::<u32>() as u32;
let result = RegQueryValueExW(
hkey,
PCWSTR::from_raw(key_name.as_ptr()),
None,
None,
Some(&mut data as *mut u32 as *mut u8),
Some(&mut data_size),
);
let _ = RegCloseKey(hkey);
if result.is_err() {
return false; // Default to dark mode
}
data == 1
}
}
+441
View File
@@ -0,0 +1,441 @@
use windows::core::PCWSTR;
use windows::Win32::Foundation::*;
use windows::Win32::Graphics::Gdi::*;
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
use windows::Win32::UI::Shell::{
ExtractIconExW, Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_WARNING,
NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW,
};
use windows::Win32::UI::WindowsAndMessaging::*;
use crate::native_interop::{self, Color, WM_APP_TRAY};
const CLAUDE_TRAY_ICON_ID: u32 = 1;
const CODEX_TRAY_ICON_ID: u32 = 2;
/// Menu item ID for toggling widget visibility (used by window.rs context menu).
pub const IDM_TOGGLE_WIDGET: u16 = 50;
/// Actions the tray message handler can request from the main window.
pub enum TrayAction {
None,
ToggleWidget,
ShowContextMenu,
}
#[derive(Clone, Copy)]
pub enum TrayIconKind {
Claude,
Codex,
}
pub struct TrayIconData {
pub kind: TrayIconKind,
pub percent: Option<f64>,
pub tooltip: String,
}
impl TrayIconKind {
fn id(self) -> u32 {
match self {
Self::Claude => CLAUDE_TRAY_ICON_ID,
Self::Codex => CODEX_TRAY_ICON_ID,
}
}
}
fn lerp_channel(start: u8, end: u8, t: f64) -> u8 {
(start as f64 + (end as f64 - start as f64) * t.clamp(0.0, 1.0)).round() as u8
}
fn lerp_color(start: Color, end: Color, t: f64) -> Color {
Color::new(
lerp_channel(start.r, end.r, t),
lerp_channel(start.g, end.g, t),
lerp_channel(start.b, end.b, t),
)
}
fn interpolated_fill(percent: f64) -> Color {
if percent <= 50.0 {
return Color::from_hex("#D97757");
}
let stops = [
(50.0, Color::from_hex("#D97757")),
(70.0, Color::from_hex("#D08540")),
(85.0, Color::from_hex("#CC8C20")),
(95.0, Color::from_hex("#C45020")),
(100.0, Color::from_hex("#B82020")),
];
for pair in stops.windows(2) {
let (start_pct, start_color) = pair[0];
let (end_pct, end_color) = pair[1];
if percent <= end_pct {
let span = (end_pct - start_pct).max(f64::EPSILON);
let t = (percent - start_pct) / span;
return lerp_color(start_color, end_color, t);
}
}
stops[stops.len() - 1].1
}
fn codex_fill(percent: f64) -> Color {
if percent >= 90.0 {
Color::from_hex("#FFFFFF")
} else {
Color::from_hex("#111111")
}
}
/// Create a rounded-rectangle tray icon badge showing the usage percentage.
/// For Claude, `percent` = None uses the embedded app icon as the loading state.
/// For Codex, `percent` = None uses a black/white Codex placeholder badge.
pub fn create_icon(kind: TrayIconKind, percent: Option<f64>) -> HICON {
if matches!(kind, TrayIconKind::Claude) && percent.is_none() {
let app_icon = load_embedded_app_icon();
if !app_icon.is_invalid() {
return app_icon;
}
}
let size = 64_i32;
let margin = 0_i32;
let radius = 2_i32;
let outline = if matches!(kind, TrayIconKind::Codex) {
3_i32
} else {
0_i32
};
let fill = match kind {
TrayIconKind::Claude => interpolated_fill(percent.unwrap_or(0.0)),
TrayIconKind::Codex => codex_fill(percent.unwrap_or(0.0)),
};
let text_col = match kind {
TrayIconKind::Claude => Color::from_hex("#FFFFFF"),
TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"),
TrayIconKind::Codex => Color::from_hex("#FFFFFF"),
};
let outline_col = match kind {
TrayIconKind::Claude => fill,
TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"),
TrayIconKind::Codex => Color::from_hex("#FFFFFF"),
};
let display_text = match percent {
Some(p) => format!("{}", p.round().clamp(0.0, 999.0) as u32),
None => match kind {
TrayIconKind::Claude => String::new(),
TrayIconKind::Codex => "C".to_string(),
},
};
let font_h = match display_text.len() {
1 => -50,
2 => -42,
_ => -30,
};
unsafe {
let screen_dc = GetDC(HWND::default());
let mem_dc = CreateCompatibleDC(screen_dc);
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: size,
biHeight: -size,
biPlanes: 1,
biBitCount: 32,
biCompression: 0,
..Default::default()
},
..Default::default()
};
let mut bits: *mut std::ffi::c_void = std::ptr::null_mut();
let dib =
CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default();
if dib.is_invalid() {
let _ = DeleteDC(mem_dc);
ReleaseDC(HWND::default(), screen_dc);
return HICON::default();
}
let old_bmp = SelectObject(mem_dc, dib);
// Zero-fill (transparent background)
let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, (size * size) as usize);
for px in pixel_data.iter_mut() {
*px = 0;
}
// Draw rounded rectangle badge
let null_pen = GetStockObject(NULL_PEN);
let old_pen = SelectObject(mem_dc, null_pen);
if outline > 0 {
let br_outline = CreateSolidBrush(COLORREF(outline_col.to_colorref()));
let old_brush = SelectObject(mem_dc, br_outline);
let _ = RoundRect(
mem_dc,
margin,
margin,
size - margin + 1,
size - margin + 1,
(radius + 1) * 2,
(radius + 1) * 2,
);
SelectObject(mem_dc, old_brush);
let _ = DeleteObject(br_outline);
}
let br_fill = CreateSolidBrush(COLORREF(fill.to_colorref()));
let old_brush = SelectObject(mem_dc, br_fill);
let _ = RoundRect(
mem_dc,
margin + outline,
margin + outline,
size - margin - outline + 1,
size - margin - outline + 1,
(radius - 1) * 2,
(radius - 1) * 2,
);
SelectObject(mem_dc, old_brush);
SelectObject(mem_dc, old_pen);
let _ = DeleteObject(br_fill);
// Draw centered percentage text
let font_name = native_interop::wide_str("Arial Bold");
let font = CreateFontW(
font_h,
0,
0,
0,
FW_BOLD.0 as i32,
0,
0,
0,
DEFAULT_CHARSET.0 as u32,
OUT_TT_PRECIS.0 as u32,
CLIP_DEFAULT_PRECIS.0 as u32,
ANTIALIASED_QUALITY.0 as u32,
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
PCWSTR::from_raw(font_name.as_ptr()),
);
let old_font = SelectObject(mem_dc, font);
let _ = SetBkMode(mem_dc, TRANSPARENT);
let _ = SetTextColor(mem_dc, COLORREF(text_col.to_colorref()));
let mut text_rect = RECT {
left: margin,
top: margin,
right: size - margin,
bottom: size - margin,
};
let mut text_wide: Vec<u16> = display_text.encode_utf16().collect();
let _ = DrawTextW(
mem_dc,
&mut text_wide,
&mut text_rect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE,
);
SelectObject(mem_dc, old_font);
let _ = DeleteObject(font);
// Set alpha: non-zero BGR pixel -> fully opaque; background stays transparent
for px in pixel_data.iter_mut() {
if *px != 0 {
*px = (*px & 0x00FF_FFFF) | 0xFF00_0000;
}
}
// Monochrome mask (per-pixel alpha from colour bitmap)
let mask_bytes = vec![0u8; ((size * size + 7) / 8) as usize];
let mask_bmp = CreateBitmap(
size,
size,
1,
1,
Some(mask_bytes.as_ptr() as *const std::ffi::c_void),
);
let icon_info = ICONINFO {
fIcon: TRUE,
xHotspot: 0,
yHotspot: 0,
hbmMask: mask_bmp,
hbmColor: dib,
};
let hicon = CreateIconIndirect(&icon_info).unwrap_or_default();
let _ = DeleteObject(mask_bmp);
SelectObject(mem_dc, old_bmp);
let _ = DeleteObject(dib);
let _ = DeleteDC(mem_dc);
ReleaseDC(HWND::default(), screen_dc);
hicon
}
}
fn load_embedded_app_icon() -> HICON {
unsafe {
let mut exe_buf = [0u16; 260];
let len = GetModuleFileNameW(None, &mut exe_buf) as usize;
if len == 0 {
return HICON::default();
}
let mut small_icon = HICON::default();
let mut large_icon = HICON::default();
let extracted = ExtractIconExW(
PCWSTR::from_raw(exe_buf.as_ptr()),
0,
Some(&mut large_icon),
Some(&mut small_icon),
1,
);
if extracted == 0 {
HICON::default()
} else if !small_icon.is_invalid() {
small_icon
} else {
large_icon
}
}
}
/// Show a Windows balloon notification from the tray icon.
/// Used to alert the user when re-authentication is required.
pub fn notify_balloon(hwnd: HWND, kind: TrayIconKind, title: &str, message: &str) {
unsafe {
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
nid.hWnd = hwnd;
nid.uID = kind.id();
nid.uFlags = NIF_INFO;
nid.dwInfoFlags = NIIF_WARNING;
copy_wide(title, &mut nid.szInfoTitle);
copy_wide_256(message, &mut nid.szInfo);
let _ = Shell_NotifyIconW(NIM_MODIFY, &nid);
}
}
/// Copy a string into a fixed-size wide buffer (truncates to fit).
fn copy_wide<const N: usize>(s: &str, buf: &mut [u16; N]) {
let wide: Vec<u16> = s.encode_utf16().collect();
let len = wide.len().min(N - 1);
buf[..len].copy_from_slice(&wide[..len]);
buf[len] = 0;
}
/// Copy a string into a 256-wide buffer.
fn copy_wide_256(s: &str, buf: &mut [u16; 256]) {
copy_wide(s, buf)
}
/// Register the tray icon with the shell.
pub fn add(hwnd: HWND, kind: TrayIconKind, percent: Option<f64>, tooltip: &str) {
let hicon = create_icon(kind, percent);
unsafe {
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
nid.hWnd = hwnd;
nid.uID = kind.id();
nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
nid.uCallbackMessage = WM_APP_TRAY;
nid.hIcon = hicon;
copy_to_tip(tooltip, &mut nid.szTip);
let _ = Shell_NotifyIconW(NIM_ADD, &nid);
if !hicon.is_invalid() {
let _ = DestroyIcon(hicon);
}
}
}
/// Update the tray icon colour and tooltip to reflect current usage.
pub fn update(hwnd: HWND, kind: TrayIconKind, percent: Option<f64>, tooltip: &str) {
let hicon = create_icon(kind, percent);
unsafe {
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
nid.hWnd = hwnd;
nid.uID = kind.id();
nid.uFlags = NIF_ICON | NIF_TIP;
nid.hIcon = hicon;
copy_to_tip(tooltip, &mut nid.szTip);
let _ = Shell_NotifyIconW(NIM_MODIFY, &nid);
if !hicon.is_invalid() {
let _ = DestroyIcon(hicon);
}
}
}
/// Remove the tray icon from the shell.
pub fn remove(hwnd: HWND, kind: TrayIconKind) {
unsafe {
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
nid.hWnd = hwnd;
nid.uID = kind.id();
let _ = Shell_NotifyIconW(NIM_DELETE, &nid);
}
}
pub fn sync(hwnd: HWND, icons: &[TrayIconData]) {
let show_claude = icons
.iter()
.find(|icon| matches!(icon.kind, TrayIconKind::Claude));
let show_codex = icons
.iter()
.find(|icon| matches!(icon.kind, TrayIconKind::Codex));
if let Some(icon) = show_claude {
add(hwnd, icon.kind, icon.percent, &icon.tooltip);
update(hwnd, icon.kind, icon.percent, &icon.tooltip);
} else {
remove(hwnd, TrayIconKind::Claude);
}
if let Some(icon) = show_codex {
add(hwnd, icon.kind, icon.percent, &icon.tooltip);
update(hwnd, icon.kind, icon.percent, &icon.tooltip);
} else {
remove(hwnd, TrayIconKind::Codex);
}
}
pub fn remove_all(hwnd: HWND) {
remove(hwnd, TrayIconKind::Claude);
remove(hwnd, TrayIconKind::Codex);
}
/// Interpret a tray callback message and return the action to take.
pub fn handle_message(lparam: LPARAM) -> TrayAction {
let mouse_msg = lparam.0 as u32;
match mouse_msg {
WM_LBUTTONUP => TrayAction::ToggleWidget,
WM_RBUTTONUP => TrayAction::ShowContextMenu,
_ => TrayAction::None,
}
}
/// Copy a string into the fixed-size szTip field (max 127 chars + null).
fn copy_to_tip(s: &str, tip: &mut [u16; 128]) {
let wide: Vec<u16> = s.encode_utf16().collect();
let mut len = wide.len().min(127);
// Don't leave a lone high surrogate at the truncation point
if len > 0 && (0xD800..=0xDBFF).contains(&wide[len - 1]) {
len -= 1;
}
tip[..len].copy_from_slice(&wide[..len]);
tip[len] = 0;
}
+512
View File
@@ -0,0 +1,512 @@
use std::fs::File;
use std::io::{self, Write};
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use serde::Deserialize;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{HWND, WAIT_OBJECT_0, WAIT_TIMEOUT};
use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE};
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
const GITHUB_API_ACCEPT: &str = "application/vnd.github+json";
const GITHUB_API_VERSION: &str = "2022-11-28";
const RELEASE_ASSET_NAME: &str = "claude-code-usage-bubble.exe";
const HELPER_EXE_NAME: &str = "updater-helper.exe";
const DOWNLOAD_EXE_NAME: &str = "update-download.exe";
const CREATE_NO_WINDOW: u32 = 0x08000000;
const CREATE_NEW_CONSOLE: u32 = 0x00000010;
// Reserved for future winget submission. Until then `current_install_channel`
// always returns `Portable` and this constant is unused.
#[allow(dead_code)]
const WINGET_PACKAGE_ID: &str = "ClaudeCodeUsageBubble";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InstallChannel {
Portable,
Winget,
}
#[derive(Clone, Debug)]
pub struct ReleaseDescriptor {
pub latest_version: String,
asset_url: String,
}
#[derive(Debug)]
pub enum UpdateCheckResult {
UpToDate,
Available(ReleaseDescriptor),
}
#[derive(Deserialize)]
struct GitHubRelease {
tag_name: String,
assets: Vec<GitHubAsset>,
}
#[derive(Deserialize)]
struct GitHubAsset {
name: String,
browser_download_url: String,
}
pub fn handle_cli_mode(args: &[String]) -> Option<i32> {
if args.len() == 5 && args[1] == "--apply-update" {
let target = PathBuf::from(&args[2]);
let source = PathBuf::from(&args[3]);
let pid = args[4].parse::<u32>().unwrap_or(0);
return Some(match apply_update(target, source, pid) {
Ok(()) => 0,
Err(error) => {
show_error_message("Update failed", &error);
1
}
});
}
None
}
pub fn current_install_channel() -> InstallChannel {
InstallChannel::Portable
}
pub fn check_for_updates() -> Result<UpdateCheckResult, String> {
match fetch_latest_release()? {
Some(release) => Ok(UpdateCheckResult::Available(release)),
None => Ok(UpdateCheckResult::UpToDate),
}
}
pub fn begin_winget_update() -> Result<(), String> {
let current_exe =
std::env::current_exe().map_err(|e| format!("Unable to locate current executable: {e}"))?;
let current_dir = current_exe
.parent()
.ok_or_else(|| "Unable to determine the app directory for restart.".to_string())?;
let command = winget_upgrade_command(
std::process::id(),
&current_exe.to_string_lossy(),
&current_dir.to_string_lossy(),
);
Command::new("powershell.exe")
.arg("-NoLogo")
.arg("-Command")
.arg(&command)
.creation_flags(CREATE_NEW_CONSOLE)
.spawn()
.map_err(|e| format!("Unable to launch WinGet update command: {e}"))?;
Ok(())
}
pub fn begin_self_update(release: &ReleaseDescriptor) -> Result<(), String> {
let current_exe =
std::env::current_exe().map_err(|e| format!("Unable to locate current executable: {e}"))?;
ensure_target_location_writable(&current_exe)?;
let stage_dir = updates_dir()?;
std::fs::create_dir_all(&stage_dir)
.map_err(|e| format!("Unable to create updater working directory: {e}"))?;
let helper_path = stage_dir.join(HELPER_EXE_NAME);
let download_path = stage_dir.join(DOWNLOAD_EXE_NAME);
let partial_download_path = stage_dir.join(format!("{DOWNLOAD_EXE_NAME}.part"));
if helper_path.exists() {
let _ = std::fs::remove_file(&helper_path);
}
if download_path.exists() {
let _ = std::fs::remove_file(&download_path);
}
if partial_download_path.exists() {
let _ = std::fs::remove_file(&partial_download_path);
}
download_release_asset(&release.asset_url, &partial_download_path, &download_path)?;
std::fs::copy(&current_exe, &helper_path)
.map_err(|e| format!("Unable to prepare updater helper: {e}"))?;
let pid = std::process::id().to_string();
let target = current_exe.to_string_lossy().to_string();
let source = download_path.to_string_lossy().to_string();
Command::new(&helper_path)
.arg("--apply-update")
.arg(target)
.arg(source)
.arg(pid)
.creation_flags(CREATE_NO_WINDOW)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("Unable to launch updater helper: {e}"))?;
Ok(())
}
fn apply_update(target: PathBuf, source: PathBuf, pid: u32) -> Result<(), String> {
if !source.exists() {
return Err(format!(
"Downloaded update not found at {}",
source.display()
));
}
let _ = wait_for_process_exit(pid, Duration::from_secs(30));
replace_target_binary(&target, &source)?;
relaunch_target(&target)?;
let _ = std::fs::remove_file(&source);
Ok(())
}
fn fetch_latest_release() -> Result<Option<ReleaseDescriptor>, String> {
let (owner, repo) = github_repo()?;
let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/latest");
let agent = build_agent()?;
let response = agent
.get(&url)
.set("Accept", GITHUB_API_ACCEPT)
.set("User-Agent", user_agent())
.set("X-GitHub-Api-Version", GITHUB_API_VERSION)
.call()
.map_err(|e| format!("Unable to check GitHub releases: {e}"))?;
let release: GitHubRelease = response
.into_json()
.map_err(|e| format!("Unable to parse GitHub release data: {e}"))?;
let latest_version = release.tag_name.trim_start_matches('v').to_string();
if !is_version_newer(&latest_version, env!("CARGO_PKG_VERSION")) {
return Ok(None);
}
let asset = release
.assets
.iter()
.find(|asset| asset.name.eq_ignore_ascii_case(RELEASE_ASSET_NAME))
.or_else(|| {
release
.assets
.iter()
.find(|asset| asset.name.to_ascii_lowercase().ends_with(".exe"))
})
.ok_or_else(|| {
"No Windows executable asset was found in the latest release.".to_string()
})?;
Ok(Some(ReleaseDescriptor {
latest_version,
asset_url: asset.browser_download_url.clone(),
}))
}
fn build_agent() -> Result<ureq::Agent, String> {
let tls = native_tls::TlsConnector::new()
.map_err(|e| format!("Unable to initialize TLS support for update checks: {e}"))?;
Ok(ureq::AgentBuilder::new()
.timeout(Duration::from_secs(30))
.tls_connector(std::sync::Arc::new(tls))
.build())
}
fn download_release_asset(url: &str, partial_path: &Path, final_path: &Path) -> Result<(), String> {
let agent = build_agent()?;
let response = agent
.get(url)
.set("User-Agent", user_agent())
.call()
.map_err(|e| format!("Unable to download the latest release: {e}"))?;
let mut reader = response.into_reader();
let mut file = File::create(partial_path)
.map_err(|e| format!("Unable to create temporary download file: {e}"))?;
io::copy(&mut reader, &mut file)
.map_err(|e| format!("Unable to write the downloaded update: {e}"))?;
file.flush()
.map_err(|e| format!("Unable to finalize the downloaded update: {e}"))?;
std::fs::rename(partial_path, final_path)
.map_err(|e| format!("Unable to finalize the downloaded update file: {e}"))?;
Ok(())
}
fn replace_target_binary(target: &Path, source: &Path) -> Result<(), String> {
let backup_path = backup_path_for(target);
let mut last_error = None;
for _ in 0..60 {
let _ = std::fs::remove_file(&backup_path);
let renamed_existing = match std::fs::rename(target, &backup_path) {
Ok(()) => true,
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
Err(error) => {
last_error = Some(error);
std::thread::sleep(Duration::from_millis(500));
continue;
}
};
match std::fs::copy(source, target) {
Ok(_) => {
let _ = std::fs::remove_file(&backup_path);
return Ok(());
}
Err(error) => {
last_error = Some(error);
let _ = std::fs::remove_file(target);
if renamed_existing {
let _ = std::fs::rename(&backup_path, target);
}
}
}
std::thread::sleep(Duration::from_millis(500));
}
Err(format!(
"Unable to replace {}. {}",
target.display(),
last_error
.map(|error| error.to_string())
.unwrap_or_else(|| {
"The file may still be locked or the install directory may not be writable."
.to_string()
})
))
}
fn relaunch_target(target: &Path) -> Result<(), String> {
let mut command = Command::new(target);
if let Some(parent) = target.parent() {
command.current_dir(parent);
}
command
.creation_flags(CREATE_NO_WINDOW)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| {
format!(
"The update was installed, but the app could not be restarted automatically: {e}"
)
})?;
Ok(())
}
fn wait_for_process_exit(pid: u32, timeout: Duration) -> Result<(), String> {
if pid == 0 {
return Ok(());
}
unsafe {
let handle = OpenProcess(PROCESS_SYNCHRONIZE, false, pid)
.map_err(|e| format!("Unable to monitor the running app process: {e}"))?;
let result = WaitForSingleObject(handle, timeout.as_millis().min(u32::MAX as u128) as u32);
let _ = windows::Win32::Foundation::CloseHandle(handle);
if result == WAIT_OBJECT_0 {
Ok(())
} else if result == WAIT_TIMEOUT {
Err("Timed out waiting for the running app to exit.".to_string())
} else {
Err("Unable to confirm that the running app has exited.".to_string())
}
}
}
fn updates_dir() -> Result<PathBuf, String> {
dirs::data_local_dir()
.map(|dir| dir.join("ClaudeCodeUsageBubble").join("updates"))
.or_else(|| {
Some(
std::env::temp_dir()
.join("ClaudeCodeUsageBubble")
.join("updates"),
)
})
.ok_or_else(|| "Unable to resolve a writable local updates directory.".to_string())
}
fn winget_upgrade_command(pid: u32, target: &str, working_dir: &str) -> String {
let target = powershell_single_quoted(target);
let working_dir = powershell_single_quoted(working_dir);
let package_id = WINGET_PACKAGE_ID;
format!(
concat!(
"$ErrorActionPreference = 'Stop'; ",
"$pidToWait = {pid}; ",
"$target = '{target}'; ",
"$workingDir = '{working_dir}'; ",
"try {{ Wait-Process -Id $pidToWait -Timeout 30 -ErrorAction Stop }} catch {{ }}; ",
"winget upgrade --id {package_id} --exact; ",
"$exitCode = $LASTEXITCODE; ",
"if ($exitCode -eq 0) {{ ",
"Start-Sleep -Seconds 2; ",
"Start-Process -FilePath $target -WorkingDirectory $workingDir; ",
"exit 0 ",
"}}; ",
"Write-Host ''; ",
"Write-Host 'WinGet update failed with exit code' $exitCode; ",
"Read-Host 'Press Enter to close'; ",
"exit $exitCode"
),
pid = pid,
target = target,
working_dir = working_dir,
package_id = package_id,
)
}
fn powershell_single_quoted(value: &str) -> String {
value.replace('\'', "''")
}
fn backup_path_for(target: &Path) -> PathBuf {
let file_name = target
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("app.exe");
target.with_file_name(format!("{file_name}.old"))
}
fn ensure_target_location_writable(target: &Path) -> Result<(), String> {
let parent = target.parent().ok_or_else(|| {
"Unable to determine the install directory for the current executable.".to_string()
})?;
let probe_path = parent.join(".__ccum_update_probe");
match File::create(&probe_path) {
Ok(_) => {
let _ = std::fs::remove_file(&probe_path);
Ok(())
}
Err(error) => Err(format!(
"The current install location is not writable. Move the app to a user-writable folder or install it somewhere outside Program Files. {error}"
)),
}
}
fn github_repo() -> Result<(&'static str, &'static str), String> {
let repository = env!("CARGO_PKG_REPOSITORY").trim_end_matches('/');
let parts: Vec<&str> = repository.split('/').collect();
if parts.len() < 2 {
return Err("Package repository URL is not configured for GitHub releases.".to_string());
}
let owner = parts[parts.len() - 2];
let repo = parts[parts.len() - 1];
if owner.is_empty() || repo.is_empty() {
return Err("Package repository URL is not configured for GitHub releases.".to_string());
}
Ok((owner, repo))
}
fn user_agent() -> &'static str {
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))
}
#[allow(dead_code)]
fn is_winget_install_path(path: &Path) -> bool {
let normalized_path = normalize_path(path);
winget_install_roots()
.into_iter()
.map(|root| normalize_path(&root))
.any(|root| normalized_path.starts_with(&root))
}
#[allow(dead_code)]
fn winget_install_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
roots.push(
PathBuf::from(local_app_data)
.join("Microsoft")
.join("WinGet")
.join("Packages"),
);
}
if let Ok(program_files) = std::env::var("ProgramFiles") {
roots.push(PathBuf::from(program_files).join("WinGet").join("Packages"));
} else {
roots.push(PathBuf::from(r"C:\Program Files\WinGet\Packages"));
}
if let Ok(program_files_x86) = std::env::var("ProgramFiles(x86)") {
roots.push(
PathBuf::from(program_files_x86)
.join("WinGet")
.join("Packages"),
);
} else {
roots.push(PathBuf::from(r"C:\Program Files (x86)\WinGet\Packages"));
}
roots
}
#[allow(dead_code)]
fn normalize_path(path: &Path) -> String {
let normalized = path
.to_string_lossy()
.replace('/', "\\")
.trim_end_matches('\\')
.to_ascii_lowercase();
normalized
.strip_prefix("\\\\?\\unc\\")
.map(|rest| format!("\\\\{rest}"))
.or_else(|| normalized.strip_prefix("\\\\?\\").map(str::to_owned))
.unwrap_or(normalized)
}
fn is_version_newer(candidate: &str, current: &str) -> bool {
parse_version(candidate) > parse_version(current)
}
fn parse_version(version: &str) -> (u32, u32, u32) {
let core = version.split('-').next().unwrap_or(version);
let mut parts = core.split('.').map(|part| part.parse::<u32>().unwrap_or(0));
(
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
)
}
fn show_error_message(title: &str, message: &str) {
unsafe {
let title_wide = wide_str(title);
let message_wide = wide_str(message);
let _ = MessageBoxW(
HWND::default(),
PCWSTR::from_raw(message_wide.as_ptr()),
PCWSTR::from_raw(title_wide.as_ptr()),
MB_OK | MB_ICONERROR,
);
}
}
fn wide_str(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}