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
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,147 @@
|
||||

|
||||
[](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/<your-fork>/claude-code-usage-bubble
|
||||
cd claude-code-usage-bubble
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The binary lands at `target/release/claude-code-usage-bubble.exe`.
|
||||
|
||||
## Use
|
||||
|
||||
Run `claude-code-usage-bubble.exe`. The bubble appears near the bottom-right
|
||||
corner of your primary monitor on first launch. Drag it where you want it,
|
||||
release to snap to the nearest edge if you let go close to one.
|
||||
|
||||
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
|
||||
- **Right-click** for refresh, models, update frequency, language, "Start
|
||||
with Windows", updates, exit
|
||||
- **Drag** anywhere — it floats on top of all other windows
|
||||
- **Ctrl + MouseWheel** on the bubble to resize it
|
||||
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
|
||||
right-click opens the same menu
|
||||
|
||||
### Models
|
||||
|
||||
Use the right-click **Models** menu to choose what is shown:
|
||||
|
||||
- **Claude Code** is enabled by default
|
||||
- **Codex** can be enabled alongside Claude Code or shown by itself
|
||||
|
||||
When both models are shown, each gets its own bubble that you can position
|
||||
independently.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
```powershell
|
||||
claude-code-usage-bubble.exe --diagnose
|
||||
```
|
||||
|
||||
This writes a log file to:
|
||||
|
||||
```text
|
||||
%TEMP%\claude-code-usage-bubble.log
|
||||
```
|
||||
|
||||
Settings are saved to:
|
||||
|
||||
```text
|
||||
%APPDATA%\ClaudeCodeUsageBubble\settings.json
|
||||
```
|
||||
|
||||
## Privacy and security
|
||||
|
||||
What the app reads:
|
||||
|
||||
- Your local Claude Code OAuth credentials from `~/.claude/.credentials.json`
|
||||
- If needed, the same credentials file inside an installed WSL distro
|
||||
- If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json`
|
||||
or `~/.codex/auth.json`
|
||||
|
||||
What the app sends over the network:
|
||||
|
||||
- Requests to Anthropic's Claude endpoints to read your usage
|
||||
- Requests to ChatGPT's Codex usage endpoint, if Codex is enabled
|
||||
- Requests to GitHub only if you use the app's update-check feature
|
||||
|
||||
What the app stores locally:
|
||||
|
||||
- Bubble position(s) per model
|
||||
- Bubble size
|
||||
- Polling frequency
|
||||
- Language preference
|
||||
- Last update check time
|
||||
- Displayed model preferences
|
||||
|
||||
What it does **not** do: send credentials to any third-party server, run a
|
||||
backend service, collect analytics, upload your project files, or write to
|
||||
your Codex `auth.json` directly.
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 — see [LICENSE](LICENSE). This project is a derivative of
|
||||
[CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor)
|
||||
(MIT). Upstream attribution and the original MIT terms for the ported portions
|
||||
are recorded in [NOTICE](NOTICE).
|
||||
@@ -0,0 +1,24 @@
|
||||
use winres::{VersionInfo, WindowsResource};
|
||||
|
||||
fn main() {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let mut res = WindowsResource::new();
|
||||
let numeric_version = pack_version(version);
|
||||
|
||||
res.set_icon("src/icons/icon.ico")
|
||||
.set("FileVersion", version)
|
||||
.set("ProductVersion", version)
|
||||
.set_version_info(VersionInfo::FILEVERSION, numeric_version)
|
||||
.set_version_info(VersionInfo::PRODUCTVERSION, numeric_version);
|
||||
|
||||
res.compile().expect("Failed to compile Windows resources");
|
||||
}
|
||||
|
||||
fn pack_version(version: &str) -> u64 {
|
||||
let core = version.split('-').next().unwrap_or(version);
|
||||
let mut parts = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
|
||||
let major = parts.next().unwrap_or(0).min(u16::MAX as u64);
|
||||
let minor = parts.next().unwrap_or(0).min(u16::MAX as u64);
|
||||
let patch = parts.next().unwrap_or(0).min(u16::MAX as u64);
|
||||
(major << 48) | (minor << 32) | (patch << 16)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
# Phase 1: Bootstrap Repo
|
||||
|
||||
## Context Links
|
||||
|
||||
- Source `Cargo.toml`: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/Cargo.toml`
|
||||
- Source `build.rs`: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/build.rs`
|
||||
- Source icons: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/icons/`
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Must-first (every other phase depends on this)
|
||||
- **Status:** pending
|
||||
- **Description:** Create the new repo's foundation: `Cargo.toml` with the right windows-rs features, `build.rs` for icon embedding, LICENSE (MIT, dual attribution), README with attribution to source, fresh icon assets, project layout.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Source uses `windows = 0.58` with 12 feature flags. New repo can **drop** `Win32_UI_Accessibility` (no more `SetWinEventHook` on TrayNotifyWnd) and **drop** `Win32_UI_Input_KeyboardAndMouse` if drag is handled via `WM_NCHITTEST` + `HTCAPTION` instead of manual `SetCapture`.
|
||||
- Source uses `ureq` + `native-tls` + `serde` + `dirs` — all keep verbatim.
|
||||
- Source release profile: `opt-level="z"`, `lto=true`, `strip=true`, `codegen-units=1`, `panic="abort"` — keep all (produces ~2MB binary).
|
||||
- Package name change: `claude-code-usage-bubble` (binary name `claude-code-usage-bubble.exe`).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- `cargo build --release` produces a single-file `.exe`
|
||||
- `winres` embeds icon resource so the .exe has a proper Windows icon
|
||||
- README clearly attributes original repo + MIT license
|
||||
- Project compiles with no implementation yet (empty `main.rs` returning `()`)
|
||||
|
||||
### Non-functional
|
||||
- Binary size target: < 3 MB stripped
|
||||
- No Linux/Mac build (Windows-only `#![windows_subsystem]` in main)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
claude-code-usage-bubble/
|
||||
├── Cargo.toml
|
||||
├── build.rs
|
||||
├── LICENSE # MIT, with attribution clause
|
||||
├── README.md # Attribution + usage
|
||||
├── src/
|
||||
│ ├── main.rs # entry, mod declarations only
|
||||
│ └── icons/
|
||||
│ ├── icon.ico
|
||||
│ ├── 16x16.png, 32x32.png, 48x48.png, 256x256.png
|
||||
│ └── *.svg sources
|
||||
└── plans/ # this directory
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
**To create:**
|
||||
- `Cargo.toml`
|
||||
- `build.rs`
|
||||
- `LICENSE`
|
||||
- `README.md`
|
||||
- `src/main.rs` (stub)
|
||||
- `src/icons/*` (placeholder copies from source; can be replaced with bubble-specific art later)
|
||||
- `.gitignore`
|
||||
|
||||
**To modify:** none (greenfield repo)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create `Cargo.toml`** with package metadata, the same `windows-rs` features minus accessibility + keyboard/mouse:
|
||||
```toml
|
||||
[package]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Floating bubble showing Claude Code / Codex usage on Windows"
|
||||
repository = "<set this>"
|
||||
```
|
||||
Features to include: `Win32_Foundation`, `Win32_Globalization`, `Win32_Graphics_Gdi`, `Win32_System_LibraryLoader`, `Win32_UI_Shell`, `Win32_UI_WindowsAndMessaging`, `Win32_System_Registry`, `Win32_System_Threading`, `Win32_Security`, `Win32_UI_HiDpi`.
|
||||
2. **Create `build.rs`** mirroring source `build.rs`; embed `src/icons/icon.ico` via `winres`.
|
||||
3. **Copy `src/icons/*`** from source verbatim (placeholder; designer can replace).
|
||||
4. **Write `LICENSE`** — MIT text with a header line crediting CodeZeno/Claude-Code-Usage-Monitor.
|
||||
5. **Write `README.md`** — short, includes:
|
||||
- One-paragraph what-it-is
|
||||
- Attribution: "This project ports usage-polling, updater, and tray-icon code from [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) (MIT)."
|
||||
- Install/run section (placeholder)
|
||||
6. **Stub `src/main.rs`**:
|
||||
```rust
|
||||
#![windows_subsystem = "windows"]
|
||||
fn main() {}
|
||||
```
|
||||
7. **Run `cargo build`** — must compile clean.
|
||||
8. **Run `cargo build --release`** — confirm binary produced, check size.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Cargo.toml with correct features
|
||||
- [ ] build.rs with winres
|
||||
- [ ] Icons copied from source
|
||||
- [ ] LICENSE with attribution
|
||||
- [ ] README.md with attribution
|
||||
- [ ] src/main.rs stub
|
||||
- [ ] .gitignore (target/, Cargo.lock for libs only — keep Cargo.lock for binaries)
|
||||
- [ ] `cargo build` succeeds
|
||||
- [ ] `cargo build --release` succeeds, binary < 3 MB
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- `cargo build --release` on Windows produces a runnable .exe with embedded icon
|
||||
- README links source repo
|
||||
- LICENSE includes attribution clause
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Low.** Pure configuration; no logic.
|
||||
- Cross-compile note: if developer is on Linux/Mac, will need MinGW or Windows machine for `winres` step. Document this in README.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- N/A in this phase.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ Phase 2: port portable modules into `src/`
|
||||
@@ -0,0 +1,124 @@
|
||||
# Phase 2: Port Portable Modules
|
||||
|
||||
## Context Links
|
||||
|
||||
- Source: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/`
|
||||
- Modules to port verbatim (or near-verbatim): `models.rs`, `diagnose.rs`, `theme.rs`, `poller.rs`, `updater.rs`, `tray_icon.rs`, `localization/*`
|
||||
- Module to trim: `native_interop.rs`
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** High (must precede phases 3-4)
|
||||
- **Status:** pending
|
||||
- **Description:** Bring over the portable subsystems from the source repo with minimal changes. These represent ~2,700 lines of working, tested code — the goal is to keep them intact and only edit what's required for the new project name and the simplified Win32 surface.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- `poller.rs` (1099 lines) is fully self-contained — depends only on `serde`, `ureq`, `native-tls`, `dirs`, plus `diagnose` and `models` from the same crate.
|
||||
- `updater.rs` (510 lines) embeds `env!("CARGO_PKG_REPOSITORY")` to resolve GitHub owner/repo automatically — no hardcoded references.
|
||||
- `tray_icon.rs` uses `WM_APP_TRAY` (`WM_APP + 3`) and `IDM_TOGGLE_WIDGET = 50`. Keep the constants; `app.rs` (phase 4) will own dispatch.
|
||||
- `native_interop.rs` is the trimming target: drop `find_taskbar`, `find_child_window`, `get_taskbar_rect`, `embed_in_taskbar`, `set_tray_event_hook`, `get_window_thread_id`, `unhook_win_event`. Keep `wide_str`, `colorref`, `Color`, timer-ID constants, custom-message constants.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- `mod models; mod diagnose; mod theme; mod poller; mod updater; mod tray_icon; mod localization; mod native_interop;` all compile against current `main.rs`
|
||||
- `cargo check` passes with zero warnings beyond unused-symbol warnings (which will resolve in phases 3-4)
|
||||
- All `pub` symbols documented above remain reachable
|
||||
|
||||
### Non-functional
|
||||
- No behavioral changes vs source — diffs limited to module boundaries
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs (stub from phase 1)
|
||||
├── models.rs COPIED
|
||||
├── diagnose.rs COPIED
|
||||
├── theme.rs COPIED
|
||||
├── poller.rs COPIED
|
||||
├── updater.rs COPIED + 1-line stub for current_install_channel
|
||||
├── tray_icon.rs COPIED
|
||||
├── native_interop.rs TRIMMED (~80 lines vs source 179)
|
||||
└── localization/
|
||||
├── mod.rs COPIED
|
||||
├── english.rs COPIED
|
||||
├── dutch.rs COPIED
|
||||
├── french.rs COPIED
|
||||
├── german.rs COPIED
|
||||
├── japanese.rs COPIED
|
||||
├── korean.rs COPIED
|
||||
├── spanish.rs COPIED
|
||||
└── traditional_chinese.rs COPIED
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
**To create (copy from source):**
|
||||
- All files listed in Architecture section above.
|
||||
|
||||
**To modify after copying:**
|
||||
- `src/updater.rs` — replace `current_install_channel()` body with `InstallChannel::Portable` while keeping the rest of the function intact (preserves code for future winget enablement).
|
||||
- `src/native_interop.rs` — delete taskbar/WinEvent functions and their imports.
|
||||
- `src/main.rs` — declare modules; do NOT yet call `window::run` (window module doesn't exist yet).
|
||||
|
||||
**To delete:** none.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Copy modules verbatim:**
|
||||
```
|
||||
cp -r ../Claude-Code-Usage-Monitor/src/{models,diagnose,theme,poller,updater,tray_icon}.rs src/
|
||||
cp -r ../Claude-Code-Usage-Monitor/src/localization src/
|
||||
```
|
||||
2. **Copy & trim `native_interop.rs`:**
|
||||
- Keep: `wide_str`, `colorref`, `Color`, `TIMER_*` constants, `WM_APP_*` constants, `get_window_rect_safe`, `move_window`
|
||||
- Delete: `WS_POPUP_STYLE`, `WS_CHILD_STYLE`, `WS_CLIPSIBLINGS_STYLE` (bubble uses standard windows-rs constants), `EVENT_OBJECT_LOCATIONCHANGE`, `WINEVENT_OUTOFCONTEXT`, `find_taskbar`, `find_child_window`, `get_taskbar_rect`, `embed_in_taskbar`, `set_tray_event_hook`, `get_window_thread_id`, `unhook_win_event`
|
||||
- Drop imports for `Accessibility`, `Shell::SHAppBarMessage`/`APPBARDATA`, `Foundation::RECT` (if no longer used after trim)
|
||||
3. **Stub winget detection in `updater.rs`:**
|
||||
```rust
|
||||
pub fn current_install_channel() -> InstallChannel {
|
||||
// Bubble repo is not yet published to winget; once it is, restore the
|
||||
// is_winget_install_path probe by reading the source repo's logic.
|
||||
InstallChannel::Portable
|
||||
}
|
||||
```
|
||||
Keep `is_winget_install_path` + `winget_install_roots` + `normalize_path` as `#[allow(dead_code)]` to preserve the code path.
|
||||
4. **Update `src/main.rs`** to declare the modules:
|
||||
```rust
|
||||
#![windows_subsystem = "windows"]
|
||||
mod diagnose; mod localization; mod models; mod native_interop;
|
||||
mod poller; mod theme; mod tray_icon; mod updater;
|
||||
fn main() { /* phase-04 will wire this */ }
|
||||
```
|
||||
5. **Run `cargo check`** — expect warnings about unused public items; should be zero errors.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Copy 7 source modules + localization directory
|
||||
- [ ] Trim `native_interop.rs` to ~80 lines (drop taskbar/WinEvent helpers)
|
||||
- [ ] Stub `updater::current_install_channel`
|
||||
- [ ] Wire `mod` declarations in `main.rs`
|
||||
- [ ] `cargo check` clean (only dead-code warnings)
|
||||
- [ ] `cargo build --release` still produces a binary
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- All ported modules compile in the new crate without modification beyond what is listed above
|
||||
- No warnings about missing imports
|
||||
- Source code license headers (if any) are preserved
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Low.** Copy-with-rename operation; the trimming of `native_interop.rs` is the only judgment call.
|
||||
- Edge case: `tray_icon.rs` imports `crate::native_interop::WM_APP_TRAY` — verify constant survives the trim.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- `poller.rs` reads OAuth credentials from `~/.claude/.credentials.json` and (optionally) WSL distros. No new attack surface vs source.
|
||||
- `updater.rs` downloads .exe from GitHub. Same trust model as source. Until the new repo has releases published, this code is dormant.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ Phase 3: build the bubble window (replaces 2847 lines of `window.rs`)
|
||||
@@ -0,0 +1,166 @@
|
||||
# Phase 3: Build Floating Bubble Window
|
||||
|
||||
## Context Links
|
||||
|
||||
- Source `window.rs` painting + drag logic: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/window.rs` (lines around `UpdateLayeredWindow`, `WM_LBUTTONDOWN`, `SetCapture`)
|
||||
- Reference UX: 360 Security floating ball, IObit Advanced SystemCare RAM-boost ball — both are circular, top-most, draggable-anywhere with edge snap.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Critical — this is the heart of the new UX
|
||||
- **Status:** pending
|
||||
- **Description:** Build a circular floating bubble window that floats on top of everything, can be dragged anywhere, snaps to monitor edges, and shows usage percentage in the center over a colored progress ring. Replaces the 2847-line `window.rs` taskbar embedding code.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Use `WS_POPUP | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW` — `TOOLWINDOW` keeps it out of Alt+Tab; `NOACTIVATE` prevents focus theft.
|
||||
- Drag-anywhere = handle `WM_NCHITTEST`, return `HTCAPTION` for the entire bubble area. The OS handles drag automatically, including with proper cursor and Win+drag behavior. No need for `SetCapture`.
|
||||
- Circular alpha mask: render to a DIB section with per-pixel alpha. Pixels outside the circle = `0x00000000` (fully transparent). Then `UpdateLayeredWindow` with `ULW_ALPHA`. Click-through outside the circle happens automatically because alpha=0 doesn't hit-test (default behavior of layered windows with `WS_EX_LAYERED` + per-pixel alpha; verify via test).
|
||||
- Snap to edge: in `WM_EXITSIZEMOVE`, query current position via `GetWindowRect`, find nearest monitor via `MonitorFromPoint`, get its work area via `GetMonitorInfo`. If center is within 12px of any work-area edge, snap that edge.
|
||||
- HiDPI: use `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` early in `main`. Query `GetDpiForWindow` on `WM_DPICHANGED` and rescale bubble size + font.
|
||||
- GDI ring drawing: parametric — sweep angle proportional to percentage. Use `Polygon` filled with brush, or `AngleArc` with thick pen. For clean anti-aliased look on layered window, draw into DIB section manually with alpha-weighted line algorithm. Simpler path: use GDI+ via `gdiplus` crate, or accept GDI-aliased look for v1.
|
||||
- Bubble size default: **56×56 px** at 100% DPI (matches reference apps). Allow user to tweak in `Cargo.toml` constant for v1.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- Window appears as a circular bubble, can be dragged anywhere on any monitor
|
||||
- Bubble shows percentage text (e.g. "73%") in center
|
||||
- Colored progress ring around the percentage; color matches the source app's color stops (orange → red gradient from 50% to 100%)
|
||||
- Top-most: stays visible over other windows
|
||||
- No taskbar entry, no Alt-Tab entry
|
||||
- Left-click → posts `WM_APP_PANEL_TOGGLE` to self (panel implementation in phase 4)
|
||||
- Right-click → context menu (phase 4 owns menu items; bubble owns the right-click detection)
|
||||
- Drag releases → snap to nearest monitor edge if within 12 px of it
|
||||
- Repaints when percentage / theme / DPI changes
|
||||
|
||||
### Non-functional
|
||||
- 60 FPS not required; redraws on data update only (every 60s poll cycle plus countdown ticks)
|
||||
- Per-monitor DPI aware
|
||||
- Visible on dark and light Windows themes (use `theme::is_dark_mode` for ring background tint)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── bubble.rs NEW — Window class, message loop owner, GDI painting,
|
||||
│ drag + snap, DPI, hit-testing
|
||||
└── (other modules unchanged from phase 2)
|
||||
```
|
||||
|
||||
Bubble owns:
|
||||
- HWND lifecycle (`RegisterClassExW` + `CreateWindowExW`)
|
||||
- DIB section + `UpdateLayeredWindow` call
|
||||
- Percentage state (`Option<f64>` for each enabled model)
|
||||
- Drag state (managed by OS via `HTCAPTION`)
|
||||
- Snap math
|
||||
- DPI scale factor cache
|
||||
|
||||
Bubble delegates:
|
||||
- Polling → `poller::poll` (phase 4 wires the background thread)
|
||||
- Panel toggle → `app::on_panel_toggle` (phase 4)
|
||||
- Right-click menu → `app::on_show_context_menu` (phase 4)
|
||||
- Settings → `settings` module (phase 4)
|
||||
|
||||
## Related Code Files
|
||||
|
||||
**To create:**
|
||||
- `src/bubble.rs` (target: 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<f64>,
|
||||
}
|
||||
pub fn update_percentage(hwnd: HWND, percentage: Option<f64>); // called from poll thread via PostMessage
|
||||
```
|
||||
For dual-bubble mode, phase 4 spawns one `bubble::run` per enabled model on separate threads (each with its own message loop) — simpler than juggling two HWNDs in one thread.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Window class + creation with correct styles
|
||||
- [ ] Per-monitor DPI awareness on entry
|
||||
- [ ] DIB section + layered window painting pipeline
|
||||
- [ ] Circle fill with theme-aware background
|
||||
- [ ] Progress ring painted at correct sweep angle, correct color stop
|
||||
- [ ] Percentage text drawn centered
|
||||
- [ ] `WM_NCHITTEST` returns `HTCAPTION` inside circle, `HTTRANSPARENT` outside
|
||||
- [ ] Drag works smoothly across monitors
|
||||
- [ ] Snap on release within 12px of work-area edge
|
||||
- [ ] Left-click (no drag) posts panel-toggle message
|
||||
- [ ] Right-click posts context-menu request
|
||||
- [ ] Public API `run`, `update_percentage`
|
||||
- [ ] Manual test: bubble visible, draggable, snaps, percentage updates
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Bubble appears on Windows 10/11 with a Visual Studio-clean cargo build
|
||||
- Drag works smoothly with no flicker
|
||||
- Edge snap engages reliably from 12 px
|
||||
- Bubble survives display reconnection (laptop → external monitor → unplug)
|
||||
- Percentage text remains crisp on 100%, 125%, 150%, 175% DPI
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **High** — this is novel code with no exact analog in source.
|
||||
- Risk: ClearType sub-pixel text rendering on a per-pixel-alpha layered window looks bad. Source repo's `window.rs` solved this with a black background-pixel hack (`alpha = 0x01` so it's nearly transparent but still gets ClearType). Apply the same trick for the circle's interior fill region.
|
||||
- Risk: GDI `AngleArc` doesn't anti-alias. Mitigation: either accept aliased v1, or render to a 2x supersampled DIB and downsample.
|
||||
- Risk: Snap math wrong on rotated taskbar or unusual DPI configurations. Mitigation: clamp to monitor work area only, ignore taskbar position.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- N/A in this phase; bubble does not handle user input beyond mouse position and clicks.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ Phase 4: expanded panel, settings persistence, polling thread, orchestration
|
||||
@@ -0,0 +1,190 @@
|
||||
# Phase 4: Expanded Panel + Settings Persistence + Orchestration
|
||||
|
||||
## Context Links
|
||||
|
||||
- Source `window.rs` — borrow message-loop dispatch, polling-thread orchestration, settings persistence pattern
|
||||
- Source `poller.rs` — `poll`, `credential_watch_snapshot`, `format_line`, `time_until_display_change`
|
||||
- Source `tray_icon.rs` — `add/update/remove/sync`, `handle_message`
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** High
|
||||
- **Status:** pending
|
||||
- **Description:** Build (a) the expanded panel that appears on bubble click and shows both 5h and 7d bars with countdowns, (b) settings persistence to `%APPDATA%\ClaudeCodeUsageBubble\settings.json`, (c) the orchestrating `app.rs` module that owns polling, message routing, context menus, and dual-bubble lifecycle.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Panel is a separate window: `WS_POPUP | WS_EX_LAYERED | WS_EX_TOPMOST`, opaque background, shown adjacent to bubble. Source's draw code for the horizontal bars can be ported almost directly (it already draws progress bars + countdown text via GDI).
|
||||
- Settings file location matches source pattern: `%APPDATA%\ClaudeCodeUsageBubble\settings.json` (renamed dir). Use `dirs::config_dir()`.
|
||||
- Polling: background `std::thread` spawned in `app::run`. Posts `WM_APP_USAGE_UPDATED` to each bubble window when data refreshes. Source's poll-loop logic is copy-friendly.
|
||||
- Context menu: built via `CreatePopupMenu` + `AppendMenuW` + `TrackPopupMenu`. Source has the full menu structure — port it but remove "Reset position" → rename to "Reset bubble position" (per model).
|
||||
- Dual-bubble: each enabled model gets its own HWND + tray icon + bubble window. Settings stores `bubble_positions: { claude: {x, y}, codex: {x, y} }`.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- Settings persist across restarts: window positions, polling frequency, enabled models, language, "Start with Windows" state, last update check
|
||||
- Expanded panel: shows session bar + weekly bar + countdowns + reset times for the model whose bubble was clicked
|
||||
- Panel auto-closes on focus loss or after a brief timeout (optional)
|
||||
- Right-click menu mirrors source's menu structure: Refresh, Models, Update frequency, Language, Start with Windows, Reset position, Updates, Exit
|
||||
- Single-instance enforced via named mutex `Global\ClaudeCodeUsageBubble`
|
||||
- Polling runs in background, posts updates via `PostMessageW(WM_APP_USAGE_UPDATED, ...)`
|
||||
- Countdown timer adapts to display granularity (`time_until_display_change`)
|
||||
|
||||
### Non-functional
|
||||
- Settings file is atomically written (write to `.tmp`, rename)
|
||||
- Polling thread cannot block UI thread
|
||||
- Mutex released on clean shutdown
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── app.rs NEW — orchestrator: spawns bubbles, polls, routes messages,
|
||||
│ owns tray icons, owns context menu builder
|
||||
├── panel.rs NEW — expanded panel window (one per model on demand)
|
||||
├── settings.rs NEW — load/save settings.json, schema
|
||||
└── main.rs modified — calls app::run
|
||||
```
|
||||
|
||||
Message flow:
|
||||
|
||||
```
|
||||
poll thread ────PostMessage(WM_APP_USAGE_UPDATED)──▶ bubble HWND
|
||||
└─▶ updates percentage, redraws
|
||||
bubble click ──PostMessage(WM_APP_PANEL_TOGGLE)─▶ app handler (in bubble wndproc)
|
||||
└─▶ panel::show_for(model)
|
||||
right-click ──app::show_context_menu(hwnd)──▶ TrackPopupMenu ─▶ WM_COMMAND
|
||||
└─▶ menu action dispatch
|
||||
tray icon ────WM_APP_TRAY───────────▶ tray_icon::handle_message ─▶ TrayAction
|
||||
└─▶ toggle/ shutdown / refresh
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
**To create:**
|
||||
- `src/app.rs` (target: 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<String> = std::env::args().collect();
|
||||
if args.iter().any(|a| a == "--diagnose") {
|
||||
if let Ok(path) = diagnose::init() {
|
||||
diagnose::log(format!("startup args={args:?} log_path={}", path.display()));
|
||||
}
|
||||
}
|
||||
if let Some(exit_code) = updater::handle_cli_mode(&args) {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
app::run();
|
||||
}
|
||||
```
|
||||
|
||||
**To delete:** none.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **`settings.rs`** — define schema:
|
||||
```rust
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
pub show_claude_code: bool, // default true
|
||||
pub show_codex: bool, // default false
|
||||
pub bubble_positions: BubblePositions,
|
||||
pub poll_minutes: u32, // default 5
|
||||
pub language: Option<String>, // None = system
|
||||
pub start_with_windows: bool,
|
||||
pub last_update_check_unix: Option<i64>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct BubblePositions {
|
||||
pub claude: Option<(i32, i32)>,
|
||||
pub codex: Option<(i32, i32)>,
|
||||
}
|
||||
pub fn load() -> Settings { /* dirs::config_dir + read + serde + atomic */ }
|
||||
pub fn save(s: &Settings) { /* write .tmp + rename */ }
|
||||
```
|
||||
2. **`panel.rs`** — port the existing horizontal-bar painting code from source `window.rs`:
|
||||
- Window class `ClaudeCodeUsageBubblePanel`
|
||||
- On `WM_LBUTTONDOWN` outside → close
|
||||
- On `WM_KILLFOCUS` → close (with debounce so it doesn't close instantly when bubble is clicked again to toggle off)
|
||||
- Paints two rows (5h, 7d) for the model that was clicked; uses `poller::format_line` for the countdown text
|
||||
- Position: anchor next to bubble; flip side if would go off-screen
|
||||
3. **`app.rs`** — orchestrator:
|
||||
- `pub fn run() -> !`:
|
||||
1. Acquire single-instance mutex; if already running, exit.
|
||||
2. `SetProcessDpiAwarenessContext`.
|
||||
3. Load settings.
|
||||
4. Resolve language (`localization::resolve_language`).
|
||||
5. Start polling thread.
|
||||
6. For each enabled model, spawn a bubble window thread.
|
||||
7. Run main message loop on UI thread (the bubble windows can be on the same thread — easier than multi-thread message pumps).
|
||||
- Polling thread:
|
||||
```rust
|
||||
loop {
|
||||
match poller::poll(show_claude, show_codex) {
|
||||
Ok(data) => post_update_to_bubbles(data),
|
||||
Err(e) => post_error_to_app(e),
|
||||
}
|
||||
sleep(poll_interval);
|
||||
}
|
||||
```
|
||||
- Context menu builder: replicate source's menu structure verbatim; localized strings via `Strings`. Map `WM_COMMAND` IDs to handlers (refresh, toggle model, change interval, etc.).
|
||||
- "Start with Windows": registry write to `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run\ClaudeCodeUsageBubble`.
|
||||
- Token-expired flow: if `PollError::TokenExpired`, show tray balloon via `tray_icon::notify_balloon` with localized title/body.
|
||||
4. **Wire `bubble.rs` events back to `app.rs`:**
|
||||
- `WM_APP_PANEL_TOGGLE` handler in bubble wndproc → call `panel::show_for_model(model)` (registered via callback or via `app::handle_panel_toggle`).
|
||||
- `WM_RBUTTONUP` → call `app::show_context_menu_at(point, model)`.
|
||||
- `WM_EXITSIZEMOVE` → call `app::on_bubble_moved(model, x, y)` which updates settings.
|
||||
|
||||
5. **Reuse `tray_icon.rs`** as the source-app's secondary indicator:
|
||||
- In `app::run`, after creating bubbles, call `tray_icon::sync(hwnd, &[TrayIconData{kind: Claude, percent: …, tooltip: …}, …])`.
|
||||
- Tray icon clicked → `tray_icon::handle_message` → if `ToggleWidget`, toggle bubble visibility (set `WS_VISIBLE` style); if `ShowContextMenu`, dispatch to `app::show_context_menu_at`.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] `settings.rs` with atomic save
|
||||
- [ ] `panel.rs` with bar painting + auto-close
|
||||
- [ ] `app.rs` orchestrator
|
||||
- [ ] Single-instance mutex acquisition + release
|
||||
- [ ] Polling thread spawning + `PostMessage` updates
|
||||
- [ ] Context menu localized + dispatchable
|
||||
- [ ] Start-with-Windows registry roundtrip
|
||||
- [ ] Tray icons synced from polling updates
|
||||
- [ ] Bubble-to-app event callbacks wired
|
||||
- [ ] Dual-bubble mode tested (both Claude + Codex enabled)
|
||||
- [ ] `cargo build --release` → working binary
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Launching the app twice: second launch exits silently
|
||||
- Bubble + panel + tray icon all show correct usage after first poll
|
||||
- Right-click menu functional for all items
|
||||
- Toggle Claude/Codex via menu: bubbles appear/disappear; settings persist
|
||||
- Restart app: bubble reappears at last saved position
|
||||
- Token-expired triggers tray balloon
|
||||
- Poll frequency change takes effect within one poll cycle
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Medium.** Most logic is structural — message routing and state management. Source repo has all the patterns.
|
||||
- Risk: dual-bubble on same UI thread with two HWNDs — should work, but verify message routing keys off `hwnd` parameter.
|
||||
- Risk: panel auto-close races with bubble re-click. Mitigation: 200 ms debounce on `WM_KILLFOCUS` before destroying panel; if bubble clicked within that window, cancel close.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Settings file written to `%APPDATA%`, user-scoped, no privileged ops.
|
||||
- Single-instance mutex name `Global\ClaudeCodeUsageBubble` — distinct from source app to allow coexistence.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ Phase 5: polish (HiDPI testing, multi-monitor, README, attribution, etc.)
|
||||
@@ -0,0 +1,120 @@
|
||||
# Phase 5: Polish & Finishing
|
||||
|
||||
## Context Links
|
||||
|
||||
- All prior phases
|
||||
- Source README for attribution / language list
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** Required before any release
|
||||
- **Status:** pending
|
||||
- **Description:** Multi-monitor and HiDPI verification, accessibility checks, README, license attribution, version 0.1.0 tag, optional CI workflow.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- Bubble renders crisply at 100% / 125% / 150% / 175% / 200% DPI
|
||||
- Bubble survives display add/remove (laptop dock/undock)
|
||||
- Bubble respects monitor work area when snapping (does not overlap taskbar)
|
||||
- README has install + run + uninstall sections
|
||||
- LICENSE has CodeZeno attribution paragraph
|
||||
- `--diagnose` flag works (writes `%TEMP%\claude-code-usage-bubble.log`)
|
||||
|
||||
### Non-functional
|
||||
- Cargo build is fully reproducible
|
||||
- No clippy warnings on `cargo clippy -- -D warnings` (or document the ones you keep)
|
||||
|
||||
## Architecture
|
||||
|
||||
No new modules.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
**To modify:**
|
||||
- `README.md` — fill out final content
|
||||
- `LICENSE` — final attribution paragraph
|
||||
- Maybe `.github/workflows/build.yml` for CI Windows build
|
||||
|
||||
**To delete:** none.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **HiDPI manual test matrix:**
|
||||
- Windows 10 at 100% DPI: bubble visible, text readable, ring smooth
|
||||
- Windows 11 at 150%: same
|
||||
- 4K monitor at 200%: same
|
||||
- Mixed-DPI dual monitor: drag bubble between monitors → verify rescale on `WM_DPICHANGED`
|
||||
2. **Multi-monitor edge tests:**
|
||||
- Snap bubble to right edge of secondary monitor → settings saved with correct coords
|
||||
- Disconnect monitor → bubble should reposition to primary monitor's work area on next start
|
||||
- Test with taskbar on top / left / right (not just default bottom)
|
||||
3. **README content checklist:**
|
||||
- One-paragraph what-it-is + screenshot/gif placeholder
|
||||
- **Attribution section** (required by source MIT license):
|
||||
> This project is a derivative of [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor) (MIT, © 2026 Code Zeno Pty Ltd). The usage-polling, updater, tray-icon, and localization modules are ported from that codebase with minor adaptations; the floating-bubble UI is original to this project.
|
||||
- Install: cargo build instructions; future winget block
|
||||
- Use: bubble + panel + tray icon described
|
||||
- Models: same content as source
|
||||
- Diagnostics: `--diagnose` flag, log path
|
||||
- Privacy: same content as source (credentials read locally, GitHub for updates)
|
||||
- License: MIT
|
||||
4. **LICENSE file** — include both:
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 <your name>
|
||||
|
||||
Portions of this software are derived from Claude Code Usage Monitor,
|
||||
Copyright (c) 2026 Code Zeno Pty Ltd, licensed under the MIT License.
|
||||
|
||||
<rest of MIT license text>
|
||||
```
|
||||
5. **Optional CI** (`.github/workflows/build.yml`):
|
||||
- Runs `cargo fmt --check`, `cargo clippy`, `cargo build --release` on `windows-latest`
|
||||
- Uploads artifact on tag
|
||||
6. **Smoke test before tagging:**
|
||||
- Run `claude-code-usage-bubble.exe`
|
||||
- Verify bubble appears with placeholder data (or real data if Claude CLI signed in)
|
||||
- Drag, snap, expand, menu, exit — all work
|
||||
- Re-launch → second instance exits silently
|
||||
- `claude-code-usage-bubble.exe --diagnose` → log file populated
|
||||
7. **Tag v0.1.0** (only after the above passes):
|
||||
- `git tag v0.1.0`
|
||||
- Push to GitHub
|
||||
- Create release with the .exe artifact attached (so `updater.rs` works for future versions)
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] HiDPI matrix tested
|
||||
- [ ] Multi-monitor edge tests done
|
||||
- [ ] README.md final
|
||||
- [ ] LICENSE attribution finalized
|
||||
- [ ] Diagnostic log verified
|
||||
- [ ] Clippy clean
|
||||
- [ ] CI workflow (optional)
|
||||
- [ ] Smoke test green
|
||||
- [ ] v0.1.0 tagged
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- App can be downloaded fresh, built once, and used end-to-end
|
||||
- Source repo attribution is unambiguous
|
||||
- No regressions vs phases 1-4
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Low.** Polish phase.
|
||||
- Possible regression: HiDPI bug discovered late — fix in `bubble.rs` painting code.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Verify `updater.rs` `current_install_channel()` still returns `Portable`. Re-enabling winget detection is a future task — not part of v0.1.0.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ v0.1.0 release; future tasks (out of scope for this port):
|
||||
- Winget package submission (when ready)
|
||||
- Custom bubble art per model (Claude orange, Codex green) replacing inherited icons
|
||||
- Optional bubble-size setting (S/M/L) in right-click menu
|
||||
- Auto-hide when fullscreen apps active
|
||||
@@ -0,0 +1,83 @@
|
||||
# Plan: claude-code-usage-bubble — port from CodeZeno/Claude-Code-Usage-Monitor
|
||||
|
||||
**Mode:** `/ck:xia --port`
|
||||
**Source repo:** `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor` (Rust ~5.8k LOC, MIT)
|
||||
**Target repo:** `/config/workspace/CodeZeno/claude-code-usage-bubble` (new)
|
||||
**Date:** 2026-05-15
|
||||
|
||||
## Source Manifest
|
||||
|
||||
- Path: `/config/workspace/CodeZeno/Claude-Code-Usage-Monitor`
|
||||
- Branch: `main` @ `b5f038d` (v1.4.1)
|
||||
- License: MIT (attribution required in new repo README)
|
||||
- Scope: portable subsystems only — see `phase-02-port-portable-modules.md`
|
||||
|
||||
## Decision Matrix (Approved)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Platform | Windows-only (Win32 GDI + layered window) |
|
||||
| WSL credential reading | Keep |
|
||||
| Snap-to-edge | On, 12px zone, monitor work area |
|
||||
| Click behavior | Left-click = toggle panel, right-click = menu |
|
||||
| Dual-model layout | Two independent bubbles, positions persisted per model |
|
||||
| Winget channel | Code kept, `current_install_channel` stubbed to Portable |
|
||||
| Single-instance mutex | `Global\ClaudeCodeUsageBubble` |
|
||||
| Auto-hide when fullscreen | **Yes** (added to phase 3) — detect via `SHQueryUserNotificationState` or `MonitorFromWindow + window-rect == monitor-rect` against foreground HWND |
|
||||
| Bubble size customization | **Yes**, free range 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).
|
||||
@@ -0,0 +1,817 @@
|
||||
// Floating circular bubble window.
|
||||
//
|
||||
// Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE.
|
||||
// Shape is achieved via per-pixel alpha (alpha=0 outside the circle) and confirmed
|
||||
// via WM_NCHITTEST returning HTCAPTION inside the circle, HTTRANSPARENT outside.
|
||||
// The OS handles drag automatically because HTCAPTION inside the circle puts the
|
||||
// click into the system move loop.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::HiDpi::*;
|
||||
use windows::Win32::UI::Shell::ExtractIconExW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
use crate::diagnose;
|
||||
use crate::native_interop::{wide_str, Color, TIMER_FULLSCREEN_CHECK};
|
||||
use crate::tray_icon::TrayIconKind;
|
||||
|
||||
// ---------- Public types & API ----------
|
||||
|
||||
pub const MIN_BUBBLE_SIZE: i32 = 32;
|
||||
pub const MAX_BUBBLE_SIZE: i32 = 128;
|
||||
pub const DEFAULT_BUBBLE_SIZE: i32 = 56;
|
||||
const RESIZE_STEP: i32 = 8;
|
||||
const SNAP_ZONE_LOGICAL: i32 = 12;
|
||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
|
||||
const FULLSCREEN_POLL_MS: u32 = 1500;
|
||||
|
||||
pub struct BubbleConfig {
|
||||
pub model: TrayIconKind,
|
||||
pub size_logical: i32,
|
||||
pub position: Option<(i32, i32)>,
|
||||
pub percent: Option<f64>,
|
||||
pub is_dark: bool,
|
||||
}
|
||||
|
||||
/// Register the bubble window class. Idempotent; safe to call before the first
|
||||
/// `create()` from the UI thread.
|
||||
pub fn register_class() {
|
||||
static REGISTERED: OnceLock<()> = OnceLock::new();
|
||||
REGISTERED.get_or_init(|| unsafe {
|
||||
let class_w = wide_str(CLASS_NAME);
|
||||
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
|
||||
let wc = WNDCLASSEXW {
|
||||
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(wnd_proc),
|
||||
hInstance: HINSTANCE(hinstance.0),
|
||||
hCursor: LoadCursorW(HINSTANCE::default(), IDC_SIZEALL).unwrap_or_default(),
|
||||
hbrBackground: HBRUSH(std::ptr::null_mut()),
|
||||
lpszClassName: PCWSTR::from_raw(class_w.as_ptr()),
|
||||
..Default::default()
|
||||
};
|
||||
if RegisterClassExW(&wc) == 0 {
|
||||
diagnose::log("bubble RegisterClassExW returned 0");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a bubble window. Returns the HWND. The caller (app::run) owns the
|
||||
/// message-loop dispatch.
|
||||
pub fn create(config: BubbleConfig) -> HWND {
|
||||
register_class();
|
||||
let initial_size_logical = config
|
||||
.size_logical
|
||||
.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||||
let hwnd = unsafe {
|
||||
let class_w = wide_str(CLASS_NAME);
|
||||
let title_w = wide_str("Claude Code Usage Bubble");
|
||||
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
|
||||
let dpi = primary_dpi();
|
||||
let size_px = scale_to_dpi(initial_size_logical, dpi);
|
||||
let (x, y) =
|
||||
config
|
||||
.position
|
||||
.unwrap_or_else(|| default_position(size_px, config.model));
|
||||
CreateWindowExW(
|
||||
WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE,
|
||||
PCWSTR::from_raw(class_w.as_ptr()),
|
||||
PCWSTR::from_raw(title_w.as_ptr()),
|
||||
WS_POPUP,
|
||||
x,
|
||||
y,
|
||||
size_px,
|
||||
size_px,
|
||||
HWND::default(),
|
||||
HMENU::default(),
|
||||
hinstance,
|
||||
None,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
if hwnd == HWND::default() {
|
||||
diagnose::log("bubble CreateWindowExW failed");
|
||||
return hwnd;
|
||||
}
|
||||
|
||||
// Embed app icon in window non-client (mostly cosmetic; toolwindows
|
||||
// don't show captions but the icon helps in dev tooling).
|
||||
unsafe {
|
||||
let mut large_icon = HICON::default();
|
||||
let mut small_icon = HICON::default();
|
||||
let mut exe = [0u16; 260];
|
||||
GetModuleFileNameW(HMODULE::default(), &mut exe);
|
||||
let _ = ExtractIconExW(
|
||||
PCWSTR::from_raw(exe.as_ptr()),
|
||||
0,
|
||||
Some(&mut large_icon),
|
||||
Some(&mut small_icon),
|
||||
1,
|
||||
);
|
||||
if !large_icon.is_invalid() {
|
||||
let _ = SendMessageW(
|
||||
hwnd,
|
||||
WM_SETICON,
|
||||
WPARAM(ICON_BIG as usize),
|
||||
LPARAM(large_icon.0 as isize),
|
||||
);
|
||||
}
|
||||
if !small_icon.is_invalid() {
|
||||
let _ = SendMessageW(
|
||||
hwnd,
|
||||
WM_SETICON,
|
||||
WPARAM(ICON_SMALL as usize),
|
||||
LPARAM(small_icon.0 as isize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let dpi = unsafe { GetDpiForWindow(hwnd).max(96) };
|
||||
lock_bubbles().insert(
|
||||
hwnd.0 as isize,
|
||||
BubbleState {
|
||||
model: config.model,
|
||||
size_logical: initial_size_logical,
|
||||
dpi,
|
||||
percent: config.percent,
|
||||
is_dark: config.is_dark,
|
||||
drag_start_pos: None,
|
||||
hidden_by_fullscreen: false,
|
||||
user_hidden: false,
|
||||
},
|
||||
);
|
||||
|
||||
render(hwnd);
|
||||
unsafe {
|
||||
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||||
// Periodic fullscreen-foreground check.
|
||||
SetTimer(hwnd, TIMER_FULLSCREEN_CHECK, FULLSCREEN_POLL_MS, None);
|
||||
}
|
||||
|
||||
hwnd
|
||||
}
|
||||
|
||||
pub fn destroy(hwnd: HWND) {
|
||||
unsafe {
|
||||
let _ = KillTimer(hwnd, TIMER_FULLSCREEN_CHECK);
|
||||
let _ = DestroyWindow(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_percentage(hwnd: HWND, percent: Option<f64>) {
|
||||
{
|
||||
let mut bubbles = lock_bubbles();
|
||||
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
|
||||
return;
|
||||
};
|
||||
b.percent = percent;
|
||||
}
|
||||
render(hwnd);
|
||||
}
|
||||
|
||||
pub fn update_dark_mode(hwnd: HWND, is_dark: bool) {
|
||||
{
|
||||
let mut bubbles = lock_bubbles();
|
||||
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
|
||||
return;
|
||||
};
|
||||
b.is_dark = is_dark;
|
||||
}
|
||||
render(hwnd);
|
||||
}
|
||||
|
||||
pub fn set_user_visible(hwnd: HWND, visible: bool) {
|
||||
{
|
||||
let mut bubbles = lock_bubbles();
|
||||
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
|
||||
return;
|
||||
};
|
||||
b.user_hidden = !visible;
|
||||
}
|
||||
unsafe {
|
||||
let cmd = if visible { SW_SHOWNOACTIVATE } else { SW_HIDE };
|
||||
let _ = ShowWindow(hwnd, cmd);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn position(hwnd: HWND) -> Option<(i32, i32)> {
|
||||
let mut r = RECT::default();
|
||||
unsafe {
|
||||
if GetWindowRect(hwnd, &mut r).is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((r.left, r.top))
|
||||
}
|
||||
|
||||
pub fn model(hwnd: HWND) -> Option<TrayIconKind> {
|
||||
lock_bubbles()
|
||||
.get(&(hwnd.0 as isize))
|
||||
.map(|b| b.model)
|
||||
}
|
||||
|
||||
pub fn size_logical(hwnd: HWND) -> Option<i32> {
|
||||
lock_bubbles()
|
||||
.get(&(hwnd.0 as isize))
|
||||
.map(|b| b.size_logical)
|
||||
}
|
||||
|
||||
// ---------- State ----------
|
||||
|
||||
struct BubbleState {
|
||||
model: TrayIconKind,
|
||||
size_logical: i32,
|
||||
dpi: u32,
|
||||
percent: Option<f64>,
|
||||
is_dark: bool,
|
||||
drag_start_pos: Option<(i32, i32)>,
|
||||
hidden_by_fullscreen: bool,
|
||||
user_hidden: bool,
|
||||
}
|
||||
|
||||
fn bubbles() -> &'static Mutex<HashMap<isize, BubbleState>> {
|
||||
static BUBBLES: OnceLock<Mutex<HashMap<isize, BubbleState>>> = OnceLock::new();
|
||||
BUBBLES.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn lock_bubbles() -> MutexGuard<'static, HashMap<isize, BubbleState>> {
|
||||
bubbles().lock().expect("bubble state mutex poisoned")
|
||||
}
|
||||
|
||||
// ---------- Window proc ----------
|
||||
|
||||
unsafe extern "system" fn wnd_proc(
|
||||
hwnd: HWND,
|
||||
msg: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
match msg {
|
||||
WM_NCHITTEST => hit_test(hwnd, lparam),
|
||||
WM_ENTERSIZEMOVE => {
|
||||
let mut r = RECT::default();
|
||||
let _ = GetWindowRect(hwnd, &mut r);
|
||||
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
|
||||
b.drag_start_pos = Some((r.left, r.top));
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_EXITSIZEMOVE => {
|
||||
// WM_NCLBUTTONUP isn't reliably delivered for HTCAPTION drags; instead
|
||||
// we infer click-vs-drag from whether the window actually moved.
|
||||
let start = {
|
||||
let mut bubbles = lock_bubbles();
|
||||
let start = bubbles
|
||||
.get(&(hwnd.0 as isize))
|
||||
.and_then(|b| b.drag_start_pos);
|
||||
if let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) {
|
||||
b.drag_start_pos = None;
|
||||
}
|
||||
start
|
||||
};
|
||||
let mut current = RECT::default();
|
||||
let _ = GetWindowRect(hwnd, &mut current);
|
||||
let moved = match start {
|
||||
Some((sx, sy)) => (current.left - sx).abs() >= 3 || (current.top - sy).abs() >= 3,
|
||||
None => false,
|
||||
};
|
||||
if moved {
|
||||
snap_to_edge(hwnd);
|
||||
if let Some(model) = model(hwnd) {
|
||||
if let Some(pos) = position(hwnd) {
|
||||
crate::app::on_bubble_moved(model, pos);
|
||||
}
|
||||
}
|
||||
} else if let Some(model) = model(hwnd) {
|
||||
crate::app::on_bubble_click(hwnd, model);
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_NCRBUTTONUP => {
|
||||
if let Some(model) = model(hwnd) {
|
||||
let pt = lparam_to_point(lparam);
|
||||
crate::app::on_bubble_right_click(hwnd, model, pt);
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_MOUSEWHEEL => {
|
||||
let modifiers = (wparam.0 & 0xFFFF) as u32;
|
||||
const MK_CONTROL: u32 = 0x0008;
|
||||
if modifiers & MK_CONTROL != 0 {
|
||||
let delta = ((wparam.0 >> 16) & 0xFFFF) as i16;
|
||||
let step = if delta > 0 { RESIZE_STEP } else { -RESIZE_STEP };
|
||||
resize_step(hwnd, step);
|
||||
LRESULT(0)
|
||||
} else {
|
||||
DefWindowProcW(hwnd, msg, wparam, lparam)
|
||||
}
|
||||
}
|
||||
WM_DPICHANGED => {
|
||||
let new_dpi = ((wparam.0 >> 16) & 0xFFFF) as u32;
|
||||
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
|
||||
b.dpi = new_dpi;
|
||||
}
|
||||
let rect_ptr = lparam.0 as *const RECT;
|
||||
if !rect_ptr.is_null() {
|
||||
let r = *rect_ptr;
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
HWND::default(),
|
||||
r.left,
|
||||
r.top,
|
||||
r.right - r.left,
|
||||
r.bottom - r.top,
|
||||
SWP_NOZORDER | SWP_NOACTIVATE,
|
||||
);
|
||||
}
|
||||
render(hwnd);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_TIMER => {
|
||||
if wparam.0 == TIMER_FULLSCREEN_CHECK {
|
||||
check_fullscreen(hwnd);
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_COMMAND => {
|
||||
crate::app::on_menu_command(wparam.0 as u32, hwnd);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_DESTROY => {
|
||||
lock_bubbles().remove(&(hwnd.0 as isize));
|
||||
LRESULT(0)
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||
}
|
||||
}
|
||||
|
||||
fn hit_test(hwnd: HWND, lparam: LPARAM) -> LRESULT {
|
||||
let pt = lparam_to_point(lparam);
|
||||
let mut r = RECT::default();
|
||||
unsafe {
|
||||
if GetWindowRect(hwnd, &mut r).is_err() {
|
||||
return LRESULT(HTNOWHERE as isize);
|
||||
}
|
||||
}
|
||||
let cx = (r.left + r.right) / 2;
|
||||
let cy = (r.top + r.bottom) / 2;
|
||||
let radius = ((r.right - r.left) / 2).max(1);
|
||||
let dx = pt.x - cx;
|
||||
let dy = pt.y - cy;
|
||||
if dx * dx + dy * dy <= radius * radius {
|
||||
LRESULT(HTCAPTION as isize)
|
||||
} else {
|
||||
LRESULT(HTTRANSPARENT as isize)
|
||||
}
|
||||
}
|
||||
|
||||
fn lparam_to_point(lparam: LPARAM) -> POINT {
|
||||
let lo = (lparam.0 & 0xFFFF) as i16 as i32;
|
||||
let hi = ((lparam.0 >> 16) & 0xFFFF) as i16 as i32;
|
||||
POINT { x: lo, y: hi }
|
||||
}
|
||||
|
||||
// ---------- Resize / snap ----------
|
||||
|
||||
fn resize_step(hwnd: HWND, delta: i32) {
|
||||
let (new_logical, dpi) = {
|
||||
let mut bubbles = lock_bubbles();
|
||||
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
|
||||
return;
|
||||
};
|
||||
let new_logical = (b.size_logical + delta).clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||||
if new_logical == b.size_logical {
|
||||
return;
|
||||
}
|
||||
b.size_logical = new_logical;
|
||||
(new_logical, b.dpi)
|
||||
};
|
||||
let size_px = scale_to_dpi(new_logical, dpi);
|
||||
let mut r = RECT::default();
|
||||
unsafe {
|
||||
let _ = GetWindowRect(hwnd, &mut r);
|
||||
// Resize centered on existing center.
|
||||
let cx = (r.left + r.right) / 2;
|
||||
let cy = (r.top + r.bottom) / 2;
|
||||
let new_x = cx - size_px / 2;
|
||||
let new_y = cy - size_px / 2;
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
HWND::default(),
|
||||
new_x,
|
||||
new_y,
|
||||
size_px,
|
||||
size_px,
|
||||
SWP_NOZORDER | SWP_NOACTIVATE,
|
||||
);
|
||||
}
|
||||
render(hwnd);
|
||||
if let Some(m) = model(hwnd) {
|
||||
crate::app::on_bubble_resized(m, new_logical);
|
||||
}
|
||||
}
|
||||
|
||||
fn snap_to_edge(hwnd: HWND) {
|
||||
let dpi = lock_bubbles()
|
||||
.get(&(hwnd.0 as isize))
|
||||
.map(|b| b.dpi)
|
||||
.unwrap_or(96);
|
||||
let snap_zone = scale_to_dpi(SNAP_ZONE_LOGICAL, dpi);
|
||||
let mut r = RECT::default();
|
||||
let monitor;
|
||||
unsafe {
|
||||
if GetWindowRect(hwnd, &mut r).is_err() {
|
||||
return;
|
||||
}
|
||||
monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
}
|
||||
if monitor.is_invalid() {
|
||||
return;
|
||||
}
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
unsafe {
|
||||
if !GetMonitorInfoW(monitor, &mut info).as_bool() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let wa = info.rcWork;
|
||||
let w = r.right - r.left;
|
||||
let h = r.bottom - r.top;
|
||||
let mut nx = r.left;
|
||||
let mut ny = r.top;
|
||||
|
||||
if (nx - wa.left).abs() < snap_zone {
|
||||
nx = wa.left;
|
||||
} else if (wa.right - (nx + w)).abs() < snap_zone {
|
||||
nx = wa.right - w;
|
||||
}
|
||||
if (ny - wa.top).abs() < snap_zone {
|
||||
ny = wa.top;
|
||||
} else if (wa.bottom - (ny + h)).abs() < snap_zone {
|
||||
ny = wa.bottom - h;
|
||||
}
|
||||
|
||||
// Clamp into the work area in any case (so the bubble can't be lost off-screen).
|
||||
nx = nx.clamp(wa.left, wa.right - w);
|
||||
ny = ny.clamp(wa.top, wa.bottom - h);
|
||||
|
||||
if nx != r.left || ny != r.top {
|
||||
unsafe {
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
HWND::default(),
|
||||
nx,
|
||||
ny,
|
||||
0,
|
||||
0,
|
||||
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Fullscreen detection ----------
|
||||
|
||||
fn check_fullscreen(bubble_hwnd: HWND) {
|
||||
let fg = unsafe { GetForegroundWindow() };
|
||||
if fg == HWND::default() || fg == bubble_hwnd {
|
||||
return;
|
||||
}
|
||||
let mut fr = RECT::default();
|
||||
unsafe {
|
||||
if GetWindowRect(fg, &mut fr).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) };
|
||||
if monitor.is_invalid() {
|
||||
return;
|
||||
}
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let ok = unsafe { GetMonitorInfoW(monitor, &mut info).as_bool() };
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
let mr = info.rcMonitor;
|
||||
let is_fullscreen =
|
||||
fr.left <= mr.left && fr.top <= mr.top && fr.right >= mr.right && fr.bottom >= mr.bottom;
|
||||
|
||||
let (was_hidden_by_fs, user_hidden) = {
|
||||
let bubbles = lock_bubbles();
|
||||
let Some(b) = bubbles.get(&(bubble_hwnd.0 as isize)) else {
|
||||
return;
|
||||
};
|
||||
(b.hidden_by_fullscreen, b.user_hidden)
|
||||
};
|
||||
|
||||
if is_fullscreen && !was_hidden_by_fs {
|
||||
unsafe {
|
||||
let _ = ShowWindow(bubble_hwnd, SW_HIDE);
|
||||
}
|
||||
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
|
||||
b.hidden_by_fullscreen = true;
|
||||
}
|
||||
} else if !is_fullscreen && was_hidden_by_fs {
|
||||
if !user_hidden {
|
||||
unsafe {
|
||||
let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE);
|
||||
}
|
||||
}
|
||||
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
|
||||
b.hidden_by_fullscreen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Painting ----------
|
||||
|
||||
fn render(hwnd: HWND) {
|
||||
let (size_logical, dpi, percent, is_dark) = {
|
||||
let bubbles = lock_bubbles();
|
||||
let Some(b) = bubbles.get(&(hwnd.0 as isize)) else {
|
||||
return;
|
||||
};
|
||||
(b.size_logical, b.dpi, b.percent, b.is_dark)
|
||||
};
|
||||
let size_px = scale_to_dpi(size_logical, dpi);
|
||||
|
||||
unsafe {
|
||||
let screen_dc = GetDC(hwnd);
|
||||
let mem_dc = CreateCompatibleDC(screen_dc);
|
||||
let bmi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: size_px,
|
||||
biHeight: -size_px,
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: 0,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut bits: *mut c_void = std::ptr::null_mut();
|
||||
let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
|
||||
.unwrap_or_default();
|
||||
if dib.is_invalid() || bits.is_null() {
|
||||
let _ = DeleteDC(mem_dc);
|
||||
ReleaseDC(hwnd, screen_dc);
|
||||
return;
|
||||
}
|
||||
let old_bmp = SelectObject(mem_dc, dib);
|
||||
|
||||
let pixel_count = (size_px * size_px) as usize;
|
||||
let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
|
||||
|
||||
paint_background(pixels, size_px, is_dark);
|
||||
paint_ring(pixels, size_px, percent.unwrap_or(0.0), is_dark);
|
||||
paint_text(mem_dc, size_px, percent, is_dark, dpi);
|
||||
|
||||
// Final alpha pass: set alpha=255 inside circle, 0 outside.
|
||||
let cx = (size_px - 1) as f64 / 2.0;
|
||||
let cy = cx;
|
||||
let radius = (size_px / 2) as f64 - 1.0;
|
||||
let r_sq = radius * radius;
|
||||
for y in 0..size_px {
|
||||
for x in 0..size_px {
|
||||
let dx = x as f64 - cx;
|
||||
let dy = y as f64 - cy;
|
||||
let idx = (y * size_px + x) as usize;
|
||||
if dx * dx + dy * dy <= r_sq {
|
||||
pixels[idx] |= 0xFF000000;
|
||||
} else {
|
||||
pixels[idx] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut wr = RECT::default();
|
||||
let _ = GetWindowRect(hwnd, &mut wr);
|
||||
let pt_dst = POINT {
|
||||
x: wr.left,
|
||||
y: wr.top,
|
||||
};
|
||||
let pt_src = POINT { x: 0, y: 0 };
|
||||
let sz = SIZE {
|
||||
cx: size_px,
|
||||
cy: size_px,
|
||||
};
|
||||
let blend = BLENDFUNCTION {
|
||||
BlendOp: 0,
|
||||
BlendFlags: 0,
|
||||
SourceConstantAlpha: 255,
|
||||
AlphaFormat: 1, // AC_SRC_ALPHA
|
||||
};
|
||||
let _ = UpdateLayeredWindow(
|
||||
hwnd,
|
||||
screen_dc,
|
||||
Some(&pt_dst),
|
||||
Some(&sz),
|
||||
mem_dc,
|
||||
Some(&pt_src),
|
||||
COLORREF(0),
|
||||
Some(&blend),
|
||||
ULW_ALPHA,
|
||||
);
|
||||
|
||||
SelectObject(mem_dc, old_bmp);
|
||||
let _ = DeleteObject(dib);
|
||||
let _ = DeleteDC(mem_dc);
|
||||
ReleaseDC(hwnd, screen_dc);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_background(pixels: &mut [u32], size_px: i32, is_dark: bool) {
|
||||
let bg = if is_dark {
|
||||
Color::from_hex("#1F1F1F")
|
||||
} else {
|
||||
Color::from_hex("#F3F3F3")
|
||||
};
|
||||
let bg_bgr = bg.to_colorref();
|
||||
let cx = (size_px - 1) as f64 / 2.0;
|
||||
let cy = cx;
|
||||
let radius = (size_px / 2) as f64 - 1.0;
|
||||
let r_sq = radius * radius;
|
||||
for y in 0..size_px {
|
||||
for x in 0..size_px {
|
||||
let dx = x as f64 - cx;
|
||||
let dy = y as f64 - cy;
|
||||
let idx = (y * size_px + x) as usize;
|
||||
if dx * dx + dy * dy <= r_sq {
|
||||
pixels[idx] = bg_bgr;
|
||||
} else {
|
||||
pixels[idx] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_ring(pixels: &mut [u32], size_px: i32, percent: f64, is_dark: bool) {
|
||||
let ring = ring_color_for_percent(percent).to_colorref();
|
||||
let track = if is_dark {
|
||||
Color::from_hex("#3A3A3A").to_colorref()
|
||||
} else {
|
||||
Color::from_hex("#D6D6D6").to_colorref()
|
||||
};
|
||||
let cx = (size_px - 1) as f64 / 2.0;
|
||||
let cy = cx;
|
||||
let outer = (size_px / 2) as f64 - 1.0;
|
||||
let thickness = ((size_px as f64) * 0.12).max(3.0);
|
||||
let inner = outer - thickness;
|
||||
let inner_sq = inner * inner;
|
||||
let outer_sq = outer * outer;
|
||||
let sweep = (percent.clamp(0.0, 100.0) / 100.0) * 2.0 * std::f64::consts::PI;
|
||||
for y in 0..size_px {
|
||||
for x in 0..size_px {
|
||||
let dx = x as f64 - cx;
|
||||
let dy = y as f64 - cy;
|
||||
let d_sq = dx * dx + dy * dy;
|
||||
if d_sq <= outer_sq && d_sq >= inner_sq {
|
||||
// Angle from 12 o'clock, clockwise.
|
||||
let mut a = dx.atan2(-dy);
|
||||
if a < 0.0 {
|
||||
a += 2.0 * std::f64::consts::PI;
|
||||
}
|
||||
let idx = (y * size_px + x) as usize;
|
||||
pixels[idx] = if a <= sweep { ring } else { track };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_text(hdc: HDC, size_px: i32, percent: Option<f64>, is_dark: bool, _dpi: u32) {
|
||||
let text = match percent {
|
||||
Some(p) => format!("{:.0}%", p),
|
||||
None => "—".to_string(),
|
||||
};
|
||||
let mut text_w = wide_str(&text);
|
||||
let text_color = if is_dark {
|
||||
Color::from_hex("#F5F5F5")
|
||||
} else {
|
||||
Color::from_hex("#1F1F1F")
|
||||
};
|
||||
let font_height = -(size_px / 4).max(8);
|
||||
let font_name = wide_str("Segoe UI");
|
||||
unsafe {
|
||||
let font = CreateFontW(
|
||||
font_height,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
FW_SEMIBOLD.0 as i32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
DEFAULT_CHARSET.0 as u32,
|
||||
OUT_DEFAULT_PRECIS.0 as u32,
|
||||
CLIP_DEFAULT_PRECIS.0 as u32,
|
||||
CLEARTYPE_QUALITY.0 as u32,
|
||||
(FF_SWISS.0 | DEFAULT_PITCH.0) as u32,
|
||||
PCWSTR::from_raw(font_name.as_ptr()),
|
||||
);
|
||||
let old_font = SelectObject(hdc, font);
|
||||
SetTextColor(hdc, COLORREF(text_color.to_colorref()));
|
||||
SetBkMode(hdc, TRANSPARENT);
|
||||
let mut rect = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: size_px,
|
||||
bottom: size_px,
|
||||
};
|
||||
// Trim the trailing NUL — DrawTextW reads slice length as count.
|
||||
let len_no_nul = text_w.len().saturating_sub(1);
|
||||
let _ = DrawTextW(
|
||||
hdc,
|
||||
&mut text_w[..len_no_nul],
|
||||
&mut rect,
|
||||
DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP,
|
||||
);
|
||||
SelectObject(hdc, old_font);
|
||||
let _ = DeleteObject(font);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ring_color_for_percent(percent: f64) -> Color {
|
||||
if percent <= 50.0 {
|
||||
return Color::from_hex("#D97757");
|
||||
}
|
||||
let stops: [(f64, Color); 5] = [
|
||||
(50.0, Color::from_hex("#D97757")),
|
||||
(70.0, Color::from_hex("#D08540")),
|
||||
(85.0, Color::from_hex("#CC8C20")),
|
||||
(95.0, Color::from_hex("#C45020")),
|
||||
(100.0, Color::from_hex("#B82020")),
|
||||
];
|
||||
for pair in stops.windows(2) {
|
||||
let (sp, sc) = pair[0];
|
||||
let (ep, ec) = pair[1];
|
||||
if percent <= ep {
|
||||
let span = (ep - sp).max(f64::EPSILON);
|
||||
let t = ((percent - sp) / span).clamp(0.0, 1.0);
|
||||
return Color::new(
|
||||
lerp_u8(sc.r, ec.r, t),
|
||||
lerp_u8(sc.g, ec.g, t),
|
||||
lerp_u8(sc.b, ec.b, t),
|
||||
);
|
||||
}
|
||||
}
|
||||
Color::from_hex("#B82020")
|
||||
}
|
||||
|
||||
fn lerp_u8(a: u8, b: u8, t: f64) -> u8 {
|
||||
(a as f64 + (b as f64 - a as f64) * t).round() as u8
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
fn primary_dpi() -> u32 {
|
||||
unsafe { GetDpiForSystem().max(96) }
|
||||
}
|
||||
|
||||
fn scale_to_dpi(logical: i32, dpi: u32) -> i32 {
|
||||
((logical as i64) * (dpi as i64) / 96) as i32
|
||||
}
|
||||
|
||||
fn default_position(size_px: i32, model: TrayIconKind) -> (i32, i32) {
|
||||
// Bottom-right of primary work area, with a 24-pixel gap from the edges.
|
||||
// Stagger the Codex bubble above the Claude one if both are enabled.
|
||||
unsafe {
|
||||
let monitor = MonitorFromPoint(POINT { x: 0, y: 0 }, MONITOR_DEFAULTTOPRIMARY);
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let wa = if GetMonitorInfoW(monitor, &mut info).as_bool() {
|
||||
info.rcWork
|
||||
} else {
|
||||
RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 1920,
|
||||
bottom: 1080,
|
||||
}
|
||||
};
|
||||
let gap = 24;
|
||||
let stagger = match model {
|
||||
TrayIconKind::Claude => 0,
|
||||
TrayIconKind::Codex => size_px + gap,
|
||||
};
|
||||
let x = wa.right - size_px - gap;
|
||||
let y = wa.bottom - size_px - gap - stagger;
|
||||
(x, y)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct DiagnoseState {
|
||||
file: Mutex<File>,
|
||||
}
|
||||
|
||||
static DIAGNOSE_STATE: OnceLock<DiagnoseState> = OnceLock::new();
|
||||
|
||||
pub fn init() -> Result<PathBuf, String> {
|
||||
let path = std::env::temp_dir().join("claude-code-usage-bubble.log");
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("Unable to open diagnostic log file {}: {e}", path.display()))?;
|
||||
|
||||
let _ = DIAGNOSE_STATE.set(DiagnoseState {
|
||||
file: Mutex::new(file),
|
||||
});
|
||||
|
||||
log("diagnostic logging enabled");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn is_enabled() -> bool {
|
||||
DIAGNOSE_STATE.get().is_some()
|
||||
}
|
||||
|
||||
pub fn log(message: impl AsRef<str>) {
|
||||
let Some(state) = DIAGNOSE_STATE.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
if let Ok(mut file) = state.file.lock() {
|
||||
let _ = writeln!(file, "[{timestamp}] {}", message.as_ref());
|
||||
let _ = file.flush();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_error(context: &str, error: impl std::fmt::Display) {
|
||||
log(format!("{context}: {error}"));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="0.5" y="0.5" width="15" height="15" rx="1" stroke="#D97757" stroke-width="1"></rect>
|
||||
<rect x="2" y="2" width="5" height="5" rx="1" fill="#D97757"></rect>
|
||||
<rect x="9" y="2" width="5" height="5" rx="1" fill="#F3D6CC"></rect>
|
||||
<rect x="2" y="9" width="5" height="5" rx="1" fill="#D97757"></rect>
|
||||
<rect x="9" y="9" width="5" height="5" rx="1" fill="#F3D6CC"></rect>
|
||||
<path d="M 10 2 L 11 2 L 11 7 L 10 7 C 9.448 7 9 6.552 9 6 L 9 3 C 9 2.448 9.448 2 10 2 Z" fill="#D97757"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 575 B |
|
After Width: | Height: | Size: 165 B |
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none">
|
||||
<rect x="4" y="36" width="248" height="184" rx="16" stroke="#D97757" stroke-width="8"></rect>
|
||||
<rect x="15" y="72" width="19" height="48" rx="4" fill="#D97757"></rect>
|
||||
<rect x="38" y="72" width="19" height="48" rx="4" fill="#D97757"></rect>
|
||||
<rect x="61" y="72" width="19" height="48" rx="4" fill="#D97757"></rect>
|
||||
<rect x="84" y="72" width="19" height="48" rx="4" fill="#D97757"></rect>
|
||||
<rect x="107" y="72" width="19" height="48" rx="4" fill="#D97757"></rect>
|
||||
<rect x="130" y="72" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<rect x="153" y="72" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<rect x="176" y="72" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<rect x="199" y="72" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<rect x="222" y="72" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<rect x="15" y="136" width="19" height="48" rx="4" fill="#D97757"></rect>
|
||||
<rect x="38" y="136" width="19" height="48" rx="4" fill="#D97757"></rect>
|
||||
<rect x="61" y="136" width="19" height="48" rx="4" fill="#D97757"></rect>
|
||||
<rect x="84" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<rect x="107" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<rect x="130" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<rect x="153" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<rect x="176" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<rect x="199" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<rect x="222" y="136" width="19" height="48" rx="4" fill="#F3D6CC"></rect>
|
||||
<path d="M 134 72 L 143 72 L 143 120 L 134 120 C 131.791 120 130 118.209 130 116 L 130 76 C 130 73.791 131.791 72 134 72 Z" fill="#D97757"></path>
|
||||
<path d="M 88 136 L 89 136 L 89 184 L 88 184 C 85.791 184 84 182.209 84 180 L 84 140 C 84 137.791 85.791 136 88 136 Z" fill="#D97757"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect x="1" y="1" width="30" height="30" rx="4" stroke="#D97757" stroke-width="2"></rect>
|
||||
<rect x="3" y="4" width="8" height="11" rx="2" fill="#D97757"></rect>
|
||||
<rect x="12" y="4" width="8" height="11" rx="2" fill="#D97757"></rect>
|
||||
<rect x="21" y="4" width="8" height="11" rx="2" fill="#F3D6CC"></rect>
|
||||
<path d="M 23 4 L 25 4 L 25 15 L 23 15 C 21.895 15 21 14.18 21 13.167 L 21 5.833 C 21 4.82 21.895 4 23 4 Z" fill="#D97757"></path>
|
||||
<rect x="3" y="17" width="8" height="11" rx="2" fill="#D97757"></rect>
|
||||
<rect x="12" y="17" width="8" height="11" rx="2" fill="#D97757"></rect>
|
||||
<rect x="21" y="17" width="8" height="11" rx="2" fill="#F3D6CC"></rect>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 743 B |
|
After Width: | Height: | Size: 284 B |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="2" y="2" width="44" height="44" rx="6" stroke="#D97757" stroke-width="4"></rect>
|
||||
<rect x="7" y="10" width="6" height="12" rx="2" fill="#D97757"></rect>
|
||||
<rect x="14" y="10" width="6" height="12" rx="2" fill="#D97757"></rect>
|
||||
<rect x="21" y="10" width="6" height="12" rx="2" fill="#D97757"></rect>
|
||||
<rect x="28" y="10" width="6" height="12" rx="2" fill="#F3D6CC"></rect>
|
||||
<path d="M 30 10 L 31 10 L 31 22 L 30 22 C 28.895 22 28 21.105 28 20 L 28 12 C 28 10.895 28.895 10 30 10 Z" fill="#D97757"></path>
|
||||
<rect x="35" y="10" width="6" height="12" rx="2" fill="#F3D6CC"></rect>
|
||||
<rect x="7" y="26" width="6" height="12" rx="2" fill="#D97757"></rect>
|
||||
<rect x="14" y="26" width="6" height="12" rx="2" fill="#D97757"></rect>
|
||||
<rect x="21" y="26" width="6" height="12" rx="2" fill="#F3D6CC"></rect>
|
||||
<rect x="28" y="26" width="6" height="12" rx="2" fill="#F3D6CC"></rect>
|
||||
<rect x="35" y="26" width="6" height="12" rx="2" fill="#F3D6CC"></rect>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 339 B |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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: "秒",
|
||||
};
|
||||
@@ -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: "초",
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
mod dutch;
|
||||
mod english;
|
||||
mod french;
|
||||
mod german;
|
||||
mod japanese;
|
||||
mod korean;
|
||||
mod spanish;
|
||||
mod traditional_chinese;
|
||||
|
||||
use windows::core::PWSTR;
|
||||
use windows::Win32::Globalization::{
|
||||
GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages,
|
||||
LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LanguageId {
|
||||
English,
|
||||
Dutch,
|
||||
Spanish,
|
||||
French,
|
||||
German,
|
||||
Japanese,
|
||||
Korean,
|
||||
TraditionalChinese,
|
||||
}
|
||||
|
||||
impl LanguageId {
|
||||
pub const ALL: [LanguageId; 8] = [
|
||||
LanguageId::English,
|
||||
LanguageId::Dutch,
|
||||
LanguageId::Spanish,
|
||||
LanguageId::French,
|
||||
LanguageId::German,
|
||||
LanguageId::Japanese,
|
||||
LanguageId::Korean,
|
||||
LanguageId::TraditionalChinese,
|
||||
];
|
||||
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::English => "en",
|
||||
Self::Dutch => "nl",
|
||||
Self::Spanish => "es",
|
||||
Self::French => "fr",
|
||||
Self::German => "de",
|
||||
Self::Japanese => "ja",
|
||||
Self::Korean => "ko",
|
||||
Self::TraditionalChinese => "zh-TW",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn native_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::English => "English",
|
||||
Self::Dutch => "Nederlands",
|
||||
Self::Spanish => "Español",
|
||||
Self::French => "Français",
|
||||
Self::German => "Deutsch",
|
||||
Self::Japanese => "日本語",
|
||||
Self::Korean => "한국어",
|
||||
Self::TraditionalChinese => "繁體中文",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strings(self) -> Strings {
|
||||
match self {
|
||||
Self::English => english::STRINGS,
|
||||
Self::Dutch => dutch::STRINGS,
|
||||
Self::Spanish => spanish::STRINGS,
|
||||
Self::French => french::STRINGS,
|
||||
Self::German => german::STRINGS,
|
||||
Self::Japanese => japanese::STRINGS,
|
||||
Self::Korean => korean::STRINGS,
|
||||
Self::TraditionalChinese => traditional_chinese::STRINGS,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_via_winget_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::English => english::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::Dutch => dutch::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::Spanish => spanish::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::French => french::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::German => german::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::Japanese => japanese::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::Korean => korean::UPDATE_VIA_WINGET_LABEL,
|
||||
Self::TraditionalChinese => traditional_chinese::UPDATE_VIA_WINGET_LABEL,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(code: &str) -> Option<Self> {
|
||||
let normalized = code.trim().replace('_', "-").to_ascii_lowercase();
|
||||
if normalized.is_empty() || normalized == "system" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefix = normalized.split('-').next().unwrap_or_default();
|
||||
match prefix {
|
||||
"en" => Some(Self::English),
|
||||
"nl" => Some(Self::Dutch),
|
||||
"es" => Some(Self::Spanish),
|
||||
"fr" => Some(Self::French),
|
||||
"de" => Some(Self::German),
|
||||
"ja" => Some(Self::Japanese),
|
||||
"ko" => Some(Self::Korean),
|
||||
"zh" => {
|
||||
if normalized.contains("tw")
|
||||
|| normalized.contains("hk")
|
||||
|| normalized.contains("hant")
|
||||
{
|
||||
Some(Self::TraditionalChinese)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Strings {
|
||||
pub window_title: &'static str,
|
||||
pub refresh: &'static str,
|
||||
pub update_frequency: &'static str,
|
||||
pub one_minute: &'static str,
|
||||
pub five_minutes: &'static str,
|
||||
pub fifteen_minutes: &'static str,
|
||||
pub one_hour: &'static str,
|
||||
pub models: &'static str,
|
||||
pub claude_code_model: &'static str,
|
||||
pub codex_model: &'static str,
|
||||
pub settings: &'static str,
|
||||
pub start_with_windows: &'static str,
|
||||
pub reset_position: &'static str,
|
||||
pub language: &'static str,
|
||||
pub system_default: &'static str,
|
||||
pub check_for_updates: &'static str,
|
||||
pub checking_for_updates: &'static str,
|
||||
pub updates: &'static str,
|
||||
pub update_in_progress: &'static str,
|
||||
pub up_to_date: &'static str,
|
||||
pub up_to_date_short: &'static str,
|
||||
pub update_failed: &'static str,
|
||||
pub applying_update: &'static str,
|
||||
pub update_to: &'static str,
|
||||
pub update_available: &'static str,
|
||||
pub update_prompt_now: &'static str,
|
||||
pub exit: &'static str,
|
||||
pub show_widget: &'static str,
|
||||
pub session_window: &'static str,
|
||||
pub weekly_window: &'static str,
|
||||
pub now: &'static str,
|
||||
pub day_suffix: &'static str,
|
||||
pub hour_suffix: &'static str,
|
||||
pub minute_suffix: &'static str,
|
||||
pub second_suffix: &'static str,
|
||||
pub token_expired_title: &'static str,
|
||||
pub token_expired_body: &'static str,
|
||||
pub codex_token_expired_title: &'static str,
|
||||
pub codex_token_expired_body: &'static str,
|
||||
pub codex_window_title: &'static str,
|
||||
}
|
||||
|
||||
pub fn resolve_language(language_override: Option<LanguageId>) -> LanguageId {
|
||||
language_override.unwrap_or_else(detect_system_language)
|
||||
}
|
||||
|
||||
pub fn detect_system_language() -> LanguageId {
|
||||
preferred_ui_languages()
|
||||
.into_iter()
|
||||
.find_map(|locale| LanguageId::from_code(&locale))
|
||||
.or_else(default_ui_locale)
|
||||
.or_else(default_locale_name)
|
||||
.unwrap_or(LanguageId::English)
|
||||
}
|
||||
|
||||
pub fn update_via_winget(language: LanguageId) -> &'static str {
|
||||
language.update_via_winget_label()
|
||||
}
|
||||
|
||||
fn preferred_ui_languages() -> Vec<String> {
|
||||
unsafe {
|
||||
let mut num_languages = 0u32;
|
||||
let mut buffer_len = 0u32;
|
||||
if GetUserPreferredUILanguages(
|
||||
MUI_LANGUAGE_NAME,
|
||||
&mut num_languages,
|
||||
PWSTR::null(),
|
||||
&mut buffer_len,
|
||||
)
|
||||
.is_err()
|
||||
|| buffer_len == 0
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut buffer = vec![0u16; buffer_len as usize];
|
||||
if GetUserPreferredUILanguages(
|
||||
MUI_LANGUAGE_NAME,
|
||||
&mut num_languages,
|
||||
PWSTR(buffer.as_mut_ptr()),
|
||||
&mut buffer_len,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
buffer
|
||||
.split(|unit| *unit == 0)
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(String::from_utf16_lossy)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_ui_locale() -> Option<LanguageId> {
|
||||
unsafe {
|
||||
let lang_id = GetUserDefaultUILanguage();
|
||||
let mut buffer = [0u16; MAX_LOCALE_NAME as usize];
|
||||
let len = LCIDToLocaleName(
|
||||
lang_id as u32,
|
||||
Some(&mut buffer),
|
||||
LOCALE_ALLOW_NEUTRAL_NAMES,
|
||||
);
|
||||
if len <= 1 {
|
||||
return None;
|
||||
}
|
||||
let locale = String::from_utf16_lossy(&buffer[..(len as usize - 1)]);
|
||||
LanguageId::from_code(&locale)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_locale_name() -> Option<LanguageId> {
|
||||
unsafe {
|
||||
let mut buffer = [0u16; MAX_LOCALE_NAME as usize];
|
||||
let len = GetUserDefaultLocaleName(&mut buffer);
|
||||
if len <= 1 {
|
||||
return None;
|
||||
}
|
||||
let locale = String::from_utf16_lossy(&buffer[..(len as usize - 1)]);
|
||||
LanguageId::from_code(&locale)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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: "秒",
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
mod app;
|
||||
mod bubble;
|
||||
mod diagnose;
|
||||
mod localization;
|
||||
mod models;
|
||||
mod native_interop;
|
||||
mod panel;
|
||||
mod poller;
|
||||
mod settings;
|
||||
mod theme;
|
||||
mod tray_icon;
|
||||
mod updater;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let diagnose_enabled = args.iter().any(|a| a == "--diagnose");
|
||||
if diagnose_enabled {
|
||||
if let Ok(path) = diagnose::init() {
|
||||
diagnose::log(format!(
|
||||
"startup args={args:?} log_path={}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(exit_code) = updater::handle_cli_mode(&args) {
|
||||
if diagnose_enabled {
|
||||
diagnose::log(format!("cli mode exited with code {exit_code}"));
|
||||
}
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
if diagnose_enabled {
|
||||
diagnose::log("entering app::run");
|
||||
}
|
||||
app::run();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct UsageSection {
|
||||
pub percentage: f64,
|
||||
pub resets_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct UsageData {
|
||||
pub session: UsageSection,
|
||||
pub weekly: UsageSection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AppUsageData {
|
||||
pub claude_code: Option<UsageData>,
|
||||
pub codex: Option<UsageData>,
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use windows::Win32::Foundation::{HWND, RECT};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetWindowRect, MoveWindow};
|
||||
|
||||
// Timer IDs (used by SetTimer / KillTimer with the bubble HWND)
|
||||
pub const TIMER_POLL: usize = 1;
|
||||
pub const TIMER_COUNTDOWN: usize = 2;
|
||||
pub const TIMER_RESET_POLL: usize = 3;
|
||||
pub const TIMER_UPDATE_CHECK: usize = 4;
|
||||
pub const TIMER_FULLSCREEN_CHECK: usize = 5;
|
||||
|
||||
// Custom messages
|
||||
pub const WM_APP: u32 = 0x8000;
|
||||
pub const WM_APP_USAGE_UPDATED: u32 = WM_APP + 1;
|
||||
pub const WM_APP_PANEL_TOGGLE: u32 = WM_APP + 2;
|
||||
pub const WM_APP_TRAY: u32 = WM_APP + 3;
|
||||
pub const WM_APP_PANEL_CLOSE: u32 = WM_APP + 4;
|
||||
|
||||
/// Get the bounding rectangle of a window in screen coordinates.
|
||||
pub fn get_window_rect_safe(hwnd: HWND) -> Option<RECT> {
|
||||
unsafe {
|
||||
let mut rect = RECT::default();
|
||||
if GetWindowRect(hwnd, &mut rect).is_ok() {
|
||||
Some(rect)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move and resize a window (top-level coordinates).
|
||||
pub fn move_window(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) {
|
||||
unsafe {
|
||||
let _ = MoveWindow(hwnd, x, y, w, h, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Rust string to a null-terminated UTF-16 vector suitable for
|
||||
/// passing as `PCWSTR`.
|
||||
pub fn wide_str(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// COLORREF byte order: 0x00BBGGRR.
|
||||
pub fn colorref(r: u8, g: u8, b: u8) -> u32 {
|
||||
r as u32 | (g as u32) << 8 | (b as u32) << 16
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Color {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self { r, g, b }
|
||||
}
|
||||
|
||||
pub fn from_hex(hex: &str) -> Self {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
|
||||
Self { r, g, b }
|
||||
}
|
||||
|
||||
pub fn to_colorref(self) -> u32 {
|
||||
colorref(self.r, self.g, self.b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
// Expanded panel shown when a bubble is left-clicked.
|
||||
//
|
||||
// Plain opaque top-most popup with two horizontal usage bars (session/5h and
|
||||
// weekly/7d) plus countdown text. Closes on focus loss.
|
||||
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
use crate::diagnose;
|
||||
use crate::localization::Strings;
|
||||
use crate::native_interop::{wide_str, Color};
|
||||
use crate::tray_icon::TrayIconKind;
|
||||
|
||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
|
||||
const PANEL_W_LOGICAL: i32 = 280;
|
||||
const PANEL_H_LOGICAL: i32 = 120;
|
||||
const PADDING_LOGICAL: i32 = 14;
|
||||
const ROW_GAP_LOGICAL: i32 = 8;
|
||||
const LABEL_W_LOGICAL: i32 = 28;
|
||||
const RIGHT_TEXT_W_LOGICAL: i32 = 96;
|
||||
const BAR_HEIGHT_LOGICAL: i32 = 14;
|
||||
|
||||
pub struct PanelData {
|
||||
pub model: TrayIconKind,
|
||||
pub session_pct: f64,
|
||||
pub session_text: String,
|
||||
pub weekly_pct: f64,
|
||||
pub weekly_text: String,
|
||||
pub is_dark: bool,
|
||||
pub strings: Strings,
|
||||
pub claude_label: String,
|
||||
pub codex_label: String,
|
||||
}
|
||||
|
||||
struct PanelState {
|
||||
hwnd: HWND,
|
||||
data: PanelData,
|
||||
}
|
||||
|
||||
fn state() -> &'static Mutex<Option<PanelState>> {
|
||||
static S: OnceLock<Mutex<Option<PanelState>>> = OnceLock::new();
|
||||
S.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
fn lock_state() -> MutexGuard<'static, Option<PanelState>> {
|
||||
state().lock().expect("panel state mutex poisoned")
|
||||
}
|
||||
|
||||
pub fn register_class() {
|
||||
static REGISTERED: OnceLock<()> = OnceLock::new();
|
||||
REGISTERED.get_or_init(|| unsafe {
|
||||
let class_w = wide_str(CLASS_NAME);
|
||||
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
|
||||
let wc = WNDCLASSEXW {
|
||||
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(wnd_proc),
|
||||
hInstance: HINSTANCE(hinstance.0),
|
||||
hCursor: LoadCursorW(HINSTANCE::default(), IDC_ARROW).unwrap_or_default(),
|
||||
hbrBackground: HBRUSH(std::ptr::null_mut()),
|
||||
lpszClassName: PCWSTR::from_raw(class_w.as_ptr()),
|
||||
..Default::default()
|
||||
};
|
||||
if RegisterClassExW(&wc) == 0 {
|
||||
diagnose::log("panel RegisterClassExW returned 0");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn is_visible() -> bool {
|
||||
let s = lock_state();
|
||||
s.as_ref()
|
||||
.map(|p| unsafe { IsWindowVisible(p.hwnd).as_bool() })
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn current_model() -> Option<TrayIconKind> {
|
||||
lock_state().as_ref().map(|p| p.data.model)
|
||||
}
|
||||
|
||||
/// Show the panel for the given model, anchored near the supplied bubble HWND.
|
||||
/// If a panel is already visible, hide it instead (toggle).
|
||||
pub fn toggle(data: PanelData, anchor_hwnd: HWND) {
|
||||
if is_visible() && current_model() == Some(data.model) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
show(data, anchor_hwnd);
|
||||
}
|
||||
|
||||
pub fn show(data: PanelData, anchor_hwnd: HWND) {
|
||||
register_class();
|
||||
|
||||
let mut anchor_rect = RECT::default();
|
||||
unsafe {
|
||||
let _ = GetWindowRect(anchor_hwnd, &mut anchor_rect);
|
||||
}
|
||||
let dpi = unsafe { GetDpiForWindow(anchor_hwnd).max(96) };
|
||||
let panel_w = scale_to_dpi(PANEL_W_LOGICAL, dpi);
|
||||
let panel_h = scale_to_dpi(PANEL_H_LOGICAL, dpi);
|
||||
let (x, y) = place_near(anchor_rect, panel_w, panel_h);
|
||||
|
||||
let existing_hwnd = lock_state().as_ref().map(|p| p.hwnd);
|
||||
let hwnd = match existing_hwnd {
|
||||
Some(h) => h,
|
||||
None => match create_panel_window(x, y, panel_w, panel_h) {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
|
||||
{
|
||||
let mut guard = lock_state();
|
||||
if let Some(p) = guard.as_mut() {
|
||||
p.data = data;
|
||||
} else {
|
||||
*guard = Some(PanelState { hwnd, data });
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
HWND::default(),
|
||||
x,
|
||||
y,
|
||||
panel_w,
|
||||
panel_h,
|
||||
SWP_NOZORDER | SWP_NOACTIVATE,
|
||||
);
|
||||
let _ = InvalidateRect(hwnd, None, true);
|
||||
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||||
let _ = SetForegroundWindow(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
||||
let hwnd = unsafe {
|
||||
let class_w = wide_str(CLASS_NAME);
|
||||
let title_w = wide_str("Usage panel");
|
||||
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
|
||||
CreateWindowExW(
|
||||
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
||||
PCWSTR::from_raw(class_w.as_ptr()),
|
||||
PCWSTR::from_raw(title_w.as_ptr()),
|
||||
WS_POPUP | WS_BORDER,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
HWND::default(),
|
||||
HMENU::default(),
|
||||
hinstance,
|
||||
None,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if hwnd == HWND::default() {
|
||||
diagnose::log("panel CreateWindowExW failed");
|
||||
None
|
||||
} else {
|
||||
Some(hwnd)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hide() {
|
||||
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
||||
if let Some(hwnd) = hwnd_opt {
|
||||
unsafe {
|
||||
let _ = ShowWindow(hwnd, SW_HIDE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroy() {
|
||||
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
||||
if let Some(hwnd) = hwnd_opt {
|
||||
unsafe {
|
||||
let _ = DestroyWindow(hwnd);
|
||||
}
|
||||
}
|
||||
*lock_state() = None;
|
||||
}
|
||||
|
||||
/// Refresh the panel's data (called from app when poll cycle completes).
|
||||
pub fn refresh_data(data: PanelData) {
|
||||
let hwnd_opt = {
|
||||
let mut guard = lock_state();
|
||||
let Some(p) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if p.data.model != data.model {
|
||||
// Showing a different model right now — caller can decide whether to swap.
|
||||
return;
|
||||
}
|
||||
p.data = data;
|
||||
Some(p.hwnd)
|
||||
};
|
||||
if let Some(hwnd) = hwnd_opt {
|
||||
unsafe {
|
||||
let _ = InvalidateRect(hwnd, None, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Window proc & painting ----------
|
||||
|
||||
unsafe extern "system" fn wnd_proc(
|
||||
hwnd: HWND,
|
||||
msg: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
match msg {
|
||||
WM_PAINT => {
|
||||
let mut ps = PAINTSTRUCT::default();
|
||||
let hdc = BeginPaint(hwnd, &mut ps);
|
||||
paint(hwnd, hdc);
|
||||
let _ = EndPaint(hwnd, &ps);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_ERASEBKGND => LRESULT(1),
|
||||
WM_KILLFOCUS => {
|
||||
// Close the panel when it loses focus. Use ShowWindow rather than
|
||||
// DestroyWindow so we can re-show it next click without re-creating.
|
||||
let _ = ShowWindow(hwnd, SW_HIDE);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_DESTROY => {
|
||||
*lock_state() = None;
|
||||
LRESULT(0)
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(hwnd: HWND, hdc: HDC) {
|
||||
let Some(data) = clone_data() else {
|
||||
return;
|
||||
};
|
||||
let mut rc = RECT::default();
|
||||
unsafe {
|
||||
let _ = GetClientRect(hwnd, &mut rc);
|
||||
}
|
||||
let dpi = unsafe { GetDpiForWindow(hwnd).max(96) };
|
||||
let scaled = |v: i32| scale_to_dpi(v, dpi);
|
||||
|
||||
let bg = if data.is_dark {
|
||||
Color::from_hex("#1F1F1F")
|
||||
} else {
|
||||
Color::from_hex("#FAFAFA")
|
||||
};
|
||||
let text_color = if data.is_dark {
|
||||
Color::from_hex("#EAEAEA")
|
||||
} else {
|
||||
Color::from_hex("#1F1F1F")
|
||||
};
|
||||
let track = if data.is_dark {
|
||||
Color::from_hex("#3A3A3A")
|
||||
} else {
|
||||
Color::from_hex("#D6D6D6")
|
||||
};
|
||||
let accent = bar_color_for(data.session_pct.max(data.weekly_pct), data.is_dark);
|
||||
|
||||
unsafe {
|
||||
let bg_brush = CreateSolidBrush(COLORREF(bg.to_colorref()));
|
||||
FillRect(hdc, &rc, bg_brush);
|
||||
let _ = DeleteObject(bg_brush);
|
||||
|
||||
// Header row: model label
|
||||
let header = match data.model {
|
||||
TrayIconKind::Claude => data.claude_label.clone(),
|
||||
TrayIconKind::Codex => data.codex_label.clone(),
|
||||
};
|
||||
draw_text(
|
||||
hdc,
|
||||
&header,
|
||||
text_color,
|
||||
scaled(PADDING_LOGICAL),
|
||||
scaled(PADDING_LOGICAL),
|
||||
rc.right - 2 * scaled(PADDING_LOGICAL),
|
||||
scaled(18),
|
||||
true,
|
||||
dpi,
|
||||
);
|
||||
|
||||
let bar_x = scaled(PADDING_LOGICAL) + scaled(LABEL_W_LOGICAL) + scaled(4);
|
||||
let bar_w = rc.right
|
||||
- bar_x
|
||||
- scaled(PADDING_LOGICAL)
|
||||
- scaled(RIGHT_TEXT_W_LOGICAL)
|
||||
- scaled(4);
|
||||
let row1_y = scaled(PADDING_LOGICAL) + scaled(24);
|
||||
let row2_y = row1_y + scaled(BAR_HEIGHT_LOGICAL) + scaled(ROW_GAP_LOGICAL) + scaled(8);
|
||||
|
||||
draw_row(
|
||||
hdc,
|
||||
data.strings.session_window,
|
||||
scaled(PADDING_LOGICAL),
|
||||
row1_y,
|
||||
bar_x,
|
||||
bar_w,
|
||||
scaled(BAR_HEIGHT_LOGICAL),
|
||||
data.session_pct,
|
||||
&data.session_text,
|
||||
text_color,
|
||||
track,
|
||||
accent,
|
||||
dpi,
|
||||
);
|
||||
|
||||
draw_row(
|
||||
hdc,
|
||||
data.strings.weekly_window,
|
||||
scaled(PADDING_LOGICAL),
|
||||
row2_y,
|
||||
bar_x,
|
||||
bar_w,
|
||||
scaled(BAR_HEIGHT_LOGICAL),
|
||||
data.weekly_pct,
|
||||
&data.weekly_text,
|
||||
text_color,
|
||||
track,
|
||||
accent,
|
||||
dpi,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_row(
|
||||
hdc: HDC,
|
||||
label: &str,
|
||||
label_x: i32,
|
||||
row_y: i32,
|
||||
bar_x: i32,
|
||||
bar_w: i32,
|
||||
bar_h: i32,
|
||||
pct: f64,
|
||||
right_text: &str,
|
||||
text_color: Color,
|
||||
track: Color,
|
||||
accent: Color,
|
||||
dpi: u32,
|
||||
) {
|
||||
let scaled = |v: i32| scale_to_dpi(v, dpi);
|
||||
let label_w = scaled(LABEL_W_LOGICAL);
|
||||
draw_text(
|
||||
hdc,
|
||||
label,
|
||||
text_color,
|
||||
label_x,
|
||||
row_y - scaled(2),
|
||||
label_w,
|
||||
bar_h + scaled(4),
|
||||
false,
|
||||
dpi,
|
||||
);
|
||||
|
||||
unsafe {
|
||||
let track_brush = CreateSolidBrush(COLORREF(track.to_colorref()));
|
||||
let bar_rect = RECT {
|
||||
left: bar_x,
|
||||
top: row_y,
|
||||
right: bar_x + bar_w,
|
||||
bottom: row_y + bar_h,
|
||||
};
|
||||
FillRect(hdc, &bar_rect, track_brush);
|
||||
let _ = DeleteObject(track_brush);
|
||||
|
||||
let fill_w = ((pct.clamp(0.0, 100.0) / 100.0) * bar_w as f64).round() as i32;
|
||||
if fill_w > 0 {
|
||||
let accent_brush = CreateSolidBrush(COLORREF(accent.to_colorref()));
|
||||
let fill_rect = RECT {
|
||||
left: bar_x,
|
||||
top: row_y,
|
||||
right: bar_x + fill_w,
|
||||
bottom: row_y + bar_h,
|
||||
};
|
||||
FillRect(hdc, &fill_rect, accent_brush);
|
||||
let _ = DeleteObject(accent_brush);
|
||||
}
|
||||
}
|
||||
|
||||
let right_text_x = bar_x + bar_w + scaled(8);
|
||||
let right_text_w = scaled(RIGHT_TEXT_W_LOGICAL);
|
||||
draw_text(
|
||||
hdc,
|
||||
right_text,
|
||||
text_color,
|
||||
right_text_x,
|
||||
row_y - scaled(2),
|
||||
right_text_w,
|
||||
bar_h + scaled(4),
|
||||
false,
|
||||
dpi,
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_text(
|
||||
hdc: HDC,
|
||||
text: &str,
|
||||
color: Color,
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
bold: bool,
|
||||
dpi: u32,
|
||||
) {
|
||||
let mut text_w: Vec<u16> = text.encode_utf16().collect();
|
||||
let font_size = if bold {
|
||||
scale_to_dpi(13, dpi)
|
||||
} else {
|
||||
scale_to_dpi(11, dpi)
|
||||
};
|
||||
let font_name = wide_str("Segoe UI");
|
||||
unsafe {
|
||||
let weight = if bold { FW_SEMIBOLD.0 } else { FW_NORMAL.0 } as i32;
|
||||
let font = CreateFontW(
|
||||
-font_size,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
weight,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
DEFAULT_CHARSET.0 as u32,
|
||||
OUT_DEFAULT_PRECIS.0 as u32,
|
||||
CLIP_DEFAULT_PRECIS.0 as u32,
|
||||
CLEARTYPE_QUALITY.0 as u32,
|
||||
(FF_SWISS.0 | DEFAULT_PITCH.0) as u32,
|
||||
PCWSTR::from_raw(font_name.as_ptr()),
|
||||
);
|
||||
let old_font = SelectObject(hdc, font);
|
||||
SetTextColor(hdc, COLORREF(color.to_colorref()));
|
||||
SetBkMode(hdc, TRANSPARENT);
|
||||
let mut rect = RECT {
|
||||
left: x,
|
||||
top: y,
|
||||
right: x + w,
|
||||
bottom: y + h,
|
||||
};
|
||||
let _ = DrawTextW(
|
||||
hdc,
|
||||
&mut text_w,
|
||||
&mut rect,
|
||||
DT_LEFT | DT_VCENTER | DT_SINGLELINE,
|
||||
);
|
||||
SelectObject(hdc, old_font);
|
||||
let _ = DeleteObject(font);
|
||||
}
|
||||
}
|
||||
|
||||
fn bar_color_for(percent: f64, _is_dark: bool) -> Color {
|
||||
crate::bubble::ring_color_for_percent(percent)
|
||||
}
|
||||
|
||||
fn clone_data() -> Option<PanelData> {
|
||||
let guard = lock_state();
|
||||
let p = guard.as_ref()?;
|
||||
Some(PanelData {
|
||||
model: p.data.model,
|
||||
session_pct: p.data.session_pct,
|
||||
session_text: p.data.session_text.clone(),
|
||||
weekly_pct: p.data.weekly_pct,
|
||||
weekly_text: p.data.weekly_text.clone(),
|
||||
is_dark: p.data.is_dark,
|
||||
strings: p.data.strings,
|
||||
claude_label: p.data.claude_label.clone(),
|
||||
codex_label: p.data.codex_label.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
|
||||
// Anchor below the bubble by default; flip above if it would clip.
|
||||
let mut x = anchor.left;
|
||||
let mut y = anchor.bottom + 8;
|
||||
let virtual_screen_h = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
|
||||
let virtual_screen_w = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
|
||||
if y + panel_h > virtual_screen_h {
|
||||
y = anchor.top - panel_h - 8;
|
||||
}
|
||||
if y < 0 {
|
||||
y = anchor.top;
|
||||
}
|
||||
if x + panel_w > virtual_screen_w {
|
||||
x = virtual_screen_w - panel_w - 8;
|
||||
}
|
||||
if x < 0 {
|
||||
x = 8;
|
||||
}
|
||||
(x, y)
|
||||
}
|
||||
|
||||
fn scale_to_dpi(logical: i32, dpi: u32) -> i32 {
|
||||
((logical as i64) * (dpi as i64) / 96) as i32
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bubble::DEFAULT_BUBBLE_SIZE;
|
||||
use crate::tray_icon::TrayIconKind;
|
||||
|
||||
const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble";
|
||||
const SETTINGS_FILE: &str = "settings.json";
|
||||
|
||||
pub const POLL_1_MIN: u32 = 60_000;
|
||||
pub const POLL_5_MIN: u32 = 5 * 60_000;
|
||||
pub const POLL_15_MIN: u32 = 15 * 60_000;
|
||||
pub const POLL_1_HOUR: u32 = 60 * 60_000;
|
||||
|
||||
fn default_show_claude() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_show_codex() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_widget_visible() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_bubble_size() -> i32 {
|
||||
DEFAULT_BUBBLE_SIZE
|
||||
}
|
||||
fn default_poll_interval_ms() -> u32 {
|
||||
POLL_5_MIN
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BubblePositions {
|
||||
pub claude: Option<(i32, i32)>,
|
||||
pub codex: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
impl BubblePositions {
|
||||
pub fn get(&self, model: TrayIconKind) -> Option<(i32, i32)> {
|
||||
match model {
|
||||
TrayIconKind::Claude => self.claude,
|
||||
TrayIconKind::Codex => self.codex,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, model: TrayIconKind, pos: (i32, i32)) {
|
||||
match model {
|
||||
TrayIconKind::Claude => self.claude = Some(pos),
|
||||
TrayIconKind::Codex => self.codex = Some(pos),
|
||||
}
|
||||
}
|
||||
pub fn reset(&mut self, model: TrayIconKind) {
|
||||
match model {
|
||||
TrayIconKind::Claude => self.claude = None,
|
||||
TrayIconKind::Codex => self.codex = None,
|
||||
}
|
||||
}
|
||||
pub fn reset_all(&mut self) {
|
||||
self.claude = None;
|
||||
self.codex = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
#[serde(default = "default_show_claude")]
|
||||
pub show_claude_code: bool,
|
||||
#[serde(default = "default_show_codex")]
|
||||
pub show_codex: bool,
|
||||
#[serde(default)]
|
||||
pub bubble_positions: BubblePositions,
|
||||
#[serde(default = "default_bubble_size")]
|
||||
pub bubble_size_logical: i32,
|
||||
#[serde(default = "default_poll_interval_ms")]
|
||||
pub poll_interval_ms: u32,
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_update_check_unix: Option<u64>,
|
||||
#[serde(default = "default_widget_visible")]
|
||||
pub widget_visible: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_claude_code: default_show_claude(),
|
||||
show_codex: default_show_codex(),
|
||||
bubble_positions: BubblePositions::default(),
|
||||
bubble_size_logical: default_bubble_size(),
|
||||
poll_interval_ms: default_poll_interval_ms(),
|
||||
language: None,
|
||||
last_update_check_unix: None,
|
||||
widget_visible: default_widget_visible(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_dir() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join(APP_DIR_NAME))
|
||||
}
|
||||
|
||||
pub fn settings_path() -> PathBuf {
|
||||
settings_dir()
|
||||
.unwrap_or_else(|| std::env::temp_dir().join(APP_DIR_NAME))
|
||||
.join(SETTINGS_FILE)
|
||||
}
|
||||
|
||||
pub fn load() -> Settings {
|
||||
let path = settings_path();
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Settings::default(),
|
||||
};
|
||||
let mut settings: Settings = serde_json::from_str(&content).unwrap_or_default();
|
||||
// At least one model must be visible. Otherwise the app has nothing to show.
|
||||
if !settings.show_claude_code && !settings.show_codex {
|
||||
settings.show_claude_code = true;
|
||||
}
|
||||
// Clamp bubble size to safe range in case settings.json was hand-edited.
|
||||
settings.bubble_size_logical = settings
|
||||
.bubble_size_logical
|
||||
.clamp(crate::bubble::MIN_BUBBLE_SIZE, crate::bubble::MAX_BUBBLE_SIZE);
|
||||
settings
|
||||
}
|
||||
|
||||
pub fn save(settings: &Settings) {
|
||||
let path = settings_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let Ok(json) = serde_json::to_string_pretty(settings) else {
|
||||
return;
|
||||
};
|
||||
// Atomic write: tmp then rename. Falls back to direct write on rename failure.
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp_path, &json).is_ok() && std::fs::rename(&tmp_path, &path).is_ok() {
|
||||
return;
|
||||
}
|
||||
let _ = std::fs::write(&path, json);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::System::Registry::*;
|
||||
|
||||
use crate::native_interop::wide_str;
|
||||
|
||||
const REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
|
||||
const REGISTRY_KEY: &str = "SystemUsesLightTheme";
|
||||
|
||||
/// Check if the system is in dark mode by reading the registry
|
||||
pub fn is_dark_mode() -> bool {
|
||||
!is_light_theme()
|
||||
}
|
||||
|
||||
fn is_light_theme() -> bool {
|
||||
unsafe {
|
||||
let path = wide_str(REGISTRY_PATH);
|
||||
let key_name = wide_str(REGISTRY_KEY);
|
||||
|
||||
let mut hkey = HKEY::default();
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(path.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut hkey,
|
||||
);
|
||||
|
||||
if result.is_err() {
|
||||
return false; // Default to dark mode
|
||||
}
|
||||
|
||||
let mut data: u32 = 0;
|
||||
let mut data_size: u32 = std::mem::size_of::<u32>() as u32;
|
||||
let result = RegQueryValueExW(
|
||||
hkey,
|
||||
PCWSTR::from_raw(key_name.as_ptr()),
|
||||
None,
|
||||
None,
|
||||
Some(&mut data as *mut u32 as *mut u8),
|
||||
Some(&mut data_size),
|
||||
);
|
||||
|
||||
let _ = RegCloseKey(hkey);
|
||||
|
||||
if result.is_err() {
|
||||
return false; // Default to dark mode
|
||||
}
|
||||
|
||||
data == 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
|
||||
use windows::Win32::UI::Shell::{
|
||||
ExtractIconExW, Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_WARNING,
|
||||
NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
use crate::native_interop::{self, Color, WM_APP_TRAY};
|
||||
|
||||
const CLAUDE_TRAY_ICON_ID: u32 = 1;
|
||||
const CODEX_TRAY_ICON_ID: u32 = 2;
|
||||
|
||||
/// Menu item ID for toggling widget visibility (used by window.rs context menu).
|
||||
pub const IDM_TOGGLE_WIDGET: u16 = 50;
|
||||
|
||||
/// Actions the tray message handler can request from the main window.
|
||||
pub enum TrayAction {
|
||||
None,
|
||||
ToggleWidget,
|
||||
ShowContextMenu,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum TrayIconKind {
|
||||
Claude,
|
||||
Codex,
|
||||
}
|
||||
|
||||
pub struct TrayIconData {
|
||||
pub kind: TrayIconKind,
|
||||
pub percent: Option<f64>,
|
||||
pub tooltip: String,
|
||||
}
|
||||
|
||||
impl TrayIconKind {
|
||||
fn id(self) -> u32 {
|
||||
match self {
|
||||
Self::Claude => CLAUDE_TRAY_ICON_ID,
|
||||
Self::Codex => CODEX_TRAY_ICON_ID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lerp_channel(start: u8, end: u8, t: f64) -> u8 {
|
||||
(start as f64 + (end as f64 - start as f64) * t.clamp(0.0, 1.0)).round() as u8
|
||||
}
|
||||
|
||||
fn lerp_color(start: Color, end: Color, t: f64) -> Color {
|
||||
Color::new(
|
||||
lerp_channel(start.r, end.r, t),
|
||||
lerp_channel(start.g, end.g, t),
|
||||
lerp_channel(start.b, end.b, t),
|
||||
)
|
||||
}
|
||||
|
||||
fn interpolated_fill(percent: f64) -> Color {
|
||||
if percent <= 50.0 {
|
||||
return Color::from_hex("#D97757");
|
||||
}
|
||||
|
||||
let stops = [
|
||||
(50.0, Color::from_hex("#D97757")),
|
||||
(70.0, Color::from_hex("#D08540")),
|
||||
(85.0, Color::from_hex("#CC8C20")),
|
||||
(95.0, Color::from_hex("#C45020")),
|
||||
(100.0, Color::from_hex("#B82020")),
|
||||
];
|
||||
|
||||
for pair in stops.windows(2) {
|
||||
let (start_pct, start_color) = pair[0];
|
||||
let (end_pct, end_color) = pair[1];
|
||||
if percent <= end_pct {
|
||||
let span = (end_pct - start_pct).max(f64::EPSILON);
|
||||
let t = (percent - start_pct) / span;
|
||||
return lerp_color(start_color, end_color, t);
|
||||
}
|
||||
}
|
||||
|
||||
stops[stops.len() - 1].1
|
||||
}
|
||||
|
||||
fn codex_fill(percent: f64) -> Color {
|
||||
if percent >= 90.0 {
|
||||
Color::from_hex("#FFFFFF")
|
||||
} else {
|
||||
Color::from_hex("#111111")
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a rounded-rectangle tray icon badge showing the usage percentage.
|
||||
/// For Claude, `percent` = None uses the embedded app icon as the loading state.
|
||||
/// For Codex, `percent` = None uses a black/white Codex placeholder badge.
|
||||
pub fn create_icon(kind: TrayIconKind, percent: Option<f64>) -> HICON {
|
||||
if matches!(kind, TrayIconKind::Claude) && percent.is_none() {
|
||||
let app_icon = load_embedded_app_icon();
|
||||
if !app_icon.is_invalid() {
|
||||
return app_icon;
|
||||
}
|
||||
}
|
||||
|
||||
let size = 64_i32;
|
||||
let margin = 0_i32;
|
||||
let radius = 2_i32;
|
||||
let outline = if matches!(kind, TrayIconKind::Codex) {
|
||||
3_i32
|
||||
} else {
|
||||
0_i32
|
||||
};
|
||||
|
||||
let fill = match kind {
|
||||
TrayIconKind::Claude => interpolated_fill(percent.unwrap_or(0.0)),
|
||||
TrayIconKind::Codex => codex_fill(percent.unwrap_or(0.0)),
|
||||
};
|
||||
let text_col = match kind {
|
||||
TrayIconKind::Claude => Color::from_hex("#FFFFFF"),
|
||||
TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"),
|
||||
TrayIconKind::Codex => Color::from_hex("#FFFFFF"),
|
||||
};
|
||||
let outline_col = match kind {
|
||||
TrayIconKind::Claude => fill,
|
||||
TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"),
|
||||
TrayIconKind::Codex => Color::from_hex("#FFFFFF"),
|
||||
};
|
||||
|
||||
let display_text = match percent {
|
||||
Some(p) => format!("{}", p.round().clamp(0.0, 999.0) as u32),
|
||||
None => match kind {
|
||||
TrayIconKind::Claude => String::new(),
|
||||
TrayIconKind::Codex => "C".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let font_h = match display_text.len() {
|
||||
1 => -50,
|
||||
2 => -42,
|
||||
_ => -30,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let screen_dc = GetDC(HWND::default());
|
||||
let mem_dc = CreateCompatibleDC(screen_dc);
|
||||
|
||||
let bmi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: size,
|
||||
biHeight: -size,
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: 0,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut bits: *mut std::ffi::c_void = std::ptr::null_mut();
|
||||
let dib =
|
||||
CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default();
|
||||
|
||||
if dib.is_invalid() {
|
||||
let _ = DeleteDC(mem_dc);
|
||||
ReleaseDC(HWND::default(), screen_dc);
|
||||
return HICON::default();
|
||||
}
|
||||
|
||||
let old_bmp = SelectObject(mem_dc, dib);
|
||||
|
||||
// Zero-fill (transparent background)
|
||||
let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, (size * size) as usize);
|
||||
for px in pixel_data.iter_mut() {
|
||||
*px = 0;
|
||||
}
|
||||
|
||||
// Draw rounded rectangle badge
|
||||
let null_pen = GetStockObject(NULL_PEN);
|
||||
let old_pen = SelectObject(mem_dc, null_pen);
|
||||
|
||||
if outline > 0 {
|
||||
let br_outline = CreateSolidBrush(COLORREF(outline_col.to_colorref()));
|
||||
let old_brush = SelectObject(mem_dc, br_outline);
|
||||
let _ = RoundRect(
|
||||
mem_dc,
|
||||
margin,
|
||||
margin,
|
||||
size - margin + 1,
|
||||
size - margin + 1,
|
||||
(radius + 1) * 2,
|
||||
(radius + 1) * 2,
|
||||
);
|
||||
SelectObject(mem_dc, old_brush);
|
||||
let _ = DeleteObject(br_outline);
|
||||
}
|
||||
|
||||
let br_fill = CreateSolidBrush(COLORREF(fill.to_colorref()));
|
||||
let old_brush = SelectObject(mem_dc, br_fill);
|
||||
let _ = RoundRect(
|
||||
mem_dc,
|
||||
margin + outline,
|
||||
margin + outline,
|
||||
size - margin - outline + 1,
|
||||
size - margin - outline + 1,
|
||||
(radius - 1) * 2,
|
||||
(radius - 1) * 2,
|
||||
);
|
||||
|
||||
SelectObject(mem_dc, old_brush);
|
||||
SelectObject(mem_dc, old_pen);
|
||||
let _ = DeleteObject(br_fill);
|
||||
|
||||
// Draw centered percentage text
|
||||
let font_name = native_interop::wide_str("Arial Bold");
|
||||
let font = CreateFontW(
|
||||
font_h,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
FW_BOLD.0 as i32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
DEFAULT_CHARSET.0 as u32,
|
||||
OUT_TT_PRECIS.0 as u32,
|
||||
CLIP_DEFAULT_PRECIS.0 as u32,
|
||||
ANTIALIASED_QUALITY.0 as u32,
|
||||
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
|
||||
PCWSTR::from_raw(font_name.as_ptr()),
|
||||
);
|
||||
let old_font = SelectObject(mem_dc, font);
|
||||
let _ = SetBkMode(mem_dc, TRANSPARENT);
|
||||
let _ = SetTextColor(mem_dc, COLORREF(text_col.to_colorref()));
|
||||
|
||||
let mut text_rect = RECT {
|
||||
left: margin,
|
||||
top: margin,
|
||||
right: size - margin,
|
||||
bottom: size - margin,
|
||||
};
|
||||
let mut text_wide: Vec<u16> = display_text.encode_utf16().collect();
|
||||
let _ = DrawTextW(
|
||||
mem_dc,
|
||||
&mut text_wide,
|
||||
&mut text_rect,
|
||||
DT_CENTER | DT_VCENTER | DT_SINGLELINE,
|
||||
);
|
||||
|
||||
SelectObject(mem_dc, old_font);
|
||||
let _ = DeleteObject(font);
|
||||
|
||||
// Set alpha: non-zero BGR pixel -> fully opaque; background stays transparent
|
||||
for px in pixel_data.iter_mut() {
|
||||
if *px != 0 {
|
||||
*px = (*px & 0x00FF_FFFF) | 0xFF00_0000;
|
||||
}
|
||||
}
|
||||
|
||||
// Monochrome mask (per-pixel alpha from colour bitmap)
|
||||
let mask_bytes = vec![0u8; ((size * size + 7) / 8) as usize];
|
||||
let mask_bmp = CreateBitmap(
|
||||
size,
|
||||
size,
|
||||
1,
|
||||
1,
|
||||
Some(mask_bytes.as_ptr() as *const std::ffi::c_void),
|
||||
);
|
||||
|
||||
let icon_info = ICONINFO {
|
||||
fIcon: TRUE,
|
||||
xHotspot: 0,
|
||||
yHotspot: 0,
|
||||
hbmMask: mask_bmp,
|
||||
hbmColor: dib,
|
||||
};
|
||||
let hicon = CreateIconIndirect(&icon_info).unwrap_or_default();
|
||||
|
||||
let _ = DeleteObject(mask_bmp);
|
||||
SelectObject(mem_dc, old_bmp);
|
||||
let _ = DeleteObject(dib);
|
||||
let _ = DeleteDC(mem_dc);
|
||||
ReleaseDC(HWND::default(), screen_dc);
|
||||
|
||||
hicon
|
||||
}
|
||||
}
|
||||
|
||||
fn load_embedded_app_icon() -> HICON {
|
||||
unsafe {
|
||||
let mut exe_buf = [0u16; 260];
|
||||
let len = GetModuleFileNameW(None, &mut exe_buf) as usize;
|
||||
if len == 0 {
|
||||
return HICON::default();
|
||||
}
|
||||
|
||||
let mut small_icon = HICON::default();
|
||||
let mut large_icon = HICON::default();
|
||||
let extracted = ExtractIconExW(
|
||||
PCWSTR::from_raw(exe_buf.as_ptr()),
|
||||
0,
|
||||
Some(&mut large_icon),
|
||||
Some(&mut small_icon),
|
||||
1,
|
||||
);
|
||||
|
||||
if extracted == 0 {
|
||||
HICON::default()
|
||||
} else if !small_icon.is_invalid() {
|
||||
small_icon
|
||||
} else {
|
||||
large_icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a Windows balloon notification from the tray icon.
|
||||
/// Used to alert the user when re-authentication is required.
|
||||
pub fn notify_balloon(hwnd: HWND, kind: TrayIconKind, title: &str, message: &str) {
|
||||
unsafe {
|
||||
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
|
||||
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
|
||||
nid.hWnd = hwnd;
|
||||
nid.uID = kind.id();
|
||||
nid.uFlags = NIF_INFO;
|
||||
nid.dwInfoFlags = NIIF_WARNING;
|
||||
copy_wide(title, &mut nid.szInfoTitle);
|
||||
copy_wide_256(message, &mut nid.szInfo);
|
||||
let _ = Shell_NotifyIconW(NIM_MODIFY, &nid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a string into a fixed-size wide buffer (truncates to fit).
|
||||
fn copy_wide<const N: usize>(s: &str, buf: &mut [u16; N]) {
|
||||
let wide: Vec<u16> = s.encode_utf16().collect();
|
||||
let len = wide.len().min(N - 1);
|
||||
buf[..len].copy_from_slice(&wide[..len]);
|
||||
buf[len] = 0;
|
||||
}
|
||||
|
||||
/// Copy a string into a 256-wide buffer.
|
||||
fn copy_wide_256(s: &str, buf: &mut [u16; 256]) {
|
||||
copy_wide(s, buf)
|
||||
}
|
||||
|
||||
/// Register the tray icon with the shell.
|
||||
pub fn add(hwnd: HWND, kind: TrayIconKind, percent: Option<f64>, tooltip: &str) {
|
||||
let hicon = create_icon(kind, percent);
|
||||
unsafe {
|
||||
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
|
||||
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
|
||||
nid.hWnd = hwnd;
|
||||
nid.uID = kind.id();
|
||||
nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||
nid.uCallbackMessage = WM_APP_TRAY;
|
||||
nid.hIcon = hicon;
|
||||
copy_to_tip(tooltip, &mut nid.szTip);
|
||||
let _ = Shell_NotifyIconW(NIM_ADD, &nid);
|
||||
if !hicon.is_invalid() {
|
||||
let _ = DestroyIcon(hicon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the tray icon colour and tooltip to reflect current usage.
|
||||
pub fn update(hwnd: HWND, kind: TrayIconKind, percent: Option<f64>, tooltip: &str) {
|
||||
let hicon = create_icon(kind, percent);
|
||||
unsafe {
|
||||
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
|
||||
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
|
||||
nid.hWnd = hwnd;
|
||||
nid.uID = kind.id();
|
||||
nid.uFlags = NIF_ICON | NIF_TIP;
|
||||
nid.hIcon = hicon;
|
||||
copy_to_tip(tooltip, &mut nid.szTip);
|
||||
let _ = Shell_NotifyIconW(NIM_MODIFY, &nid);
|
||||
if !hicon.is_invalid() {
|
||||
let _ = DestroyIcon(hicon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the tray icon from the shell.
|
||||
pub fn remove(hwnd: HWND, kind: TrayIconKind) {
|
||||
unsafe {
|
||||
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
|
||||
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
|
||||
nid.hWnd = hwnd;
|
||||
nid.uID = kind.id();
|
||||
let _ = Shell_NotifyIconW(NIM_DELETE, &nid);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync(hwnd: HWND, icons: &[TrayIconData]) {
|
||||
let show_claude = icons
|
||||
.iter()
|
||||
.find(|icon| matches!(icon.kind, TrayIconKind::Claude));
|
||||
let show_codex = icons
|
||||
.iter()
|
||||
.find(|icon| matches!(icon.kind, TrayIconKind::Codex));
|
||||
|
||||
if let Some(icon) = show_claude {
|
||||
add(hwnd, icon.kind, icon.percent, &icon.tooltip);
|
||||
update(hwnd, icon.kind, icon.percent, &icon.tooltip);
|
||||
} else {
|
||||
remove(hwnd, TrayIconKind::Claude);
|
||||
}
|
||||
|
||||
if let Some(icon) = show_codex {
|
||||
add(hwnd, icon.kind, icon.percent, &icon.tooltip);
|
||||
update(hwnd, icon.kind, icon.percent, &icon.tooltip);
|
||||
} else {
|
||||
remove(hwnd, TrayIconKind::Codex);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_all(hwnd: HWND) {
|
||||
remove(hwnd, TrayIconKind::Claude);
|
||||
remove(hwnd, TrayIconKind::Codex);
|
||||
}
|
||||
|
||||
/// Interpret a tray callback message and return the action to take.
|
||||
pub fn handle_message(lparam: LPARAM) -> TrayAction {
|
||||
let mouse_msg = lparam.0 as u32;
|
||||
match mouse_msg {
|
||||
WM_LBUTTONUP => TrayAction::ToggleWidget,
|
||||
WM_RBUTTONUP => TrayAction::ShowContextMenu,
|
||||
_ => TrayAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a string into the fixed-size szTip field (max 127 chars + null).
|
||||
fn copy_to_tip(s: &str, tip: &mut [u16; 128]) {
|
||||
let wide: Vec<u16> = s.encode_utf16().collect();
|
||||
let mut len = wide.len().min(127);
|
||||
// Don't leave a lone high surrogate at the truncation point
|
||||
if len > 0 && (0xD800..=0xDBFF).contains(&wide[len - 1]) {
|
||||
len -= 1;
|
||||
}
|
||||
tip[..len].copy_from_slice(&wide[..len]);
|
||||
tip[len] = 0;
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::{HWND, WAIT_OBJECT_0, WAIT_TIMEOUT};
|
||||
use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
|
||||
|
||||
const GITHUB_API_ACCEPT: &str = "application/vnd.github+json";
|
||||
const GITHUB_API_VERSION: &str = "2022-11-28";
|
||||
const RELEASE_ASSET_NAME: &str = "claude-code-usage-bubble.exe";
|
||||
const HELPER_EXE_NAME: &str = "updater-helper.exe";
|
||||
const DOWNLOAD_EXE_NAME: &str = "update-download.exe";
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
const CREATE_NEW_CONSOLE: u32 = 0x00000010;
|
||||
// Reserved for future winget submission. Until then `current_install_channel`
|
||||
// always returns `Portable` and this constant is unused.
|
||||
#[allow(dead_code)]
|
||||
const WINGET_PACKAGE_ID: &str = "ClaudeCodeUsageBubble";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum InstallChannel {
|
||||
Portable,
|
||||
Winget,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReleaseDescriptor {
|
||||
pub latest_version: String,
|
||||
asset_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UpdateCheckResult {
|
||||
UpToDate,
|
||||
Available(ReleaseDescriptor),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitHubRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GitHubAsset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitHubAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
pub fn handle_cli_mode(args: &[String]) -> Option<i32> {
|
||||
if args.len() == 5 && args[1] == "--apply-update" {
|
||||
let target = PathBuf::from(&args[2]);
|
||||
let source = PathBuf::from(&args[3]);
|
||||
let pid = args[4].parse::<u32>().unwrap_or(0);
|
||||
|
||||
return Some(match apply_update(target, source, pid) {
|
||||
Ok(()) => 0,
|
||||
Err(error) => {
|
||||
show_error_message("Update failed", &error);
|
||||
1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn current_install_channel() -> InstallChannel {
|
||||
InstallChannel::Portable
|
||||
}
|
||||
|
||||
pub fn check_for_updates() -> Result<UpdateCheckResult, String> {
|
||||
match fetch_latest_release()? {
|
||||
Some(release) => Ok(UpdateCheckResult::Available(release)),
|
||||
None => Ok(UpdateCheckResult::UpToDate),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin_winget_update() -> Result<(), String> {
|
||||
let current_exe =
|
||||
std::env::current_exe().map_err(|e| format!("Unable to locate current executable: {e}"))?;
|
||||
let current_dir = current_exe
|
||||
.parent()
|
||||
.ok_or_else(|| "Unable to determine the app directory for restart.".to_string())?;
|
||||
let command = winget_upgrade_command(
|
||||
std::process::id(),
|
||||
¤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<Option<ReleaseDescriptor>, String> {
|
||||
let (owner, repo) = github_repo()?;
|
||||
let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/latest");
|
||||
let agent = build_agent()?;
|
||||
|
||||
let response = agent
|
||||
.get(&url)
|
||||
.set("Accept", GITHUB_API_ACCEPT)
|
||||
.set("User-Agent", user_agent())
|
||||
.set("X-GitHub-Api-Version", GITHUB_API_VERSION)
|
||||
.call()
|
||||
.map_err(|e| format!("Unable to check GitHub releases: {e}"))?;
|
||||
|
||||
let release: GitHubRelease = response
|
||||
.into_json()
|
||||
.map_err(|e| format!("Unable to parse GitHub release data: {e}"))?;
|
||||
|
||||
let latest_version = release.tag_name.trim_start_matches('v').to_string();
|
||||
if !is_version_newer(&latest_version, env!("CARGO_PKG_VERSION")) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name.eq_ignore_ascii_case(RELEASE_ASSET_NAME))
|
||||
.or_else(|| {
|
||||
release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name.to_ascii_lowercase().ends_with(".exe"))
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
"No Windows executable asset was found in the latest release.".to_string()
|
||||
})?;
|
||||
|
||||
Ok(Some(ReleaseDescriptor {
|
||||
latest_version,
|
||||
asset_url: asset.browser_download_url.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_agent() -> Result<ureq::Agent, String> {
|
||||
let tls = native_tls::TlsConnector::new()
|
||||
.map_err(|e| format!("Unable to initialize TLS support for update checks: {e}"))?;
|
||||
Ok(ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.tls_connector(std::sync::Arc::new(tls))
|
||||
.build())
|
||||
}
|
||||
|
||||
fn download_release_asset(url: &str, partial_path: &Path, final_path: &Path) -> Result<(), String> {
|
||||
let agent = build_agent()?;
|
||||
let response = agent
|
||||
.get(url)
|
||||
.set("User-Agent", user_agent())
|
||||
.call()
|
||||
.map_err(|e| format!("Unable to download the latest release: {e}"))?;
|
||||
|
||||
let mut reader = response.into_reader();
|
||||
let mut file = File::create(partial_path)
|
||||
.map_err(|e| format!("Unable to create temporary download file: {e}"))?;
|
||||
|
||||
io::copy(&mut reader, &mut file)
|
||||
.map_err(|e| format!("Unable to write the downloaded update: {e}"))?;
|
||||
file.flush()
|
||||
.map_err(|e| format!("Unable to finalize the downloaded update: {e}"))?;
|
||||
|
||||
std::fs::rename(partial_path, final_path)
|
||||
.map_err(|e| format!("Unable to finalize the downloaded update file: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_target_binary(target: &Path, source: &Path) -> Result<(), String> {
|
||||
let backup_path = backup_path_for(target);
|
||||
let mut last_error = None;
|
||||
|
||||
for _ in 0..60 {
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
|
||||
let renamed_existing = match std::fs::rename(target, &backup_path) {
|
||||
Ok(()) => true,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
|
||||
Err(error) => {
|
||||
last_error = Some(error);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match std::fs::copy(source, target) {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => {
|
||||
last_error = Some(error);
|
||||
let _ = std::fs::remove_file(target);
|
||||
if renamed_existing {
|
||||
let _ = std::fs::rename(&backup_path, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Unable to replace {}. {}",
|
||||
target.display(),
|
||||
last_error
|
||||
.map(|error| error.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
"The file may still be locked or the install directory may not be writable."
|
||||
.to_string()
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
fn relaunch_target(target: &Path) -> Result<(), String> {
|
||||
let mut command = Command::new(target);
|
||||
if let Some(parent) = target.parent() {
|
||||
command.current_dir(parent);
|
||||
}
|
||||
|
||||
command
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"The update was installed, but the app could not be restarted automatically: {e}"
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_process_exit(pid: u32, timeout: Duration) -> Result<(), String> {
|
||||
if pid == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_SYNCHRONIZE, false, pid)
|
||||
.map_err(|e| format!("Unable to monitor the running app process: {e}"))?;
|
||||
|
||||
let result = WaitForSingleObject(handle, timeout.as_millis().min(u32::MAX as u128) as u32);
|
||||
let _ = windows::Win32::Foundation::CloseHandle(handle);
|
||||
|
||||
if result == WAIT_OBJECT_0 {
|
||||
Ok(())
|
||||
} else if result == WAIT_TIMEOUT {
|
||||
Err("Timed out waiting for the running app to exit.".to_string())
|
||||
} else {
|
||||
Err("Unable to confirm that the running app has exited.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn updates_dir() -> Result<PathBuf, String> {
|
||||
dirs::data_local_dir()
|
||||
.map(|dir| dir.join("ClaudeCodeUsageBubble").join("updates"))
|
||||
.or_else(|| {
|
||||
Some(
|
||||
std::env::temp_dir()
|
||||
.join("ClaudeCodeUsageBubble")
|
||||
.join("updates"),
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| "Unable to resolve a writable local updates directory.".to_string())
|
||||
}
|
||||
|
||||
fn winget_upgrade_command(pid: u32, target: &str, working_dir: &str) -> String {
|
||||
let target = powershell_single_quoted(target);
|
||||
let working_dir = powershell_single_quoted(working_dir);
|
||||
let package_id = WINGET_PACKAGE_ID;
|
||||
|
||||
format!(
|
||||
concat!(
|
||||
"$ErrorActionPreference = 'Stop'; ",
|
||||
"$pidToWait = {pid}; ",
|
||||
"$target = '{target}'; ",
|
||||
"$workingDir = '{working_dir}'; ",
|
||||
"try {{ Wait-Process -Id $pidToWait -Timeout 30 -ErrorAction Stop }} catch {{ }}; ",
|
||||
"winget upgrade --id {package_id} --exact; ",
|
||||
"$exitCode = $LASTEXITCODE; ",
|
||||
"if ($exitCode -eq 0) {{ ",
|
||||
"Start-Sleep -Seconds 2; ",
|
||||
"Start-Process -FilePath $target -WorkingDirectory $workingDir; ",
|
||||
"exit 0 ",
|
||||
"}}; ",
|
||||
"Write-Host ''; ",
|
||||
"Write-Host 'WinGet update failed with exit code' $exitCode; ",
|
||||
"Read-Host 'Press Enter to close'; ",
|
||||
"exit $exitCode"
|
||||
),
|
||||
pid = pid,
|
||||
target = target,
|
||||
working_dir = working_dir,
|
||||
package_id = package_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn powershell_single_quoted(value: &str) -> String {
|
||||
value.replace('\'', "''")
|
||||
}
|
||||
|
||||
fn backup_path_for(target: &Path) -> PathBuf {
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("app.exe");
|
||||
target.with_file_name(format!("{file_name}.old"))
|
||||
}
|
||||
|
||||
fn ensure_target_location_writable(target: &Path) -> Result<(), String> {
|
||||
let parent = target.parent().ok_or_else(|| {
|
||||
"Unable to determine the install directory for the current executable.".to_string()
|
||||
})?;
|
||||
|
||||
let probe_path = parent.join(".__ccum_update_probe");
|
||||
match File::create(&probe_path) {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&probe_path);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(format!(
|
||||
"The current install location is not writable. Move the app to a user-writable folder or install it somewhere outside Program Files. {error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn github_repo() -> Result<(&'static str, &'static str), String> {
|
||||
let repository = env!("CARGO_PKG_REPOSITORY").trim_end_matches('/');
|
||||
let parts: Vec<&str> = repository.split('/').collect();
|
||||
if parts.len() < 2 {
|
||||
return Err("Package repository URL is not configured for GitHub releases.".to_string());
|
||||
}
|
||||
|
||||
let owner = parts[parts.len() - 2];
|
||||
let repo = parts[parts.len() - 1];
|
||||
if owner.is_empty() || repo.is_empty() {
|
||||
return Err("Package repository URL is not configured for GitHub releases.".to_string());
|
||||
}
|
||||
|
||||
Ok((owner, repo))
|
||||
}
|
||||
|
||||
fn user_agent() -> &'static str {
|
||||
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn is_winget_install_path(path: &Path) -> bool {
|
||||
let normalized_path = normalize_path(path);
|
||||
winget_install_roots()
|
||||
.into_iter()
|
||||
.map(|root| normalize_path(&root))
|
||||
.any(|root| normalized_path.starts_with(&root))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn winget_install_roots() -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
|
||||
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
|
||||
roots.push(
|
||||
PathBuf::from(local_app_data)
|
||||
.join("Microsoft")
|
||||
.join("WinGet")
|
||||
.join("Packages"),
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(program_files) = std::env::var("ProgramFiles") {
|
||||
roots.push(PathBuf::from(program_files).join("WinGet").join("Packages"));
|
||||
} else {
|
||||
roots.push(PathBuf::from(r"C:\Program Files\WinGet\Packages"));
|
||||
}
|
||||
|
||||
if let Ok(program_files_x86) = std::env::var("ProgramFiles(x86)") {
|
||||
roots.push(
|
||||
PathBuf::from(program_files_x86)
|
||||
.join("WinGet")
|
||||
.join("Packages"),
|
||||
);
|
||||
} else {
|
||||
roots.push(PathBuf::from(r"C:\Program Files (x86)\WinGet\Packages"));
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn normalize_path(path: &Path) -> String {
|
||||
let normalized = path
|
||||
.to_string_lossy()
|
||||
.replace('/', "\\")
|
||||
.trim_end_matches('\\')
|
||||
.to_ascii_lowercase();
|
||||
|
||||
normalized
|
||||
.strip_prefix("\\\\?\\unc\\")
|
||||
.map(|rest| format!("\\\\{rest}"))
|
||||
.or_else(|| normalized.strip_prefix("\\\\?\\").map(str::to_owned))
|
||||
.unwrap_or(normalized)
|
||||
}
|
||||
|
||||
fn is_version_newer(candidate: &str, current: &str) -> bool {
|
||||
parse_version(candidate) > parse_version(current)
|
||||
}
|
||||
|
||||
fn parse_version(version: &str) -> (u32, u32, u32) {
|
||||
let core = version.split('-').next().unwrap_or(version);
|
||||
let mut parts = core.split('.').map(|part| part.parse::<u32>().unwrap_or(0));
|
||||
|
||||
(
|
||||
parts.next().unwrap_or(0),
|
||||
parts.next().unwrap_or(0),
|
||||
parts.next().unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
fn show_error_message(title: &str, message: &str) {
|
||||
unsafe {
|
||||
let title_wide = wide_str(title);
|
||||
let message_wide = wide_str(message);
|
||||
let _ = MessageBoxW(
|
||||
HWND::default(),
|
||||
PCWSTR::from_raw(message_wide.as_ptr()),
|
||||
PCWSTR::from_raw(title_wide.as_ptr()),
|
||||
MB_OK | MB_ICONERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn wide_str(value: &str) -> Vec<u16> {
|
||||
value.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||