commit c0f3e3f860e018f041d712473d73d7b976ab50bb Author: tiennm99 Date: Fri May 15 21:27:31 2026 +0700 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dd698e2 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..df7a474 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..957086e --- /dev/null +++ b/NOTICE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6661b35 --- /dev/null +++ b/README.md @@ -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 (32–128 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//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). diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..3146884 --- /dev/null +++ b/build.rs @@ -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::().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) +} diff --git a/plans/20260515-claude-code-usage-bubble-port/phase-01-bootstrap-repo.md b/plans/20260515-claude-code-usage-bubble-port/phase-01-bootstrap-repo.md new file mode 100644 index 0000000..aba585f --- /dev/null +++ b/plans/20260515-claude-code-usage-bubble-port/phase-01-bootstrap-repo.md @@ -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 = "" + ``` + 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/` diff --git a/plans/20260515-claude-code-usage-bubble-port/phase-02-port-portable-modules.md b/plans/20260515-claude-code-usage-bubble-port/phase-02-port-portable-modules.md new file mode 100644 index 0000000..470effd --- /dev/null +++ b/plans/20260515-claude-code-usage-bubble-port/phase-02-port-portable-modules.md @@ -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`) diff --git a/plans/20260515-claude-code-usage-bubble-port/phase-03-build-bubble-window.md b/plans/20260515-claude-code-usage-bubble-port/phase-03-build-bubble-window.md new file mode 100644 index 0000000..e8ec33b --- /dev/null +++ b/plans/20260515-claude-code-usage-bubble-port/phase-03-build-bubble-window.md @@ -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` 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: 400–700 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, + } + pub fn update_percentage(hwnd: HWND, percentage: Option); // 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 diff --git a/plans/20260515-claude-code-usage-bubble-port/phase-04-panel-and-orchestration.md b/plans/20260515-claude-code-usage-bubble-port/phase-04-panel-and-orchestration.md new file mode 100644 index 0000000..0bc6d86 --- /dev/null +++ b/plans/20260515-claude-code-usage-bubble-port/phase-04-panel-and-orchestration.md @@ -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: 500–800 lines) +- `src/panel.rs` (target: 300–500 lines) +- `src/settings.rs` (target: 150–250 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 = 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, // None = system + pub start_with_windows: bool, + pub last_update_check_unix: Option, + } + #[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.) diff --git a/plans/20260515-claude-code-usage-bubble-port/phase-05-polish-and-finishing.md b/plans/20260515-claude-code-usage-bubble-port/phase-05-polish-and-finishing.md new file mode 100644 index 0000000..6d45f64 --- /dev/null +++ b/plans/20260515-claude-code-usage-bubble-port/phase-05-polish-and-finishing.md @@ -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 + + Portions of this software are derived from Claude Code Usage Monitor, + Copyright (c) 2026 Code Zeno Pty Ltd, licensed under the MIT License. + + + ``` +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 diff --git a/plans/20260515-claude-code-usage-bubble-port/plan.md b/plans/20260515-claude-code-usage-bubble-port/plan.md new file mode 100644 index 0000000..110edb9 --- /dev/null +++ b/plans/20260515-claude-code-usage-bubble-port/plan.md @@ -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 32–128 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: 1–2h +- Phase 2: 1h (mostly file copies + import path fixes) +- Phase 3: 6–10h (the heavy lift) +- Phase 4: 3–5h +- Phase 5: 2–4h + +**Total:** ~15–22h 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 8–10). diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..aa47313 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1354 @@ +// Application orchestrator: single-instance mutex, settings, polling thread, +// tray icons, context menu, message-only window for cross-thread updates, and +// dispatch from bubble UI callbacks. + +use std::collections::HashMap; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use windows::core::PCWSTR; +use windows::Win32::Foundation::*; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::Registry::*; +use windows::Win32::System::Threading::CreateMutexW; +use windows::Win32::UI::HiDpi::*; +use windows::Win32::UI::WindowsAndMessaging::*; + +use crate::bubble; +use crate::diagnose; +use crate::localization::{self, LanguageId, Strings}; +use crate::models::AppUsageData; +use crate::native_interop::{ + wide_str, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, WM_APP_TRAY, + WM_APP_USAGE_UPDATED, +}; +use crate::panel::{self, PanelData}; +use crate::poller::{self, PollError}; +use crate::settings::{self, BubblePositions, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN}; +use crate::theme; +use crate::tray_icon::{self, TrayAction, TrayIconData, TrayIconKind}; +use crate::updater::{self, InstallChannel, UpdateCheckResult}; + +const APP_MUTEX_NAME: &str = "Global\\ClaudeCodeUsageBubble"; +const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; +const STARTUP_VALUE_NAME: &str = "ClaudeCodeUsageBubble"; +const APP_CLASS_NAME: &str = "ClaudeCodeUsageBubbleApp"; +const UPDATE_CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; +const RETRY_BASE_MS: u32 = 30_000; + +// ---------- Menu command IDs ---------- + +const IDM_REFRESH: u16 = 1; +const IDM_EXIT: u16 = 2; + +const IDM_FREQ_1MIN: u16 = 10; +const IDM_FREQ_5MIN: u16 = 11; +const IDM_FREQ_15MIN: u16 = 12; +const IDM_FREQ_1HOUR: u16 = 13; + +const IDM_MODEL_CLAUDE_CODE: u16 = 20; +const IDM_MODEL_CODEX: u16 = 21; + +const IDM_START_WITH_WINDOWS: u16 = 30; +const IDM_RESET_POSITION: u16 = 31; +const IDM_VERSION_ACTION: u16 = 32; + +const IDM_LANG_SYSTEM: u16 = 40; +const IDM_LANG_ENGLISH: u16 = 41; +const IDM_LANG_DUTCH: u16 = 42; +const IDM_LANG_SPANISH: u16 = 43; +const IDM_LANG_FRENCH: u16 = 44; +const IDM_LANG_GERMAN: u16 = 45; +const IDM_LANG_JAPANESE: u16 = 46; +const IDM_LANG_KOREAN: u16 = 47; +const IDM_LANG_TRADITIONAL_CHINESE: u16 = 48; + +// ---------- State ---------- + +#[derive(Clone, Copy, Default)] +struct SendHwnd(isize); +unsafe impl Send for SendHwnd {} +impl SendHwnd { + fn from_hwnd(h: HWND) -> Self { + Self(h.0 as isize) + } + fn to_hwnd(self) -> HWND { + HWND(self.0 as *mut _) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UpdateStatus { + Idle, + Checking, + UpToDate, + Available, + Applying, + Failed, +} + +struct AppState { + msg_hwnd: SendHwnd, + bubbles: HashMap, + settings: Settings, + language: LanguageId, + is_dark: bool, + install_channel: InstallChannel, + last_poll_ok: bool, + retry_count: u32, + session_text: String, + weekly_text: String, + codex_session_text: String, + codex_weekly_text: String, + session_percent: f64, + weekly_percent: f64, + codex_session_percent: f64, + codex_weekly_percent: f64, + data: AppUsageData, + update_status: UpdateStatus, + update_release: Option, + last_balloon_shown_at: Option, + auth_watch_mode: poller::CredentialWatchMode, + auth_watch_snapshot: poller::CredentialWatchSnapshot, + auth_error_paused_polling: bool, +} + +#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)] +enum TrayIconKindKey { + Claude, + Codex, +} +impl From for TrayIconKindKey { + fn from(k: TrayIconKind) -> Self { + match k { + TrayIconKind::Claude => TrayIconKindKey::Claude, + TrayIconKind::Codex => TrayIconKindKey::Codex, + } + } +} +impl From for TrayIconKind { + fn from(k: TrayIconKindKey) -> Self { + match k { + TrayIconKindKey::Claude => TrayIconKind::Claude, + TrayIconKindKey::Codex => TrayIconKind::Codex, + } + } +} + +fn state() -> &'static Mutex> { + static S: OnceLock>> = OnceLock::new(); + S.get_or_init(|| Mutex::new(None)) +} + +fn lock_state() -> MutexGuard<'static, Option> { + state().lock().expect("app state mutex poisoned") +} + +// ---------- Entry ---------- + +pub fn run() { + unsafe { + let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + } + + // Single-instance guard. + let mutex_name = wide_str(APP_MUTEX_NAME); + let _mutex = unsafe { + let handle = CreateMutexW(None, false, PCWSTR::from_raw(mutex_name.as_ptr())); + match handle { + Ok(h) => { + if GetLastError() == ERROR_ALREADY_EXISTS { + diagnose::log("startup aborted: another instance is already running"); + return; + } + h + } + Err(e) => { + diagnose::log_error("startup aborted: unable to create single-instance mutex", e); + return; + } + } + }; + + let settings = settings::load(); + let language = localization::resolve_language( + settings.language.as_deref().and_then(LanguageId::from_code), + ); + let is_dark = theme::is_dark_mode(); + let install_channel = updater::current_install_channel(); + + let msg_hwnd = match create_message_window() { + Some(h) => h, + None => { + diagnose::log("startup aborted: unable to create app message window"); + return; + } + }; + + *lock_state() = Some(AppState { + msg_hwnd: SendHwnd::from_hwnd(msg_hwnd), + bubbles: HashMap::new(), + settings, + language, + is_dark, + install_channel, + last_poll_ok: false, + retry_count: 0, + session_text: String::new(), + weekly_text: String::new(), + codex_session_text: String::new(), + codex_weekly_text: String::new(), + session_percent: 0.0, + weekly_percent: 0.0, + codex_session_percent: 0.0, + codex_weekly_percent: 0.0, + data: AppUsageData::default(), + update_status: UpdateStatus::Idle, + update_release: None, + last_balloon_shown_at: None, + auth_watch_mode: poller::CredentialWatchMode::ActiveSource, + auth_watch_snapshot: Vec::new(), + auth_error_paused_polling: false, + }); + + create_initial_bubbles(); + refresh_tray_icons(); + + // Timers + unsafe { + let interval = current_poll_interval_ms(); + SetTimer(msg_hwnd, TIMER_POLL, interval, None); + } + schedule_update_check_timer(msg_hwnd); + spawn_poll_thread(); + + diagnose::log("app::run entered message loop"); + let mut msg = MSG::default(); + unsafe { + while GetMessageW(&mut msg, HWND::default(), 0, 0).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } +} + +// ---------- Window creation ---------- + +fn create_message_window() -> Option { + unsafe { + let class_w = wide_str(APP_CLASS_NAME); + let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default(); + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + lpfnWndProc: Some(msg_wnd_proc), + hInstance: HINSTANCE(hinstance.0), + lpszClassName: PCWSTR::from_raw(class_w.as_ptr()), + ..Default::default() + }; + let _ = RegisterClassExW(&wc); + let title_w = wide_str("Claude Code Usage Bubble"); + let hwnd = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + PCWSTR::from_raw(class_w.as_ptr()), + PCWSTR::from_raw(title_w.as_ptr()), + WS_POPUP, + -1000, + -1000, + 1, + 1, + HWND::default(), + HMENU::default(), + hinstance, + None, + ) + .ok()?; + Some(hwnd) + } +} + +fn create_initial_bubbles() { + let (settings, is_dark) = { + let s = lock_state(); + let Some(s) = s.as_ref() else { + return; + }; + (s.settings.clone(), s.is_dark) + }; + + let mut to_create: Vec<(TrayIconKind, Option<(i32, i32)>)> = Vec::new(); + if settings.show_claude_code { + to_create.push((TrayIconKind::Claude, settings.bubble_positions.get(TrayIconKind::Claude))); + } + if settings.show_codex { + to_create.push((TrayIconKind::Codex, settings.bubble_positions.get(TrayIconKind::Codex))); + } + + for (model, pos) in to_create { + let hwnd = bubble::create(bubble::BubbleConfig { + model, + size_logical: settings.bubble_size_logical, + position: pos, + percent: None, + is_dark, + }); + if hwnd != HWND::default() { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.bubbles.insert(model.into(), SendHwnd::from_hwnd(hwnd)); + } + } + } +} + +// ---------- Message-only window proc ---------- + +unsafe extern "system" fn msg_wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_APP_USAGE_UPDATED => { + apply_usage_update(); + LRESULT(0) + } + WM_APP_TRAY => { + let action = tray_icon::handle_message(lparam); + handle_tray_action(action); + LRESULT(0) + } + WM_TIMER => { + on_timer(hwnd, wparam.0); + LRESULT(0) + } + WM_DESTROY => { + PostQuitMessage(0); + LRESULT(0) + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +// ---------- Bubble UI callbacks (called from bubble::wnd_proc) ---------- + +pub fn on_bubble_click(hwnd: HWND, model: TrayIconKind) { + // Toggle expanded panel for this model. + let data = build_panel_data(model); + panel::toggle(data, hwnd); +} + +pub fn on_bubble_right_click(hwnd: HWND, _model: TrayIconKind, _pt: POINT) { + show_context_menu(hwnd); +} + +pub fn on_bubble_moved(model: TrayIconKind, pos: (i32, i32)) { + let snap = { + let mut state = lock_state(); + let Some(s) = state.as_mut() else { + return; + }; + s.settings.bubble_positions.set(model, pos); + s.settings.clone() + }; + settings::save(&snap); +} + +pub fn on_bubble_resized(_model: TrayIconKind, size_logical: i32) { + let snap = { + let mut state = lock_state(); + let Some(s) = state.as_mut() else { + return; + }; + s.settings.bubble_size_logical = size_logical; + s.settings.clone() + }; + settings::save(&snap); +} + +pub fn on_menu_command(id: u32, owner_hwnd: HWND) { + let id = (id & 0xFFFF) as u16; + match id { + IDM_REFRESH => spawn_poll_thread(), + IDM_EXIT => unsafe { + PostQuitMessage(0); + }, + IDM_FREQ_1MIN => set_poll_interval(POLL_1_MIN), + IDM_FREQ_5MIN => set_poll_interval(POLL_5_MIN), + IDM_FREQ_15MIN => set_poll_interval(POLL_15_MIN), + IDM_FREQ_1HOUR => set_poll_interval(POLL_1_HOUR), + IDM_MODEL_CLAUDE_CODE => toggle_model(TrayIconKind::Claude), + IDM_MODEL_CODEX => toggle_model(TrayIconKind::Codex), + IDM_START_WITH_WINDOWS => toggle_startup(), + IDM_RESET_POSITION => reset_positions(), + IDM_VERSION_ACTION => version_action(owner_hwnd), + IDM_LANG_SYSTEM => set_language(None), + IDM_LANG_ENGLISH => set_language(Some(LanguageId::English)), + IDM_LANG_DUTCH => set_language(Some(LanguageId::Dutch)), + IDM_LANG_SPANISH => set_language(Some(LanguageId::Spanish)), + IDM_LANG_FRENCH => set_language(Some(LanguageId::French)), + IDM_LANG_GERMAN => set_language(Some(LanguageId::German)), + IDM_LANG_JAPANESE => set_language(Some(LanguageId::Japanese)), + IDM_LANG_KOREAN => set_language(Some(LanguageId::Korean)), + IDM_LANG_TRADITIONAL_CHINESE => set_language(Some(LanguageId::TraditionalChinese)), + tray_icon::IDM_TOGGLE_WIDGET => toggle_widget_visibility(), + _ => {} + } +} + +// ---------- Timer dispatch ---------- + +fn on_timer(hwnd: HWND, id: usize) { + match id { + TIMER_POLL => spawn_poll_thread(), + TIMER_RESET_POLL => spawn_poll_thread(), + TIMER_COUNTDOWN => refresh_countdowns(), + TIMER_UPDATE_CHECK => { + unsafe { + let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK); + } + begin_update_check(hwnd, false); + } + _ => {} + } +} + +// ---------- Poll thread / data application ---------- + +fn spawn_poll_thread() { + let (show_claude, show_codex, msg_hwnd) = { + let state = lock_state(); + let Some(s) = state.as_ref() else { + return; + }; + ( + s.settings.show_claude_code, + s.settings.show_codex, + s.msg_hwnd, + ) + }; + std::thread::spawn(move || { + let result = poller::poll(show_claude, show_codex); + handle_poll_result(result, msg_hwnd); + }); +} + +fn handle_poll_result(result: Result, msg_hwnd: SendHwnd) { + match result { + Ok(data) => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + apply_data(s, data); + s.last_poll_ok = true; + s.retry_count = 0; + s.auth_error_paused_polling = false; + s.auth_watch_mode = poller::CredentialWatchMode::ActiveSource; + s.auth_watch_snapshot.clear(); + } + } + unsafe { + let _ = PostMessageW( + msg_hwnd.to_hwnd(), + WM_APP_USAGE_UPDATED, + WPARAM(0), + LPARAM(0), + ); + } + } + Err(error) => { + let auth_problem = matches!( + error, + PollError::AuthRequired | PollError::TokenExpired | PollError::NoCredentials + ); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.last_poll_ok = false; + s.retry_count = s.retry_count.saturating_add(1); + if auth_problem { + let mode = if matches!(error, PollError::NoCredentials) { + poller::CredentialWatchMode::AllSources + } else { + poller::CredentialWatchMode::ActiveSource + }; + s.auth_watch_mode = mode; + s.auth_watch_snapshot = poller::credential_watch_snapshot(mode); + s.auth_error_paused_polling = true; + s.session_text = "!".into(); + s.weekly_text = "!".into(); + s.codex_session_text = "!".into(); + s.codex_weekly_text = "!".into(); + } else { + s.session_text = "...".into(); + s.weekly_text = "...".into(); + s.codex_session_text = "...".into(); + s.codex_weekly_text = "...".into(); + } + } + } + unsafe { + let _ = PostMessageW( + msg_hwnd.to_hwnd(), + WM_APP_USAGE_UPDATED, + WPARAM(0), + LPARAM(0), + ); + } + if auth_problem { + show_token_expired_balloon(); + } + } + } +} + +fn apply_data(s: &mut AppState, data: AppUsageData) { + if let Some(c) = data.claude_code.as_ref() { + s.session_percent = c.session.percentage; + s.weekly_percent = c.weekly.percentage; + } else if s.settings.show_claude_code { + s.session_percent = 0.0; + s.weekly_percent = 0.0; + } + if let Some(c) = data.codex.as_ref() { + s.codex_session_percent = c.session.percentage; + s.codex_weekly_percent = c.weekly.percentage; + } else if s.settings.show_codex { + s.codex_session_percent = 0.0; + s.codex_weekly_percent = 0.0; + } + s.data = data; + refresh_text_fields(s); +} + +fn refresh_text_fields(s: &mut AppState) { + let strings = s.language.strings(); + if let Some(c) = s.data.claude_code.as_ref() { + s.session_text = poller::format_line(&c.session, strings); + s.weekly_text = poller::format_line(&c.weekly, strings); + } + if let Some(c) = s.data.codex.as_ref() { + s.codex_session_text = poller::format_line(&c.session, strings); + s.codex_weekly_text = poller::format_line(&c.weekly, strings); + } +} + +fn refresh_countdowns() { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + refresh_text_fields(s); + } + } + apply_usage_update(); +} + +fn apply_usage_update() { + let snapshot = { + let s = lock_state(); + s.as_ref().map(|s| UsageSnapshot { + bubbles: s.bubbles.clone(), + session_percent: s.session_percent, + weekly_percent: s.weekly_percent, + codex_session_percent: s.codex_session_percent, + codex_weekly_percent: s.codex_weekly_percent, + session_text: s.session_text.clone(), + weekly_text: s.weekly_text.clone(), + codex_session_text: s.codex_session_text.clone(), + codex_weekly_text: s.codex_weekly_text.clone(), + language: s.language, + is_dark: s.is_dark, + settings: s.settings.clone(), + }) + }; + let Some(snap) = snapshot else { + return; + }; + + for (kind, hwnd) in snap.bubbles.iter() { + let model = TrayIconKind::from(*kind); + let pct = match model { + TrayIconKind::Claude => Some(snap.session_percent), + TrayIconKind::Codex => Some(snap.codex_session_percent), + }; + bubble::update_percentage(hwnd.to_hwnd(), pct); + } + + refresh_tray_icons(); + + // Refresh expanded panel if showing. + if panel::is_visible() { + if let Some(model) = panel::current_model() { + panel::refresh_data(build_panel_data_from(&snap, model)); + } + } + + // Adaptive countdown timer. + schedule_countdown_timer(); +} + +#[derive(Clone)] +struct UsageSnapshot { + bubbles: HashMap, + session_percent: f64, + weekly_percent: f64, + codex_session_percent: f64, + codex_weekly_percent: f64, + session_text: String, + weekly_text: String, + codex_session_text: String, + codex_weekly_text: String, + language: LanguageId, + is_dark: bool, + settings: Settings, +} + +fn build_panel_data(model: TrayIconKind) -> PanelData { + let snap = { + let s = lock_state(); + s.as_ref().map(|s| UsageSnapshot { + bubbles: s.bubbles.clone(), + session_percent: s.session_percent, + weekly_percent: s.weekly_percent, + codex_session_percent: s.codex_session_percent, + codex_weekly_percent: s.codex_weekly_percent, + session_text: s.session_text.clone(), + weekly_text: s.weekly_text.clone(), + codex_session_text: s.codex_session_text.clone(), + codex_weekly_text: s.codex_weekly_text.clone(), + language: s.language, + is_dark: s.is_dark, + settings: s.settings.clone(), + }) + } + .unwrap_or_else(|| UsageSnapshot { + bubbles: HashMap::new(), + session_percent: 0.0, + weekly_percent: 0.0, + codex_session_percent: 0.0, + codex_weekly_percent: 0.0, + session_text: String::new(), + weekly_text: String::new(), + codex_session_text: String::new(), + codex_weekly_text: String::new(), + language: LanguageId::English, + is_dark: false, + settings: Settings::default(), + }); + build_panel_data_from(&snap, model) +} + +fn build_panel_data_from(snap: &UsageSnapshot, model: TrayIconKind) -> PanelData { + let (sp, st, wp, wt) = match model { + TrayIconKind::Claude => ( + snap.session_percent, + snap.session_text.clone(), + snap.weekly_percent, + snap.weekly_text.clone(), + ), + TrayIconKind::Codex => ( + snap.codex_session_percent, + snap.codex_session_text.clone(), + snap.codex_weekly_percent, + snap.codex_weekly_text.clone(), + ), + }; + let strings = snap.language.strings(); + PanelData { + model, + session_pct: sp, + session_text: st, + weekly_pct: wp, + weekly_text: wt, + is_dark: snap.is_dark, + strings, + claude_label: strings.claude_code_model.to_string(), + codex_label: strings.codex_model.to_string(), + } +} + +fn schedule_countdown_timer() { + let (msg_hwnd, ttl) = { + let s = lock_state(); + let Some(s) = s.as_ref() else { + return; + }; + let mut min_ttl: Option = None; + if let Some(c) = s.data.claude_code.as_ref() { + for section in [&c.session, &c.weekly] { + if let Some(d) = poller::time_until_display_change(section.resets_at) { + min_ttl = Some(match min_ttl { + Some(prev) => prev.min(d), + None => d, + }); + } + } + } + if let Some(c) = s.data.codex.as_ref() { + for section in [&c.session, &c.weekly] { + if let Some(d) = poller::time_until_display_change(section.resets_at) { + min_ttl = Some(match min_ttl { + Some(prev) => prev.min(d), + None => d, + }); + } + } + } + (s.msg_hwnd, min_ttl) + }; + if let Some(d) = ttl { + let ms = (d.as_millis() as u64).min(u32::MAX as u64) as u32; + unsafe { + let _ = KillTimer(msg_hwnd.to_hwnd(), TIMER_COUNTDOWN); + SetTimer(msg_hwnd.to_hwnd(), TIMER_COUNTDOWN, ms.max(1000), None); + } + } +} + +// ---------- Tray icons ---------- + +fn refresh_tray_icons() { + let (icons, msg_hwnd) = { + let s = lock_state(); + let Some(s) = s.as_ref() else { + return; + }; + let strings = s.language.strings(); + let mut icons = Vec::new(); + if s.settings.show_claude_code { + icons.push(TrayIconData { + kind: TrayIconKind::Claude, + percent: if s.last_poll_ok { + Some(s.session_percent) + } else { + None + }, + tooltip: format!( + "{} 5h: {} | 7d: {}", + strings.claude_code_model, s.session_text, s.weekly_text + ), + }); + } + if s.settings.show_codex { + icons.push(TrayIconData { + kind: TrayIconKind::Codex, + percent: if s.last_poll_ok { + Some(s.codex_session_percent) + } else { + None + }, + tooltip: format!( + "{} 5h: {} | 7d: {}", + strings.codex_model, s.codex_session_text, s.codex_weekly_text + ), + }); + } + (icons, s.msg_hwnd) + }; + tray_icon::sync(msg_hwnd.to_hwnd(), &icons); +} + +fn handle_tray_action(action: TrayAction) { + match action { + TrayAction::None => {} + TrayAction::ToggleWidget => toggle_widget_visibility(), + TrayAction::ShowContextMenu => { + // Use the first bubble as menu owner; fall back to msg_hwnd. + let owner = { + let s = lock_state(); + s.as_ref() + .and_then(|s| s.bubbles.values().next().copied()) + .map(|h| h.to_hwnd()) + .unwrap_or_default() + }; + if owner != HWND::default() { + show_context_menu(owner); + } + } + } +} + +fn show_token_expired_balloon() { + let payload: Option<(SendHwnd, TrayIconKind, String, String)> = { + let mut state = lock_state(); + let Some(s) = state.as_mut() else { + return; + }; + if let Some(last) = s.last_balloon_shown_at { + if last.elapsed() < Duration::from_secs(30 * 60) { + return; + } + } + s.last_balloon_shown_at = Some(Instant::now()); + let strings = s.language.strings(); + if s.settings.show_claude_code { + Some(( + s.msg_hwnd, + TrayIconKind::Claude, + strings.token_expired_title.to_string(), + strings.token_expired_body.to_string(), + )) + } else { + Some(( + s.msg_hwnd, + TrayIconKind::Codex, + strings.codex_token_expired_title.to_string(), + strings.codex_token_expired_body.to_string(), + )) + } + }; + if let Some((hwnd, kind, title, body)) = payload { + tray_icon::notify_balloon(hwnd.to_hwnd(), kind, &title, &body); + } +} + +// ---------- Context menu ---------- + +fn show_context_menu(owner_hwnd: HWND) { + let (strings, language, install_channel, update_status, current_interval, show_claude, show_codex, widget_visible, language_override) = { + let s = lock_state(); + let Some(s) = s.as_ref() else { + return; + }; + ( + s.language.strings(), + s.language, + s.install_channel, + s.update_status, + s.settings.poll_interval_ms, + s.settings.show_claude_code, + s.settings.show_codex, + s.settings.widget_visible, + s.settings.language.as_deref().and_then(LanguageId::from_code), + ) + }; + + unsafe { + let menu = match CreatePopupMenu() { + Ok(m) => m, + Err(_) => return, + }; + + append_menu_item(menu, IDM_REFRESH, strings.refresh, MENU_ITEM_FLAGS(0)); + + // Update frequency submenu + let freq_menu = CreatePopupMenu().unwrap(); + for (id, interval, label) in [ + (IDM_FREQ_1MIN, POLL_1_MIN, strings.one_minute), + (IDM_FREQ_5MIN, POLL_5_MIN, strings.five_minutes), + (IDM_FREQ_15MIN, POLL_15_MIN, strings.fifteen_minutes), + (IDM_FREQ_1HOUR, POLL_1_HOUR, strings.one_hour), + ] { + let flags = if interval == current_interval { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + append_menu_item(freq_menu, id, label, flags); + } + append_submenu(menu, freq_menu, strings.update_frequency); + + // Models submenu + let models_menu = CreatePopupMenu().unwrap(); + append_menu_item( + models_menu, + IDM_MODEL_CLAUDE_CODE, + strings.claude_code_model, + if show_claude { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }, + ); + append_menu_item( + models_menu, + IDM_MODEL_CODEX, + strings.codex_model, + if show_codex { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }, + ); + append_submenu(menu, models_menu, strings.models); + + // Settings submenu + let settings_menu = CreatePopupMenu().unwrap(); + append_menu_item( + settings_menu, + IDM_START_WITH_WINDOWS, + strings.start_with_windows, + if is_startup_enabled() { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }, + ); + append_menu_item( + settings_menu, + IDM_RESET_POSITION, + strings.reset_position, + MENU_ITEM_FLAGS(0), + ); + + // Language submenu + let lang_menu = CreatePopupMenu().unwrap(); + append_menu_item( + lang_menu, + IDM_LANG_SYSTEM, + strings.system_default, + if language_override.is_none() { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }, + ); + for lang in LanguageId::ALL { + let id = lang_menu_id_for(lang); + let label = lang.native_name(); + let flags = if language_override == Some(lang) { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + append_menu_item(lang_menu, id, label, flags); + } + append_submenu(settings_menu, lang_menu, strings.language); + + let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null()); + + let version_label = version_action_label(strings, language, install_channel, update_status); + let version_flags = if matches!(update_status, UpdateStatus::Checking | UpdateStatus::Applying) { + MF_GRAYED + } else { + MENU_ITEM_FLAGS(0) + }; + append_menu_item( + settings_menu, + IDM_VERSION_ACTION, + &version_label, + version_flags, + ); + + append_submenu(menu, settings_menu, strings.settings); + + append_menu_item( + menu, + tray_icon::IDM_TOGGLE_WIDGET, + strings.show_widget, + if widget_visible { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }, + ); + + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); + append_menu_item(menu, IDM_EXIT, strings.exit, MENU_ITEM_FLAGS(0)); + + let mut pt = POINT::default(); + let _ = GetCursorPos(&mut pt); + let _ = SetForegroundWindow(owner_hwnd); + let _ = TrackPopupMenu(menu, TPM_RIGHTBUTTON, pt.x, pt.y, 0, owner_hwnd, None); + let _ = DestroyMenu(menu); + } +} + +fn append_menu_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) { + let label_w = wide_str(label); + unsafe { + let _ = AppendMenuW(menu, flags, id as usize, PCWSTR::from_raw(label_w.as_ptr())); + } +} + +fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) { + let label_w = wide_str(label); + unsafe { + let _ = AppendMenuW( + menu, + MF_POPUP, + submenu.0 as usize, + PCWSTR::from_raw(label_w.as_ptr()), + ); + } +} + +fn lang_menu_id_for(lang: LanguageId) -> u16 { + match lang { + LanguageId::English => IDM_LANG_ENGLISH, + LanguageId::Dutch => IDM_LANG_DUTCH, + LanguageId::Spanish => IDM_LANG_SPANISH, + LanguageId::French => IDM_LANG_FRENCH, + LanguageId::German => IDM_LANG_GERMAN, + LanguageId::Japanese => IDM_LANG_JAPANESE, + LanguageId::Korean => IDM_LANG_KOREAN, + LanguageId::TraditionalChinese => IDM_LANG_TRADITIONAL_CHINESE, + } +} + +fn version_action_label( + strings: Strings, + language: LanguageId, + install_channel: InstallChannel, + status: UpdateStatus, +) -> String { + let base = match status { + UpdateStatus::Idle => strings.check_for_updates.to_string(), + UpdateStatus::Checking => strings.checking_for_updates.to_string(), + UpdateStatus::UpToDate => strings.up_to_date.to_string(), + UpdateStatus::Available => strings.update_available.to_string(), + UpdateStatus::Applying => strings.applying_update.to_string(), + UpdateStatus::Failed => strings.update_failed.to_string(), + }; + match install_channel { + InstallChannel::Winget => format!("{base} ({})", localization::update_via_winget(language)), + InstallChannel::Portable => base, + } +} + +// ---------- Menu actions ---------- + +fn set_poll_interval(ms: u32) { + let (snap, msg_hwnd) = { + let mut state = lock_state(); + let Some(s) = state.as_mut() else { + return; + }; + s.settings.poll_interval_ms = ms; + (s.settings.clone(), s.msg_hwnd) + }; + settings::save(&snap); + unsafe { + let _ = KillTimer(msg_hwnd.to_hwnd(), TIMER_POLL); + SetTimer(msg_hwnd.to_hwnd(), TIMER_POLL, ms, None); + } +} + +fn current_poll_interval_ms() -> u32 { + lock_state() + .as_ref() + .map(|s| s.settings.poll_interval_ms) + .unwrap_or(POLL_5_MIN) +} + +fn toggle_model(model: TrayIconKind) { + let (settings, is_dark) = { + let mut state = lock_state(); + let Some(s) = state.as_mut() else { + return; + }; + match model { + TrayIconKind::Claude => s.settings.show_claude_code = !s.settings.show_claude_code, + TrayIconKind::Codex => s.settings.show_codex = !s.settings.show_codex, + } + if !s.settings.show_claude_code && !s.settings.show_codex { + // Don't let user turn both off. + match model { + TrayIconKind::Claude => s.settings.show_claude_code = true, + TrayIconKind::Codex => s.settings.show_codex = true, + } + } + (s.settings.clone(), s.is_dark) + }; + settings::save(&settings); + + let want_show = match model { + TrayIconKind::Claude => settings.show_claude_code, + TrayIconKind::Codex => settings.show_codex, + }; + let existing = { + let s = lock_state(); + s.as_ref() + .and_then(|s| s.bubbles.get(&model.into()).copied()) + }; + + match (want_show, existing) { + (true, None) => { + let hwnd = bubble::create(bubble::BubbleConfig { + model, + size_logical: settings.bubble_size_logical, + position: settings.bubble_positions.get(model), + percent: None, + is_dark, + }); + if hwnd != HWND::default() { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.bubbles.insert(model.into(), SendHwnd::from_hwnd(hwnd)); + } + } + } + (false, Some(hwnd)) => { + bubble::destroy(hwnd.to_hwnd()); + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.bubbles.remove(&model.into()); + } + } + _ => {} + } + refresh_tray_icons(); + spawn_poll_thread(); +} + +fn toggle_widget_visibility() { + let (new_visible, snap) = { + let mut state = lock_state(); + let Some(s) = state.as_mut() else { + return; + }; + s.settings.widget_visible = !s.settings.widget_visible; + (s.settings.widget_visible, s.settings.clone()) + }; + settings::save(&snap); + let hwnds: Vec = { + let state = lock_state(); + state + .as_ref() + .map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect()) + .unwrap_or_default() + }; + for h in hwnds { + bubble::set_user_visible(h, new_visible); + } +} + +fn reset_positions() { + let snap = { + let mut state = lock_state(); + let Some(s) = state.as_mut() else { + return; + }; + s.settings.bubble_positions.reset_all(); + s.settings.clone() + }; + settings::save(&snap); + let hwnds: Vec = { + let state = lock_state(); + state + .as_ref() + .map(|s| s.bubbles.values().map(|h| h.to_hwnd()).collect()) + .unwrap_or_default() + }; + for h in hwnds { + bubble::destroy(h); + } + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.bubbles.clear(); + } + } + create_initial_bubbles(); +} + +fn set_language(override_lang: Option) { + let snap = { + let mut state = lock_state(); + let Some(s) = state.as_mut() else { + return; + }; + s.language = match override_lang { + Some(l) => l, + None => localization::detect_system_language(), + }; + s.settings.language = override_lang.map(|l| l.code().to_string()); + refresh_text_fields(s); + s.settings.clone() + }; + settings::save(&snap); + apply_usage_update(); +} + +fn version_action(_owner_hwnd: HWND) { + enum Act { + Apply(updater::ReleaseDescriptor, InstallChannel), + Check(SendHwnd), + } + let act = { + let s = lock_state(); + let Some(s) = s.as_ref() else { + return; + }; + match (s.update_status, s.update_release.as_ref()) { + (UpdateStatus::Available, Some(release)) => { + Act::Apply(release.clone(), s.install_channel) + } + _ => Act::Check(s.msg_hwnd), + } + }; + match act { + Act::Apply(release, channel) => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.update_status = UpdateStatus::Applying; + } + } + let result = match channel { + InstallChannel::Winget => updater::begin_winget_update(), + InstallChannel::Portable => updater::begin_self_update(&release), + }; + match result { + Ok(()) => unsafe { + PostQuitMessage(0); + }, + Err(error) => { + diagnose::log(format!("update apply failed: {error}")); + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.update_status = UpdateStatus::Failed; + } + } + } + } + Act::Check(hwnd) => { + begin_update_check(hwnd.to_hwnd(), true); + } + } +} + +// ---------- Updates ---------- + +fn schedule_update_check_timer(hwnd: HWND) { + let last = { + let s = lock_state(); + s.as_ref().and_then(|s| s.settings.last_update_check_unix) + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let due = last.map_or(true, |t| now.saturating_sub(t) >= UPDATE_CHECK_INTERVAL_SECS); + if due { + begin_update_check(hwnd, false); + } else { + let remaining = UPDATE_CHECK_INTERVAL_SECS.saturating_sub(now.saturating_sub(last.unwrap_or(0))); + let ms = (remaining.saturating_mul(1000)).min(u32::MAX as u64) as u32; + unsafe { + SetTimer(hwnd, TIMER_UPDATE_CHECK, ms, None); + } + } +} + +fn begin_update_check(hwnd: HWND, _user_initiated: bool) { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.update_status = UpdateStatus::Checking; + } + } + let send_hwnd = SendHwnd::from_hwnd(hwnd); + std::thread::spawn(move || { + let result = updater::check_for_updates(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let snap_opt: Option = { + let mut state = lock_state(); + state.as_mut().map(|s| { + s.settings.last_update_check_unix = Some(now); + match result { + Ok(UpdateCheckResult::UpToDate) => { + s.update_status = UpdateStatus::UpToDate; + s.update_release = None; + } + Ok(UpdateCheckResult::Available(release)) => { + s.update_status = UpdateStatus::Available; + s.update_release = Some(release); + } + Err(_) => { + s.update_status = UpdateStatus::Failed; + } + } + s.settings.clone() + }) + }; + if let Some(snap) = snap_opt { + settings::save(&snap); + } + unsafe { + SetTimer( + send_hwnd.to_hwnd(), + TIMER_UPDATE_CHECK, + (UPDATE_CHECK_INTERVAL_SECS as u32) * 1000, + None, + ); + } + }); +} + +// ---------- Start-with-Windows registry ---------- + +fn is_startup_enabled() -> bool { + let path_w = wide_str(STARTUP_REGISTRY_PATH); + let name_w = wide_str(STARTUP_VALUE_NAME); + unsafe { + let mut hkey = HKEY::default(); + if RegOpenKeyExW( + HKEY_CURRENT_USER, + PCWSTR::from_raw(path_w.as_ptr()), + 0, + KEY_READ, + &mut hkey, + ) + .is_err() + { + return false; + } + let mut buf = [0u16; 1024]; + let mut size = (buf.len() * 2) as u32; + let res = RegQueryValueExW( + hkey, + PCWSTR::from_raw(name_w.as_ptr()), + None, + None, + Some(buf.as_mut_ptr() as *mut u8), + Some(&mut size), + ); + let _ = RegCloseKey(hkey); + res.is_ok() + } +} + +fn toggle_startup() { + let enabled = is_startup_enabled(); + let path_w = wide_str(STARTUP_REGISTRY_PATH); + let name_w = wide_str(STARTUP_VALUE_NAME); + unsafe { + let mut hkey = HKEY::default(); + if RegOpenKeyExW( + HKEY_CURRENT_USER, + PCWSTR::from_raw(path_w.as_ptr()), + 0, + KEY_WRITE, + &mut hkey, + ) + .is_err() + { + return; + } + if enabled { + let _ = RegDeleteValueW(hkey, PCWSTR::from_raw(name_w.as_ptr())); + } else { + if let Ok(exe) = std::env::current_exe() { + let exe_str = format!("\"{}\"", exe.to_string_lossy()); + let exe_w = wide_str(&exe_str); + let bytes = std::slice::from_raw_parts( + exe_w.as_ptr() as *const u8, + exe_w.len() * 2, + ); + let _ = RegSetValueExW( + hkey, + PCWSTR::from_raw(name_w.as_ptr()), + 0, + REG_SZ, + Some(bytes), + ); + } + } + let _ = RegCloseKey(hkey); + } +} + diff --git a/src/bubble.rs b/src/bubble.rs new file mode 100644 index 0000000..a9a71c3 --- /dev/null +++ b/src/bubble.rs @@ -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, + 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::() 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) { + { + 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 { + lock_bubbles() + .get(&(hwnd.0 as isize)) + .map(|b| b.model) +} + +pub fn size_logical(hwnd: HWND) -> Option { + lock_bubbles() + .get(&(hwnd.0 as isize)) + .map(|b| b.size_logical) +} + +// ---------- State ---------- + +struct BubbleState { + model: TrayIconKind, + size_logical: i32, + dpi: u32, + percent: Option, + is_dark: bool, + drag_start_pos: Option<(i32, i32)>, + hidden_by_fullscreen: bool, + user_hidden: bool, +} + +fn bubbles() -> &'static Mutex> { + static BUBBLES: OnceLock>> = OnceLock::new(); + BUBBLES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn lock_bubbles() -> MutexGuard<'static, HashMap> { + 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::() 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::() 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::() 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, 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::() 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) + } +} diff --git a/src/diagnose.rs b/src/diagnose.rs new file mode 100644 index 0000000..029e469 --- /dev/null +++ b/src/diagnose.rs @@ -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, +} + +static DIAGNOSE_STATE: OnceLock = OnceLock::new(); + +pub fn init() -> Result { + 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) { + 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}")); +} diff --git a/src/icons/16.svg b/src/icons/16.svg new file mode 100644 index 0000000..7984182 --- /dev/null +++ b/src/icons/16.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/icons/16x16.png b/src/icons/16x16.png new file mode 100644 index 0000000..bec1057 Binary files /dev/null and b/src/icons/16x16.png differ diff --git a/src/icons/256.svg b/src/icons/256.svg new file mode 100644 index 0000000..a38ceab --- /dev/null +++ b/src/icons/256.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/icons/256x256.png b/src/icons/256x256.png new file mode 100644 index 0000000..5e25386 Binary files /dev/null and b/src/icons/256x256.png differ diff --git a/src/icons/32.svg b/src/icons/32.svg new file mode 100644 index 0000000..447cddf --- /dev/null +++ b/src/icons/32.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/icons/32x32.png b/src/icons/32x32.png new file mode 100644 index 0000000..f4bcd5f Binary files /dev/null and b/src/icons/32x32.png differ diff --git a/src/icons/48.svg b/src/icons/48.svg new file mode 100644 index 0000000..2f542ac --- /dev/null +++ b/src/icons/48.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/icons/48x48.png b/src/icons/48x48.png new file mode 100644 index 0000000..9f5d19d Binary files /dev/null and b/src/icons/48x48.png differ diff --git a/src/icons/icon.ico b/src/icons/icon.ico new file mode 100644 index 0000000..1c31bae Binary files /dev/null and b/src/icons/icon.ico differ diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs new file mode 100644 index 0000000..0eb486d --- /dev/null +++ b/src/localization/dutch.rs @@ -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", +}; diff --git a/src/localization/english.rs b/src/localization/english.rs new file mode 100644 index 0000000..2b92f36 --- /dev/null +++ b/src/localization/english.rs @@ -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", +}; diff --git a/src/localization/french.rs b/src/localization/french.rs new file mode 100644 index 0000000..fa448fb --- /dev/null +++ b/src/localization/french.rs @@ -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", +}; diff --git a/src/localization/german.rs b/src/localization/german.rs new file mode 100644 index 0000000..5c7bb23 --- /dev/null +++ b/src/localization/german.rs @@ -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", +}; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs new file mode 100644 index 0000000..0020018 --- /dev/null +++ b/src/localization/japanese.rs @@ -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: "秒", +}; diff --git a/src/localization/korean.rs b/src/localization/korean.rs new file mode 100644 index 0000000..59e3829 --- /dev/null +++ b/src/localization/korean.rs @@ -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: "초", +}; diff --git a/src/localization/mod.rs b/src/localization/mod.rs new file mode 100644 index 0000000..146b419 --- /dev/null +++ b/src/localization/mod.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs new file mode 100644 index 0000000..8e6513e --- /dev/null +++ b/src/localization/spanish.rs @@ -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", +}; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs new file mode 100644 index 0000000..809ebba --- /dev/null +++ b/src/localization/traditional_chinese.rs @@ -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: "秒", +}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d7a9d08 --- /dev/null +++ b/src/main.rs @@ -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 = 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(); +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..bd3a456 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,19 @@ +use std::time::SystemTime; + +#[derive(Clone, Debug, Default)] +pub struct UsageSection { + pub percentage: f64, + pub resets_at: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct UsageData { + pub session: UsageSection, + pub weekly: UsageSection, +} + +#[derive(Clone, Debug, Default)] +pub struct AppUsageData { + pub claude_code: Option, + pub codex: Option, +} diff --git a/src/native_interop.rs b/src/native_interop.rs new file mode 100644 index 0000000..43fe243 --- /dev/null +++ b/src/native_interop.rs @@ -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 { + 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 { + 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) + } +} diff --git a/src/panel.rs b/src/panel.rs new file mode 100644 index 0000000..c567127 --- /dev/null +++ b/src/panel.rs @@ -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> { + static S: OnceLock>> = OnceLock::new(); + S.get_or_init(|| Mutex::new(None)) +} + +fn lock_state() -> MutexGuard<'static, Option> { + 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::() 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 { + 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 { + 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 = 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 { + 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 +} diff --git a/src/poller.rs b/src/poller.rs new file mode 100644 index 0000000..5bd02bc --- /dev/null +++ b/src/poller.rs @@ -0,0 +1,1099 @@ +use std::path::PathBuf; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; +use std::os::windows::process::CommandExt; + +use crate::diagnose; +use crate::localization::Strings; +use crate::models::{AppUsageData, UsageData, UsageSection}; + +const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; +const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; +const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage"; +const CREATE_NO_WINDOW: u32 = 0x08000000; + +const MODEL_FALLBACK_CHAIN: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"]; + +#[derive(Debug)] +pub enum PollError { + AuthRequired, + NoCredentials, + TokenExpired, + RequestFailed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CredentialWatchMode { + ActiveSource, + AllSources, +} + +pub type CredentialWatchSnapshot = Vec; + +#[derive(Deserialize)] +struct UsageResponse { + five_hour: Option, + seven_day: Option, +} + +#[derive(Deserialize)] +struct UsageBucket { + utilization: f64, + resets_at: Option, +} + +#[derive(Deserialize)] +struct CodexAuthFile { + tokens: Option, +} + +#[derive(Clone, Deserialize)] +struct CodexTokenData { + access_token: String, + account_id: Option, +} + +#[derive(Deserialize)] +struct CodexUsageResponse { + rate_limit: Option>>, +} + +#[derive(Deserialize)] +struct CodexRateLimitDetails { + primary_window: Option>>, + secondary_window: Option>>, +} + +#[derive(Deserialize)] +struct CodexRateLimitWindow { + used_percent: f64, + reset_at: i64, +} + +pub fn poll(show_claude_code: bool, show_codex: bool) -> Result { + let mut data = AppUsageData::default(); + + if show_claude_code { + data.claude_code = Some(poll_claude_code()?); + } + + if show_codex { + match poll_codex() { + Ok(codex) => data.codex = Some(codex), + Err(error) if !show_claude_code => return Err(error), + Err(error) => diagnose::log(format!("Codex usage poll failed: {error:?}")), + } + } + + if data.claude_code.is_none() && data.codex.is_none() { + Err(PollError::RequestFailed) + } else { + Ok(data) + } +} + +fn poll_claude_code() -> Result { + let creds = match read_first_credentials() { + Some(c) => c, + None => { + diagnose::log("poll failed: no Claude credentials found"); + return Err(PollError::NoCredentials); + } + }; + + let creds = refresh_or_fallback(creds)?; + + fetch_usage_with_fallback(&creds.access_token) +} + +fn poll_codex() -> Result { + let creds = match read_codex_credentials() { + Some(creds) => creds, + None => { + diagnose::log("Codex usage poll failed: no Codex credentials found"); + return Err(PollError::NoCredentials); + } + }; + + match fetch_codex_usage(&creds.access_token, creds.account_id.as_deref()) { + Ok(data) => Ok(data), + Err(PollError::AuthRequired) => { + cli_refresh_codex_token(); + let refreshed = read_codex_credentials().ok_or(PollError::TokenExpired)?; + fetch_codex_usage(&refreshed.access_token, refreshed.account_id.as_deref()) + } + Err(error) => Err(error), + } +} + +fn refresh_or_fallback(mut creds: Credentials) -> Result { + loop { + if !is_token_expired(creds.expires_at) { + return Ok(creds); + } + + let source = creds.source.clone(); + cli_refresh_token(&source); + + match read_credentials_from_source(&source) { + Some(refreshed) if !is_token_expired(refreshed.expires_at) => return Ok(refreshed), + Some(_) => diagnose::log(format!( + "credentials from {source:?} still expired after refresh attempt" + )), + None => diagnose::log(format!( + "credentials from {source:?} unavailable after refresh attempt" + )), + } + + match read_next_credentials_after(&source) { + Some(next) => creds = next, + None => return Err(PollError::TokenExpired), + } + } +} + +/// Invoke the Claude CLI with a minimal prompt to force its internal +/// OAuth token refresh. +fn cli_refresh_token(source: &CredentialSource) { + match source { + CredentialSource::Windows(_) => cli_refresh_windows_token(), + CredentialSource::Wsl { distro } => cli_refresh_wsl_token(distro), + } +} + +fn cli_refresh_windows_token() { + let claude_path = resolve_windows_claude_path(); + let is_cmd = claude_path.to_lowercase().ends_with(".cmd"); + diagnose::log(format!( + "attempting Windows Claude token refresh via {claude_path}" + )); + + let args: &[&str] = &["-p", "."]; + + let mut cmd = if is_cmd { + let mut c = Command::new("cmd.exe"); + c.arg("/c").arg(&claude_path).args(args); + c + } else { + let mut c = Command::new(&claude_path); + c.args(args); + c + }; + cmd.env_remove("CLAUDECODE") + .env_remove("CLAUDE_CODE_ENTRYPOINT") + .creation_flags(CREATE_NO_WINDOW) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(error) => { + diagnose::log_error("unable to spawn Windows Claude token refresh", error); + return; + } + }; + + // Wait up to 30 seconds — don't block the poll thread forever + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) => { + if start.elapsed() > Duration::from_secs(30) { + let _ = child.kill(); + break; + } + std::thread::sleep(Duration::from_millis(500)); + } + Err(_) => break, + } + } +} + +fn cli_refresh_wsl_token(distro: &str) { + diagnose::log(format!( + "attempting WSL Claude token refresh in distro {distro}" + )); + let mut cmd = Command::new("wsl.exe"); + cmd.arg("-d") + .arg(distro) + .arg("--") + .arg("bash") + .arg("-lic") + .arg("if command -v claude >/dev/null 2>&1; then claude -p .; elif [ -x \"$HOME/.local/bin/claude\" ]; then \"$HOME/.local/bin/claude\" -p .; else exit 127; fi") + .env_remove("CLAUDECODE") + .env_remove("CLAUDE_CODE_ENTRYPOINT") + .creation_flags(CREATE_NO_WINDOW) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(error) => { + diagnose::log_error("unable to spawn WSL Claude token refresh", error); + return; + } + }; + + wait_for_refresh(&mut child); +} + +fn cli_refresh_codex_token() { + let codex_path = resolve_windows_codex_path(); + let is_cmd = codex_path.to_lowercase().ends_with(".cmd"); + let is_ps1 = codex_path.to_lowercase().ends_with(".ps1"); + diagnose::log(format!( + "attempting Windows Codex token refresh via {codex_path}" + )); + + let args: &[&str] = &["exec", "."]; + + let mut cmd = if is_cmd { + let mut c = Command::new("cmd.exe"); + c.arg("/c").arg(&codex_path).args(args); + c + } else if is_ps1 { + let mut c = Command::new("powershell.exe"); + c.arg("-NoProfile") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-File") + .arg(&codex_path) + .args(args); + c + } else { + let mut c = Command::new(&codex_path); + c.args(args); + c + }; + cmd.creation_flags(CREATE_NO_WINDOW) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(error) => { + diagnose::log_error("unable to spawn Windows Codex token refresh", error); + return; + } + }; + + wait_for_refresh(&mut child); +} + +/// Spawn a command and wait up to `timeout` for it to finish. +/// Returns None if the process fails to start or exceeds the deadline. +fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option { + let mut child = cmd.spawn().ok()?; + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => return child.wait_with_output().ok(), + Ok(None) => { + if start.elapsed() > timeout { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(_) => return None, + } + } +} + +fn wait_for_refresh(child: &mut std::process::Child) { + // Wait up to 30 seconds; don't block the poll thread forever. + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) => { + if start.elapsed() > Duration::from_secs(30) { + let _ = child.kill(); + break; + } + std::thread::sleep(Duration::from_millis(500)); + } + Err(_) => break, + } + } +} + +/// Resolve the full path to the `claude` CLI executable. +fn resolve_windows_claude_path() -> String { + for name in &["claude.cmd", "claude"] { + if Command::new(name) + .arg("--version") + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok() + { + return name.to_string(); + } + } + + for name in &["claude.cmd", "claude"] { + if let Ok(output) = Command::new("where.exe") + .arg(name) + .creation_flags(CREATE_NO_WINDOW) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(first_line) = stdout.lines().next() { + let path = first_line.trim().to_string(); + if !path.is_empty() { + return path; + } + } + } + } + } + + "claude.cmd".to_string() +} + +fn resolve_windows_codex_path() -> String { + for name in &["codex.cmd", "codex.ps1", "codex.exe", "codex"] { + if Command::new(name) + .arg("--version") + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok() + { + return name.to_string(); + } + } + + for name in &["codex.cmd", "codex.ps1", "codex.exe", "codex"] { + if let Ok(output) = Command::new("where.exe") + .arg(name) + .creation_flags(CREATE_NO_WINDOW) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(first_line) = stdout.lines().next() { + let path = first_line.trim().to_string(); + if !path.is_empty() { + return path; + } + } + } + } + } + + "codex.cmd".to_string() +} + +fn build_agent() -> Result { + let tls = native_tls::TlsConnector::new().map_err(|_| PollError::RequestFailed)?; + Ok(ureq::AgentBuilder::new() + .timeout(Duration::from_secs(30)) + .tls_connector(std::sync::Arc::new(tls)) + .build()) +} + +pub fn credential_watch_snapshot(mode: CredentialWatchMode) -> CredentialWatchSnapshot { + let sources = match mode { + CredentialWatchMode::ActiveSource => read_first_credentials() + .map(|creds| vec![creds.source]) + .unwrap_or_else(all_known_credential_sources), + CredentialWatchMode::AllSources => all_known_credential_sources(), + }; + + let mut snapshot: CredentialWatchSnapshot = sources + .into_iter() + .filter_map(|source| credential_watch_signature(&source)) + .collect(); + snapshot.sort(); + snapshot.dedup(); + snapshot +} + +fn all_known_credential_sources() -> Vec { + let mut sources = Vec::new(); + if let Some(source) = windows_credential_source() { + sources.push(source); + } + for distro in list_wsl_distros() { + sources.push(CredentialSource::Wsl { distro }); + } + sources +} + +fn windows_credential_source() -> Option { + let home = dirs::home_dir()?; + Some(CredentialSource::Windows( + home.join(".claude").join(".credentials.json"), + )) +} + +fn credential_watch_signature(source: &CredentialSource) -> Option { + match source { + CredentialSource::Windows(path) => Some(windows_credential_watch_signature(path)), + CredentialSource::Wsl { distro } => wsl_credential_watch_signature(distro), + } +} + +fn windows_credential_watch_signature(path: &PathBuf) -> String { + let key = format!("win:{}", path.display()); + match std::fs::metadata(path) { + Ok(metadata) => { + let modified = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_secs()) + .unwrap_or(0); + format!("{key}|present|{}|{modified}", metadata.len()) + } + Err(_) => format!("{key}|missing"), + } +} + +fn wsl_credential_watch_signature(distro: &str) -> Option { + let output = run_with_timeout( + Command::new("wsl.exe") + .arg("-d") + .arg(distro) + .arg("--") + .arg("sh") + .arg("-lc") + .arg( + "if [ -f ~/.claude/.credentials.json ]; then \ + stat -c 'present|%s|%Y' ~/.claude/.credentials.json; \ + else echo missing; fi", + ) + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()), + Duration::from_secs(5), + )?; + + let state = if output.status.success() { + decode_wsl_text(&output.stdout).trim().to_string() + } else { + format!("status-{}", output.status) + }; + + Some(format!("wsl:{distro}|{state}")) +} + +fn fetch_usage_with_fallback(token: &str) -> Result { + // Try the dedicated usage endpoint first + match try_usage_endpoint(token)? { + Some(data) => { + // If reset timers are missing, fill them in from the Messages API + if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() { + if let Ok(fallback) = fetch_usage_via_messages(token) { + let mut merged = data; + if merged.session.resets_at.is_none() { + merged.session.resets_at = fallback.session.resets_at; + } + if merged.weekly.resets_at.is_none() { + merged.weekly.resets_at = fallback.weekly.resets_at; + } + return Ok(merged); + } + } + return Ok(data); + } + None => {} + } + + // Fall back to Messages API with rate limit headers + let result = fetch_usage_via_messages(token); + if result.is_err() { + diagnose::log("usage endpoint and Messages API fallback both failed"); + } + result +} + +fn try_usage_endpoint(token: &str) -> Result, PollError> { + let agent = build_agent()?; + + let resp = match agent + .get(USAGE_URL) + .set("Authorization", &format!("Bearer {token}")) + .set("anthropic-beta", "oauth-2025-04-20") + .call() + { + Ok(resp) => resp, + Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { + diagnose::log(format!( + "usage endpoint returned auth error status {code}; re-login required" + )); + return Err(PollError::AuthRequired); + } + Err(_) => return Ok(None), + }; + + let response: UsageResponse = match resp.into_json() { + Ok(response) => response, + Err(_) => return Ok(None), + }; + let mut data = UsageData::default(); + + if let Some(bucket) = &response.five_hour { + data.session.percentage = bucket.utilization; + data.session.resets_at = parse_iso8601(bucket.resets_at.as_deref()); + } + + if let Some(bucket) = &response.seven_day { + data.weekly.percentage = bucket.utilization; + data.weekly.resets_at = parse_iso8601(bucket.resets_at.as_deref()); + } + + Ok(Some(data)) +} + +fn fetch_usage_via_messages(token: &str) -> Result { + let agent = build_agent()?; + + for model in MODEL_FALLBACK_CHAIN { + let body = serde_json::json!({ + "model": model, + "max_tokens": 1, + "messages": [{"role": "user", "content": "."}] + }); + + let response = match agent + .post(MESSAGES_URL) + .set("Authorization", &format!("Bearer {token}")) + .set("anthropic-version", "2023-06-01") + .set("anthropic-beta", "oauth-2025-04-20") + .send_json(&body) + { + Ok(resp) => resp, + Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { + diagnose::log(format!( + "messages endpoint returned auth error status {code}; re-login required" + )); + return Err(PollError::AuthRequired); + } + Err(ureq::Error::Status(_code, resp)) => resp, + Err(_) => continue, + }; + + let h5 = response.header("anthropic-ratelimit-unified-5h-utilization"); + let h7 = response.header("anthropic-ratelimit-unified-7d-utilization"); + let hs = response.header("anthropic-ratelimit-unified-status"); + + if h5.is_some() || h7.is_some() || hs.is_some() { + return Ok(parse_rate_limit_headers(&response)); + } + } + + Err(PollError::RequestFailed) +} + +fn parse_rate_limit_headers(response: &ureq::Response) -> UsageData { + let mut data = UsageData::default(); + + data.session.percentage = + get_header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0; + data.session.resets_at = unix_to_system_time(get_header_i64( + response, + "anthropic-ratelimit-unified-5h-reset", + )); + + data.weekly.percentage = + get_header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0; + data.weekly.resets_at = unix_to_system_time(get_header_i64( + response, + "anthropic-ratelimit-unified-7d-reset", + )); + + let overall_reset = get_header_i64(response, "anthropic-ratelimit-unified-reset"); + + if data.session.percentage == 0.0 && data.weekly.percentage == 0.0 { + let status = response.header("anthropic-ratelimit-unified-status"); + if status == Some("rejected") { + let claim = response.header("anthropic-ratelimit-unified-representative-claim"); + match claim { + Some("five_hour") => data.session.percentage = 100.0, + Some("seven_day") => data.weekly.percentage = 100.0, + _ => {} + } + } + + if data.session.resets_at.is_none() && overall_reset.is_some() { + data.session.resets_at = unix_to_system_time(overall_reset); + } + } + + data +} + +fn fetch_codex_usage(token: &str, account_id: Option<&str>) -> Result { + let agent = build_agent()?; + let mut request = agent + .get(CODEX_USAGE_URL) + .set("Authorization", &format!("Bearer {token}")) + .set("User-Agent", "codex-cli"); + + if let Some(account_id) = account_id.filter(|value| !value.is_empty()) { + request = request.set("ChatGPT-Account-Id", account_id); + } + + let resp = match request.call() { + Ok(resp) => resp, + Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { + diagnose::log(format!( + "Codex usage endpoint returned auth error status {code}; refresh required" + )); + return Err(PollError::AuthRequired); + } + Err(error) => { + diagnose::log_error("Codex usage endpoint request failed", error); + return Err(PollError::RequestFailed); + } + }; + + let response: CodexUsageResponse = match resp.into_json() { + Ok(response) => response, + Err(error) => { + diagnose::log_error("unable to parse Codex usage response", error); + return Err(PollError::RequestFailed); + } + }; + + codex_usage_from_response(response).ok_or(PollError::RequestFailed) +} + +fn codex_usage_from_response(response: CodexUsageResponse) -> Option { + let details = *response.rate_limit.flatten()?; + let mut data = UsageData::default(); + + if let Some(window) = details.primary_window.flatten() { + data.session = codex_section_from_window(&window); + } + + if let Some(window) = details.secondary_window.flatten() { + data.weekly = codex_section_from_window(&window); + } + + Some(data) +} + +fn codex_section_from_window(window: &CodexRateLimitWindow) -> UsageSection { + UsageSection { + percentage: window.used_percent, + resets_at: unix_to_system_time(Some(window.reset_at)), + } +} + +fn get_header_f64(response: &ureq::Response, name: &str) -> f64 { + response + .header(name) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0) +} + +fn get_header_i64(response: &ureq::Response, name: &str) -> Option { + response.header(name).and_then(|s| s.parse::().ok()) +} + +fn unix_to_system_time(unix_secs: Option) -> Option { + let secs = unix_secs?; + if secs < 0 { + return None; + } + Some(UNIX_EPOCH + Duration::from_secs(secs as u64)) +} + +struct Credentials { + access_token: String, + expires_at: Option, + source: CredentialSource, +} + +#[derive(Clone, Debug)] +enum CredentialSource { + Windows(PathBuf), + Wsl { distro: String }, +} + +fn read_first_credentials() -> Option { + if let Some(creds) = read_windows_credentials() { + return Some(creds); + } + + for distro in list_wsl_distros() { + if let Some(creds) = read_wsl_credentials(&distro) { + return Some(creds); + } + } + + None +} + +fn read_windows_credentials() -> Option { + let CredentialSource::Windows(cred_path) = windows_credential_source()? else { + return None; + }; + let content = match std::fs::read_to_string(&cred_path) { + Ok(content) => content, + Err(error) => { + if diagnose::is_enabled() { + diagnose::log_error( + &format!( + "unable to read Windows credentials at {}", + cred_path.display() + ), + error, + ); + } + return None; + } + }; + parse_credentials(&content, CredentialSource::Windows(cred_path)) +} + +fn read_credentials_from_source(source: &CredentialSource) -> Option { + match source { + CredentialSource::Windows(path) => { + let content = std::fs::read_to_string(path).ok()?; + parse_credentials(&content, source.clone()) + } + CredentialSource::Wsl { distro } => read_wsl_credentials(distro), + } +} + +fn codex_auth_path() -> Option { + if let Some(codex_home) = std::env::var_os("CODEX_HOME").map(PathBuf::from) { + return Some(codex_home.join("auth.json")); + } + + Some(dirs::home_dir()?.join(".codex").join("auth.json")) +} + +fn read_codex_credentials() -> Option { + let auth_path = codex_auth_path()?; + let content = match std::fs::read_to_string(&auth_path) { + Ok(content) => content, + Err(error) => { + diagnose::log_error( + &format!( + "unable to read Codex credentials at {}", + auth_path.display() + ), + error, + ); + return None; + } + }; + + let auth: CodexAuthFile = serde_json::from_str(&content).ok()?; + auth.tokens.filter(|tokens| !tokens.access_token.is_empty()) +} + +fn read_wsl_credentials(distro: &str) -> Option { + let output = run_with_timeout( + Command::new("wsl.exe") + .arg("-d") + .arg(distro) + .arg("--") + .arg("sh") + .arg("-lc") + .arg("cat ~/.claude/.credentials.json") + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()), + Duration::from_secs(5), + )?; + + if !output.status.success() { + diagnose::log(format!( + "WSL credentials probe failed for distro {distro} with status {}", + output.status + )); + return None; + } + + let content = String::from_utf8(output.stdout).ok()?; + parse_credentials( + &content, + CredentialSource::Wsl { + distro: distro.to_string(), + }, + ) +} + +fn parse_credentials(content: &str, source: CredentialSource) -> Option { + let json: serde_json::Value = serde_json::from_str(content).ok()?; + + let oauth = json.get("claudeAiOauth")?; + let access_token = oauth + .get("accessToken") + .and_then(|v| v.as_str())? + .to_string(); + let expires_at = oauth.get("expiresAt").and_then(|v| v.as_i64()); + + Some(Credentials { + access_token, + expires_at, + source, + }) +} + +fn read_next_credentials_after(source: &CredentialSource) -> Option { + match source { + CredentialSource::Windows(_) => { + for distro in list_wsl_distros() { + if let Some(creds) = read_wsl_credentials(&distro) { + return Some(creds); + } + } + } + CredentialSource::Wsl { distro } => { + let mut past_current = false; + for candidate_distro in list_wsl_distros() { + if !past_current { + past_current = candidate_distro == *distro; + continue; + } + if let Some(creds) = read_wsl_credentials(&candidate_distro) { + return Some(creds); + } + } + } + } + + None +} + +fn list_wsl_distros() -> Vec { + let output = match run_with_timeout( + Command::new("wsl.exe") + .args(["-l", "-q"]) + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()), + Duration::from_secs(5), + ) { + Some(output) if output.status.success() => output, + _ => { + diagnose::log("unable to enumerate WSL distros"); + return Vec::new(); + } + }; + + let stdout = decode_wsl_text(&output.stdout); + stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn decode_wsl_text(bytes: &[u8]) -> String { + if bytes.is_empty() { + return String::new(); + } + + if let Some(decoded) = decode_utf16le(bytes) { + return decoded; + } + + String::from_utf8_lossy(bytes).into_owned() +} + +fn decode_utf16le(bytes: &[u8]) -> Option { + if bytes.len() < 2 || bytes.len() % 2 != 0 { + return None; + } + + let body = if bytes.starts_with(&[0xFF, 0xFE]) { + &bytes[2..] + } else if looks_like_utf16le(bytes) { + bytes + } else { + return None; + }; + + let units: Vec = body + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + + Some(String::from_utf16_lossy(&units)) +} + +fn looks_like_utf16le(bytes: &[u8]) -> bool { + let sample_len = bytes.len().min(128); + let units = sample_len / 2; + if units == 0 { + return false; + } + + let nul_high_bytes = bytes[..sample_len] + .chunks_exact(2) + .filter(|chunk| chunk[1] == 0) + .count(); + + nul_high_bytes * 2 >= units +} + +fn is_token_expired(expires_at: Option) -> bool { + let Some(exp) = expires_at else { return false }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + now >= exp +} + +/// Parse an ISO 8601 timestamp string into a SystemTime. +fn parse_iso8601(s: Option<&str>) -> Option { + let s = s?; + // Strip timezone offset to get "YYYY-MM-DDTHH:MM:SS" or with fractional seconds + // The API returns formats like "2026-03-05T08:00:00.321598+00:00" + let datetime_part = s.split('+').next().unwrap_or(s); + let datetime_part = datetime_part.split('Z').next().unwrap_or(datetime_part); + + // Try parsing with and without fractional seconds + let formats = ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S"]; + for fmt in &formats { + if let Ok(secs) = parse_datetime_to_unix(datetime_part, fmt) { + return Some(UNIX_EPOCH + Duration::from_secs(secs)); + } + } + None +} + +/// Minimal datetime parser — avoids pulling in chrono/time crates. +fn parse_datetime_to_unix(s: &str, _fmt: &str) -> Result { + // Extract date and time parts from "YYYY-MM-DDTHH:MM:SS[.frac]" + let (date_str, time_str) = s.split_once('T').ok_or(())?; + let date_parts: Vec<&str> = date_str.split('-').collect(); + if date_parts.len() != 3 { + return Err(()); + } + + let year: u64 = date_parts[0].parse().map_err(|_| ())?; + let month: u64 = date_parts[1].parse().map_err(|_| ())?; + let day: u64 = date_parts[2].parse().map_err(|_| ())?; + + // Strip fractional seconds + let time_base = time_str.split('.').next().unwrap_or(time_str); + let time_parts: Vec<&str> = time_base.split(':').collect(); + if time_parts.len() != 3 { + return Err(()); + } + + let hour: u64 = time_parts[0].parse().map_err(|_| ())?; + let min: u64 = time_parts[1].parse().map_err(|_| ())?; + let sec: u64 = time_parts[2].parse().map_err(|_| ())?; + + // Days from year (using a simplified calculation for dates after 1970) + let mut days: u64 = 0; + for y in 1970..year { + days += if is_leap(y) { 366 } else { 365 }; + } + + let month_days = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + for m in 1..month { + days += month_days[m as usize]; + if m == 2 && is_leap(year) { + days += 1; + } + } + days += day - 1; + + Ok(days * 86400 + hour * 3600 + min * 60 + sec) +} + +fn is_leap(y: u64) -> bool { + (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 +} + +/// Format a usage section as "X% · Yh" style text +pub fn format_line(section: &UsageSection, strings: Strings) -> String { + let pct = format!("{:.0}%", section.percentage); + let cd = format_countdown(section.resets_at, strings); + if cd.is_empty() { + pct + } else { + format!("{pct} \u{00b7} {cd}") + } +} + +fn format_countdown(resets_at: Option, strings: Strings) -> String { + let reset = match resets_at { + Some(t) => t, + None => return String::new(), + }; + + let remaining = match reset.duration_since(SystemTime::now()) { + Ok(d) => d, + Err(_) => return strings.now.to_string(), + }; + + format_countdown_from_secs(remaining.as_secs(), strings) +} + +/// Calculate how long until the display text would change +pub fn time_until_display_change(resets_at: Option) -> Option { + let reset = resets_at?; + let remaining = reset.duration_since(SystemTime::now()).ok()?; + Some(time_until_display_change_from_secs(remaining.as_secs())) +} + +fn format_countdown_from_secs(total_secs: u64, strings: Strings) -> String { + let total_mins = total_secs / 60; + let total_hours = total_secs / 3600; + let total_days = total_secs / 86400; + + if total_days >= 1 { + format!("{total_days}{}", strings.day_suffix) + } else if total_hours >= 1 { + format!("{total_hours}{}", strings.hour_suffix) + } else if total_mins >= 1 { + format!("{total_mins}{}", strings.minute_suffix) + } else { + format!("{total_secs}{}", strings.second_suffix) + } +} + +fn time_until_display_change_from_secs(total_secs: u64) -> Duration { + let total_mins = total_secs / 60; + let total_hours = total_secs / 3600; + let total_days = total_secs / 86400; + + let current_bucket_start = if total_days >= 1 { + total_days * 86400 + } else if total_hours >= 1 { + total_hours * 3600 + } else if total_mins >= 1 { + total_mins * 60 + } else { + total_secs + }; + + Duration::from_secs(total_secs.saturating_sub(current_bucket_start) + 1) +} + +/// Returns true if either section has reached "now" (reset time has passed). +pub fn is_past_reset(data: &UsageData) -> bool { + let now = SystemTime::now(); + let past = |s: &UsageSection| matches!(s.resets_at, Some(t) if now.duration_since(t).is_ok()); + past(&data.session) || past(&data.weekly) +} + +pub fn app_is_past_reset(data: &AppUsageData) -> bool { + data.claude_code.as_ref().is_some_and(is_past_reset) + || data.codex.as_ref().is_some_and(is_past_reset) +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..d61c10d --- /dev/null +++ b/src/settings.rs @@ -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, + #[serde(default)] + pub last_update_check_unix: Option, + #[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 { + 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); +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..05ad0bb --- /dev/null +++ b/src/theme.rs @@ -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::() 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 + } +} diff --git a/src/tray_icon.rs b/src/tray_icon.rs new file mode 100644 index 0000000..27c3352 --- /dev/null +++ b/src/tray_icon.rs @@ -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, + 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) -> 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::() 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 = 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::() 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(s: &str, buf: &mut [u16; N]) { + let wide: Vec = 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, tooltip: &str) { + let hicon = create_icon(kind, percent); + unsafe { + let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); + nid.cbSize = std::mem::size_of::() 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, tooltip: &str) { + let hicon = create_icon(kind, percent); + unsafe { + let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); + nid.cbSize = std::mem::size_of::() 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::() 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 = 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; +} diff --git a/src/updater.rs b/src/updater.rs new file mode 100644 index 0000000..7d80add --- /dev/null +++ b/src/updater.rs @@ -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, +} + +#[derive(Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +pub fn handle_cli_mode(args: &[String]) -> Option { + 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::().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 { + 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(), + ¤t_exe.to_string_lossy(), + ¤t_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(¤t_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(¤t_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, 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 { + 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 { + 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 { + 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::().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 { + value.encode_utf16().chain(std::iter::once(0)).collect() +}