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.
This commit is contained in:
tiennm99 committed 2026-05-16 13:51:16 +07:00
1 parent 0f3acd40d4
commit 1ef1bfa7b2
5 files changed
+161 -70

No files matched your search

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.4"
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.4"
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"
+130 -56
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);
} }
} }
@@ -467,21 +504,24 @@ fn apply_results(
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 +752,7 @@ fn handle_tray_action(action: TrayAction) {
} }
} }
fn show_token_expired_balloon() { 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 +765,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 +824,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 +844,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 +863,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 +881,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 +921,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 +1145,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()),
} }
+12 -3
View File
@@ -888,7 +888,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 +1301,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 +1321,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);
+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)
} }