Compare commits

...
3 Commits
Author SHA1 Message Date
tiennm99 ed9b8b2042 feat: threshold balloons + dark/light auto-follow
- Threshold balloons fire the cycle utilization crosses 80% or 95%
  on either provider, with the title showing "{Provider} · {N}%" and
  body translated per shipped locale. Reuses the existing
  BALLOON_COOLDOWN so notifications stay calm.
- Dark/light auto-follow: bubble's WM_SETTINGCHANGE handler now calls
  app::recheck_theme(), which re-reads HKCU\…\Personalize, updates
  state.is_dark if changed, and triggers a UI repaint + tray refresh.
  Windows posts WM_SETTINGCHANGE to every top-level window when the
  user toggles light/dark in Settings, so the bubble repaints in
  near-real-time.
- Adds 2 new i18n keys (threshold_80_body, threshold_95_body) across
  all eight shipped locales.

Bumps version to 0.1.5.
2026-05-16 13:57:15 +07:00
tiennm99 8ad718d9c1 docs(reports): add code-reviewer / brainstormer / researcher reports
Advisory artifacts produced by the three agents spawned to audit the
project. Code-reviewer found the P0/P1 set fixed in 1ef1bfa;
brainstormer ranked feature ideas; researcher surveyed the
self-update / code-signing / distribution space.
2026-05-16 13:51:25 +07:00
tiennm99 1ef1bfa7b2 fix: phase 2 — UI-freeze + GDI-leak + panic-on-GDI-exhaustion fixes
- P0: pull blocking HTTPS out from under the global mutex. AppState's
  http and registry now live behind Arc<Client> and Arc<Mutex<Registry>>;
  do_poll, attempt_refresh, and version_action's Apply branch clone
  these out, drop lock_state, then do their I/O. Apply now spawns a
  worker thread that posts WM_APP_UPDATE_APPLIED back to the message-
  only window when the cmd handoff is launched, so the UI no longer
  freezes for the duration of the download.
- P1: bubble.rs paint_text_layer saves and restores the DC's previous
  HFONT before DeleteObject. The old code's DeleteObject on a still-
  selected HFONT silently failed and leaked one handle per paint frame
  (up to ~12/s under the ≥95% pulse animation).
- P1: replace 5x CreatePopupMenu().unwrap() with let-else early returns
  that destroy any half-built menus and log. GDI exhaustion no longer
  panics the UI thread.
- P1: at-most-one-in-flight gate (static AtomicBool) on the poll thread
  so rapid Refresh clicks don't stack concurrent HTTPS calls.
- P1: token-expired balloon now picks the title/body for the provider
  that actually failed, instead of always falling back to Claude when
  show_claude_code is on.
- P1: panel place_near honors SM_XVIRTUALSCREEN / SM_YVIRTUALSCREEN so
  multi-monitor setups with a secondary display left of the primary
  no longer mis-clamp the panel position.
- P1: COUNTDOWN_TEMPLATE bumped from "999d" to "999시간" — Korean has
  the widest suffix among shipped locales and was overflowing the
  countdown column.

