mirror of
https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git
synced 2026-06-20 10:19:27 +08:00
fix: resolve taskbar positioning deadlock - Thanks to @owovouo and @aa333 for surfacing and working on this in PR #13 and PR #14
2260 lines
72 KiB
Rust
2260 lines
72 KiB
Rust
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::sync::{Mutex, MutexGuard};
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use windows::core::PCWSTR;
|
|
use windows::Win32::Foundation::*;
|
|
use windows::Win32::Graphics::Gdi::*;
|
|
use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW};
|
|
use windows::Win32::System::Registry::*;
|
|
use windows::Win32::System::Threading::CreateMutexW;
|
|
use windows::Win32::UI::Accessibility::HWINEVENTHOOK;
|
|
use windows::Win32::UI::HiDpi::*;
|
|
use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture};
|
|
use windows::Win32::UI::Shell::ExtractIconExW;
|
|
use windows::Win32::UI::WindowsAndMessaging::*;
|
|
|
|
use crate::diagnose;
|
|
use crate::localization::{self, LanguageId, Strings};
|
|
use crate::models::UsageData;
|
|
use crate::native_interop::{
|
|
self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK,
|
|
WM_APP_TRAY, WM_APP_USAGE_UPDATED,
|
|
};
|
|
use crate::tray_icon;
|
|
use crate::poller;
|
|
use crate::theme;
|
|
use crate::updater::{self, InstallChannel, ReleaseDescriptor, UpdateCheckResult};
|
|
|
|
/// Wrapper to make HWND sendable across threads (safe for PostMessage usage)
|
|
#[derive(Clone, Copy)]
|
|
struct SendHwnd(isize);
|
|
|
|
unsafe impl Send for SendHwnd {}
|
|
|
|
impl SendHwnd {
|
|
fn from_hwnd(hwnd: HWND) -> Self {
|
|
Self(hwnd.0 as isize)
|
|
}
|
|
fn to_hwnd(self) -> HWND {
|
|
HWND(self.0 as *mut _)
|
|
}
|
|
}
|
|
|
|
/// Shared application state
|
|
struct AppState {
|
|
hwnd: SendHwnd,
|
|
taskbar_hwnd: Option<HWND>,
|
|
tray_notify_hwnd: Option<HWND>,
|
|
win_event_hook: Option<HWINEVENTHOOK>,
|
|
is_dark: bool,
|
|
embedded: bool,
|
|
language_override: Option<LanguageId>,
|
|
language: LanguageId,
|
|
install_channel: InstallChannel,
|
|
|
|
session_percent: f64,
|
|
session_text: String,
|
|
weekly_percent: f64,
|
|
weekly_text: String,
|
|
|
|
data: Option<UsageData>,
|
|
|
|
poll_interval_ms: u32,
|
|
retry_count: u32,
|
|
last_poll_ok: bool,
|
|
update_status: UpdateStatus,
|
|
last_update_check_unix: Option<u64>,
|
|
|
|
tray_offset: i32,
|
|
dragging: bool,
|
|
drag_start_mouse_x: i32,
|
|
drag_start_offset: i32,
|
|
|
|
widget_visible: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum UpdateStatus {
|
|
Idle,
|
|
Checking,
|
|
Applying,
|
|
UpToDate,
|
|
Available(ReleaseDescriptor),
|
|
}
|
|
|
|
const RETRY_BASE_MS: u32 = 30_000; // 30 seconds
|
|
|
|
const POLL_1_MIN: u32 = 60_000;
|
|
const POLL_5_MIN: u32 = 300_000;
|
|
const POLL_15_MIN: u32 = 900_000;
|
|
const POLL_1_HOUR: u32 = 3_600_000;
|
|
|
|
// Menu item IDs for update frequency
|
|
const IDM_FREQ_1MIN: u16 = 10;
|
|
const IDM_FREQ_5MIN: u16 = 11;
|
|
const IDM_FREQ_15MIN: u16 = 12;
|
|
const IDM_FREQ_1HOUR: u16 = 13;
|
|
const IDM_START_WITH_WINDOWS: u16 = 20;
|
|
const IDM_RESET_POSITION: u16 = 30;
|
|
const IDM_VERSION_ACTION: u16 = 31;
|
|
const IDM_LANG_SYSTEM: u16 = 40;
|
|
const IDM_LANG_ENGLISH: u16 = 41;
|
|
const IDM_LANG_SPANISH: u16 = 42;
|
|
const IDM_LANG_FRENCH: u16 = 43;
|
|
const IDM_LANG_GERMAN: u16 = 44;
|
|
const IDM_LANG_JAPANESE: u16 = 45;
|
|
const IDM_LANG_KOREAN: u16 = 46;
|
|
|
|
const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN
|
|
|
|
const WM_DPICHANGED_MSG: u32 = 0x02E0;
|
|
const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2;
|
|
|
|
/// Current system DPI (96 = 100% scaling, 144 = 150%, 192 = 200%, etc.)
|
|
static CURRENT_DPI: AtomicU32 = AtomicU32::new(96);
|
|
|
|
/// Scale a base pixel value (designed at 96 DPI) to the current DPI.
|
|
fn sc(px: i32) -> i32 {
|
|
let dpi = CURRENT_DPI.load(Ordering::Relaxed);
|
|
(px as f64 * dpi as f64 / 96.0).round() as i32
|
|
}
|
|
|
|
/// Re-query the monitor DPI for our window and update the cached value.
|
|
/// Uses GetDpiForWindow which returns the live DPI (unlike GetDpiForSystem
|
|
/// which is cached at process startup and never changes).
|
|
fn refresh_dpi() {
|
|
let hwnd = {
|
|
let state = lock_state();
|
|
state.as_ref().map(|s| s.hwnd.to_hwnd())
|
|
};
|
|
if let Some(hwnd) = hwnd {
|
|
let dpi = unsafe { GetDpiForWindow(hwnd) };
|
|
if dpi > 0 {
|
|
CURRENT_DPI.store(dpi, Ordering::Relaxed);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn load_embedded_app_icons() -> (HICON, HICON) {
|
|
unsafe {
|
|
let mut exe_buf = [0u16; 260];
|
|
let len = GetModuleFileNameW(None, &mut exe_buf) as usize;
|
|
if len == 0 {
|
|
return (HICON::default(), HICON::default());
|
|
}
|
|
|
|
let mut large_icon = HICON::default();
|
|
let mut small_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(), HICON::default())
|
|
} else {
|
|
(large_icon, small_icon)
|
|
}
|
|
}
|
|
}
|
|
|
|
unsafe impl Send for AppState {}
|
|
|
|
static STATE: Mutex<Option<AppState>> = Mutex::new(None);
|
|
|
|
/// Lock STATE safely, recovering from poisoned mutex
|
|
fn lock_state() -> MutexGuard<'static, Option<AppState>> {
|
|
STATE.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
fn settings_path() -> PathBuf {
|
|
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string());
|
|
PathBuf::from(appdata)
|
|
.join("ClaudeCodeUsageMonitor")
|
|
.join("settings.json")
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct SettingsFile {
|
|
#[serde(default)]
|
|
tray_offset: i32,
|
|
#[serde(default = "default_poll_interval")]
|
|
poll_interval_ms: u32,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
language: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
last_update_check_unix: Option<u64>,
|
|
#[serde(default = "default_widget_visible")]
|
|
widget_visible: bool,
|
|
}
|
|
|
|
impl Default for SettingsFile {
|
|
fn default() -> Self {
|
|
Self {
|
|
tray_offset: 0,
|
|
poll_interval_ms: default_poll_interval(),
|
|
language: None,
|
|
last_update_check_unix: None,
|
|
widget_visible: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_poll_interval() -> u32 {
|
|
POLL_15_MIN
|
|
}
|
|
|
|
fn default_widget_visible() -> bool {
|
|
true
|
|
}
|
|
|
|
fn load_settings() -> SettingsFile {
|
|
let content = match std::fs::read_to_string(settings_path()) {
|
|
Ok(c) => c,
|
|
Err(_) => return SettingsFile::default(),
|
|
};
|
|
serde_json::from_str(&content).unwrap_or_default()
|
|
}
|
|
|
|
fn save_settings(settings: &SettingsFile) {
|
|
let path = settings_path();
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
if let Ok(json) = serde_json::to_string_pretty(settings) {
|
|
let _ = std::fs::write(path, json);
|
|
}
|
|
}
|
|
|
|
fn save_state_settings() {
|
|
let state = lock_state();
|
|
if let Some(s) = state.as_ref() {
|
|
save_settings(&SettingsFile {
|
|
tray_offset: s.tray_offset,
|
|
poll_interval_ms: s.poll_interval_ms,
|
|
language: s
|
|
.language_override
|
|
.map(|language| language.code().to_string()),
|
|
last_update_check_unix: s.last_update_check_unix,
|
|
widget_visible: s.widget_visible,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn tray_icon_data_from_state() -> (Option<f64>, String) {
|
|
let state = lock_state();
|
|
match state.as_ref() {
|
|
Some(s) if s.last_poll_ok => {
|
|
let tooltip = format!("5h: {} | 7d: {}", s.session_text, s.weekly_text);
|
|
(Some(s.session_percent), tooltip)
|
|
}
|
|
_ => (None, "Claude Code Usage Monitor".to_string()),
|
|
}
|
|
}
|
|
|
|
fn toggle_widget_visibility(hwnd: HWND) {
|
|
let new_visible = {
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
s.widget_visible = !s.widget_visible;
|
|
s.widget_visible
|
|
} else {
|
|
return;
|
|
}
|
|
};
|
|
save_state_settings();
|
|
unsafe {
|
|
if new_visible {
|
|
position_at_taskbar();
|
|
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
|
render_layered();
|
|
} else {
|
|
let _ = ShowWindow(hwnd, SW_HIDE);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn now_unix_secs() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
}
|
|
|
|
fn update_check_interval() -> Duration {
|
|
Duration::from_secs(24 * 60 * 60)
|
|
}
|
|
|
|
fn auto_update_check_due(last_update_check_unix: Option<u64>) -> bool {
|
|
let Some(last_update_check_unix) = last_update_check_unix else {
|
|
return true;
|
|
};
|
|
|
|
now_unix_secs().saturating_sub(last_update_check_unix) >= update_check_interval().as_secs()
|
|
}
|
|
|
|
fn schedule_auto_update_check(hwnd: HWND) {
|
|
let delay_ms = {
|
|
let state = lock_state();
|
|
let Some(s) = state.as_ref() else {
|
|
return;
|
|
};
|
|
|
|
if auto_update_check_due(s.last_update_check_unix) {
|
|
None
|
|
} else {
|
|
let elapsed = now_unix_secs().saturating_sub(s.last_update_check_unix.unwrap_or(0));
|
|
let remaining_secs = update_check_interval().as_secs().saturating_sub(elapsed);
|
|
Some((remaining_secs.saturating_mul(1000)).min(u32::MAX as u64) as u32)
|
|
}
|
|
};
|
|
|
|
unsafe {
|
|
let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK);
|
|
if let Some(delay_ms) = delay_ms {
|
|
SetTimer(hwnd, TIMER_UPDATE_CHECK, delay_ms.max(1), None);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn refresh_usage_texts(state: &mut AppState) {
|
|
if !state.last_poll_ok {
|
|
return;
|
|
}
|
|
|
|
let strings = state.language.strings();
|
|
let Some((session_text, weekly_text)) = state.data.as_ref().map(|data| {
|
|
(
|
|
poller::format_line(&data.session, strings),
|
|
poller::format_line(&data.weekly, strings),
|
|
)
|
|
}) else {
|
|
return;
|
|
};
|
|
|
|
state.session_text = session_text;
|
|
state.weekly_text = weekly_text;
|
|
}
|
|
|
|
fn set_window_title(hwnd: HWND, strings: Strings) {
|
|
unsafe {
|
|
let title = native_interop::wide_str(strings.window_title);
|
|
let _ = SetWindowTextW(hwnd, PCWSTR::from_raw(title.as_ptr()));
|
|
}
|
|
}
|
|
|
|
fn show_info_message(hwnd: HWND, title: &str, message: &str) {
|
|
unsafe {
|
|
let title_wide = native_interop::wide_str(title);
|
|
let message_wide = native_interop::wide_str(message);
|
|
let _ = MessageBoxW(
|
|
hwnd,
|
|
PCWSTR::from_raw(message_wide.as_ptr()),
|
|
PCWSTR::from_raw(title_wide.as_ptr()),
|
|
MB_OK | MB_ICONINFORMATION,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn show_error_message(hwnd: HWND, title: &str, message: &str) {
|
|
unsafe {
|
|
let title_wide = native_interop::wide_str(title);
|
|
let message_wide = native_interop::wide_str(message);
|
|
let _ = MessageBoxW(
|
|
hwnd,
|
|
PCWSTR::from_raw(message_wide.as_ptr()),
|
|
PCWSTR::from_raw(title_wide.as_ptr()),
|
|
MB_OK | MB_ICONERROR,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn show_update_prompt(hwnd: HWND, strings: Strings, release: &ReleaseDescriptor) -> bool {
|
|
let message = strings
|
|
.update_prompt_now
|
|
.replace("{version}", &release.latest_version);
|
|
|
|
unsafe {
|
|
let title_wide = native_interop::wide_str(strings.update_available);
|
|
let message_wide = native_interop::wide_str(&message);
|
|
MessageBoxW(
|
|
hwnd,
|
|
PCWSTR::from_raw(message_wide.as_ptr()),
|
|
PCWSTR::from_raw(title_wide.as_ptr()),
|
|
MB_YESNO | MB_ICONQUESTION,
|
|
) == IDYES
|
|
}
|
|
}
|
|
|
|
fn apply_language_to_state(state: &mut AppState, language_override: Option<LanguageId>) {
|
|
state.language_override = language_override;
|
|
state.language = localization::resolve_language(language_override);
|
|
set_window_title(state.hwnd.to_hwnd(), state.language.strings());
|
|
refresh_usage_texts(state);
|
|
}
|
|
|
|
fn update_language_change() -> bool {
|
|
let mut state = lock_state();
|
|
let Some(app_state) = state.as_mut() else {
|
|
return false;
|
|
};
|
|
|
|
if app_state.language_override.is_some() {
|
|
return false;
|
|
}
|
|
|
|
let new_language = localization::detect_system_language();
|
|
if new_language == app_state.language {
|
|
return false;
|
|
}
|
|
|
|
apply_language_to_state(app_state, None);
|
|
true
|
|
}
|
|
|
|
fn version_action_label(
|
|
strings: Strings,
|
|
language: LanguageId,
|
|
install_channel: InstallChannel,
|
|
status: &UpdateStatus,
|
|
) -> String {
|
|
let current = env!("CARGO_PKG_VERSION");
|
|
match status {
|
|
UpdateStatus::Idle => format!("v{current} - {}", strings.check_for_updates),
|
|
UpdateStatus::Checking => format!("v{current} - {}", strings.checking_for_updates),
|
|
UpdateStatus::Applying => format!("v{current} - {}", strings.applying_update),
|
|
UpdateStatus::UpToDate => format!("v{current} - {}", strings.up_to_date_short),
|
|
UpdateStatus::Available(release) => match install_channel {
|
|
InstallChannel::Portable => {
|
|
format!(
|
|
"v{current} - {} v{}",
|
|
strings.update_to, release.latest_version
|
|
)
|
|
}
|
|
InstallChannel::Winget => format!(
|
|
"v{current} - {} v{}",
|
|
localization::update_via_winget(language),
|
|
release.latest_version
|
|
),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn begin_update_check(hwnd: HWND, interactive: bool) {
|
|
let send_hwnd = SendHwnd::from_hwnd(hwnd);
|
|
let (strings, install_channel) = {
|
|
let mut state = lock_state();
|
|
let Some(app_state) = state.as_mut() else {
|
|
return;
|
|
};
|
|
|
|
if matches!(
|
|
app_state.update_status,
|
|
UpdateStatus::Checking | UpdateStatus::Applying
|
|
) {
|
|
if interactive {
|
|
show_info_message(
|
|
hwnd,
|
|
app_state.language.strings().updates,
|
|
app_state.language.strings().update_in_progress,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
app_state.update_status = UpdateStatus::Checking;
|
|
(app_state.language.strings(), app_state.install_channel)
|
|
};
|
|
|
|
std::thread::spawn(move || {
|
|
let hwnd = send_hwnd.to_hwnd();
|
|
let checked_at = now_unix_secs();
|
|
match updater::check_for_updates() {
|
|
Ok(UpdateCheckResult::UpToDate) => {
|
|
{
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
s.update_status = UpdateStatus::UpToDate;
|
|
s.last_update_check_unix = Some(checked_at);
|
|
}
|
|
}
|
|
save_state_settings();
|
|
if interactive {
|
|
show_info_message(hwnd, strings.updates, strings.up_to_date);
|
|
}
|
|
unsafe {
|
|
let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0));
|
|
}
|
|
}
|
|
Ok(UpdateCheckResult::Available(release)) => {
|
|
{
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
s.update_status = UpdateStatus::Available(release.clone());
|
|
s.last_update_check_unix = Some(checked_at);
|
|
}
|
|
}
|
|
save_state_settings();
|
|
if interactive && show_update_prompt(hwnd, strings, &release) {
|
|
match install_channel {
|
|
InstallChannel::Portable => begin_update_apply(hwnd, release),
|
|
InstallChannel::Winget => begin_winget_update(hwnd),
|
|
}
|
|
}
|
|
unsafe {
|
|
let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0));
|
|
}
|
|
}
|
|
Err(error) => {
|
|
{
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
s.update_status = UpdateStatus::Idle;
|
|
s.last_update_check_unix = Some(checked_at);
|
|
}
|
|
}
|
|
save_state_settings();
|
|
if interactive {
|
|
let message = format!("{}.\n\n{}", strings.update_failed, error);
|
|
show_error_message(hwnd, strings.updates, &message);
|
|
}
|
|
unsafe {
|
|
let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn begin_update_apply(hwnd: HWND, release: ReleaseDescriptor) {
|
|
let send_hwnd = SendHwnd::from_hwnd(hwnd);
|
|
let strings = {
|
|
let mut state = lock_state();
|
|
let Some(app_state) = state.as_mut() else {
|
|
return;
|
|
};
|
|
|
|
if matches!(
|
|
app_state.update_status,
|
|
UpdateStatus::Checking | UpdateStatus::Applying
|
|
) {
|
|
show_info_message(
|
|
hwnd,
|
|
app_state.language.strings().updates,
|
|
app_state.language.strings().update_in_progress,
|
|
);
|
|
return;
|
|
}
|
|
|
|
app_state.update_status = UpdateStatus::Applying;
|
|
app_state.language.strings()
|
|
};
|
|
|
|
std::thread::spawn(move || {
|
|
let hwnd = send_hwnd.to_hwnd();
|
|
match updater::begin_self_update(&release) {
|
|
Ok(()) => unsafe {
|
|
let _ = PostMessageW(hwnd, WM_CLOSE, WPARAM(0), LPARAM(0));
|
|
},
|
|
Err(error) => {
|
|
{
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
s.update_status = UpdateStatus::Available(release);
|
|
}
|
|
}
|
|
let message = format!("{}.\n\n{}", strings.update_failed, error);
|
|
show_error_message(hwnd, strings.updates, &message);
|
|
unsafe {
|
|
let _ = PostMessageW(hwnd, WM_APP_UPDATE_CHECK_COMPLETE, WPARAM(0), LPARAM(0));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn begin_winget_update(hwnd: HWND) {
|
|
let strings = {
|
|
let state = lock_state();
|
|
state.as_ref().map(|s| s.language.strings())
|
|
}
|
|
.unwrap_or(LanguageId::English.strings());
|
|
|
|
match updater::begin_winget_update() {
|
|
Ok(()) => unsafe {
|
|
let _ = PostMessageW(hwnd, WM_CLOSE, WPARAM(0), LPARAM(0));
|
|
},
|
|
Err(error) => {
|
|
let message = format!("{}.\n\n{}", strings.update_failed, error);
|
|
show_error_message(hwnd, strings.updates, &message);
|
|
}
|
|
}
|
|
}
|
|
|
|
const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
|
|
const STARTUP_REGISTRY_KEY: &str = "ClaudeCodeUsageMonitor";
|
|
|
|
/// Returns true only if the startup registry value points to this executable.
|
|
fn is_startup_enabled() -> bool {
|
|
unsafe {
|
|
let path = native_interop::wide_str(STARTUP_REGISTRY_PATH);
|
|
let key_name = native_interop::wide_str(STARTUP_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;
|
|
}
|
|
|
|
// Query the size of the value
|
|
let mut data_size: u32 = 0;
|
|
let result = RegQueryValueExW(
|
|
hkey,
|
|
PCWSTR::from_raw(key_name.as_ptr()),
|
|
None,
|
|
None,
|
|
None,
|
|
Some(&mut data_size),
|
|
);
|
|
if result.is_err() || data_size == 0 {
|
|
let _ = RegCloseKey(hkey);
|
|
return false;
|
|
}
|
|
|
|
// Read the value
|
|
let mut buf = vec![0u8; data_size as usize];
|
|
let result = RegQueryValueExW(
|
|
hkey,
|
|
PCWSTR::from_raw(key_name.as_ptr()),
|
|
None,
|
|
None,
|
|
Some(buf.as_mut_ptr()),
|
|
Some(&mut data_size),
|
|
);
|
|
let _ = RegCloseKey(hkey);
|
|
if result.is_err() {
|
|
return false;
|
|
}
|
|
|
|
// Convert the registry value (UTF-16) to a string
|
|
let wide_slice =
|
|
std::slice::from_raw_parts(buf.as_ptr() as *const u16, data_size as usize / 2);
|
|
let reg_value = String::from_utf16_lossy(wide_slice)
|
|
.trim_end_matches('\0')
|
|
.to_string();
|
|
|
|
// Get the current executable path
|
|
let mut exe_buf = [0u16; 260];
|
|
let len = GetModuleFileNameW(None, &mut exe_buf) as usize;
|
|
if len == 0 {
|
|
return false;
|
|
}
|
|
let current_exe = String::from_utf16_lossy(&exe_buf[..len]);
|
|
|
|
// Case-insensitive comparison (Windows paths are case-insensitive)
|
|
reg_value.eq_ignore_ascii_case(¤t_exe)
|
|
}
|
|
}
|
|
|
|
fn set_startup_enabled(enable: bool) {
|
|
unsafe {
|
|
let path = native_interop::wide_str(STARTUP_REGISTRY_PATH);
|
|
|
|
let mut hkey = HKEY::default();
|
|
let result = RegOpenKeyExW(
|
|
HKEY_CURRENT_USER,
|
|
PCWSTR::from_raw(path.as_ptr()),
|
|
0,
|
|
KEY_SET_VALUE,
|
|
&mut hkey,
|
|
);
|
|
if result.is_err() {
|
|
return;
|
|
}
|
|
|
|
let key_name = native_interop::wide_str(STARTUP_REGISTRY_KEY);
|
|
|
|
if enable {
|
|
let mut exe_buf = [0u16; 260];
|
|
let len = GetModuleFileNameW(None, &mut exe_buf) as usize;
|
|
if len > 0 {
|
|
// Write the wide string including null terminator
|
|
let byte_len = ((len + 1) * 2) as u32;
|
|
let _ = RegSetValueExW(
|
|
hkey,
|
|
PCWSTR::from_raw(key_name.as_ptr()),
|
|
0,
|
|
REG_SZ,
|
|
Some(std::slice::from_raw_parts(
|
|
exe_buf.as_ptr() as *const u8,
|
|
byte_len as usize,
|
|
)),
|
|
);
|
|
}
|
|
} else {
|
|
let _ = RegDeleteValueW(hkey, PCWSTR::from_raw(key_name.as_ptr()));
|
|
}
|
|
|
|
let _ = RegCloseKey(hkey);
|
|
}
|
|
}
|
|
|
|
// Dimensions matching the C# version
|
|
const SEGMENT_W: i32 = 10;
|
|
const SEGMENT_H: i32 = 13;
|
|
const SEGMENT_GAP: i32 = 1;
|
|
const SEGMENT_COUNT: i32 = 10;
|
|
const CORNER_RADIUS: i32 = 2;
|
|
|
|
const LEFT_DIVIDER_W: i32 = 3;
|
|
const DIVIDER_RIGHT_MARGIN: i32 = 10;
|
|
const LABEL_WIDTH: i32 = 18;
|
|
const LABEL_RIGHT_MARGIN: i32 = 10;
|
|
const BAR_RIGHT_MARGIN: i32 = 4;
|
|
const TEXT_WIDTH: i32 = 62;
|
|
const RIGHT_MARGIN: i32 = 1;
|
|
const WIDGET_HEIGHT: i32 = 46;
|
|
|
|
fn total_widget_width() -> i32 {
|
|
sc(LEFT_DIVIDER_W)
|
|
+ sc(DIVIDER_RIGHT_MARGIN)
|
|
+ sc(LABEL_WIDTH)
|
|
+ sc(LABEL_RIGHT_MARGIN)
|
|
+ (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * SEGMENT_COUNT
|
|
- sc(SEGMENT_GAP)
|
|
+ sc(BAR_RIGHT_MARGIN)
|
|
+ sc(TEXT_WIDTH)
|
|
+ sc(RIGHT_MARGIN)
|
|
}
|
|
|
|
pub fn run() {
|
|
// Enable Per-Monitor DPI Awareness V2 for crisp rendering at any scale factor
|
|
unsafe {
|
|
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
|
CURRENT_DPI.store(GetDpiForSystem(), Ordering::Relaxed);
|
|
}
|
|
diagnose::log("window::run started");
|
|
|
|
// Single-instance guard: silently exit if another instance is running
|
|
let mutex_name = native_interop::wide_str("Global\\ClaudeCodeUsageMonitor");
|
|
let _mutex = unsafe {
|
|
let handle = CreateMutexW(None, false, PCWSTR::from_raw(mutex_name.as_ptr()));
|
|
match handle {
|
|
Ok(h) => {
|
|
if GetLastError() == ERROR_ALREADY_EXISTS {
|
|
diagnose::log("startup aborted: another instance is already running");
|
|
return;
|
|
}
|
|
h
|
|
}
|
|
Err(error) => {
|
|
diagnose::log_error("startup aborted: unable to create single-instance mutex", error);
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
let class_name = native_interop::wide_str("ClaudeCodeUsageMonitor");
|
|
|
|
unsafe {
|
|
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap();
|
|
let (large_icon, small_icon) = load_embedded_app_icons();
|
|
|
|
let wc = WNDCLASSEXW {
|
|
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
|
style: CS_HREDRAW | CS_VREDRAW,
|
|
lpfnWndProc: Some(wnd_proc),
|
|
hInstance: HINSTANCE(hinstance.0),
|
|
hIcon: large_icon,
|
|
hIconSm: small_icon,
|
|
hCursor: LoadCursorW(HINSTANCE::default(), IDC_ARROW).unwrap_or_default(),
|
|
hbrBackground: HBRUSH(std::ptr::null_mut()),
|
|
lpszClassName: PCWSTR::from_raw(class_name.as_ptr()),
|
|
..Default::default()
|
|
};
|
|
|
|
let atom = RegisterClassExW(&wc);
|
|
if atom == 0 {
|
|
diagnose::log("RegisterClassExW returned 0");
|
|
}
|
|
|
|
let settings = load_settings();
|
|
let language_override = settings.language.as_deref().and_then(LanguageId::from_code);
|
|
let language = localization::resolve_language(language_override);
|
|
let install_channel = updater::current_install_channel();
|
|
|
|
// Create as layered popup (will be reparented into taskbar)
|
|
let title = native_interop::wide_str(language.strings().window_title);
|
|
let hwnd = CreateWindowExW(
|
|
WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_NOACTIVATE,
|
|
PCWSTR::from_raw(class_name.as_ptr()),
|
|
PCWSTR::from_raw(title.as_ptr()),
|
|
WS_POPUP,
|
|
0,
|
|
0,
|
|
total_widget_width(),
|
|
sc(WIDGET_HEIGHT),
|
|
HWND::default(),
|
|
HMENU::default(),
|
|
hinstance,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
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),
|
|
);
|
|
}
|
|
|
|
diagnose::log(format!("main window created hwnd={:?}", hwnd));
|
|
|
|
let is_dark = theme::is_dark_mode();
|
|
let mut embedded = false;
|
|
|
|
{
|
|
let mut state = lock_state();
|
|
*state = Some(AppState {
|
|
hwnd: SendHwnd::from_hwnd(hwnd),
|
|
taskbar_hwnd: None,
|
|
tray_notify_hwnd: None,
|
|
win_event_hook: None,
|
|
is_dark,
|
|
embedded: false,
|
|
language_override,
|
|
language,
|
|
install_channel,
|
|
session_percent: 0.0,
|
|
session_text: "--".to_string(),
|
|
weekly_percent: 0.0,
|
|
weekly_text: "--".to_string(),
|
|
data: None,
|
|
poll_interval_ms: settings.poll_interval_ms,
|
|
retry_count: 0,
|
|
last_poll_ok: false,
|
|
update_status: UpdateStatus::Idle,
|
|
last_update_check_unix: settings.last_update_check_unix,
|
|
tray_offset: settings.tray_offset,
|
|
dragging: false,
|
|
drag_start_mouse_x: 0,
|
|
drag_start_offset: 0,
|
|
widget_visible: settings.widget_visible,
|
|
});
|
|
}
|
|
|
|
// Try to embed in taskbar
|
|
if let Some(taskbar_hwnd) = native_interop::find_taskbar() {
|
|
diagnose::log(format!("taskbar found hwnd={:?}", taskbar_hwnd));
|
|
native_interop::embed_in_taskbar(hwnd, taskbar_hwnd);
|
|
embedded = true;
|
|
|
|
let mut state = lock_state();
|
|
let s = state.as_mut().unwrap();
|
|
s.taskbar_hwnd = Some(taskbar_hwnd);
|
|
s.embedded = true;
|
|
|
|
let tray_notify = native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd");
|
|
s.tray_notify_hwnd = tray_notify;
|
|
if tray_notify.is_some() {
|
|
diagnose::log("TrayNotifyWnd found");
|
|
} else {
|
|
diagnose::log("TrayNotifyWnd not found");
|
|
}
|
|
|
|
if let Some(tray_hwnd) = tray_notify {
|
|
let thread_id = native_interop::get_window_thread_id(tray_hwnd);
|
|
let hook = native_interop::set_tray_event_hook(thread_id, on_tray_location_changed);
|
|
s.win_event_hook = hook;
|
|
if hook.is_some() {
|
|
diagnose::log("tray event hook installed");
|
|
} else {
|
|
diagnose::log("tray event hook could not be installed");
|
|
}
|
|
}
|
|
} else {
|
|
diagnose::log("taskbar not found; using fallback popup window");
|
|
}
|
|
|
|
// If not embedded, fall back to topmost popup with SetLayeredWindowAttributes
|
|
if !embedded {
|
|
let _ = SetLayeredWindowAttributes(hwnd, COLORREF(0), 255, LWA_ALPHA);
|
|
let _ = SetWindowPos(
|
|
hwnd,
|
|
HWND_TOPMOST,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
|
|
);
|
|
}
|
|
|
|
// Register system tray icon
|
|
let (tray_pct, tray_tooltip) = tray_icon_data_from_state();
|
|
tray_icon::add(hwnd, tray_pct, &tray_tooltip);
|
|
|
|
// Position and show (only if widget_visible preference is true)
|
|
position_at_taskbar();
|
|
if settings.widget_visible {
|
|
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
|
}
|
|
diagnose::log("window shown");
|
|
|
|
// Initial render via UpdateLayeredWindow (for embedded) or InvalidateRect (fallback)
|
|
render_layered();
|
|
|
|
// Poll timer: 15 minutes
|
|
let initial_poll_ms = {
|
|
let state = lock_state();
|
|
state
|
|
.as_ref()
|
|
.map(|s| s.poll_interval_ms)
|
|
.unwrap_or(POLL_15_MIN)
|
|
};
|
|
SetTimer(hwnd, TIMER_POLL, initial_poll_ms, None);
|
|
|
|
// Initial poll
|
|
let send_hwnd = SendHwnd::from_hwnd(hwnd);
|
|
std::thread::spawn(move || {
|
|
diagnose::log("initial poll thread started");
|
|
do_poll(send_hwnd);
|
|
});
|
|
|
|
schedule_auto_update_check(hwnd);
|
|
let should_check_updates = {
|
|
let state = lock_state();
|
|
state
|
|
.as_ref()
|
|
.map(|s| auto_update_check_due(s.last_update_check_unix))
|
|
.unwrap_or(false)
|
|
};
|
|
if should_check_updates {
|
|
begin_update_check(hwnd, false);
|
|
}
|
|
|
|
// Initial theme check
|
|
check_theme_change();
|
|
|
|
// Message loop
|
|
let mut msg = MSG::default();
|
|
while GetMessageW(&mut msg, HWND::default(), 0, 0).as_bool() {
|
|
let _ = TranslateMessage(&msg);
|
|
DispatchMessageW(&msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Render widget content and push to the layered window via UpdateLayeredWindow.
|
|
/// Renders fully opaque with the actual taskbar background colour so that
|
|
/// ClearType sub-pixel font rendering can be used for crisp, OS-native text.
|
|
fn render_layered() {
|
|
refresh_dpi();
|
|
let (hwnd_val, is_dark, embedded, strings, session_pct, session_text, weekly_pct, weekly_text) = {
|
|
let state = lock_state();
|
|
match state.as_ref() {
|
|
Some(s) => (
|
|
s.hwnd,
|
|
s.is_dark,
|
|
s.embedded,
|
|
s.language.strings(),
|
|
s.session_percent,
|
|
s.session_text.clone(),
|
|
s.weekly_percent,
|
|
s.weekly_text.clone(),
|
|
),
|
|
None => return,
|
|
}
|
|
};
|
|
|
|
let hwnd = hwnd_val.to_hwnd();
|
|
|
|
// For non-embedded fallback, just invalidate and let WM_PAINT handle it
|
|
if !embedded {
|
|
unsafe {
|
|
let _ = InvalidateRect(hwnd, None, false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let width = total_widget_width();
|
|
let height = sc(WIDGET_HEIGHT);
|
|
|
|
let accent = Color::from_hex("#D97757");
|
|
let track = if is_dark {
|
|
Color::from_hex("#444444")
|
|
} else {
|
|
Color::from_hex("#AAAAAA")
|
|
};
|
|
let text_color = if is_dark {
|
|
Color::from_hex("#888888")
|
|
} else {
|
|
Color::from_hex("#404040")
|
|
};
|
|
let bg_color = if is_dark {
|
|
Color::from_hex("#1C1C1C")
|
|
} else {
|
|
Color::from_hex("#F3F3F3")
|
|
};
|
|
|
|
unsafe {
|
|
let screen_dc = GetDC(hwnd);
|
|
|
|
let bmi = BITMAPINFO {
|
|
bmiHeader: BITMAPINFOHEADER {
|
|
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
|
biWidth: width,
|
|
biHeight: -height, // top-down
|
|
biPlanes: 1,
|
|
biBitCount: 32,
|
|
biCompression: 0, // BI_RGB
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
let mut bits: *mut std::ffi::c_void = std::ptr::null_mut();
|
|
let mem_dc = CreateCompatibleDC(screen_dc);
|
|
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 = (width * height) as usize;
|
|
|
|
// Render once with the actual taskbar background colour.
|
|
// Using an opaque background lets us use CLEARTYPE_QUALITY for
|
|
// sub-pixel font rendering that matches the rest of the OS.
|
|
paint_content(
|
|
mem_dc,
|
|
width,
|
|
height,
|
|
is_dark,
|
|
&bg_color,
|
|
&text_color,
|
|
&accent,
|
|
&track,
|
|
strings,
|
|
session_pct,
|
|
&session_text,
|
|
weekly_pct,
|
|
&weekly_text,
|
|
);
|
|
|
|
// Background pixels → alpha 1 (nearly invisible but still hittable for right-click).
|
|
// Content pixels → fully opaque (preserves ClearType sub-pixel rendering).
|
|
let bg_bgr = bg_color.to_colorref();
|
|
let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
|
|
for px in pixel_data.iter_mut() {
|
|
let rgb = *px & 0x00FFFFFF;
|
|
if rgb == bg_bgr {
|
|
*px = 0x01000000;
|
|
} else {
|
|
*px = rgb | 0xFF000000;
|
|
}
|
|
}
|
|
|
|
// Push to window via UpdateLayeredWindow
|
|
let pt_src = POINT { x: 0, y: 0 };
|
|
let sz = SIZE {
|
|
cx: width,
|
|
cy: height,
|
|
};
|
|
let blend = BLENDFUNCTION {
|
|
BlendOp: 0, // AC_SRC_OVER
|
|
BlendFlags: 0,
|
|
SourceConstantAlpha: 255,
|
|
AlphaFormat: 1, // AC_SRC_ALPHA
|
|
};
|
|
|
|
let _ = UpdateLayeredWindow(
|
|
hwnd,
|
|
screen_dc,
|
|
None,
|
|
Some(&sz),
|
|
mem_dc,
|
|
Some(&pt_src),
|
|
COLORREF(0),
|
|
Some(&blend),
|
|
ULW_ALPHA,
|
|
);
|
|
|
|
// Cleanup
|
|
SelectObject(mem_dc, old_bmp);
|
|
let _ = DeleteObject(dib);
|
|
let _ = DeleteDC(mem_dc);
|
|
ReleaseDC(hwnd, screen_dc);
|
|
}
|
|
}
|
|
|
|
/// Paint all widget content onto a DC with a given background color.
|
|
fn paint_content(
|
|
hdc: HDC,
|
|
width: i32,
|
|
height: i32,
|
|
is_dark: bool,
|
|
bg: &Color,
|
|
text_color: &Color,
|
|
accent: &Color,
|
|
track: &Color,
|
|
strings: Strings,
|
|
session_pct: f64,
|
|
session_text: &str,
|
|
weekly_pct: f64,
|
|
weekly_text: &str,
|
|
) {
|
|
unsafe {
|
|
let client_rect = RECT {
|
|
left: 0,
|
|
top: 0,
|
|
right: width,
|
|
bottom: height,
|
|
};
|
|
|
|
let bg_brush = CreateSolidBrush(COLORREF(bg.to_colorref()));
|
|
FillRect(hdc, &client_rect, bg_brush);
|
|
let _ = DeleteObject(bg_brush);
|
|
|
|
// Left divider
|
|
let divider_h = sc(25);
|
|
let divider_top = (height - divider_h) / 2;
|
|
let divider_bottom = divider_top + divider_h;
|
|
|
|
let (div_left, div_right) = if is_dark {
|
|
((80, 80, 80), (40, 40, 40))
|
|
} else {
|
|
((160, 160, 160), (230, 230, 230))
|
|
};
|
|
|
|
let left_brush = CreateSolidBrush(COLORREF(native_interop::colorref(
|
|
div_left.0, div_left.1, div_left.2,
|
|
)));
|
|
let left_rect = RECT {
|
|
left: 0,
|
|
top: divider_top,
|
|
right: sc(2),
|
|
bottom: divider_bottom,
|
|
};
|
|
FillRect(hdc, &left_rect, left_brush);
|
|
let _ = DeleteObject(left_brush);
|
|
|
|
let right_brush = CreateSolidBrush(COLORREF(native_interop::colorref(
|
|
div_right.0,
|
|
div_right.1,
|
|
div_right.2,
|
|
)));
|
|
let right_rect = RECT {
|
|
left: sc(2),
|
|
top: divider_top,
|
|
right: sc(3),
|
|
bottom: divider_bottom,
|
|
};
|
|
FillRect(hdc, &right_rect, right_brush);
|
|
let _ = DeleteObject(right_brush);
|
|
|
|
let content_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN);
|
|
let row2_y = height - sc(5) - sc(SEGMENT_H);
|
|
let row1_y = row2_y - sc(10) - sc(SEGMENT_H);
|
|
|
|
let _ = SetBkMode(hdc, TRANSPARENT);
|
|
let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref()));
|
|
|
|
let font_name = native_interop::wide_str("Segoe UI");
|
|
let font = CreateFontW(
|
|
sc(-12),
|
|
0,
|
|
0,
|
|
0,
|
|
FW_MEDIUM.0 as i32,
|
|
0,
|
|
0,
|
|
0,
|
|
DEFAULT_CHARSET.0 as u32,
|
|
OUT_TT_PRECIS.0 as u32,
|
|
CLIP_DEFAULT_PRECIS.0 as u32,
|
|
CLEARTYPE_QUALITY.0 as u32,
|
|
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
|
|
PCWSTR::from_raw(font_name.as_ptr()),
|
|
);
|
|
let old_font = SelectObject(hdc, font);
|
|
|
|
draw_row(
|
|
hdc,
|
|
content_x,
|
|
row1_y,
|
|
strings.session_window,
|
|
session_pct,
|
|
session_text,
|
|
accent,
|
|
track,
|
|
);
|
|
draw_row(
|
|
hdc,
|
|
content_x,
|
|
row2_y,
|
|
strings.weekly_window,
|
|
weekly_pct,
|
|
weekly_text,
|
|
accent,
|
|
track,
|
|
);
|
|
|
|
SelectObject(hdc, old_font);
|
|
let _ = DeleteObject(font);
|
|
}
|
|
}
|
|
|
|
fn do_poll(send_hwnd: SendHwnd) {
|
|
let hwnd = send_hwnd.to_hwnd();
|
|
match poller::poll() {
|
|
Ok(data) => {
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
s.session_percent = data.session.percentage;
|
|
s.weekly_percent = data.weekly.percentage;
|
|
// Stop fast-poll if reset data is now fresh
|
|
if !poller::is_past_reset(&data) {
|
|
unsafe {
|
|
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
|
|
}
|
|
}
|
|
|
|
s.data = Some(data);
|
|
s.last_poll_ok = true;
|
|
refresh_usage_texts(s);
|
|
|
|
// Recovered from errors — restore normal poll interval
|
|
if s.retry_count > 0 {
|
|
s.retry_count = 0;
|
|
let interval = s.poll_interval_ms;
|
|
unsafe {
|
|
SetTimer(hwnd, TIMER_POLL, interval, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
unsafe {
|
|
let _ = PostMessageW(hwnd, WM_APP_USAGE_UPDATED, WPARAM(0), LPARAM(0));
|
|
}
|
|
}
|
|
Err(_e) => {
|
|
// Show refresh indicator — retry will recover silently
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
s.session_text = "...".to_string();
|
|
s.weekly_text = "...".to_string();
|
|
s.last_poll_ok = false;
|
|
|
|
// Exponential backoff retry: 30s, 60s, 120s, ... up to poll_interval
|
|
s.retry_count = s.retry_count.saturating_add(1);
|
|
let backoff = RETRY_BASE_MS
|
|
.saturating_mul(1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX));
|
|
let retry_ms = backoff.min(s.poll_interval_ms);
|
|
|
|
unsafe {
|
|
// Kill the 5-second reset poll so it doesn't bypass backoff
|
|
let _ = KillTimer(hwnd, TIMER_RESET_POLL);
|
|
SetTimer(hwnd, TIMER_POLL, retry_ms, None);
|
|
}
|
|
}
|
|
|
|
unsafe {
|
|
let _ = PostMessageW(hwnd, WM_APP_USAGE_UPDATED, WPARAM(0), LPARAM(0));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn schedule_countdown_timer() {
|
|
let state = lock_state();
|
|
let s = match state.as_ref() {
|
|
Some(s) => s,
|
|
None => return,
|
|
};
|
|
|
|
let data = match &s.data {
|
|
Some(d) => d,
|
|
None => return,
|
|
};
|
|
|
|
let hwnd = s.hwnd.to_hwnd();
|
|
|
|
// If a reset time has passed, poll every 5s to pick up fresh data
|
|
if poller::is_past_reset(data) {
|
|
unsafe {
|
|
SetTimer(hwnd, TIMER_RESET_POLL, 5_000, None);
|
|
}
|
|
}
|
|
|
|
let session_delay = poller::time_until_display_change(data.session.resets_at);
|
|
let weekly_delay = poller::time_until_display_change(data.weekly.resets_at);
|
|
|
|
let min_delay = match (session_delay, weekly_delay) {
|
|
(Some(a), Some(b)) => Some(a.min(b)),
|
|
(Some(a), None) => Some(a),
|
|
(None, Some(b)) => Some(b),
|
|
(None, None) => None,
|
|
};
|
|
|
|
let ms = min_delay
|
|
.unwrap_or(Duration::from_secs(60))
|
|
.as_millis()
|
|
.max(1000) as u32;
|
|
|
|
unsafe {
|
|
SetTimer(hwnd, TIMER_COUNTDOWN, ms, None);
|
|
}
|
|
}
|
|
|
|
fn check_theme_change() {
|
|
let new_dark = theme::is_dark_mode();
|
|
let changed = {
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
if s.is_dark != new_dark {
|
|
s.is_dark = new_dark;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
if changed {
|
|
render_layered();
|
|
}
|
|
}
|
|
|
|
fn check_language_change() {
|
|
if update_language_change() {
|
|
render_layered();
|
|
}
|
|
}
|
|
|
|
fn update_display() {
|
|
let mut state = lock_state();
|
|
let s = match state.as_mut() {
|
|
Some(s) => s,
|
|
None => return,
|
|
};
|
|
|
|
// Don't overwrite error text with stale cached data
|
|
if !s.last_poll_ok {
|
|
return;
|
|
}
|
|
|
|
refresh_usage_texts(s);
|
|
}
|
|
|
|
fn position_at_taskbar() {
|
|
refresh_dpi();
|
|
// Drop the app-state lock before any Win32 call that may synchronously
|
|
// re-enter our window procedure.
|
|
let (hwnd, embedded, tray_offset, taskbar_hwnd) = {
|
|
let state = lock_state();
|
|
let s = match state.as_ref() {
|
|
Some(s) => s,
|
|
None => return,
|
|
};
|
|
|
|
// Don't fight the user's drag
|
|
if s.dragging {
|
|
return;
|
|
}
|
|
|
|
let taskbar_hwnd = match s.taskbar_hwnd {
|
|
Some(h) => h,
|
|
None => {
|
|
diagnose::log("position_at_taskbar skipped: no taskbar handle");
|
|
return;
|
|
}
|
|
};
|
|
|
|
(s.hwnd.to_hwnd(), s.embedded, s.tray_offset, taskbar_hwnd)
|
|
};
|
|
|
|
let taskbar_rect = match native_interop::get_taskbar_rect(taskbar_hwnd) {
|
|
Some(r) => r,
|
|
None => {
|
|
diagnose::log("position_at_taskbar skipped: unable to query taskbar rect");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let taskbar_height = taskbar_rect.bottom - taskbar_rect.top;
|
|
let mut tray_left = taskbar_rect.right;
|
|
let anchor_top = taskbar_rect.top;
|
|
let anchor_height = taskbar_height;
|
|
|
|
if let Some(tray_hwnd) = native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd") {
|
|
if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd) {
|
|
tray_left = tray_rect.left;
|
|
}
|
|
}
|
|
|
|
let widget_width = total_widget_width();
|
|
|
|
let widget_height = sc(WIDGET_HEIGHT);
|
|
let y = compute_anchor_y(anchor_top, anchor_height, widget_height);
|
|
if embedded {
|
|
// Child window: coordinates relative to parent (taskbar)
|
|
let x = tray_left - taskbar_rect.left - widget_width - tray_offset;
|
|
native_interop::move_window(hwnd, x, y - taskbar_rect.top, widget_width, widget_height);
|
|
diagnose::log(format!(
|
|
"positioned embedded widget at x={x} y={} w={widget_width} h={widget_height}",
|
|
y - taskbar_rect.top
|
|
));
|
|
} else {
|
|
// Topmost popup: screen coordinates
|
|
let x = tray_left - widget_width - tray_offset;
|
|
native_interop::move_window(hwnd, x, y, widget_width, widget_height);
|
|
diagnose::log(format!(
|
|
"positioned fallback widget at x={x} y={y} w={widget_width} h={widget_height}"
|
|
));
|
|
}
|
|
}
|
|
|
|
fn compute_anchor_y(anchor_top: i32, anchor_height: i32, widget_height: i32) -> i32 {
|
|
let anchor_bottom = anchor_top + anchor_height;
|
|
(anchor_bottom - widget_height).max(anchor_top)
|
|
}
|
|
|
|
/// WinEvent callback for tray icon location changes
|
|
unsafe extern "system" fn on_tray_location_changed(
|
|
_hook: HWINEVENTHOOK,
|
|
_event: u32,
|
|
hwnd: HWND,
|
|
_id_object: i32,
|
|
_id_child: i32,
|
|
_thread: u32,
|
|
_time: u32,
|
|
) {
|
|
static LAST_REPOSITION: Mutex<Option<std::time::Instant>> = Mutex::new(None);
|
|
|
|
let is_tray = {
|
|
let state = lock_state();
|
|
state
|
|
.as_ref()
|
|
.and_then(|s| s.tray_notify_hwnd)
|
|
.map(|h| h == hwnd)
|
|
.unwrap_or(false)
|
|
};
|
|
|
|
if is_tray {
|
|
let should_reposition = {
|
|
let mut last = LAST_REPOSITION.lock().unwrap_or_else(|e| e.into_inner());
|
|
let now = std::time::Instant::now();
|
|
if last
|
|
.map(|t| now.duration_since(t).as_millis() > 500)
|
|
.unwrap_or(true)
|
|
{
|
|
*last = Some(now);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
if should_reposition {
|
|
position_at_taskbar();
|
|
render_layered();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Main window procedure
|
|
unsafe extern "system" fn wnd_proc(
|
|
hwnd: HWND,
|
|
msg: u32,
|
|
wparam: WPARAM,
|
|
lparam: LPARAM,
|
|
) -> LRESULT {
|
|
match msg {
|
|
WM_PAINT => {
|
|
// For non-embedded fallback, paint normally
|
|
let embedded = {
|
|
let state = lock_state();
|
|
state.as_ref().map(|s| s.embedded).unwrap_or(false)
|
|
};
|
|
if embedded {
|
|
// Layered windows don't use WM_PAINT; just validate the region
|
|
let mut ps = PAINTSTRUCT::default();
|
|
let _ = BeginPaint(hwnd, &mut ps);
|
|
let _ = EndPaint(hwnd, &ps);
|
|
} else {
|
|
let mut ps = PAINTSTRUCT::default();
|
|
let hdc = BeginPaint(hwnd, &mut ps);
|
|
paint(hdc, hwnd);
|
|
let _ = EndPaint(hwnd, &ps);
|
|
}
|
|
LRESULT(0)
|
|
}
|
|
WM_ERASEBKGND => LRESULT(1),
|
|
WM_DISPLAYCHANGE | WM_DPICHANGED_MSG | WM_SETTINGCHANGE => {
|
|
if msg == WM_DPICHANGED_MSG {
|
|
let new_dpi = (wparam.0 & 0xFFFF) as u32;
|
|
CURRENT_DPI.store(new_dpi, Ordering::Relaxed);
|
|
}
|
|
if msg == WM_SETTINGCHANGE {
|
|
check_theme_change();
|
|
check_language_change();
|
|
}
|
|
refresh_dpi();
|
|
position_at_taskbar();
|
|
render_layered();
|
|
LRESULT(0)
|
|
}
|
|
WM_TIMER => {
|
|
let timer_id = wparam.0;
|
|
match timer_id {
|
|
TIMER_POLL => {
|
|
let sh = SendHwnd::from_hwnd(hwnd);
|
|
std::thread::spawn(move || {
|
|
do_poll(sh);
|
|
});
|
|
}
|
|
TIMER_COUNTDOWN => {
|
|
update_display();
|
|
render_layered();
|
|
schedule_countdown_timer();
|
|
}
|
|
TIMER_RESET_POLL => {
|
|
let sh = SendHwnd::from_hwnd(hwnd);
|
|
std::thread::spawn(move || {
|
|
do_poll(sh);
|
|
});
|
|
}
|
|
TIMER_UPDATE_CHECK => {
|
|
begin_update_check(hwnd, false);
|
|
}
|
|
_ => {}
|
|
}
|
|
LRESULT(0)
|
|
}
|
|
WM_APP_USAGE_UPDATED => {
|
|
check_theme_change();
|
|
check_language_change();
|
|
render_layered();
|
|
schedule_countdown_timer();
|
|
let (pct, tooltip) = tray_icon_data_from_state();
|
|
tray_icon::update(hwnd, pct, &tooltip);
|
|
LRESULT(0)
|
|
}
|
|
WM_APP_UPDATE_CHECK_COMPLETE => {
|
|
schedule_auto_update_check(hwnd);
|
|
LRESULT(0)
|
|
}
|
|
WM_SETCURSOR => {
|
|
let is_dragging = {
|
|
let state = lock_state();
|
|
state.as_ref().map(|s| s.dragging).unwrap_or(false)
|
|
};
|
|
// Always show resize cursor while dragging or when hovering divider zone
|
|
let hit_test = (lparam.0 & 0xFFFF) as u16;
|
|
if is_dragging {
|
|
let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE).unwrap_or_default();
|
|
SetCursor(cursor);
|
|
return LRESULT(1);
|
|
}
|
|
if hit_test == 1 {
|
|
// HTCLIENT
|
|
let mut pt = POINT::default();
|
|
let _ = GetCursorPos(&mut pt);
|
|
let _ = ScreenToClient(hwnd, &mut pt);
|
|
if pt.x < sc(DIVIDER_HIT_ZONE) {
|
|
let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE).unwrap_or_default();
|
|
SetCursor(cursor);
|
|
return LRESULT(1);
|
|
}
|
|
}
|
|
DefWindowProcW(hwnd, msg, wparam, lparam)
|
|
}
|
|
WM_LBUTTONDOWN => {
|
|
let client_x = (lparam.0 & 0xFFFF) as i16 as i32;
|
|
if client_x < sc(DIVIDER_HIT_ZONE) {
|
|
let mut pt = POINT::default();
|
|
let _ = GetCursorPos(&mut pt);
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
s.dragging = true;
|
|
s.drag_start_mouse_x = pt.x;
|
|
s.drag_start_offset = s.tray_offset;
|
|
}
|
|
SetCapture(hwnd);
|
|
}
|
|
LRESULT(0)
|
|
}
|
|
WM_MOUSEMOVE => {
|
|
let is_dragging = {
|
|
let state = lock_state();
|
|
state.as_ref().map(|s| s.dragging).unwrap_or(false)
|
|
};
|
|
if is_dragging {
|
|
let mut pt = POINT::default();
|
|
let _ = GetCursorPos(&mut pt);
|
|
let move_target = {
|
|
let mut state = lock_state();
|
|
let s = match state.as_mut() {
|
|
Some(s) => s,
|
|
None => return LRESULT(0),
|
|
};
|
|
|
|
// Moving mouse left = positive delta = larger offset (further left)
|
|
let delta = s.drag_start_mouse_x - pt.x;
|
|
let mut new_offset = s.drag_start_offset + delta;
|
|
|
|
// Clamp: offset >= 0 (can't go right of default)
|
|
if new_offset < 0 {
|
|
new_offset = 0;
|
|
}
|
|
|
|
let taskbar_hwnd = s.taskbar_hwnd;
|
|
let embedded = s.embedded;
|
|
let hwnd_val = s.hwnd.to_hwnd();
|
|
|
|
// Clamp: don't go past left edge of taskbar
|
|
if let Some(taskbar_hwnd) = taskbar_hwnd {
|
|
if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) {
|
|
let mut tray_left = taskbar_rect.right;
|
|
if let Some(tray_hwnd) =
|
|
native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd")
|
|
{
|
|
if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd)
|
|
{
|
|
tray_left = tray_rect.left;
|
|
}
|
|
}
|
|
let widget_width = total_widget_width();
|
|
let max_offset = tray_left - taskbar_rect.left - widget_width;
|
|
if new_offset > max_offset {
|
|
new_offset = max_offset;
|
|
}
|
|
|
|
s.tray_offset = new_offset;
|
|
|
|
let taskbar_height = taskbar_rect.bottom - taskbar_rect.top;
|
|
let anchor_top = taskbar_rect.top;
|
|
let anchor_height = taskbar_height;
|
|
let widget_height = sc(WIDGET_HEIGHT);
|
|
let y = compute_anchor_y(anchor_top, anchor_height, widget_height);
|
|
let x = if embedded {
|
|
tray_left - taskbar_rect.left - widget_width - new_offset
|
|
} else {
|
|
tray_left - widget_width - new_offset
|
|
};
|
|
Some((
|
|
hwnd_val,
|
|
embedded,
|
|
x,
|
|
y,
|
|
taskbar_rect.top,
|
|
widget_width,
|
|
widget_height,
|
|
))
|
|
} else {
|
|
s.tray_offset = new_offset;
|
|
None
|
|
}
|
|
} else {
|
|
s.tray_offset = new_offset;
|
|
None
|
|
}
|
|
};
|
|
|
|
if let Some((hwnd_val, embedded, x, y, taskbar_top, widget_width, widget_height)) =
|
|
move_target
|
|
{
|
|
if embedded {
|
|
native_interop::move_window(
|
|
hwnd_val,
|
|
x,
|
|
y - taskbar_top,
|
|
widget_width,
|
|
widget_height,
|
|
);
|
|
} else {
|
|
native_interop::move_window(hwnd_val, x, y, widget_width, widget_height);
|
|
}
|
|
}
|
|
}
|
|
LRESULT(0)
|
|
}
|
|
WM_LBUTTONUP => {
|
|
let was_dragging = {
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
if s.dragging {
|
|
s.dragging = false;
|
|
let offset = s.tray_offset;
|
|
Some(offset)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
if was_dragging.is_some() {
|
|
let _ = ReleaseCapture();
|
|
save_state_settings();
|
|
}
|
|
LRESULT(0)
|
|
}
|
|
WM_RBUTTONUP => {
|
|
show_context_menu(hwnd);
|
|
LRESULT(0)
|
|
}
|
|
WM_COMMAND => {
|
|
let id = wparam.0 as u16;
|
|
match id {
|
|
1 => {
|
|
{
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
s.session_text = "...".to_string();
|
|
s.weekly_text = "...".to_string();
|
|
}
|
|
}
|
|
render_layered();
|
|
let sh = SendHwnd::from_hwnd(hwnd);
|
|
std::thread::spawn(move || {
|
|
do_poll(sh);
|
|
});
|
|
}
|
|
IDM_VERSION_ACTION => {
|
|
let (install_channel, release) = {
|
|
let state = lock_state();
|
|
match state.as_ref() {
|
|
Some(s) => (
|
|
s.install_channel,
|
|
match &s.update_status {
|
|
UpdateStatus::Available(release) => Some(release.clone()),
|
|
_ => None,
|
|
},
|
|
),
|
|
None => (InstallChannel::Portable, None),
|
|
}
|
|
};
|
|
|
|
match install_channel {
|
|
InstallChannel::Winget => {
|
|
if release.is_some() {
|
|
begin_winget_update(hwnd);
|
|
} else {
|
|
begin_update_check(hwnd, true);
|
|
}
|
|
}
|
|
InstallChannel::Portable => {
|
|
if let Some(release) = release {
|
|
begin_update_apply(hwnd, release);
|
|
} else {
|
|
begin_update_check(hwnd, true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
2 => {
|
|
let hook = {
|
|
let state = lock_state();
|
|
state.as_ref().and_then(|s| s.win_event_hook)
|
|
};
|
|
if let Some(h) = hook {
|
|
native_interop::unhook_win_event(h);
|
|
}
|
|
PostQuitMessage(0);
|
|
}
|
|
IDM_RESET_POSITION => {
|
|
{
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
s.tray_offset = 0;
|
|
}
|
|
}
|
|
save_state_settings();
|
|
position_at_taskbar();
|
|
}
|
|
IDM_START_WITH_WINDOWS => {
|
|
set_startup_enabled(!is_startup_enabled());
|
|
}
|
|
IDM_FREQ_1MIN | IDM_FREQ_5MIN | IDM_FREQ_15MIN | IDM_FREQ_1HOUR => {
|
|
let new_interval = match id {
|
|
IDM_FREQ_1MIN => POLL_1_MIN,
|
|
IDM_FREQ_5MIN => POLL_5_MIN,
|
|
IDM_FREQ_15MIN => POLL_15_MIN,
|
|
IDM_FREQ_1HOUR => POLL_1_HOUR,
|
|
_ => POLL_15_MIN,
|
|
};
|
|
{
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
s.poll_interval_ms = new_interval;
|
|
}
|
|
}
|
|
save_state_settings();
|
|
// Reset the poll timer with the new interval
|
|
SetTimer(hwnd, TIMER_POLL, new_interval, None);
|
|
}
|
|
IDM_LANG_SYSTEM | IDM_LANG_ENGLISH | IDM_LANG_SPANISH | IDM_LANG_FRENCH
|
|
| IDM_LANG_GERMAN | IDM_LANG_JAPANESE | IDM_LANG_KOREAN => {
|
|
let language_override = match id {
|
|
IDM_LANG_SYSTEM => None,
|
|
IDM_LANG_ENGLISH => Some(LanguageId::English),
|
|
IDM_LANG_SPANISH => Some(LanguageId::Spanish),
|
|
IDM_LANG_FRENCH => Some(LanguageId::French),
|
|
IDM_LANG_GERMAN => Some(LanguageId::German),
|
|
IDM_LANG_JAPANESE => Some(LanguageId::Japanese),
|
|
IDM_LANG_KOREAN => Some(LanguageId::Korean),
|
|
_ => None,
|
|
};
|
|
{
|
|
let mut state = lock_state();
|
|
if let Some(s) = state.as_mut() {
|
|
apply_language_to_state(s, language_override);
|
|
}
|
|
}
|
|
save_state_settings();
|
|
render_layered();
|
|
}
|
|
id if id == tray_icon::IDM_TOGGLE_WIDGET => {
|
|
toggle_widget_visibility(hwnd);
|
|
}
|
|
_ => {}
|
|
}
|
|
LRESULT(0)
|
|
}
|
|
_ if msg == WM_APP_TRAY => {
|
|
match tray_icon::handle_message(lparam) {
|
|
tray_icon::TrayAction::ToggleWidget => {
|
|
toggle_widget_visibility(hwnd);
|
|
}
|
|
tray_icon::TrayAction::ShowContextMenu => {
|
|
show_context_menu(hwnd);
|
|
}
|
|
tray_icon::TrayAction::None => {}
|
|
}
|
|
LRESULT(0)
|
|
}
|
|
WM_DESTROY => {
|
|
let hook = {
|
|
let state = lock_state();
|
|
state.as_ref().and_then(|s| s.win_event_hook)
|
|
};
|
|
if let Some(h) = hook {
|
|
native_interop::unhook_win_event(h);
|
|
}
|
|
tray_icon::remove(hwnd);
|
|
PostQuitMessage(0);
|
|
LRESULT(0)
|
|
}
|
|
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
|
}
|
|
}
|
|
|
|
fn show_context_menu(hwnd: HWND) {
|
|
unsafe {
|
|
let (
|
|
current_interval,
|
|
strings,
|
|
language,
|
|
language_override,
|
|
install_channel,
|
|
update_status,
|
|
widget_visible,
|
|
) = {
|
|
let state = lock_state();
|
|
match state.as_ref() {
|
|
Some(s) => (
|
|
s.poll_interval_ms,
|
|
s.language.strings(),
|
|
s.language,
|
|
s.language_override,
|
|
s.install_channel,
|
|
s.update_status.clone(),
|
|
s.widget_visible,
|
|
),
|
|
None => (
|
|
POLL_15_MIN,
|
|
LanguageId::English.strings(),
|
|
LanguageId::English,
|
|
None,
|
|
InstallChannel::Portable,
|
|
UpdateStatus::Idle,
|
|
true,
|
|
),
|
|
}
|
|
};
|
|
|
|
let menu = CreatePopupMenu().unwrap();
|
|
|
|
let refresh_str = native_interop::wide_str(strings.refresh);
|
|
let _ = AppendMenuW(
|
|
menu,
|
|
MENU_ITEM_FLAGS(0),
|
|
1,
|
|
PCWSTR::from_raw(refresh_str.as_ptr()),
|
|
);
|
|
|
|
// Update Frequency submenu
|
|
let freq_menu = CreatePopupMenu().unwrap();
|
|
let freq_items: [(u16, u32, &str); 4] = [
|
|
(IDM_FREQ_1MIN, POLL_1_MIN, strings.one_minute),
|
|
(IDM_FREQ_5MIN, POLL_5_MIN, strings.five_minutes),
|
|
(IDM_FREQ_15MIN, POLL_15_MIN, strings.fifteen_minutes),
|
|
(IDM_FREQ_1HOUR, POLL_1_HOUR, strings.one_hour),
|
|
];
|
|
for (id, interval, label) in freq_items {
|
|
let label_str = native_interop::wide_str(label);
|
|
let flags = if interval == current_interval {
|
|
MF_CHECKED
|
|
} else {
|
|
MENU_ITEM_FLAGS(0)
|
|
};
|
|
let _ = AppendMenuW(
|
|
freq_menu,
|
|
flags,
|
|
id as usize,
|
|
PCWSTR::from_raw(label_str.as_ptr()),
|
|
);
|
|
}
|
|
|
|
let freq_label = native_interop::wide_str(strings.update_frequency);
|
|
let _ = AppendMenuW(
|
|
menu,
|
|
MF_POPUP,
|
|
freq_menu.0 as usize,
|
|
PCWSTR::from_raw(freq_label.as_ptr()),
|
|
);
|
|
|
|
// Settings submenu
|
|
let settings_menu = CreatePopupMenu().unwrap();
|
|
|
|
let startup_str = native_interop::wide_str(strings.start_with_windows);
|
|
let startup_flags = if is_startup_enabled() {
|
|
MF_CHECKED
|
|
} else {
|
|
MENU_ITEM_FLAGS(0)
|
|
};
|
|
let _ = AppendMenuW(
|
|
settings_menu,
|
|
startup_flags,
|
|
IDM_START_WITH_WINDOWS as usize,
|
|
PCWSTR::from_raw(startup_str.as_ptr()),
|
|
);
|
|
|
|
let reset_pos_str = native_interop::wide_str(strings.reset_position);
|
|
let _ = AppendMenuW(
|
|
settings_menu,
|
|
MENU_ITEM_FLAGS(0),
|
|
IDM_RESET_POSITION as usize,
|
|
PCWSTR::from_raw(reset_pos_str.as_ptr()),
|
|
);
|
|
|
|
let language_menu = CreatePopupMenu().unwrap();
|
|
let system_label = native_interop::wide_str(strings.system_default);
|
|
let system_flags = if language_override.is_none() {
|
|
MF_CHECKED
|
|
} else {
|
|
MENU_ITEM_FLAGS(0)
|
|
};
|
|
let _ = AppendMenuW(
|
|
language_menu,
|
|
system_flags,
|
|
IDM_LANG_SYSTEM as usize,
|
|
PCWSTR::from_raw(system_label.as_ptr()),
|
|
);
|
|
|
|
for language in LanguageId::ALL {
|
|
let id = match language {
|
|
LanguageId::English => IDM_LANG_ENGLISH,
|
|
LanguageId::Spanish => IDM_LANG_SPANISH,
|
|
LanguageId::French => IDM_LANG_FRENCH,
|
|
LanguageId::German => IDM_LANG_GERMAN,
|
|
LanguageId::Japanese => IDM_LANG_JAPANESE,
|
|
LanguageId::Korean => IDM_LANG_KOREAN,
|
|
};
|
|
let label_str = native_interop::wide_str(language.native_name());
|
|
let flags = if language_override == Some(language) {
|
|
MF_CHECKED
|
|
} else {
|
|
MENU_ITEM_FLAGS(0)
|
|
};
|
|
let _ = AppendMenuW(
|
|
language_menu,
|
|
flags,
|
|
id as usize,
|
|
PCWSTR::from_raw(label_str.as_ptr()),
|
|
);
|
|
}
|
|
|
|
let language_label = native_interop::wide_str(strings.language);
|
|
let _ = AppendMenuW(
|
|
settings_menu,
|
|
MF_POPUP,
|
|
language_menu.0 as usize,
|
|
PCWSTR::from_raw(language_label.as_ptr()),
|
|
);
|
|
|
|
let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null());
|
|
|
|
let version_label =
|
|
version_action_label(strings, language, install_channel, &update_status);
|
|
let version_str = native_interop::wide_str(&version_label);
|
|
let version_flags = if matches!(update_status, UpdateStatus::Checking | UpdateStatus::Applying)
|
|
{
|
|
MF_GRAYED
|
|
} else {
|
|
MENU_ITEM_FLAGS(0)
|
|
};
|
|
let _ = AppendMenuW(
|
|
settings_menu,
|
|
version_flags,
|
|
IDM_VERSION_ACTION as usize,
|
|
PCWSTR::from_raw(version_str.as_ptr()),
|
|
);
|
|
|
|
let settings_label = native_interop::wide_str(strings.settings);
|
|
let _ = AppendMenuW(
|
|
menu,
|
|
MF_POPUP,
|
|
settings_menu.0 as usize,
|
|
PCWSTR::from_raw(settings_label.as_ptr()),
|
|
);
|
|
|
|
let widget_label = native_interop::wide_str(strings.show_widget);
|
|
let widget_flags = if widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) };
|
|
let _ = AppendMenuW(
|
|
menu,
|
|
widget_flags,
|
|
tray_icon::IDM_TOGGLE_WIDGET as usize,
|
|
PCWSTR::from_raw(widget_label.as_ptr()),
|
|
);
|
|
|
|
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
|
|
|
let exit_str = native_interop::wide_str(strings.exit);
|
|
let _ = AppendMenuW(
|
|
menu,
|
|
MENU_ITEM_FLAGS(0),
|
|
2,
|
|
PCWSTR::from_raw(exit_str.as_ptr()),
|
|
);
|
|
|
|
let mut pt = POINT::default();
|
|
let _ = GetCursorPos(&mut pt);
|
|
let _ = SetForegroundWindow(hwnd);
|
|
let _ = TrackPopupMenu(menu, TPM_RIGHTBUTTON, pt.x, pt.y, 0, hwnd, None);
|
|
let _ = DestroyMenu(menu);
|
|
}
|
|
}
|
|
|
|
/// Paint for non-embedded fallback (normal WM_PAINT path)
|
|
fn paint(hdc: HDC, hwnd: HWND) {
|
|
let (is_dark, strings, session_pct, session_text, weekly_pct, weekly_text) = {
|
|
let state = lock_state();
|
|
match state.as_ref() {
|
|
Some(s) => (
|
|
s.is_dark,
|
|
s.language.strings(),
|
|
s.session_percent,
|
|
s.session_text.clone(),
|
|
s.weekly_percent,
|
|
s.weekly_text.clone(),
|
|
),
|
|
None => return,
|
|
}
|
|
};
|
|
|
|
let accent = Color::from_hex("#D97757");
|
|
let track = if is_dark {
|
|
Color::from_hex("#444444")
|
|
} else {
|
|
Color::from_hex("#AAAAAA")
|
|
};
|
|
let text_color = if is_dark {
|
|
Color::from_hex("#888888")
|
|
} else {
|
|
Color::from_hex("#404040")
|
|
};
|
|
let bg_color = if is_dark {
|
|
Color::from_hex("#1C1C1C")
|
|
} else {
|
|
Color::from_hex("#F3F3F3")
|
|
};
|
|
|
|
unsafe {
|
|
let mut client_rect = RECT::default();
|
|
let _ = GetClientRect(hwnd, &mut client_rect);
|
|
let width = client_rect.right - client_rect.left;
|
|
let height = client_rect.bottom - client_rect.top;
|
|
|
|
if width <= 0 || height <= 0 {
|
|
return;
|
|
}
|
|
|
|
let mem_dc = CreateCompatibleDC(hdc);
|
|
let mem_bmp = CreateCompatibleBitmap(hdc, width, height);
|
|
let old_bmp = SelectObject(mem_dc, mem_bmp);
|
|
|
|
paint_content(
|
|
mem_dc,
|
|
width,
|
|
height,
|
|
is_dark,
|
|
&bg_color,
|
|
&text_color,
|
|
&accent,
|
|
&track,
|
|
strings,
|
|
session_pct,
|
|
&session_text,
|
|
weekly_pct,
|
|
&weekly_text,
|
|
);
|
|
|
|
let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY);
|
|
|
|
SelectObject(mem_dc, old_bmp);
|
|
let _ = DeleteObject(mem_bmp);
|
|
let _ = DeleteDC(mem_dc);
|
|
}
|
|
}
|
|
|
|
fn draw_row(
|
|
hdc: HDC,
|
|
x: i32,
|
|
y: i32,
|
|
label: &str,
|
|
percent: f64,
|
|
text: &str,
|
|
accent: &Color,
|
|
track: &Color,
|
|
) {
|
|
let seg_w = sc(SEGMENT_W);
|
|
let seg_h = sc(SEGMENT_H);
|
|
let seg_gap = sc(SEGMENT_GAP);
|
|
let corner_r = sc(CORNER_RADIUS);
|
|
|
|
unsafe {
|
|
let mut label_wide: Vec<u16> = label.encode_utf16().collect();
|
|
let mut label_rect = RECT {
|
|
left: x,
|
|
top: y,
|
|
right: x + sc(LABEL_WIDTH),
|
|
bottom: y + seg_h,
|
|
};
|
|
let _ = DrawTextW(
|
|
hdc,
|
|
&mut label_wide,
|
|
&mut label_rect,
|
|
DT_LEFT | DT_VCENTER | DT_SINGLELINE,
|
|
);
|
|
|
|
let bar_x = x + sc(LABEL_WIDTH) + sc(LABEL_RIGHT_MARGIN);
|
|
let percent_clamped = percent.clamp(0.0, 100.0);
|
|
|
|
for i in 0..SEGMENT_COUNT {
|
|
let seg_x = bar_x + i * (seg_w + seg_gap);
|
|
let seg_start = (i as f64) * 10.0;
|
|
let seg_end = seg_start + 10.0;
|
|
|
|
let seg_rect = RECT {
|
|
left: seg_x,
|
|
top: y,
|
|
right: seg_x + seg_w,
|
|
bottom: y + seg_h,
|
|
};
|
|
|
|
if percent_clamped >= seg_end {
|
|
draw_rounded_rect(hdc, &seg_rect, accent, corner_r);
|
|
} else if percent_clamped <= seg_start {
|
|
draw_rounded_rect(hdc, &seg_rect, track, corner_r);
|
|
} else {
|
|
draw_rounded_rect(hdc, &seg_rect, track, corner_r);
|
|
let fraction = (percent_clamped - seg_start) / 10.0;
|
|
let fill_width = (seg_w as f64 * fraction) as i32;
|
|
if fill_width > 0 {
|
|
let fill_rect = RECT {
|
|
left: seg_x,
|
|
top: y,
|
|
right: seg_x + fill_width,
|
|
bottom: y + seg_h,
|
|
};
|
|
let rgn = CreateRoundRectRgn(
|
|
seg_rect.left,
|
|
seg_rect.top,
|
|
seg_rect.right + 1,
|
|
seg_rect.bottom + 1,
|
|
corner_r * 2,
|
|
corner_r * 2,
|
|
);
|
|
let _ = SelectClipRgn(hdc, rgn);
|
|
let brush = CreateSolidBrush(COLORREF(accent.to_colorref()));
|
|
FillRect(hdc, &fill_rect, brush);
|
|
let _ = DeleteObject(brush);
|
|
let _ = SelectClipRgn(hdc, HRGN::default());
|
|
let _ = DeleteObject(rgn);
|
|
}
|
|
}
|
|
}
|
|
|
|
let text_x = bar_x + SEGMENT_COUNT * (seg_w + seg_gap) - seg_gap + sc(BAR_RIGHT_MARGIN);
|
|
let mut text_wide: Vec<u16> = text.encode_utf16().collect();
|
|
let mut text_rect = RECT {
|
|
left: text_x,
|
|
top: y,
|
|
right: text_x + sc(TEXT_WIDTH),
|
|
bottom: y + seg_h,
|
|
};
|
|
let _ = DrawTextW(
|
|
hdc,
|
|
&mut text_wide,
|
|
&mut text_rect,
|
|
DT_LEFT | DT_VCENTER | DT_SINGLELINE,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn draw_rounded_rect(hdc: HDC, rect: &RECT, color: &Color, radius: i32) {
|
|
unsafe {
|
|
let brush = CreateSolidBrush(COLORREF(color.to_colorref()));
|
|
let rgn = CreateRoundRectRgn(
|
|
rect.left,
|
|
rect.top,
|
|
rect.right + 1,
|
|
rect.bottom + 1,
|
|
radius * 2,
|
|
radius * 2,
|
|
);
|
|
let _ = FillRgn(hdc, rgn, brush);
|
|
let _ = DeleteObject(rgn);
|
|
let _ = DeleteObject(brush);
|
|
}
|
|
}
|