mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-10 18:18:13 +00:00
Whole-project review pass over the entire crate. No new features. All function definitions preserved; layout and visibility reorganised. Bug fixes: - bubble: ExtractIconExW HICON pair was leaked per bubble toggle. Extract once into a process-wide OnceLock, reuse forever (bounded). - usage/anthropic: parse_iso8601 was stripping the UTC offset without applying it — negative-offset users saw countdowns up to 14h wrong. Now parses signed minutes and computes utc_secs = local - off*60. Also rejects y<1970, mo∉[1,12], d∉[1,max_day(mo,y)] up front so malformed API responses can't index DAYS_IN_MONTH out of bounds. - usage: clamp utilization to [0,100] at all four Window construction sites so a misbehaving server can't render "121%". - bubble: GetDC and CreateCompatibleDC results weren't checked. Guard both; release the screen DC on the CreateCompatibleDC failure path. Refactor: - Drop type TrayIconKind = ProviderId aliases (5 sites); use ProviderId directly everywhere. Inline the identity-function kind_to_provider. - Delete panel::bar_color_for shim (was just argument-reorder glue). - Replace local scale_to_dpi fns in bubble.rs and panel.rs with use crate::os::dpi::scale as scale_to_dpi (brings os::dpi into the live import graph; was unused before). - Delete dead PCWSTR import + #[allow(dead_code)] sentinel in tray/badge.rs; fold the trailing `use BOOL` into the top imports. - Inline app::primary_dpi() to crate::os::dpi::for_system(). app.rs: - Add update_settings(|s: &mut AppState|) helper that locks state, runs the closure, snapshots Settings, drops the lock, then saves to disk. Convert four pure-mutate-then-save callsites. Layering: bubble.rs no longer reaches upward into crate::app::. Introduce bubble::Callbacks (fn-pointers), OnceLock<Callbacks>, and bubble::install_callbacks(). The wnd_proc dispatches the six prior upward calls via a private dispatch() helper. app::run installs callbacks once at startup; the six on_bubble_* / recheck_theme fns are demoted from pub fn to fn. Resource-warning logs added: dispatch() warns on uninstalled callbacks; app_icons() warns when ExtractIconExW returns nulls. Build: cargo build --release clean; cargo clippy reports zero new warnings (11 pre-existing, all in untouched code).
192 lines
6.3 KiB
Rust
192 lines
6.3 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use windows::Win32::Foundation::RECT;
|
|
use windows::Win32::Graphics::Gdi::{MonitorFromRect, MONITOR_DEFAULTTONULL};
|
|
|
|
use crate::bubble::DEFAULT_BUBBLE_SIZE;
|
|
use crate::usage::ProviderId;
|
|
|
|
// 140px matches MIN_BUBBLE_SIZE — a saved top-left a few px past the work-area
|
|
// edge still passes the validator, but a position fully on a disconnected
|
|
// monitor (the bug we're guarding against) fails.
|
|
const POSITION_PROBE_PX: i32 = 140;
|
|
|
|
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;
|
|
|
|
// Update-check intervals (seconds). `None` means auto-check is disabled.
|
|
pub const UPDATE_CHECK_HOURLY_SECS: u64 = 60 * 60;
|
|
pub const UPDATE_CHECK_DAILY_SECS: u64 = 24 * 60 * 60;
|
|
pub const UPDATE_CHECK_WEEKLY_SECS: u64 = 7 * 24 * 60 * 60;
|
|
|
|
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
|
|
}
|
|
fn default_update_check_interval_secs() -> Option<u64> {
|
|
Some(UPDATE_CHECK_HOURLY_SECS)
|
|
}
|
|
|
|
#[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: ProviderId) -> Option<(i32, i32)> {
|
|
match model {
|
|
ProviderId::Claude => self.claude,
|
|
ProviderId::ChatGpt => self.codex,
|
|
}
|
|
}
|
|
pub fn set(&mut self, model: ProviderId, pos: (i32, i32)) {
|
|
match model {
|
|
ProviderId::Claude => self.claude = Some(pos),
|
|
ProviderId::ChatGpt => self.codex = Some(pos),
|
|
}
|
|
}
|
|
pub fn reset(&mut self, model: ProviderId) {
|
|
match model {
|
|
ProviderId::Claude => self.claude = None,
|
|
ProviderId::ChatGpt => self.codex = None,
|
|
}
|
|
}
|
|
pub fn reset_all(&mut self) {
|
|
self.claude = None;
|
|
self.codex = None;
|
|
}
|
|
|
|
/// Drop any saved position whose top-left no longer falls on a connected
|
|
/// monitor. Guards against `bubble::create` placing the window on a
|
|
/// disconnected secondary monitor (where the user can't see or recover it).
|
|
pub fn validate(&mut self) {
|
|
if let Some((x, y)) = self.claude {
|
|
if !position_on_any_monitor(x, y) {
|
|
log::warn!("bubble position claude ({x},{y}) outside all monitors; resetting to default");
|
|
self.claude = None;
|
|
}
|
|
}
|
|
if let Some((x, y)) = self.codex {
|
|
if !position_on_any_monitor(x, y) {
|
|
log::warn!("bubble position codex ({x},{y}) outside all monitors; resetting to default");
|
|
self.codex = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn position_on_any_monitor(x: i32, y: i32) -> bool {
|
|
// MONITOR_DEFAULTTONULL returns a null HMONITOR when the rect intersects
|
|
// no connected monitor — exactly the signal we want.
|
|
let probe = RECT {
|
|
left: x,
|
|
top: y,
|
|
right: x + POSITION_PROBE_PX,
|
|
bottom: y + POSITION_PROBE_PX,
|
|
};
|
|
let monitor = unsafe { MonitorFromRect(&probe, MONITOR_DEFAULTTONULL) };
|
|
!monitor.is_invalid()
|
|
}
|
|
|
|
#[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_update_check_interval_secs")]
|
|
pub update_check_interval_secs: 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,
|
|
update_check_interval_secs: default_update_check_interval_secs(),
|
|
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);
|
|
// Drop positions on monitors that have since been disconnected.
|
|
settings.bubble_positions.validate();
|
|
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);
|
|
}
|