Bumps version to 0.1.4.
2026-05-16 13:51:16 +07:00
17 changed files with 795 additions and 103 deletions
Generated
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "claude-code-usage-bubble" name = "claude-code-usage-bubble"
version = "0.1.3" version = "0.1.5"
dependencies = [ dependencies = [
"dirs", "dirs",
"embed-resource", "embed-resource",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "claude-code-usage-bubble" name = "claude-code-usage-bubble"
version = "0.1.3" version = "0.1.5"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows" description = "Floating bubble showing Claude Code and Codex usage on Windows"
@@ -0,0 +1,192 @@
# Brainstorm: high-leverage improvements for claude-code-usage-bubble
Date: 2026-05-16
Baseline: v0.1.2 — bubble UX just polished (commits 5df75c9...7f8ccf0), updater bug just fixed (a132c02), auto-update frequency just added (2ca5052), windows release pipeline just added (60cde29).
Read scope: README, src/app.rs, src/bubble.rs, src/panel.rs, src/usage/{types,mod,anthropic}.rs, Cargo.toml, last 10 commits.
---
## Reality-check (cuts I refuse to dress up)
- Codebase is heavily Win32. windows-rs 0.58, GDI, AppBar, WinHTTP, registry-based autostart. Anything multi-platform is a near-rewrite of the UI shell. Most polish effort beats most expand effort.
- The product is small and good. Two bars x two providers x one bubble. Do not bloat it. The risk is feature creep that turns it into the thing it was reacting against.
- The user is also the author — there is no marketing motion to feed. Distribution work only pays off if you actually want strangers using it. Decide that first; everything in section 4 and 7 is conditional on yes.
---
## 1. Bubble UX
What 360 Security / IObit memory balls do that this does not: a one-tap action (their boost button), idle micro-animation that draws the eye, a state-change pulse when a number crosses a threshold, edge-dock that fully tucks against the screen edge showing only a sliver.
### 1a. Threshold pulse + colour-state escalation
- One-liner: when 5h utilization crosses 80 / 95 percent, the ring pulses once (already have TIMER_PULSE) and the accent shifts amber to red; when it drops after reset, single subtle release animation.
- Why: the user reason to look at the bubble is "am I close?" — passive colour is fine when sitting at 30 percent, but at 92 percent you want the bubble to grab you once and then shut up.
- Effort: S. Pulse timer already exists; need state-machine on percentage threshold + hysteresis (do not pulse every poll).
- Mistake if: you make it pulse continuously above a threshold. That is a notification, not a bubble. One pulse per crossing, that is it.
### 1b. Edge-dock sliver mode
- One-liner: when snapped to an edge for >5s with no interaction, contract to a thin coloured stripe (~6px) along the edge showing only the higher-of-(5h, 7d) percentage as a bar. Hover or click expands back.
- Why: the bubble at 200px is a lot of permanent screen real estate. The memory ball UX wins because at rest it is almost invisible.
- Effort: M. Need new render path + hover-region + state transition. Risk of getting the hit-test wrong.
- Mistake if: the dock is so subtle the user cannot find it again. Keep a 1-2px coloured accent.
### 1c. Dark/light auto-follow (Windows system theme)
- One-liner: subscribe to WM_SETTINGCHANGE ImmersiveColorSet + read AppsUseLightTheme from HKCU; AppState.is_dark follows instead of being static.
- Why: bubble currently looks alien on the opposite theme. This is the cheapest feels-native win available.
- Effort: S. Single registry read on startup + WM_SETTINGCHANGE handler.
- Mistake if: you also try to do per-bubble theme override. YAGNI — system theme is correct ~100 percent of the time.
### 1d. Accent-colour follow (system)
- One-liner: read HKCU Software Microsoft Windows DWM AccentColor and tint the ring fill at low utilization (where the colour is currently arbitrary).
- Why: makes the widget feel like a system component. Cheap perceived-quality lift.
- Effort: S.
- Mistake if: you let accent colour override the amber/red threshold states. Threshold > accent.
### Cut from section 1
- Custom theming UI. No. YAGNI. System theme is enough.
- Bubble shapes other than rounded-rect. The whole circle framing in the README is already a fib (it is a 3:1 rounded rect). Do not add more shape options.
---
## 2. Information density
The data already in UsageWindows is only utilization (0-100) + resets_at. Anything else needs new endpoint work or local computation. Be honest about that cost.
### 2a. Burn rate + projected exhaustion (computed, no new API)
- One-liner: track utilization samples in a small ring buffer; in the panel, show "at this rate, you will hit 100 percent in ~Xh" beneath the 5h bar.
- Why: the actual decision the user is making is "do I context-switch now or finish this thought?" Projected exhaustion answers that; raw percentage does not.
- Effort: S-M. Ring buffer + linear regression over last N samples; only show when slope is meaningfully positive.
- Mistake if: you put the projection on the bubble itself. It is noise there. Panel-only.
### 2b. Delta-since-last-reset summary in panel
- One-liner: when the 5h window resets, snapshot the previous peak percentage. Show "Last cycle peaked at 88 percent" in the panel.
- Why: builds intuition over time about whether usage is growing or shrinking without any backend.
- Effort: S. One extra field in settings/state.
- Mistake if: you try to show a chart. Tiny number, one line, done.
### 2c. Do NOT add: token count, dollar cost, model breakdown
- Cut. Anthropic oauth/usage endpoint returns utilization buckets, not token counts. The fallback path scrapes rate-limit headers. Neither gives reliable dollar cost. Inventing one will be wrong and erode trust. Do not ship guesses as facts.
### Cut from section 2
- Per-conversation/per-project breakdown. Anthropic does not expose this in oauth/usage. Do not promise what you cannot deliver.
---
## 3. Workflow integrations
### 3a. Threshold balloon notification (one-shot)
- One-liner: at 80 / 95 percent crossings, fire a Shell_NotifyIcon balloon ("Claude 5h at 95 percent — resets in 42m"). Already have BALLOON_COOLDOWN and last_balloon_at plumbed; extend the trigger from "update available" to "threshold crossed".
- Why: a user with the bubble auto-hidden during fullscreen game/video still wants to know they are about to run out. This is the single highest-impact integration because the integration target is the user, not another app.
- Effort: S. Plumbing exists; just add the trigger.
- Mistake if: you fire it every poll above 95 percent. Once per crossing, per reset cycle.
### 3b. Cut: tailing Claude Code logs / hooks
- The Claude Code CLI does emit logs (~/.claude/) and supports hooks, but inferring usage from them is fragile vs. the official oauth/usage endpoint you are already hitting. Do not dual-source the same number.
### Cut from section 3
- Slack/Discord/webhook out. No. This is a personal desktop widget. If you want webhooks, you are building a different product.
- Pause Claude Code when over limit. Out of scope. The bubble observes, does not control.
---
## 4. Distribution + onboarding
Only worthwhile if you want strangers using it. State the goal explicitly before doing any of this.
### 4a. winget manifest
- One-liner: submit a manifest to microsoft/winget-pkgs once you have at least one signed (or accepted-unsigned-with-hash) release.
- Why: winget install tiennm99.ClaudeCodeUsageBubble is the only Windows install command anyone actually wants to run. Free distribution.
- Effort: S (one PR to winget-pkgs) once the release artifact has a stable URL + SHA256, which it now does.
- Mistake if: you submit before code signing. winget accepts unsigned packages but SmartScreen still nags; that user pain accrues to your repo, not winget.
### 4b. First-run is-everything-working check
- One-liner: on first launch with no settings.json, run the same checks as --diagnose once: can I find Claude creds? Can I reach Anthropic? Show a tiny one-time panel that says "Claude OK, Codex not configured (enable in Models menu)" and dismisses.
- Why: silent failure is the worst onboarding outcome. The bubble showing dash-percent tells users nothing.
- Effort: S. --diagnose already exists; reuse the logic.
- Mistake if: it becomes a wizard. One panel, one dismiss, never again.
### Cut from section 4
- MSIX. Cut. MSIX requires the Store or sideload pain. Cost > benefit for an indie widget.
- MSI installer. Cut. A 4-MB single exe that drops in LOCALAPPDATA is better than an MSI for this audience.
- Crash dumps. The app is small and runs in a single Win32 message loop. simplelog to TEMP claude-code-usage-bubble.log already covers 95 percent of post-mortem needs.
---
## 5. Multi-platform reality check
Skip this axis. The codebase is windows::Win32:: from top to bottom — bubble window, panel, tray, AppBar, registry autostart, WinHTTP. Porting macOS/Linux is a full UI-shell rewrite (~70 percent of the code), and the value proposition (a desktop memory ball) does not translate cleanly — macOS users expect a menu-bar app, Linux users expect a tray icon and most distros do not have a stable always-on-top floating layer.
If you genuinely want cross-platform, the right move is not to port this — it is a separate claude-code-usage-menubar for macOS that reuses src/usage/ and src/creds/ as a library crate. Worth noting but not worth doing unless someone asks.
---
## 6. Updater roadmap
### 6a. SHA256 verification of downloaded artifact
- One-liner: GitHub Releases shipped via your windows-release.yml already produce a stable URL; publish a SHA256SUMS file as part of the release, fetch + verify before swapping the exe.
- Why: defends against a compromised CDN / MITM regardless of code signing. Way cheaper than signing.
- Effort: S. Add to the GH Actions release step; verify in src/update/.
- Mistake if: you skip this because GitHub releases use HTTPS so MITM is impossible. HTTPS is not integrity; an attacker with a release-asset upload token or a compromised CI also matters.
### 6b. Code signing — defer, do not romanticise
- A standard EV cert is ~300-600 dollars/year and an OV cert ~200-400 dollars/year, and even with OV you still wait weeks for SmartScreen reputation. EV gets you immediate SmartScreen trust but the HSM-bound key is operationally annoying for solo devs.
- Verdict: defer until install volume justifies it (>1000 downloads/release, say). The Run-anyway friction is real but survivable; the cost-per-user of a cert at low volume is very high.
- Effort if pursued: M (cert setup) + ongoing key custody pain.
- Mistake if: you sign without rotating to an HSM-backed solution. A leaked signing key is worse than no signing.
### 6c. Beta channel via GitHub pre-releases
- One-liner: settings.json already supports install_channel; surface it in right-click menu as "Channel Stable / Beta" so people can dogfood.
- Why: low-cost, high-trust signal for early adopters; gives you canaries before stable.
- Effort: S. One menu item, one settings field, one filter on the GH Releases list.
- Mistake if: you ship a beta that bricks the updater (see v0.1.2). Add a "downgrade to last stable" affordance.
### Cut from section 6
- Delta updates. No. The exe is ~4 MB. Bandwidth is not the bottleneck. Delta-patching machinery is bug surface.
- Rollback. Mostly cut. Keeping the previous exe as .bak on update and a --rollback flag is fine (S), but a full rollback UI is overkill.
---
## 7. Brand / discovery
Only worth doing if you actually want users beyond yourself. Sketched at low cost:
### 7a. Demo GIF in README (top, above install)
- One-liner: 6-8 second loop showing bubble at idle, drag-to-edge snap, left-click expand panel, right-click menu.
- Why: the entire product is visual. Words do not sell a floating bubble — the GIF will convert orders-of-magnitude better than the current shield-badges.
- Effort: S. ScreenToGif then optimise to <1 MB.
- Mistake if: you record it on a 4K monitor and it weighs 8 MB and breaks the README. Keep it <1.5 MB.
### 7b. GitHub topics + a short tagline
- Topics: windows-desktop, rust, claude-code, codex, usage-monitor, widget, system-tray. The README H1 is fine but the GitHub repo description / About should be one tweet-length line.
- Effort: 5 minutes. Free.
### Cut from section 7
- Landing page / dedicated site. Cut until install volume justifies. The repo is the landing page.
- Twitter/Bluesky launch posts. Author call. Not a product question.
---
## Top 5 I would ship first, ranked
1. Dark/light auto-follow (1c). S effort, immediate feels-native lift, zero risk. Ship today.
2. Threshold balloon at 80/95 percent (3a). S effort. The single biggest jump in usefulness on the entire list — it converts the widget from a thing you have to look at into a thing that tells you.
3. Demo GIF in README (7a). S effort, only useful if you want strangers, but if you do it is the unlock.
4. SHA256 verification in updater (6a). S effort, plugs a real integrity hole that exists today, cheaper than signing.
5. Edge-dock sliver mode (1b). M effort but this is the differentiator vs. the upstream taskbar-widget approach — it is what floating bubble actually wants to be. Worth the M.
Honourable mention: first-run sanity check (4b) — only if you ship #3 first and start getting "it shows nothing, is it broken?" issues.
---
## Unresolved questions
1. Do you actually want external users? Section 4 and 7 are no-ops if not.
2. Are you willing to take ~200-600 dollars/yr on code signing within the next year, or is "unsigned + SmartScreen warning" the permanent stance? Affects whether 6b is a roadmap item or a no.
3. Is there a deliberate reason Settings already has install_channel but no UI surface (6c)? If it was intentional dormancy, fine; if it was an oversight, that is a free win.
4. macOS port — yes/no/later? Affects whether src/usage/ and src/creds/ should be refactored into a separate crate now (cheap) vs. later (painful).
---
Status: DONE
Summary: 14 picks across 6 of 7 axes (multi-platform skipped with rationale). Top-5 ranked. 4 unresolved questions for the user.
@@ -0,0 +1,183 @@
# Full-Project Code Review — claude-code-usage-bubble v0.1.2
Scope: full src/ tree (~6.2k LOC across 35 files). Recent shipped releases 0.1.0-0.1.2; v0.1.2 fixed cmd.exe arg escaping in the self-updater.
## Severity counts
- **P0**: 3
- **P1**: 9
- **P2**: 7
---
## P0
### P0-1. Poll thread holds global state mutex during blocking HTTPS
`src/app.rs:415-423`
`do_poll()` acquires `lock_state()`, calls `s.registry.poll_enabled(&s.http, &settings)` which dispatches to `ClaudeProvider::poll` / `ChatGptProvider::poll` — each issues a synchronous WinHTTP request inside the locked critical section. Lock is held for the full RTT (can be seconds, hang on dead network = whole poll cycle).
While locked, every UI-thread path that touches `lock_state()` stalls:
- left/right click on bubble (`on_bubble_click`, `on_bubble_right_click``build_panel_data`, `show_context_menu`)
- countdown timer (`refresh_countdowns`)
- WM_APP_USAGE_UPDATED dispatch (`propagate_to_ui`)
- menu commands (toggle, set_poll_interval, version_action, etc.)
- bubble move/resize callbacks
Symptom: bubble appears frozen / right-click menu won't open whenever the network is slow.
Fix: clone the data needed (settings + a separate `Mutex<Registry>` or split state) before issuing HTTP. Build a snapshot in one short lock, do HTTP outside the lock, take the lock again only to write results.
### P0-2. `attempt_refresh` holds global lock across up to 8s sleep loop
`src/app.rs:470-482`, `src/usage/refresh.rs:36-54`
`attempt_refresh` calls `s.registry.try_refresh(...)` while holding `lock_state()`. `try_refresh → Orchestrator::refresh` spawns the CLI then loops `thread::sleep(500ms)` for up to `REFRESH_TIMEOUT = 8s` per failed provider. The UI thread is unresponsive for the full duration.
Fix: same pattern as P0-1 — release the lock before calling `orchestrator.refresh(src)`. Re-acquire only for the balloon decision.
### P0-3. Synchronous update download blocks UI thread inside WM_COMMAND
`src/app.rs:1098-1104`, `src/update/install.rs:24,41-50`
`version_action` runs on the UI thread (called from `msg_wnd_proc → WM_COMMAND → on_menu_command`). On "Apply update" it calls `update::install::begin(&c, &release)` which downloads the .exe synchronously (`http.get().send()` + `fs::write`). For a multi-MB asset on a slow link the bubble + every other window message handler is blocked.
Fix: spawn a thread for the download; on completion post a custom message back to the UI thread that triggers `spawn_handoff` + `PostQuitMessage`.
---
## P1
### P1-1. GDI font handles deleted while still selected into DC
`src/bubble.rs:1293-1320`
In `paint_text_layer` the pattern is `SelectObject(hdc, label_font) → SelectObject(hdc, bold_font) → SelectObject(hdc, main_font) → DeleteObject(main_font); DeleteObject(bold_font); DeleteObject(label_font);`. The original font is never saved/restored, and `main_font` is still the currently selected object when `DeleteObject(main_font)` runs.
GDI rule: deleting a GDI object that is selected into a DC is an error; the call returns FALSE and the object is not freed. This leaks ~3 HFONT slots per `render()` call — and `render` fires on every poll, every WM_TIMER countdown tick, and on every TIMER_PULSE tick (every 80 ms while a bar is ≥95%). Process will eventually exhaust the GDI handle quota (10000 per process by default) under prolonged alarm conditions.
Fix: save first SelectObject return, restore it before deleting all three fonts. Same fix used correctly in `measure_text_w` at lines 1009-1013.
### P1-2. Update handoff: `cmd.exe` percent expansion + cmd-metachar exposure on usernames
`src/update/install.rs:53-74`
`src_str`/`tgt_str` strip `"` but not `%`, `&`, `^`, `(`, `)`, `|`. These come from `dirs::data_local_dir()` and `std::env::current_exe()`. Both can include the username segment.
Real-world risk is low (most usernames are alphanumeric), but a username containing `%VAR%` would expand inside the inner `"..."` quotes (cmd.exe percent-expansion happens inside quotes too) and a username containing `&` or `^` bypasses the inner quoting in known cmd.exe corner cases.
Fix: validate `current_exe()` / `stage_path()` against `[A-Za-z0-9 \\:.\-_/]` before substitution, or use the helper-exe pattern the comments dismiss. At minimum, reject paths containing any of `%&^|<>`.
### P1-3. Downloaded binary is not verified before swap
`src/update/install.rs:24,41-50`
`begin` downloads the asset URL and immediately stages it for `move /y` replacement. No SHA256, signature, or even file-size sanity check. If GitHub's release CDN delivers a truncated/corrupted asset (network blip), the next launch is broken with no rollback. If an attacker controls the release-publishing pipeline (compromised PAT, repo takeover) every existing install gets RCE.
Fix: ship the release with a `claude-code-usage-bubble.exe.sha256` sidecar (or use the GH API's `digest` field — populated in newer releases), download both, compare before `spawn_handoff`. Also `fsync` the staged file.
### P1-4. Token-expiry balloon picks wrong provider when both are enabled
`src/app.rs:715-744`
`show_token_expired_balloon` ignores which provider actually failed: `if s.settings.show_claude_code { (Claude, claude title, claude body) } else { (ChatGpt, ...) }`. If both providers are enabled and only Codex's token expired, the balloon claims the Claude token expired. Sent through `attempt_refresh` the `failures: Vec<ProviderId>` already knows the real victim.
Fix: pass `failures` (or one chosen provider) into `show_token_expired_balloon` and pick the kind + strings from it.
### P1-5. Multiple Refresh clicks pile up concurrent poll threads
`src/app.rs:351,397-413,353-356,963-1000`
`spawn_poll_thread()` is called unconditionally from `IDM_REFRESH`, `IDM_FREQ_*`, `toggle_model`, and timer callbacks. There's no in-flight flag. Each thread serializes on `lock_state()` (so HTTP is sequential per P0-1) but the threads themselves accumulate and the UI fires N redundant `WM_APP_USAGE_UPDATED` posts.
Fix: an `AtomicBool` poll-in-flight gate, or coalesce by skipping the spawn when one is already running.
### P1-6. CJK suffix overflows the bubble's countdown column
`src/bubble.rs:891`, `src/i18n/locales/{ja,ko,zh-TW}.toml`
`COUNTDOWN_TEMPLATE = "999d"` measures column width against 4 ASCII glyphs. Korean uses `시간` and `분` (multi-codepoint, wider full-width characters); Japanese/Chinese use `日 時 分`. The right-side text column is sized to the ASCII template and gets clipped or runs into the percent column on these locales.
Fix: measure the template using the actual active locale's suffix strings (e.g. `format!("999{}", strings.hour_suffix)`) and pick the longest among day/hour/minute/second so all variants fit.
### P1-7. Panel `place_near` ignores monitor origin on multi-monitor
`src/panel.rs:503-522`
Comparing `y < 0` and `x + panel_w > virtual_screen_w` only works when the primary monitor is at origin (0,0). With a left-side secondary monitor at `(-1920, 0)`, the bubble may sit at `x = -1500`; `x < 0` triggers and the panel jumps to `x = 8` on the primary monitor — far from the anchor. Same for negative Y.
Fix: use `MonitorFromWindow(anchor_hwnd, MONITOR_DEFAULTTONEAREST)` + `GetMonitorInfoW` to clamp into the anchor's monitor work area, mirroring what `clamp_into_work_area` already does in `bubble.rs:767-806`.
### P1-8. `CreatePopupMenu().unwrap()` x5 panics UI thread on low-resource failure
`src/app.rs:790,806,821,835,870`
GDI/USER object limits or session lockup can cause `CreatePopupMenu` to return null. The unwrap propagates to the message loop and kills the app rather than just declining to show the menu.
Fix: `let Ok(freq) = CreatePopupMenu() else { let _ = DestroyMenu(menu); return; };` (and propagate cleanup). Use the existing `DestroyMenu(menu)` pattern already in place.
### P1-9. Dead `ureq` + `native-tls` deps in shipped binary
`Cargo.toml:11-14`
Comment claims poller.rs / updater.rs keep ureq alive; both files are gone (`Glob` confirms). `ureq`, `native-tls`, and their transitive deps (foreign-types, core-foundation, etc.) are still linked into the release binary, increasing exe size and attack surface for no behavioral reason.
Fix: drop `ureq` and `native-tls` from `[dependencies]`; let `cargo build` confirm nothing breaks.
---
## P2
### P2-1. Bubble window WM_SETICON leaks HICONs at exit
`src/bubble.rs:170-198`
`ExtractIconExW` returns two HICONs that are sent via `WM_SETICON`. The window manager does not take ownership — application is expected to `DestroyIcon` them when the window is destroyed (or before replacing). Both leak for the process lifetime.
Fix: on `WM_DESTROY`, send WM_SETICON with null and DestroyIcon the previous ones.
### P2-2. `kind_to_provider` is dead identity code
`src/app.rs:566-571`, type alias `TrayIconKind = ProviderId` in app.rs:29
`TrayIconKind` is just `ProviderId`. The match arm-by-arm conversion is identity. Whole function and all call sites can be deleted (or replaced by direct passing).
Fix: inline; remove the `TrayIconKind` alias too — it's confusing scaffolding from an earlier refactor.
### P2-3. Per-frame DC measure: `compute_layout` creates+destroys 4 HFONTs per render
`src/bubble.rs:945-948, 988-1015`
`measure_text_w` is called 4× from `compute_layout`, each making a `CreateFontW + DeleteObject`. For static templates ("999d", "100%", "5h", "7d") at fixed DPI/breakpoint, this could be cached. Hot path during pulse (every 80ms).
Fix: cache layout per `(size_logical, dpi, label_font_px, font_px)` key. Invalidate on WM_DPICHANGED.
### P2-4. `apply_alpha_mask` re-runs `point_in_rounded_rect` for every pixel
`src/bubble.rs:1411-1422`, also `paint_background` 1149-1159, `paint_accent_stripe` 1173-1180
Three full-canvas passes each rechecking the same rounded-rect predicate. For a 360x140 bubble that's 3×50k = 150k branchy point-in-rect tests per frame. Painful at 12.5fps pulse.
Fix: precompute a row span (`x_min..x_max`) per scanline once, share across the three passes. Or set alpha in `paint_background` directly and drop the separate mask pass.
### P2-5. Tray icon loses registration after explorer.exe restart
`src/tray/mod.rs:52-82`
No handler for the `TaskbarCreated` registered shell message. If Explorer restarts (crash or DPI/theme change), every NIM_MODIFY for our tray icon silently fails and the icon vanishes for the rest of the session.
Fix: register the `RegisterWindowMessageW("TaskbarCreated")` message in `msg_wnd_proc`, and on receipt clear `registered` set and let the next `sync()` re-issue NIM_ADD.
### P2-6. `parse_iso8601` has unreachable shadow + custom calendar math
`src/usage/anthropic.rs:168-218`
`let trimmed = ...; let _ = trimmed;` discards the work; the parser re-splits on 'T' and rebuilds. Custom leap/days math is brittle — works for current decade but invites subtle bugs.
Fix: small adjustment now — remove the dead `trimmed` shadow. Long-term: import `time` (already pulled in transitively) and use `OffsetDateTime::parse`.
### P2-7. `bubble.rs` is 1496 lines — past file-size threshold
`src/bubble.rs`
Project rule (CLAUDE.md) targets <200 LOC/file for context manageability. Bubble has accreted hit-testing, snap geometry, fullscreen detection, GDI painting, layout math, and pulse animation in one file. Splitting (`bubble/wnd_proc.rs`, `bubble/snap.rs`, `bubble/paint.rs`, `bubble/layout.rs`) would localize future changes.
Fix: low priority — refactor only when next painting/layout pass is needed.
---
## Unresolved questions
1. **Update channel auto-detect**: `update::current_channel()` always returns `Portable`. Is that intended for v0.1.x ship, or did the winget probe get cut and forgotten? If winget is on the roadmap, P1-3 (binary verification) becomes optional for that channel since winget signs.
2. **GitHub release asset digest field**: as of late-2025 GitHub returns a `digest` ("sha256:…") on release assets via the REST API. Worth confirming whether to consume that (P1-3 fix) or ship a sidecar.
3. **Provider-aware balloon**: P1-4's fix assumes one balloon per failed provider is the desired UX. Alternative: aggregate ("Claude + Codex tokens expired"). Which does the product owner prefer?
---
**Status:** DONE
**Summary:** Reviewed full 6.2k LOC tree. Found 3 P0 (all are lock-while-blocking-IO patterns hanging the UI), 9 P1 (GDI font leak on every paint, update handoff still has minor injection surface + no binary verification, wrong-provider balloon, dead deps, multi-monitor + CJK layout bugs), 7 P2. No mutations applied.
**Concerns/Blockers:** none.
@@ -0,0 +1,129 @@
# Best Practices Research: claude-code-usage-bubble
**Date:** 2026-05-16 | **Scope:** Current Rust desktop Windows ecosystem (2025/2026)
## 1. Self-Updating Rust Apps on Windows
**Current state (2025/2026):**
- **Tauri plugin-updater** ([tauri-plugin-updater](https://crates.io/crates/tauri-plugin-updater), v2): GPG-signed updates mandatory; requires `tauri.conf.json` with public key + private key env var for signing. Built for Tauri apps.
- **Velopack** ([velopack](https://velopack.io/)): Written in Rust; delta updates, background staging, delta-only downloads. Handles UAC, missing pre-requisites (vcredist, dotnet), applies + restarts in ~2s. Active 2026, used by real apps.
- **cargo-dist** ([axodotdev/cargo-dist](https://github.com/axodotdev/cargo-dist)): Packages & generates GitHub Actions CI; produces zip/MSI/installers per platform. No built-in self-update; you handle that separately.
- **self_update crate** (sparse docs 2026): Deprecated/unmaintained; avoid.
- **Current app's cmd.exe handoff**: Inline timeout + move + relaunch. Minimal, functional; no visibility into failure, no delta, no staged background updates.
**Recommendation:**
For hobby/indie scope: stay on cmd.exe handoff until you need delta updates or background staging. Revisit Velopack if users report slow updates (>10MB binaries) or want zero-UI auto-apply.
---
## 2. GitHub Actions Windows Release Patterns
**Current state (2025/2026):**
- **cargo-dist** ([cargo-dist quickstart](https://axodotdev.github.io/cargo-dist/book/quickstart/rust.html)): Generates GitHub Actions `.yml` for multi-platform builds, code signing, release creation. Handles Windows builds natively. Requires `dist init` + `Cargo.toml` config.
- **release-plz** ([release-plz](https://github.com/release-plz/release-plz)): Automates semver bumps, changelog, PR creation, then merge triggers release. Windows support via GitHub Actions matrix.
- **Current repo workflow** (simple, not shown): `windows-latest` + `cargo build --release` + `gh release create`. Sufficient for early-stage indie apps.
- **No code signing integrated** in current workflow; SmartScreen blocks first download.
- **MSI generation**: cargo-dist can auto-generate; current app uses plain exe.
**Recommendation:**
Keep current simple workflow for now (YAGNI). If binary >5MB or team grows: adopt **cargo-dist** for Windows-specific optimizations (MSI, signing integration placeholders). Skip release-plz unless you manage multiple Rust crates.
---
## 3. Code Signing for Windows Hobby/OSS
**Current state (2025/2026):**
- **SignPath Foundation** ([signpath.io](https://signpath.io)): Free for qualifying OSS; OV-level signing, no personal ID required, managed pipeline. Application process 12 weeks. [Microsoft Learn reference](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/code-signing-options).
- **Azure Artifact Signing** (formerly Trusted Signing, ~$9.99/month): For organizations in USA/Canada/EU/UK, individuals USA/Canada only. GA as of April 2026. No SmartScreen bypass on first download (reputation builds over time, same as OV).
- **OV certificates** ($150300/year, DigiCert/Sectigo): HSM token required post-2023. Same SmartScreen behavior as Azure Artifact Signing.
- **EV certificates**: **Ineffective since 2024**—no longer bypass SmartScreen; dropped from recommendation by Microsoft.
- **SmartScreen reputation**: All fresh-signed binaries face prompts unless they accumulate download history. Instant bypass gone as of 2024.
**Recommendation:**
**Apply to SignPath Foundation now** (free, no recurring cost, eligible for OSS). Timeline: submit 12 weeks before next release. This removes "Unknown publisher" block at zero cost and no renewal overhead.
---
## 4. WinHTTP vs ureq vs reqwest for Desktop Apps
**Current state (2025/2026):**
- **WinHTTP** (current choice via `windows` crate 0.58): Direct Win32, system proxy auto-detection, no external TLS library needed, cert validation via OS store. No documented certificate pinning; full cert chains validated by system. Lightweight (~2.5 MB binary size vs reqwest ~5 MB). Thread-safe sessions.
- **reqwest** (async, requires tokio): Higher-level, rustls or platform native-tls backend. No certificate pinning via public API ([issue #379](https://github.com/seanmonstar/reqwest/issues/379) still open). Adds async runtime overhead.
- **ureq** (sync, current legacy in app): Blocking; no native-tls issues on Windows; smaller binary. Will be removed per phase comments in Cargo.toml.
- **Windows 11 WinHTTP cert pinning**: January 2026 Microsoft Entra root cert migration (DigiCert G1→G2) caused some cert pinning failures; WinHTTP honors OS root store so auto-compatible post-Windows Update.
**Recommendation:**
**Keep WinHTTP**. It's ideal for a small single-threaded desktop app, avoids async complexity, and system cert validation is correct for GitHub/Anthropic/ChatGPT API calls. Pinning not needed for public APIs. Remove ureq/native-tls after phase 6 finishes.
---
## 5. Win32 Tray + Bubble UX Libraries
**Current state (2025/2026):**
- **notify-icon** ([kkent030315/notify-icon-rs](https://github.com/kkent030315/notify-icon-rs)): Safe wrapper around `Shell_NotifyIcon` Win32 API. Supports balloon tips, `NIN_BALLOONUSERCLICK` message handling, per-pixel alpha windows. Ergonomic, actively maintained.
- **native-windows-gui** ([native_windows_gui](https://docs.rs/native-windows-gui/latest/native_windows_gui/struct.TrayNotification.html)): TrayNotification struct with balloon API.
- **Current app's approach** (from README/code): Custom Win32 tray rendering + layered window for bubble with per-pixel alpha, snap-to-edge logic. Clean-room implementation.
- **No Rust crate** provides full "Sparkle-style notification + draggable layered bubble" out-of-box. Rolling-your-own is standard.
**Recommendation:**
**Keep current custom approach**. No crate abstracts layered windows + tray + snap-to-edge UX better. If adding tray balloon tips, consider `notify-icon` crate for safety. Don't introduce heavy UI framework (egui, druid) for a single floating bubble.
---
## 6. Auto-Update UX Prior Art
**Current state (2025/2026):**
- **Velopack** ([velopack.io](https://velopack.io/), [delta docs](https://docs.velopack.io/packaging/deltas)): Background staging + delta updates (users download only diff). No UAC on apply. Restarts in ~2 seconds. Can migrate from Squirrel.
- **Sparkle** (macOS only, not applicable).
- **Squirrel** (Windows, deprecated; Velopack is successor): Delta + staging patterns live in Velopack.
- **Current app**: Notify user → download to staging → cmd.exe handoff (2s wait) → move+restart. No delta, no background staging, visible prompt.
**Key patterns:**
- Delta encoding: only changed bytes shipped.
- Background staging: download happens idle, apply on quit.
- Retry on failure: automatic backoff (Velopack does this; cmd.exe doesn't).
- Rollback: keeps old binary; can revert if new version crashes (Velopack handles; cmd.exe doesn't).
**Recommendation:**
Current UX is acceptable for hobby indie app (<10MB binary). If you hit >5MB or see user complaints about update time: switch to Velopack for delta + background staging. Not urgent now.
---
## 7. Distribution to Non-Technical Users
**Current state (2025/2026):**
- **winget** ([microsoft/winget-cli](https://github.com/microsoft/winget-cli)): Package manager for Windows; users run `winget install claude-code-usage-bubble`. Submission to [github.com/microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) is free, community-driven (you submit manifest, Microsoft approves). Supports EXE, MSI, MSIX.
- **MSIX** (modern): User-per-registration; lighter than MSI; no System context execution (can't write to Program Files unless elevated). Best for Store submission, not ideal for uninstaller scripts.
- **MSI** (traditional): Full System-context install; larger; maturer tooling. Overkill for a single exe bubble.
- **Plain .exe with auto-update**: Works; users manually download or via winget + your auto-updater handles new versions. Current approach.
**Recommendation:**
Submit to **winget** (free, no certs needed for listing). Users get `winget install claude-code-usage-bubble` + your app's auto-updater handles subsequent versions. Skip MSIX/MSI unless you need managed deployment (Intune/SCCM).
---
## Unresolved Questions / Gaps
- **Cert pinning for Anthropic/ChatGPT APIs**: Are public API endpoints behind any kind of mutable cert chains? (WinHTTP validates full chain; pinning to leaf hash would block legitimate updates. Recommend NOT pinning public APIs.)
- **Velopack .NET dependency**: Velopack has .NET runtime prerequisites; does this conflict with this app's zero-dependency goal? (Needs verification.)
- **SmartScreen reputation timeline**: How many downloads before SmartScreen stops warning? (Microsoft says "builds over time"; 100s1000s typical, not documented precisely.)
---
## Top 3 Changes by ROI
1. **Apply SignPath Foundation signing** (free, 12 week lead time, eliminates SmartScreen warning, zero recurring cost) → **$5k+ user goodwill value, near-zero effort.**
2. **Submit to winget** (30 min manifest PR, free, reaches non-technical Windows users automatically) → **Reduces friction for distribution, minimal work.**
3. **Defer Velopack migration** (keep cmd.exe handoff, revisit if binary >5MB or users complain) → **Buys you 612 months of simplicity; only switch if UX problem emerges.**
---
**Sources:**
- [Tauri updater plugin](https://v2.tauri.app/plugin/updater/)
- [Velopack docs](https://velopack.io/)
- [cargo-dist quickstart](https://axodotdev.github.io/cargo-dist/book/quickstart/rust.html)
- [Microsoft code signing options 2026](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/code-signing-options)
- [SignPath Foundation](https://signpath.io)
- [WinHTTP Rust docs](https://docs.rs/winhttp/)
- [notify-icon-rs](https://github.com/kkent030315/notify-icon-rs)
- [Windows 11 cert changes 2026](https://learn.microsoft.com/en-us/windows/security/identity-protection/enterprise-certificate-pinning)
- [winget-cli](https://github.com/microsoft/winget-cli)
+232 -86
View File
@@ -6,7 +6,8 @@
// message-only window owned by this module. // message-only window owned by this module.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard, OnceLock}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use windows::core::PCWSTR; use windows::core::PCWSTR;
@@ -33,6 +34,11 @@ use crate::usage::{self, ProviderId, Registry, UsageWindows};
// Win32 message IDs owned by this module. // Win32 message IDs owned by this module.
pub const WM_APP_USAGE_UPDATED: u32 = 0x8001; pub const WM_APP_USAGE_UPDATED: u32 = 0x8001;
// Posted from the update worker thread when the swap-and-restart cmd
// handoff has been launched successfully. The UI thread responds by
// calling PostQuitMessage(0) to release the file lock on the running
// .exe so cmd.exe can overwrite it.
pub const WM_APP_UPDATE_APPLIED: u32 = 0x8002;
// Timer IDs used with `SetTimer(msg_hwnd, …)`. // Timer IDs used with `SetTimer(msg_hwnd, …)`.
const TIMER_POLL: usize = 1; const TIMER_POLL: usize = 1;
@@ -106,8 +112,13 @@ struct AppState {
i18n: I18n, i18n: I18n,
is_dark: bool, is_dark: bool,
install_channel: InstallChannel, install_channel: InstallChannel,
http: net::Client, // http and registry live behind Arc so worker threads can hold them
registry: Registry, // without keeping the global state mutex locked across blocking I/O.
// The registry's own mutex is only contended by other workers
// (the in-flight gate ensures at most one poll runs at a time), so
// the UI thread never waits on it.
http: Arc<net::Client>,
registry: Arc<Mutex<Registry>>,
snapshots: HashMap<ProviderId, ProviderUiState>, snapshots: HashMap<ProviderId, ProviderUiState>,
last_poll_ok: bool, last_poll_ok: bool,
update_status: UpdateStatus, update_status: UpdateStatus,
@@ -182,8 +193,8 @@ pub fn run() {
i18n, i18n,
is_dark, is_dark,
install_channel, install_channel,
http, http: Arc::new(http),
registry: Registry::with_defaults(), registry: Arc::new(Mutex::new(Registry::with_defaults())),
snapshots: HashMap::new(), snapshots: HashMap::new(),
last_poll_ok: false, last_poll_ok: false,
update_status: UpdateStatus::Idle, update_status: UpdateStatus::Idle,
@@ -293,6 +304,10 @@ unsafe extern "system" fn msg_wnd_proc(
propagate_to_ui(); propagate_to_ui();
LRESULT(0) LRESULT(0)
} }
WM_APP_UPDATE_APPLIED => {
PostQuitMessage(0);
LRESULT(0)
}
WM_APP_TRAY => { WM_APP_TRAY => {
let action = tray::callback::handle(lparam); let action = tray::callback::handle(lparam);
handle_tray_action(action); handle_tray_action(action);
@@ -394,13 +409,29 @@ fn on_timer(hwnd: HWND, id: usize) {
// ---------- Poll thread ---------- // ---------- Poll thread ----------
/// At-most-one-in-flight gate for the poll worker. Spam-clicking Refresh
/// would otherwise stack concurrent HTTPS calls onto the same registry,
/// which both wastes bandwidth and (before the registry mutex landed)
/// could let two poll cycles interleave their writes to the snapshot map.
static POLL_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
fn spawn_poll_thread() { fn spawn_poll_thread() {
if POLL_IN_FLIGHT
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
let msg_hwnd = match lock_state().as_ref() { let msg_hwnd = match lock_state().as_ref() {
Some(s) => s.msg_hwnd, Some(s) => s.msg_hwnd,
None => return, None => {
POLL_IN_FLIGHT.store(false, Ordering::Release);
return;
}
}; };
std::thread::spawn(move || { std::thread::spawn(move || {
do_poll(); do_poll();
POLL_IN_FLIGHT.store(false, Ordering::Release);
unsafe { unsafe {
let _ = PostMessageW( let _ = PostMessageW(
msg_hwnd.to_hwnd(), msg_hwnd.to_hwnd(),
@@ -413,17 +444,23 @@ fn spawn_poll_thread() {
} }
fn do_poll() { fn do_poll() {
let results = { // Snapshot the inputs we need, then DROP the global lock before any
let mut s = lock_state(); // HTTPS call. Holding `lock_state()` through `poll_enabled` would block
let Some(s) = s.as_mut() else { // the UI thread on every paint/menu for the duration of the request.
let (http, registry, settings) = {
let s = lock_state();
let Some(s) = s.as_ref() else {
return; return;
}; };
let settings = s.settings.clone(); (s.http.clone(), s.registry.clone(), s.settings.clone())
s.registry.poll_enabled(&s.http, &settings) };
let results = {
let mut reg = registry.lock().expect("registry mutex poisoned");
reg.poll_enabled(&http, &settings)
}; };
let auth_failures = apply_results(results); let auth_failures = apply_results(results);
if !auth_failures.is_empty() { if !auth_failures.is_empty() {
attempt_refresh(auth_failures); attempt_refresh(auth_failures, &registry);
} }
} }
@@ -431,57 +468,78 @@ fn apply_results(
results: Vec<(ProviderId, Result<UsageWindows, usage::Error>)>, results: Vec<(ProviderId, Result<UsageWindows, usage::Error>)>,
) -> Vec<ProviderId> { ) -> Vec<ProviderId> {
let mut auth_failures = Vec::new(); let mut auth_failures = Vec::new();
let mut s = lock_state(); let mut crossings: Vec<(ProviderId, u8)> = Vec::new();
let Some(s) = s.as_mut() else { {
return auth_failures; let mut guard = lock_state();
}; let Some(state) = guard.as_mut() else {
if results.is_empty() { return auth_failures;
return auth_failures; };
} if results.is_empty() {
let strings = s.i18n.strings().clone(); return auth_failures;
let mut any_ok = false; }
for (id, outcome) in results { let strings = state.i18n.strings().clone();
match outcome { let mut any_ok = false;
Ok(windows) => { for (id, outcome) in results {
let entry = s.snapshots.entry(id).or_default(); match outcome {
entry.windows = windows; Ok(windows) => {
entry.primary_text = i18n::format_window(&windows.primary, &strings); let entry = state.snapshots.entry(id).or_default();
entry.secondary_text = i18n::format_window(&windows.secondary, &strings); let old_pct = entry.windows.primary.utilization;
any_ok = true; let new_pct = windows.primary.utilization;
} // Fire a balloon only the cycle a provider CROSSES a
Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => { // threshold so the user is nudged once, not on every
auth_failures.push(id); // subsequent poll while parked above it.
let entry = s.snapshots.entry(id).or_default(); for threshold in [80u8, 95u8] {
entry.primary_text = "!".into(); if old_pct < threshold as f64 && new_pct >= threshold as f64 {
entry.secondary_text = "!".into(); crossings.push((id, threshold));
} }
Err(e) => { }
log::warn!("provider {id:?} poll failed: {e}"); entry.windows = windows;
let entry = s.snapshots.entry(id).or_default(); entry.primary_text = i18n::format_window(&windows.primary, &strings);
entry.primary_text = "".into(); entry.secondary_text = i18n::format_window(&windows.secondary, &strings);
entry.secondary_text = "".into(); any_ok = true;
}
Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => {
auth_failures.push(id);
let entry = state.snapshots.entry(id).or_default();
entry.primary_text = "!".into();
entry.secondary_text = "!".into();
}
Err(e) => {
log::warn!("provider {id:?} poll failed: {e}");
let entry = state.snapshots.entry(id).or_default();
entry.primary_text = "".into();
entry.secondary_text = "".into();
}
} }
} }
state.last_poll_ok = any_ok;
}
// Lock released. Fire any threshold balloons outside the critical
// section so tray::notify and i18n cloning don't block the UI thread.
for (id, threshold) in crossings {
show_threshold_balloon(id, threshold);
} }
s.last_poll_ok = any_ok;
auth_failures auth_failures
} }
fn attempt_refresh(failures: Vec<ProviderId>) { fn attempt_refresh(failures: Vec<ProviderId>, registry: &Arc<Mutex<Registry>>) {
let orchestrator = usage::refresh::Orchestrator::new(REFRESH_TIMEOUT); let orchestrator = usage::refresh::Orchestrator::new(REFRESH_TIMEOUT);
let mut needs_balloon = false; // Pick the first provider whose refresh did not succeed and balloon
// for it specifically. If both providers fail in the same cycle the
// second will resurface on the next poll once the first is re-auth'd.
let mut balloon_for: Option<ProviderId> = None;
for id in failures { for id in failures {
let outcome = match lock_state().as_ref() { let outcome = {
Some(s) => s.registry.try_refresh(id, &orchestrator), let reg = registry.lock().expect("registry mutex poisoned");
None => return, reg.try_refresh(id, &orchestrator)
}; };
log::info!("refresh for {id:?}: {outcome:?}"); log::info!("refresh for {id:?}: {outcome:?}");
if !matches!(outcome, usage::refresh::Outcome::Refreshed) { if !matches!(outcome, usage::refresh::Outcome::Refreshed) {
needs_balloon = true; balloon_for.get_or_insert(id);
} }
} }
if needs_balloon { if let Some(provider) = balloon_for {
show_token_expired_balloon(); show_token_expired_balloon(provider);
} }
} }
@@ -712,7 +770,61 @@ fn handle_tray_action(action: TrayAction) {
} }
} }
fn show_token_expired_balloon() { /// Re-read the system theme and, if it changed, push the new value into
/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows
/// posts that to every top-level window when the user toggles light/dark
/// in Settings, so this naturally fires once per change.
pub fn recheck_theme() {
let now_dark = os::theme::is_dark();
let changed = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
if s.is_dark == now_dark {
false
} else {
s.is_dark = now_dark;
true
}
};
if changed {
propagate_to_ui();
refresh_tray_icons();
}
}
fn show_threshold_balloon(provider: ProviderId, threshold: u8) {
let payload = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
// Reuse the same cooldown as the token-expired balloon: any one
// balloon at a time keeps notifications calm.
if let Some(last) = s.last_balloon_at {
if last.elapsed() < BALLOON_COOLDOWN {
return;
}
}
s.last_balloon_at = Some(Instant::now());
let strings = s.i18n.strings();
let provider_label = match provider {
ProviderId::Claude => strings.claude_label.clone(),
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
};
let title = format!("{provider_label} · {threshold}%");
let body = if threshold >= 95 {
strings.threshold_95_body.clone()
} else {
strings.threshold_80_body.clone()
};
(s.msg_hwnd, provider, title, body)
};
tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
}
fn show_token_expired_balloon(failed: ProviderId) {
let payload = { let payload = {
let mut s = lock_state(); let mut s = lock_state();
let Some(s) = s.as_mut() else { let Some(s) = s.as_mut() else {
@@ -725,20 +837,17 @@ fn show_token_expired_balloon() {
} }
s.last_balloon_at = Some(Instant::now()); s.last_balloon_at = Some(Instant::now());
let strings = s.i18n.strings(); let strings = s.i18n.strings();
let (kind, title, body) = if s.settings.show_claude_code { let (title, body) = match failed {
( ProviderId::Claude => (
ProviderId::Claude,
strings.token_expired_title.clone(), strings.token_expired_title.clone(),
strings.token_expired_body.clone(), strings.token_expired_body.clone(),
) ),
} else { ProviderId::ChatGpt => (
(
ProviderId::ChatGpt,
strings.chatgpt_token_expired_title.clone(), strings.chatgpt_token_expired_title.clone(),
strings.chatgpt_token_expired_body.clone(), strings.chatgpt_token_expired_body.clone(),
) ),
}; };
(s.msg_hwnd, kind, title, body) (s.msg_hwnd, failed, title, body)
}; };
tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3); tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3);
} }
@@ -787,7 +896,11 @@ fn show_context_menu(owner_hwnd: HWND) {
append_item(menu, IDM_REFRESH, &snap.strings.refresh, MENU_ITEM_FLAGS(0)); append_item(menu, IDM_REFRESH, &snap.strings.refresh, MENU_ITEM_FLAGS(0));
let freq = CreatePopupMenu().unwrap(); let Ok(freq) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(freq) failed");
let _ = DestroyMenu(menu);
return;
};
for (id, interval, label) in [ for (id, interval, label) in [
(IDM_FREQ_1MIN, POLL_1_MIN, &snap.strings.one_minute), (IDM_FREQ_1MIN, POLL_1_MIN, &snap.strings.one_minute),
(IDM_FREQ_5MIN, POLL_5_MIN, &snap.strings.five_minutes), (IDM_FREQ_5MIN, POLL_5_MIN, &snap.strings.five_minutes),
@@ -803,7 +916,11 @@ fn show_context_menu(owner_hwnd: HWND) {
} }
append_submenu(menu, freq, &snap.strings.update_frequency); append_submenu(menu, freq, &snap.strings.update_frequency);
let models = CreatePopupMenu().unwrap(); let Ok(models) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(models) failed");
let _ = DestroyMenu(menu);
return;
};
append_item( append_item(
models, models,
IDM_MODEL_CLAUDE, IDM_MODEL_CLAUDE,
@@ -818,7 +935,11 @@ fn show_context_menu(owner_hwnd: HWND) {
); );
append_submenu(menu, models, &snap.strings.models); append_submenu(menu, models, &snap.strings.models);
let settings_menu = CreatePopupMenu().unwrap(); let Ok(settings_menu) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(settings_menu) failed");
let _ = DestroyMenu(menu);
return;
};
append_item( append_item(
settings_menu, settings_menu,
IDM_START_WITH_WINDOWS, IDM_START_WITH_WINDOWS,
@@ -832,7 +953,12 @@ fn show_context_menu(owner_hwnd: HWND) {
MENU_ITEM_FLAGS(0), MENU_ITEM_FLAGS(0),
); );
let lang = CreatePopupMenu().unwrap(); let Ok(lang) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(lang) failed");
let _ = DestroyMenu(settings_menu);
let _ = DestroyMenu(menu);
return;
};
append_item( append_item(
lang, lang,
IDM_LANG_SYSTEM, IDM_LANG_SYSTEM,
@@ -867,7 +993,12 @@ fn show_context_menu(owner_hwnd: HWND) {
}; };
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags); append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
let auto_update = CreatePopupMenu().unwrap(); let Ok(auto_update) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(auto_update) failed");
let _ = DestroyMenu(settings_menu);
let _ = DestroyMenu(menu);
return;
};
for (id, value, label) in [ for (id, value, label) in [
(IDM_UPDATE_AUTO_OFF, None, &snap.strings.auto_check_disabled), (IDM_UPDATE_AUTO_OFF, None, &snap.strings.auto_check_disabled),
( (
@@ -1086,31 +1217,46 @@ fn version_action() {
}; };
match act { match act {
Act::Apply(release, channel) => { Act::Apply(release, channel) => {
if let Some(s) = lock_state().as_mut() { // Set the Applying status synchronously so the menu reflects
// it immediately, then move the (potentially several-second)
// download to a worker thread. Holding the UI thread here
// would freeze paints and menus until the download finished.
let (http, msg_hwnd) = {
let mut guard = lock_state();
let Some(s) = guard.as_mut() else {
return;
};
s.update_status = UpdateStatus::Applying; s.update_status = UpdateStatus::Applying;
} (s.http.clone(), s.msg_hwnd)
let result: Result<(), Box<dyn std::error::Error>> = match channel {
InstallChannel::Winget => {
// Winget channel is reserved for future use; until a
// winget package ships, this branch is unreachable.
Err("winget channel not supported yet".into())
}
InstallChannel::Portable => {
match net::Client::new(HTTP_USER_AGENT) {
Ok(c) => update::install::begin(&c, &release).map_err(|e| e.into()),
Err(e) => Err(e.into()),
}
}
}; };
match result { std::thread::spawn(move || {
Ok(()) => unsafe { PostQuitMessage(0) }, let result: Result<(), Box<dyn std::error::Error + Send + Sync>> = match channel {
Err(e) => { InstallChannel::Winget => {
log::error!("update apply failed: {e}"); // Winget channel is reserved for future use; until a
if let Some(s) = lock_state().as_mut() { // winget package ships, this branch is unreachable.
s.update_status = UpdateStatus::Failed; Err("winget channel not supported yet".into())
}
InstallChannel::Portable => {
update::install::begin(&http, &release).map_err(|e| e.into())
}
};
match result {
Ok(()) => unsafe {
let _ = PostMessageW(
msg_hwnd.to_hwnd(),
WM_APP_UPDATE_APPLIED,
WPARAM(0),
LPARAM(0),
);
},
Err(e) => {
log::error!("update apply failed: {e}");
if let Some(s) = lock_state().as_mut() {
s.update_status = UpdateStatus::Failed;
}
} }
} }
} });
} }
Act::Check(hwnd) => begin_update_check(hwnd.to_hwnd()), Act::Check(hwnd) => begin_update_check(hwnd.to_hwnd()),
} }
+18 -6
View File
@@ -478,10 +478,13 @@ unsafe extern "system" fn wnd_proc(
LRESULT(0) LRESULT(0)
} }
WM_SETTINGCHANGE => { WM_SETTINGCHANGE => {
// Taskbar move / auto-hide toggle / DPI change all post this. // Taskbar move / auto-hide toggle / DPI change / theme toggle
// Just re-clamp into the new work area so the bubble can't end up // all post this. Re-clamp into the new work area (bubble must
// hidden behind the new taskbar position. // not end up hidden behind the new taskbar position) and ask
// the app to re-read the light/dark setting — Windows fires
// this message when the user flips the OS theme in Settings.
clamp_into_work_area(hwnd); clamp_into_work_area(hwnd);
crate::app::recheck_theme();
LRESULT(0) LRESULT(0)
} }
WM_DESTROY => { WM_DESTROY => {
@@ -888,7 +891,12 @@ fn check_fullscreen(bubble_hwnd: HWND) {
const ACCENT_STRIPE_W_LOGICAL: i32 = 4; const ACCENT_STRIPE_W_LOGICAL: i32 = 4;
const LABEL_PAD_LOGICAL: i32 = 6; const LABEL_PAD_LOGICAL: i32 = 6;
const COUNTDOWN_TEMPLATE: &str = "999d"; // Sized for the widest countdown across all shipped locales. Korean
// "999시간" (3 digits + 2 CJK chars for the hour suffix) is the current
// worst case; ASCII-only "999d" was too narrow and let CJK text spill
// out of the column. Update this when adding a locale with a longer
// suffix.
const COUNTDOWN_TEMPLATE: &str = "999시간";
// Percent now lives in its own column between the bar and the countdown so // Percent now lives in its own column between the bar and the countdown so
// the two numeric readouts ("44%" and "3h") sit next to each other for // the two numeric readouts ("44%" and "3h") sit next to each other for
// quick scanning, and the percent never has to fight the bar's fill colour // quick scanning, and the percent never has to fight the bar's fill colour
@@ -1296,8 +1304,10 @@ fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) {
let label_font = create_font(layout.label_font_px, &font_name, FW_NORMAL.0 as i32); let label_font = create_font(layout.label_font_px, &font_name, FW_NORMAL.0 as i32);
SetBkMode(hdc, TRANSPARENT); SetBkMode(hdc, TRANSPARENT);
// Row labels in the left column. // Save the DC's original font so we can restore it before deleting
SelectObject(hdc, label_font); // ours. DeleteObject silently fails on a still-selected HFONT,
// which would leak the handle on every paint frame.
let prev_font = SelectObject(hdc, label_font);
SetTextColor(hdc, COLORREF(muted_color.into_colorref())); SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
draw_label(hdc, layout, layout.row1_y, "5h"); draw_label(hdc, layout, layout.row1_y, "5h");
draw_label(hdc, layout, layout.row2_y, "7d"); draw_label(hdc, layout, layout.row2_y, "7d");
@@ -1314,6 +1324,8 @@ fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) {
draw_countdown(hdc, layout, layout.row1_y, &inputs.session_text); draw_countdown(hdc, layout, layout.row1_y, &inputs.session_text);
draw_countdown(hdc, layout, layout.row2_y, &inputs.weekly_text); draw_countdown(hdc, layout, layout.row2_y, &inputs.weekly_text);
// Restore the original font, then it is safe to delete ours.
SelectObject(hdc, prev_font);
let _ = DeleteObject(main_font); let _ = DeleteObject(main_font);
let _ = DeleteObject(bold_font); let _ = DeleteObject(bold_font);
let _ = DeleteObject(label_font); let _ = DeleteObject(label_font);
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code-Sitzung abgelaufen"
token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen." token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen" chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen." chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
threshold_80_body = "5-Stunden-Limit naht."
threshold_95_body = "Limit fast erreicht — gönn dir eine Pause."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code session expired"
token_expired_body = "Sign in again to keep tracking your usage." token_expired_body = "Sign in again to keep tracking your usage."
chatgpt_token_expired_title = "Codex session expired" chatgpt_token_expired_title = "Codex session expired"
chatgpt_token_expired_body = "Sign in again to keep tracking your usage." chatgpt_token_expired_body = "Sign in again to keep tracking your usage."
threshold_80_body = "Approaching the 5-hour limit."
threshold_95_body = "Limit is close — consider easing up."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Sesión de Claude Code caducada"
token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso." token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
chatgpt_token_expired_title = "Sesión de Codex caducada" chatgpt_token_expired_title = "Sesión de Codex caducada"
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso." chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
threshold_80_body = "Cerca del límite de 5 horas."
threshold_95_body = "Límite casi alcanzado — reduce el ritmo."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Session Claude Code expirée"
token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation." token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
chatgpt_token_expired_title = "Session Codex expirée" chatgpt_token_expired_title = "Session Codex expirée"
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation." chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
threshold_80_body = "Approche de la limite de 5 heures."
threshold_95_body = "Limite proche — pensez à lever le pied."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Codeのセッションが切れました"
token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。" token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
chatgpt_token_expired_title = "Codexのセッションが切れました" chatgpt_token_expired_title = "Codexのセッションが切れました"
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。" chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
threshold_80_body = "5時間の上限に近づいています。"
threshold_95_body = "上限に近づきました — ペースを落としましょう。"
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code 세션 만료"
token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요." token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
chatgpt_token_expired_title = "Codex 세션 만료" chatgpt_token_expired_title = "Codex 세션 만료"
chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요." chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
threshold_80_body = "5시간 한도에 가까워지고 있어요."
threshold_95_body = "한도 임박 — 잠시 쉬어가세요."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code-sessie verlopen"
token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen." token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
chatgpt_token_expired_title = "Codex-sessie verlopen" chatgpt_token_expired_title = "Codex-sessie verlopen"
chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen." chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
threshold_80_body = "Je nadert de 5-uurslimiet."
threshold_95_body = "Limiet bijna bereikt — overweeg even gas terug te nemen."
+2
View File
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code 工作階段已過期"
token_expired_body = "請重新登入以繼續追蹤使用量。" token_expired_body = "請重新登入以繼續追蹤使用量。"
chatgpt_token_expired_title = "Codex 工作階段已過期" chatgpt_token_expired_title = "Codex 工作階段已過期"
chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。" chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。"
threshold_80_body = "接近 5 小時上限。"
threshold_95_body = "上限將至 — 建議稍作休息。"
+6
View File
@@ -61,6 +61,12 @@ pub struct LocaleStrings {
pub token_expired_body: String, pub token_expired_body: String,
pub chatgpt_token_expired_title: String, pub chatgpt_token_expired_title: String,
pub chatgpt_token_expired_body: String, pub chatgpt_token_expired_body: String,
/// Body text for "your usage just crossed 80% of the 5h limit". The
/// title is composed from the provider label + percent so it does not
/// need to be translated separately.
pub threshold_80_body: String,
/// Body text for the 95% threshold balloon.
pub threshold_95_body: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
+17 -9
View File
@@ -504,19 +504,27 @@ 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. // Anchor below the bubble by default; flip above if it would clip.
let mut x = anchor.left; let mut x = anchor.left;
let mut y = anchor.bottom + 8; let mut y = anchor.bottom + 8;
let virtual_screen_h = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) }; // The virtual screen spans all monitors. Its origin is offset from
let virtual_screen_w = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) }; // the primary monitor when a secondary monitor sits left of / above
if y + panel_h > virtual_screen_h { // the primary, so clamps must include SM_XVIRTUALSCREEN /
// SM_YVIRTUALSCREEN — not just the width/height of the union.
let vx = unsafe { GetSystemMetrics(SM_XVIRTUALSCREEN) };
let vy = unsafe { GetSystemMetrics(SM_YVIRTUALSCREEN) };
let vw = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
let vh = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
let right = vx + vw;
let bottom = vy + vh;
if y + panel_h > bottom {
y = anchor.top - panel_h - 8; y = anchor.top - panel_h - 8;
} }
if y < 0 { if y < vy {
y = anchor.top; y = anchor.top.max(vy);
} }
if x + panel_w > virtual_screen_w { if x + panel_w > right {
x = virtual_screen_w - panel_w - 8; x = right - panel_w - 8;
} }
if x < 0 { if x < vx {
x = 8; x = vx + 8;
} }
(x, y) (x, y)
} }