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