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

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

- Ported verbatim: poller, updater, tray_icon, theme, localization,
  diagnose, models (~2,700 LOC)
- Original: bubble (circular layered window, drag-anywhere via
  WM_NCHITTEST+HTCAPTION, snap-to-edge, Ctrl+Wheel resize, auto-hide
  on fullscreen), panel (expanded 5h/7d view), app (orchestrator,
  single-instance mutex, polling thread, context menu, dual-bubble
  lifecycle), settings (settings.json persistence)
- Cargo.toml features cover Win32 GDI, HiDpi, Registry, Threading,
  Shell, WindowsAndMessaging, and KeyboardAndMouse
This commit is contained in:
2026-05-15 21:27:31 +07:00
commit c0f3e3f860
42 changed files with 7046 additions and 0 deletions
+1354
View File
File diff suppressed because it is too large Load Diff
+817
View File
@@ -0,0 +1,817 @@
// Floating circular bubble window.
//
// Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE.
// Shape is achieved via per-pixel alpha (alpha=0 outside the circle) and confirmed
// via WM_NCHITTEST returning HTCAPTION inside the circle, HTTRANSPARENT outside.
// The OS handles drag automatically because HTCAPTION inside the circle puts the
// click into the system move loop.
use std::collections::HashMap;
use std::ffi::c_void;
use std::sync::{Mutex, MutexGuard, OnceLock};
use windows::core::PCWSTR;
use windows::Win32::Foundation::*;
use windows::Win32::Graphics::Gdi::*;
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::HiDpi::*;
use windows::Win32::UI::Shell::ExtractIconExW;
use windows::Win32::UI::WindowsAndMessaging::*;
use crate::diagnose;
use crate::native_interop::{wide_str, Color, TIMER_FULLSCREEN_CHECK};
use crate::tray_icon::TrayIconKind;
// ---------- Public types & API ----------
pub const MIN_BUBBLE_SIZE: i32 = 32;
pub const MAX_BUBBLE_SIZE: i32 = 128;
pub const DEFAULT_BUBBLE_SIZE: i32 = 56;
const RESIZE_STEP: i32 = 8;
const SNAP_ZONE_LOGICAL: i32 = 12;
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
const FULLSCREEN_POLL_MS: u32 = 1500;
pub struct BubbleConfig {
pub model: TrayIconKind,
pub size_logical: i32,
pub position: Option<(i32, i32)>,
pub percent: Option<f64>,
pub is_dark: bool,
}
/// Register the bubble window class. Idempotent; safe to call before the first
/// `create()` from the UI thread.
pub fn register_class() {
static REGISTERED: OnceLock<()> = OnceLock::new();
REGISTERED.get_or_init(|| unsafe {
let class_w = wide_str(CLASS_NAME);
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(wnd_proc),
hInstance: HINSTANCE(hinstance.0),
hCursor: LoadCursorW(HINSTANCE::default(), IDC_SIZEALL).unwrap_or_default(),
hbrBackground: HBRUSH(std::ptr::null_mut()),
lpszClassName: PCWSTR::from_raw(class_w.as_ptr()),
..Default::default()
};
if RegisterClassExW(&wc) == 0 {
diagnose::log("bubble RegisterClassExW returned 0");
}
});
}
/// Create a bubble window. Returns the HWND. The caller (app::run) owns the
/// message-loop dispatch.
pub fn create(config: BubbleConfig) -> HWND {
register_class();
let initial_size_logical = config
.size_logical
.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
let hwnd = unsafe {
let class_w = wide_str(CLASS_NAME);
let title_w = wide_str("Claude Code Usage Bubble");
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
let dpi = primary_dpi();
let size_px = scale_to_dpi(initial_size_logical, dpi);
let (x, y) =
config
.position
.unwrap_or_else(|| default_position(size_px, config.model));
CreateWindowExW(
WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE,
PCWSTR::from_raw(class_w.as_ptr()),
PCWSTR::from_raw(title_w.as_ptr()),
WS_POPUP,
x,
y,
size_px,
size_px,
HWND::default(),
HMENU::default(),
hinstance,
None,
)
.unwrap_or_default()
};
if hwnd == HWND::default() {
diagnose::log("bubble CreateWindowExW failed");
return hwnd;
}
// Embed app icon in window non-client (mostly cosmetic; toolwindows
// don't show captions but the icon helps in dev tooling).
unsafe {
let mut large_icon = HICON::default();
let mut small_icon = HICON::default();
let mut exe = [0u16; 260];
GetModuleFileNameW(HMODULE::default(), &mut exe);
let _ = ExtractIconExW(
PCWSTR::from_raw(exe.as_ptr()),
0,
Some(&mut large_icon),
Some(&mut small_icon),
1,
);
if !large_icon.is_invalid() {
let _ = SendMessageW(
hwnd,
WM_SETICON,
WPARAM(ICON_BIG as usize),
LPARAM(large_icon.0 as isize),
);
}
if !small_icon.is_invalid() {
let _ = SendMessageW(
hwnd,
WM_SETICON,
WPARAM(ICON_SMALL as usize),
LPARAM(small_icon.0 as isize),
);
}
}
let dpi = unsafe { GetDpiForWindow(hwnd).max(96) };
lock_bubbles().insert(
hwnd.0 as isize,
BubbleState {
model: config.model,
size_logical: initial_size_logical,
dpi,
percent: config.percent,
is_dark: config.is_dark,
drag_start_pos: None,
hidden_by_fullscreen: false,
user_hidden: false,
},
);
render(hwnd);
unsafe {
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
// Periodic fullscreen-foreground check.
SetTimer(hwnd, TIMER_FULLSCREEN_CHECK, FULLSCREEN_POLL_MS, None);
}
hwnd
}
pub fn destroy(hwnd: HWND) {
unsafe {
let _ = KillTimer(hwnd, TIMER_FULLSCREEN_CHECK);
let _ = DestroyWindow(hwnd);
}
}
pub fn update_percentage(hwnd: HWND, percent: Option<f64>) {
{
let mut bubbles = lock_bubbles();
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
return;
};
b.percent = percent;
}
render(hwnd);
}
pub fn update_dark_mode(hwnd: HWND, is_dark: bool) {
{
let mut bubbles = lock_bubbles();
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
return;
};
b.is_dark = is_dark;
}
render(hwnd);
}
pub fn set_user_visible(hwnd: HWND, visible: bool) {
{
let mut bubbles = lock_bubbles();
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
return;
};
b.user_hidden = !visible;
}
unsafe {
let cmd = if visible { SW_SHOWNOACTIVATE } else { SW_HIDE };
let _ = ShowWindow(hwnd, cmd);
}
}
pub fn position(hwnd: HWND) -> Option<(i32, i32)> {
let mut r = RECT::default();
unsafe {
if GetWindowRect(hwnd, &mut r).is_err() {
return None;
}
}
Some((r.left, r.top))
}
pub fn model(hwnd: HWND) -> Option<TrayIconKind> {
lock_bubbles()
.get(&(hwnd.0 as isize))
.map(|b| b.model)
}
pub fn size_logical(hwnd: HWND) -> Option<i32> {
lock_bubbles()
.get(&(hwnd.0 as isize))
.map(|b| b.size_logical)
}
// ---------- State ----------
struct BubbleState {
model: TrayIconKind,
size_logical: i32,
dpi: u32,
percent: Option<f64>,
is_dark: bool,
drag_start_pos: Option<(i32, i32)>,
hidden_by_fullscreen: bool,
user_hidden: bool,
}
fn bubbles() -> &'static Mutex<HashMap<isize, BubbleState>> {
static BUBBLES: OnceLock<Mutex<HashMap<isize, BubbleState>>> = OnceLock::new();
BUBBLES.get_or_init(|| Mutex::new(HashMap::new()))
}
fn lock_bubbles() -> MutexGuard<'static, HashMap<isize, BubbleState>> {
bubbles().lock().expect("bubble state mutex poisoned")
}
// ---------- Window proc ----------
unsafe extern "system" fn wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
WM_NCHITTEST => hit_test(hwnd, lparam),
WM_ENTERSIZEMOVE => {
let mut r = RECT::default();
let _ = GetWindowRect(hwnd, &mut r);
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
b.drag_start_pos = Some((r.left, r.top));
}
LRESULT(0)
}
WM_EXITSIZEMOVE => {
// WM_NCLBUTTONUP isn't reliably delivered for HTCAPTION drags; instead
// we infer click-vs-drag from whether the window actually moved.
let start = {
let mut bubbles = lock_bubbles();
let start = bubbles
.get(&(hwnd.0 as isize))
.and_then(|b| b.drag_start_pos);
if let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) {
b.drag_start_pos = None;
}
start
};
let mut current = RECT::default();
let _ = GetWindowRect(hwnd, &mut current);
let moved = match start {
Some((sx, sy)) => (current.left - sx).abs() >= 3 || (current.top - sy).abs() >= 3,
None => false,
};
if moved {
snap_to_edge(hwnd);
if let Some(model) = model(hwnd) {
if let Some(pos) = position(hwnd) {
crate::app::on_bubble_moved(model, pos);
}
}
} else if let Some(model) = model(hwnd) {
crate::app::on_bubble_click(hwnd, model);
}
LRESULT(0)
}
WM_NCRBUTTONUP => {
if let Some(model) = model(hwnd) {
let pt = lparam_to_point(lparam);
crate::app::on_bubble_right_click(hwnd, model, pt);
}
LRESULT(0)
}
WM_MOUSEWHEEL => {
let modifiers = (wparam.0 & 0xFFFF) as u32;
const MK_CONTROL: u32 = 0x0008;
if modifiers & MK_CONTROL != 0 {
let delta = ((wparam.0 >> 16) & 0xFFFF) as i16;
let step = if delta > 0 { RESIZE_STEP } else { -RESIZE_STEP };
resize_step(hwnd, step);
LRESULT(0)
} else {
DefWindowProcW(hwnd, msg, wparam, lparam)
}
}
WM_DPICHANGED => {
let new_dpi = ((wparam.0 >> 16) & 0xFFFF) as u32;
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
b.dpi = new_dpi;
}
let rect_ptr = lparam.0 as *const RECT;
if !rect_ptr.is_null() {
let r = *rect_ptr;
let _ = SetWindowPos(
hwnd,
HWND::default(),
r.left,
r.top,
r.right - r.left,
r.bottom - r.top,
SWP_NOZORDER | SWP_NOACTIVATE,
);
}
render(hwnd);
LRESULT(0)
}
WM_TIMER => {
if wparam.0 == TIMER_FULLSCREEN_CHECK {
check_fullscreen(hwnd);
}
LRESULT(0)
}
WM_COMMAND => {
crate::app::on_menu_command(wparam.0 as u32, hwnd);
LRESULT(0)
}
WM_DESTROY => {
lock_bubbles().remove(&(hwnd.0 as isize));
LRESULT(0)
}
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}
fn hit_test(hwnd: HWND, lparam: LPARAM) -> LRESULT {
let pt = lparam_to_point(lparam);
let mut r = RECT::default();
unsafe {
if GetWindowRect(hwnd, &mut r).is_err() {
return LRESULT(HTNOWHERE as isize);
}
}
let cx = (r.left + r.right) / 2;
let cy = (r.top + r.bottom) / 2;
let radius = ((r.right - r.left) / 2).max(1);
let dx = pt.x - cx;
let dy = pt.y - cy;
if dx * dx + dy * dy <= radius * radius {
LRESULT(HTCAPTION as isize)
} else {
LRESULT(HTTRANSPARENT as isize)
}
}
fn lparam_to_point(lparam: LPARAM) -> POINT {
let lo = (lparam.0 & 0xFFFF) as i16 as i32;
let hi = ((lparam.0 >> 16) & 0xFFFF) as i16 as i32;
POINT { x: lo, y: hi }
}
// ---------- Resize / snap ----------
fn resize_step(hwnd: HWND, delta: i32) {
let (new_logical, dpi) = {
let mut bubbles = lock_bubbles();
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
return;
};
let new_logical = (b.size_logical + delta).clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
if new_logical == b.size_logical {
return;
}
b.size_logical = new_logical;
(new_logical, b.dpi)
};
let size_px = scale_to_dpi(new_logical, dpi);
let mut r = RECT::default();
unsafe {
let _ = GetWindowRect(hwnd, &mut r);
// Resize centered on existing center.
let cx = (r.left + r.right) / 2;
let cy = (r.top + r.bottom) / 2;
let new_x = cx - size_px / 2;
let new_y = cy - size_px / 2;
let _ = SetWindowPos(
hwnd,
HWND::default(),
new_x,
new_y,
size_px,
size_px,
SWP_NOZORDER | SWP_NOACTIVATE,
);
}
render(hwnd);
if let Some(m) = model(hwnd) {
crate::app::on_bubble_resized(m, new_logical);
}
}
fn snap_to_edge(hwnd: HWND) {
let dpi = lock_bubbles()
.get(&(hwnd.0 as isize))
.map(|b| b.dpi)
.unwrap_or(96);
let snap_zone = scale_to_dpi(SNAP_ZONE_LOGICAL, dpi);
let mut r = RECT::default();
let monitor;
unsafe {
if GetWindowRect(hwnd, &mut r).is_err() {
return;
}
monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
}
if monitor.is_invalid() {
return;
}
let mut info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
unsafe {
if !GetMonitorInfoW(monitor, &mut info).as_bool() {
return;
}
}
let wa = info.rcWork;
let w = r.right - r.left;
let h = r.bottom - r.top;
let mut nx = r.left;
let mut ny = r.top;
if (nx - wa.left).abs() < snap_zone {
nx = wa.left;
} else if (wa.right - (nx + w)).abs() < snap_zone {
nx = wa.right - w;
}
if (ny - wa.top).abs() < snap_zone {
ny = wa.top;
} else if (wa.bottom - (ny + h)).abs() < snap_zone {
ny = wa.bottom - h;
}
// Clamp into the work area in any case (so the bubble can't be lost off-screen).
nx = nx.clamp(wa.left, wa.right - w);
ny = ny.clamp(wa.top, wa.bottom - h);
if nx != r.left || ny != r.top {
unsafe {
let _ = SetWindowPos(
hwnd,
HWND::default(),
nx,
ny,
0,
0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE,
);
}
}
}
// ---------- Fullscreen detection ----------
fn check_fullscreen(bubble_hwnd: HWND) {
let fg = unsafe { GetForegroundWindow() };
if fg == HWND::default() || fg == bubble_hwnd {
return;
}
let mut fr = RECT::default();
unsafe {
if GetWindowRect(fg, &mut fr).is_err() {
return;
}
}
let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) };
if monitor.is_invalid() {
return;
}
let mut info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
let ok = unsafe { GetMonitorInfoW(monitor, &mut info).as_bool() };
if !ok {
return;
}
let mr = info.rcMonitor;
let is_fullscreen =
fr.left <= mr.left && fr.top <= mr.top && fr.right >= mr.right && fr.bottom >= mr.bottom;
let (was_hidden_by_fs, user_hidden) = {
let bubbles = lock_bubbles();
let Some(b) = bubbles.get(&(bubble_hwnd.0 as isize)) else {
return;
};
(b.hidden_by_fullscreen, b.user_hidden)
};
if is_fullscreen && !was_hidden_by_fs {
unsafe {
let _ = ShowWindow(bubble_hwnd, SW_HIDE);
}
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
b.hidden_by_fullscreen = true;
}
} else if !is_fullscreen && was_hidden_by_fs {
if !user_hidden {
unsafe {
let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE);
}
}
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
b.hidden_by_fullscreen = false;
}
}
}
// ---------- Painting ----------
fn render(hwnd: HWND) {
let (size_logical, dpi, percent, is_dark) = {
let bubbles = lock_bubbles();
let Some(b) = bubbles.get(&(hwnd.0 as isize)) else {
return;
};
(b.size_logical, b.dpi, b.percent, b.is_dark)
};
let size_px = scale_to_dpi(size_logical, dpi);
unsafe {
let screen_dc = GetDC(hwnd);
let mem_dc = CreateCompatibleDC(screen_dc);
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: size_px,
biHeight: -size_px,
biPlanes: 1,
biBitCount: 32,
biCompression: 0,
..Default::default()
},
..Default::default()
};
let mut bits: *mut c_void = std::ptr::null_mut();
let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
.unwrap_or_default();
if dib.is_invalid() || bits.is_null() {
let _ = DeleteDC(mem_dc);
ReleaseDC(hwnd, screen_dc);
return;
}
let old_bmp = SelectObject(mem_dc, dib);
let pixel_count = (size_px * size_px) as usize;
let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
paint_background(pixels, size_px, is_dark);
paint_ring(pixels, size_px, percent.unwrap_or(0.0), is_dark);
paint_text(mem_dc, size_px, percent, is_dark, dpi);
// Final alpha pass: set alpha=255 inside circle, 0 outside.
let cx = (size_px - 1) as f64 / 2.0;
let cy = cx;
let radius = (size_px / 2) as f64 - 1.0;
let r_sq = radius * radius;
for y in 0..size_px {
for x in 0..size_px {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
let idx = (y * size_px + x) as usize;
if dx * dx + dy * dy <= r_sq {
pixels[idx] |= 0xFF000000;
} else {
pixels[idx] = 0;
}
}
}
let mut wr = RECT::default();
let _ = GetWindowRect(hwnd, &mut wr);
let pt_dst = POINT {
x: wr.left,
y: wr.top,
};
let pt_src = POINT { x: 0, y: 0 };
let sz = SIZE {
cx: size_px,
cy: size_px,
};
let blend = BLENDFUNCTION {
BlendOp: 0,
BlendFlags: 0,
SourceConstantAlpha: 255,
AlphaFormat: 1, // AC_SRC_ALPHA
};
let _ = UpdateLayeredWindow(
hwnd,
screen_dc,
Some(&pt_dst),
Some(&sz),
mem_dc,
Some(&pt_src),
COLORREF(0),
Some(&blend),
ULW_ALPHA,
);
SelectObject(mem_dc, old_bmp);
let _ = DeleteObject(dib);
let _ = DeleteDC(mem_dc);
ReleaseDC(hwnd, screen_dc);
}
}
fn paint_background(pixels: &mut [u32], size_px: i32, is_dark: bool) {
let bg = if is_dark {
Color::from_hex("#1F1F1F")
} else {
Color::from_hex("#F3F3F3")
};
let bg_bgr = bg.to_colorref();
let cx = (size_px - 1) as f64 / 2.0;
let cy = cx;
let radius = (size_px / 2) as f64 - 1.0;
let r_sq = radius * radius;
for y in 0..size_px {
for x in 0..size_px {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
let idx = (y * size_px + x) as usize;
if dx * dx + dy * dy <= r_sq {
pixels[idx] = bg_bgr;
} else {
pixels[idx] = 0;
}
}
}
}
fn paint_ring(pixels: &mut [u32], size_px: i32, percent: f64, is_dark: bool) {
let ring = ring_color_for_percent(percent).to_colorref();
let track = if is_dark {
Color::from_hex("#3A3A3A").to_colorref()
} else {
Color::from_hex("#D6D6D6").to_colorref()
};
let cx = (size_px - 1) as f64 / 2.0;
let cy = cx;
let outer = (size_px / 2) as f64 - 1.0;
let thickness = ((size_px as f64) * 0.12).max(3.0);
let inner = outer - thickness;
let inner_sq = inner * inner;
let outer_sq = outer * outer;
let sweep = (percent.clamp(0.0, 100.0) / 100.0) * 2.0 * std::f64::consts::PI;
for y in 0..size_px {
for x in 0..size_px {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
let d_sq = dx * dx + dy * dy;
if d_sq <= outer_sq && d_sq >= inner_sq {
// Angle from 12 o'clock, clockwise.
let mut a = dx.atan2(-dy);
if a < 0.0 {
a += 2.0 * std::f64::consts::PI;
}
let idx = (y * size_px + x) as usize;
pixels[idx] = if a <= sweep { ring } else { track };
}
}
}
}
fn paint_text(hdc: HDC, size_px: i32, percent: Option<f64>, is_dark: bool, _dpi: u32) {
let text = match percent {
Some(p) => format!("{:.0}%", p),
None => "".to_string(),
};
let mut text_w = wide_str(&text);
let text_color = if is_dark {
Color::from_hex("#F5F5F5")
} else {
Color::from_hex("#1F1F1F")
};
let font_height = -(size_px / 4).max(8);
let font_name = wide_str("Segoe UI");
unsafe {
let font = CreateFontW(
font_height,
0,
0,
0,
FW_SEMIBOLD.0 as i32,
0,
0,
0,
DEFAULT_CHARSET.0 as u32,
OUT_DEFAULT_PRECIS.0 as u32,
CLIP_DEFAULT_PRECIS.0 as u32,
CLEARTYPE_QUALITY.0 as u32,
(FF_SWISS.0 | DEFAULT_PITCH.0) as u32,
PCWSTR::from_raw(font_name.as_ptr()),
);
let old_font = SelectObject(hdc, font);
SetTextColor(hdc, COLORREF(text_color.to_colorref()));
SetBkMode(hdc, TRANSPARENT);
let mut rect = RECT {
left: 0,
top: 0,
right: size_px,
bottom: size_px,
};
// Trim the trailing NUL — DrawTextW reads slice length as count.
let len_no_nul = text_w.len().saturating_sub(1);
let _ = DrawTextW(
hdc,
&mut text_w[..len_no_nul],
&mut rect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP,
);
SelectObject(hdc, old_font);
let _ = DeleteObject(font);
}
}
pub fn ring_color_for_percent(percent: f64) -> Color {
if percent <= 50.0 {
return Color::from_hex("#D97757");
}
let stops: [(f64, Color); 5] = [
(50.0, Color::from_hex("#D97757")),
(70.0, Color::from_hex("#D08540")),
(85.0, Color::from_hex("#CC8C20")),
(95.0, Color::from_hex("#C45020")),
(100.0, Color::from_hex("#B82020")),
];
for pair in stops.windows(2) {
let (sp, sc) = pair[0];
let (ep, ec) = pair[1];
if percent <= ep {
let span = (ep - sp).max(f64::EPSILON);
let t = ((percent - sp) / span).clamp(0.0, 1.0);
return Color::new(
lerp_u8(sc.r, ec.r, t),
lerp_u8(sc.g, ec.g, t),
lerp_u8(sc.b, ec.b, t),
);
}
}
Color::from_hex("#B82020")
}
fn lerp_u8(a: u8, b: u8, t: f64) -> u8 {
(a as f64 + (b as f64 - a as f64) * t).round() as u8
}
// ---------- Helpers ----------
fn primary_dpi() -> u32 {
unsafe { GetDpiForSystem().max(96) }
}
fn scale_to_dpi(logical: i32, dpi: u32) -> i32 {
((logical as i64) * (dpi as i64) / 96) as i32
}
fn default_position(size_px: i32, model: TrayIconKind) -> (i32, i32) {
// Bottom-right of primary work area, with a 24-pixel gap from the edges.
// Stagger the Codex bubble above the Claude one if both are enabled.
unsafe {
let monitor = MonitorFromPoint(POINT { x: 0, y: 0 }, MONITOR_DEFAULTTOPRIMARY);
let mut info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
let wa = if GetMonitorInfoW(monitor, &mut info).as_bool() {
info.rcWork
} else {
RECT {
left: 0,
top: 0,
right: 1920,
bottom: 1080,
}
};
let gap = 24;
let stagger = match model {
TrayIconKind::Claude => 0,
TrayIconKind::Codex => size_px + gap,
};
let x = wa.right - size_px - gap;
let y = wa.bottom - size_px - gap - stagger;
(x, y)
}
}
+52
View File
@@ -0,0 +1,52 @@
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
struct DiagnoseState {
file: Mutex<File>,
}
static DIAGNOSE_STATE: OnceLock<DiagnoseState> = OnceLock::new();
pub fn init() -> Result<PathBuf, String> {
let path = std::env::temp_dir().join("claude-code-usage-bubble.log");
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)
.map_err(|e| format!("Unable to open diagnostic log file {}: {e}", path.display()))?;
let _ = DIAGNOSE_STATE.set(DiagnoseState {
file: Mutex::new(file),
});
log("diagnostic logging enabled");
Ok(path)
}
pub fn is_enabled() -> bool {
DIAGNOSE_STATE.get().is_some()
}
pub fn log(message: impl AsRef<str>) {
let Some(state) = DIAGNOSE_STATE.get() else {
return;
};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
if let Ok(mut file) = state.file.lock() {
let _ = writeln!(file, "[{timestamp}] {}", message.as_ref());
let _ = file.flush();
}
}
pub fn log_error(context: &str, error: impl std::fmt::Display) {
log(format!("{context}: {error}"));
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<rect x="0.5" y="0.5" width="15" height="15" rx="1" stroke="#D97757" stroke-width="1"></rect>
<rect x="2" y="2" width="5" height="5" rx="1" fill="#D97757"></rect>
<rect x="9" y="2" width="5" height="5" rx="1" fill="#F3D6CC"></rect>
<rect x="2" y="9" width="5" height="5" rx="1" fill="#D97757"></rect>
<rect x="9" y="9" width="5" height="5" rx="1" fill="#F3D6CC"></rect>
<path d="M 10 2 L 11 2 L 11 7 L 10 7 C 9.448 7 9 6.552 9 6 L 9 3 C 9 2.448 9.448 2 10 2 Z" fill="#D97757"></path>
</svg>

After

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

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

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 743 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

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

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

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