Peak hours support

This commit is contained in:
A333 2026-04-16 16:26:37 +03:00
parent a407f69453
commit 481c7eef9a
16 changed files with 891 additions and 23 deletions

1
.gitignore vendored
View File

@ -24,3 +24,4 @@ workflow.cmd
# Local WinGet manifest generation output
/manifests/
.claude/settings.local.json

View File

@ -38,6 +38,7 @@ features = [
"Win32_Security",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_HiDpi",
"Win32_System_SystemInformation",
]
[build-dependencies]

96
DEVELOPMENT.md Normal file
View File

@ -0,0 +1,96 @@
# Development Guide
This document covers how to build and run **Claude Code Usage Monitor** locally.
## Prerequisites
- **Windows 10 or Windows 11** (the app uses Win32 APIs and can only be built and run on Windows)
- **Rust** (stable toolchain) — install via [rustup](https://rustup.rs/)
- **MSVC build tools** — required by the `windows` crate and `winres`
- Install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and select the **Desktop development with C++** workload, or install a full Visual Studio edition
## Clone the Repository
```powershell
git clone https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git
cd Claude-Code-Usage-Monitor
```
## Build
### Debug build
```powershell
`cargo build`
```
The binary is output to `target\debug\claude-code-usage-monitor.exe`.
### Release build
```powershell
cargo build --release
```
The binary is output to `target\release\claude-code-usage-monitor.exe`. The release profile applies aggressive size optimisations (`opt-level = "z"`, LTO, stripping, single codegen unit).
> **Note:** `build.rs` embeds the app icon and Windows PE version metadata during compilation using `winres`. This requires the MSVC toolchain — it will not work with the GNU (`*-pc-windows-gnu`) target.
## Run`
```powershell
cargo run
```
Or run the compiled binary directly:
```powershell
.\target\debug\claude-code-usage-monitor.exe
```
The app starts as a system tray icon. Left-click the tray icon to toggle the taskbar widget. Right-click for options such as refresh interval, language, launch-at-startup, and update checks.
## Project Structure
```
src/
main.rs # Entry point, message loop
models.rs # Data types and Claude API response structs
poller.rs # Background polling logic
window.rs # Win32 taskbar widget window
tray_icon.rs # System tray icon and context menu
theme.rs # Colour theming (light/dark mode)
updater.rs # Auto-update checker
diagnose.rs # Diagnostics / debug helpers
native_interop.rs # Low-level Win32 helpers
icons/ # Embedded icon assets
localization/ # Translated UI strings (en, fr, de, es, ja, ko)
build.rs # Embeds icon and version info into the PE binary
Cargo.toml # Manifest and dependencies
```
## Dependencies
| Crate | Purpose |
|-------|---------|
| `windows` | Win32 API bindings |
| `ureq` | HTTP client for Claude usage API (native-tls) |
| `serde` / `serde_json` | JSON serialisation |
| `dirs` | Locate user credential files |
| `winres` *(build)* | Embed icon and version info into the `.exe` |
## Useful Commands
```powershell
# Check for compile errors without producing a binary
cargo check
# Run Clippy lints
cargo clippy
# Format code
cargo fmt
# Run tests (if any)
cargo test
```

View File

@ -35,4 +35,19 @@ pub(super) const STRINGS: Strings = Strings {
hour_suffix: "h",
minute_suffix: "m",
second_suffix: "s",
peak_hours: "Peak Hours...",
peak_hours_title: "Peak Hours",
peak_start_label: "Start (HH:MM):",
peak_end_label: "End (HH:MM):",
peak_tz_label: "UTC offset (hours):",
peak_tz_hint: "(leave empty to use system timezone)",
peak_clear: "Clear",
peak_invalid_input: "Invalid Input",
peak_err_both_required:
"Both start and end times must be set, or both empty to disable peak hours.",
peak_err_start_format: "Start time must be HH:MM (e.g. 09:00).",
peak_err_end_format: "End time must be HH:MM (e.g. 17:00).",
peak_err_tz_format: "UTC offset must be a number of hours (e.g. 10 for UTC+10, -5 for UTC-5).",
peak_err_tz_range: "UTC offset must be between -12 and +14 hours.",
peak_err_start_equals_end: "Start and end times must be different.",
};

View File

@ -35,4 +35,18 @@ pub(super) const STRINGS: Strings = Strings {
hour_suffix: "h",
minute_suffix: "m",
second_suffix: "s",
peak_hours: "Heures de pointe...",
peak_hours_title: "Heures de pointe",
peak_start_label: "Début (HH:MM) :",
peak_end_label: "Fin (HH:MM) :",
peak_tz_label: "Décalage UTC (heures) :",
peak_tz_hint: "(laisser vide pour utiliser le fuseau horaire système)",
peak_clear: "Effacer",
peak_invalid_input: "Entrée invalide",
peak_err_both_required: "Les heures de début et de fin doivent être définies, ou les deux vides pour désactiver les heures de pointe.",
peak_err_start_format: "L'heure de début doit être HH:MM (ex. 09:00).",
peak_err_end_format: "L'heure de fin doit être HH:MM (ex. 17:00).",
peak_err_tz_format: "Le décalage UTC doit être un nombre d'heures (ex. 10 pour UTC+10, -5 pour UTC-5).",
peak_err_tz_range: "Le décalage UTC doit être entre -12 et +14 heures.",
peak_err_start_equals_end: "Les heures de début et de fin doivent être différentes.",
};

View File

@ -35,4 +35,18 @@ pub(super) const STRINGS: Strings = Strings {
hour_suffix: "h",
minute_suffix: "m",
second_suffix: "s",
peak_hours: "Stoßzeiten...",
peak_hours_title: "Stoßzeiten",
peak_start_label: "Beginn (HH:MM):",
peak_end_label: "Ende (HH:MM):",
peak_tz_label: "UTC-Abweichung (Stunden):",
peak_tz_hint: "(leer lassen für Systemzeitzone)",
peak_clear: "Löschen",
peak_invalid_input: "Ungültige Eingabe",
peak_err_both_required: "Sowohl Start- als auch Endzeit müssen angegeben werden, oder beide leer, um Stoßzeiten zu deaktivieren.",
peak_err_start_format: "Startzeit muss HH:MM sein (z.B. 09:00).",
peak_err_end_format: "Endzeit muss HH:MM sein (z.B. 17:00).",
peak_err_tz_format: "UTC-Abweichung muss eine Anzahl von Stunden sein (z.B. 10 für UTC+10, -5 für UTC-5).",
peak_err_tz_range: "UTC-Abweichung muss zwischen -12 und +14 Stunden liegen.",
peak_err_start_equals_end: "Start- und Endzeit müssen unterschiedlich sein.",
};

View File

@ -35,4 +35,19 @@ pub(super) const STRINGS: Strings = Strings {
hour_suffix: "時間",
minute_suffix: "分",
second_suffix: "秒",
peak_hours: "ピーク時間...",
peak_hours_title: "ピーク時間",
peak_start_label: "開始 (HH:MM):",
peak_end_label: "終了 (HH:MM):",
peak_tz_label: "UTCオフセット (時間):",
peak_tz_hint: "(空欄でシステムのタイムゾーンを使用)",
peak_clear: "クリア",
peak_invalid_input: "無効な入力",
peak_err_both_required:
"ピーク時間を無効にするには、開始と終了の両方を設定するか、両方を空にしてください。",
peak_err_start_format: "開始時刻はHH:MM形式で入力してください(例: 09:00)。",
peak_err_end_format: "終了時刻はHH:MM形式で入力してください(例: 17:00)。",
peak_err_tz_format: "UTCオフセットは時間数で入力してください(例: UTC+10は10、UTC-5は-5)。",
peak_err_tz_range: "UTCオフセットは-12から+14時間の範囲で入力してください。",
peak_err_start_equals_end: "開始時刻と終了時刻は異なる必要があります。",
};

View File

@ -35,4 +35,19 @@ pub(super) const STRINGS: Strings = Strings {
hour_suffix: "시간",
minute_suffix: "분",
second_suffix: "초",
peak_hours: "피크 시간...",
peak_hours_title: "피크 시간",
peak_start_label: "시작 (HH:MM):",
peak_end_label: "종료 (HH:MM):",
peak_tz_label: "UTC 오프셋 (시간):",
peak_tz_hint: "(비워두면 시스템 시간대 사용)",
peak_clear: "지우기",
peak_invalid_input: "잘못된 입력",
peak_err_both_required:
"피크 시간을 비활성화하려면 시작과 종료 시간을 모두 설정하거나 모두 비워야 합니다.",
peak_err_start_format: "시작 시간은 HH:MM 형식이어야 합니다 (예: 09:00).",
peak_err_end_format: "종료 시간은 HH:MM 형식이어야 합니다 (예: 17:00).",
peak_err_tz_format: "UTC 오프셋은 시간 단위의 숫자여야 합니다 (예: UTC+10은 10, UTC-5는 -5).",
peak_err_tz_range: "UTC 오프셋은 -12에서 +14 시간 사이여야 합니다.",
peak_err_start_equals_end: "시작 시간과 종료 시간은 달라야 합니다.",
};

View File

@ -127,6 +127,20 @@ pub struct Strings {
pub hour_suffix: &'static str,
pub minute_suffix: &'static str,
pub second_suffix: &'static str,
pub peak_hours: &'static str,
pub peak_hours_title: &'static str,
pub peak_start_label: &'static str,
pub peak_end_label: &'static str,
pub peak_tz_label: &'static str,
pub peak_tz_hint: &'static str,
pub peak_clear: &'static str,
pub peak_invalid_input: &'static str,
pub peak_err_both_required: &'static str,
pub peak_err_start_format: &'static str,
pub peak_err_end_format: &'static str,
pub peak_err_tz_format: &'static str,
pub peak_err_tz_range: &'static str,
pub peak_err_start_equals_end: &'static str,
}
pub fn resolve_language(language_override: Option<LanguageId>) -> LanguageId {

View File

@ -35,4 +35,18 @@ pub(super) const STRINGS: Strings = Strings {
hour_suffix: "h",
minute_suffix: "m",
second_suffix: "s",
peak_hours: "Horas pico...",
peak_hours_title: "Horas pico",
peak_start_label: "Inicio (HH:MM):",
peak_end_label: "Fin (HH:MM):",
peak_tz_label: "Desfase UTC (horas):",
peak_tz_hint: "(dejar vacío para usar la zona horaria del sistema)",
peak_clear: "Borrar",
peak_invalid_input: "Entrada no válida",
peak_err_both_required: "Ambas horas de inicio y fin deben estar establecidas, o ambas vacías para desactivar las horas pico.",
peak_err_start_format: "La hora de inicio debe ser HH:MM (ej. 09:00).",
peak_err_end_format: "La hora de fin debe ser HH:MM (ej. 17:00).",
peak_err_tz_format: "El desfase UTC debe ser un número de horas (ej. 10 para UTC+10, -5 para UTC-5).",
peak_err_tz_range: "El desfase UTC debe estar entre -12 y +14 horas.",
peak_err_start_equals_end: "Las horas de inicio y fin deben ser diferentes.",
};

View File

@ -4,6 +4,7 @@ mod diagnose;
mod localization;
mod models;
mod native_interop;
mod peak_dialog;
mod poller;
mod theme;
mod tray_icon;
@ -15,10 +16,7 @@ fn main() {
let diagnose_enabled = args.iter().any(|arg| arg == "--diagnose");
if diagnose_enabled {
match diagnose::init() {
Ok(path) => diagnose::log(format!(
"startup args={args:?} log_path={}",
path.display()
)),
Ok(path) => diagnose::log(format!("startup args={args:?} log_path={}", path.display())),
Err(error) => {
// Logging may not be available yet, but keep startup behavior unchanged.
let _ = error;

View File

@ -18,6 +18,7 @@ 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_PEAK: usize = 5;
// Custom messages
pub const WM_APP: u32 = 0x8000;

391
src/peak_dialog.rs Normal file
View File

@ -0,0 +1,391 @@
//! Peak hours configuration dialog.
//!
//! Presents a small Win32 dialog (built from an in-memory DLGTEMPLATE) that lets
//! the user configure start/end times (HH:MM) and an optional UTC offset in hours.
//! Leaving the UTC offset empty means "use system local time".
use std::sync::Mutex;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{HINSTANCE, HWND, LPARAM, WPARAM};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::WindowsAndMessaging::*;
use crate::localization::Strings;
use crate::native_interop::wide_str;
// ── Control IDs ───────────────────────────────────────────────────────────────
const IDC_START: i32 = 101;
const IDC_END: i32 = 102;
const IDC_TZ: i32 = 103;
const IDC_CLEAR: i32 = 104;
// ── Thread-local dialog state (main thread only) ──────────────────────────────
struct DialogState {
start: String,
end: String,
tz: String,
strings: Strings,
}
static DIALOG_INPUT: Mutex<Option<DialogState>> = Mutex::new(None);
static DIALOG_OUTPUT: Mutex<Option<(String, String, String)>> = Mutex::new(None);
// ── Public API ────────────────────────────────────────────────────────────────
pub struct PeakDialogResult {
/// "HH:MM" or empty to disable
pub start: String,
/// "HH:MM" or empty to disable
pub end: String,
/// Hours from UTC (e.g. "10"), or empty for system timezone
pub tz: String,
}
/// Show the peak-hours dialog modally over `parent`.
/// Returns `None` if the user cancelled; `Some(result)` on OK.
pub fn show(
parent: HWND,
start: &str,
end: &str,
tz: &str,
strings: Strings,
) -> Option<PeakDialogResult> {
unsafe {
*DIALOG_INPUT.lock().unwrap_or_else(|e| e.into_inner()) = Some(DialogState {
start: start.to_string(),
end: end.to_string(),
tz: tz.to_string(),
strings,
});
*DIALOG_OUTPUT.lock().unwrap_or_else(|e| e.into_inner()) = None;
let hmodule = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
let hinstance = HINSTANCE(hmodule.0);
let template = build_template(&strings);
let ret = DialogBoxIndirectParamW(
hinstance,
template.as_ptr() as *const DLGTEMPLATE,
parent,
Some(dlg_proc),
LPARAM(0),
);
if ret <= 0 {
return None;
}
DIALOG_OUTPUT
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
.map(|(s, e, t)| PeakDialogResult {
start: s,
end: e,
tz: t,
})
}
}
/// Parse an "HH:MM" string into (hour, minute). Public so window.rs can reuse it.
pub fn parse_time(s: &str) -> Result<(u8, u8), ()> {
let s = s.trim();
let (h_str, m_str) = s.split_once(':').ok_or(())?;
let h: u8 = h_str.trim().parse().map_err(|_| ())?;
let m: u8 = m_str.trim().parse().map_err(|_| ())?;
if h > 23 || m > 59 {
return Err(());
}
Ok((h, m))
}
// ── Dialog procedure ──────────────────────────────────────────────────────────
unsafe extern "system" fn dlg_proc(hdlg: HWND, msg: u32, wparam: WPARAM, _lparam: LPARAM) -> isize {
match msg {
WM_INITDIALOG => {
let input = DIALOG_INPUT.lock().unwrap_or_else(|e| e.into_inner());
if let Some(state) = input.as_ref() {
set_text(hdlg, IDC_START, &state.start);
set_text(hdlg, IDC_END, &state.end);
set_text(hdlg, IDC_TZ, &state.tz);
}
1 // TRUE: let dialog manager set initial focus
}
WM_COMMAND => {
let id = (wparam.0 & 0xFFFF) as i32;
match id {
1 /* IDOK */ => {
let start = get_text(hdlg, IDC_START);
let end = get_text(hdlg, IDC_END);
let tz = get_text(hdlg, IDC_TZ);
let strings = {
let input = DIALOG_INPUT.lock().unwrap_or_else(|e| e.into_inner());
input
.as_ref()
.map(|s| s.strings)
.unwrap_or_else(|| crate::localization::LanguageId::English.strings())
};
if let Err(err_msg) = validate(&start, &end, &tz, &strings) {
let msg_w = wide_str(&err_msg);
let title_w = wide_str(strings.peak_invalid_input);
MessageBoxW(
hdlg,
PCWSTR::from_raw(msg_w.as_ptr()),
PCWSTR::from_raw(title_w.as_ptr()),
MB_OK | MB_ICONWARNING,
);
return 1;
}
*DIALOG_OUTPUT.lock().unwrap_or_else(|e| e.into_inner()) =
Some((start, end, tz));
let _ = EndDialog(hdlg, 1);
1
}
2 /* IDCANCEL */ => {
let _ = EndDialog(hdlg, 0);
1
}
IDC_CLEAR => {
set_text(hdlg, IDC_START, "");
set_text(hdlg, IDC_END, "");
set_text(hdlg, IDC_TZ, "");
1
}
_ => 0,
}
}
_ => 0,
}
}
// ── Validation ────────────────────────────────────────────────────────────────
fn validate(start: &str, end: &str, tz: &str, strings: &Strings) -> Result<(), String> {
if start.is_empty() != end.is_empty() {
return Err(strings.peak_err_both_required.to_string());
}
if !start.is_empty() {
let s = parse_time(start).map_err(|_| strings.peak_err_start_format.to_string())?;
if !end.is_empty() {
let e = parse_time(end).map_err(|_| strings.peak_err_end_format.to_string())?;
if s == e {
return Err(strings.peak_err_start_equals_end.to_string());
}
}
}
if !end.is_empty() {
parse_time(end).map_err(|_| strings.peak_err_end_format.to_string())?;
}
if !tz.is_empty() {
let v: i32 = tz
.trim()
.parse()
.map_err(|_| strings.peak_err_tz_format.to_string())?;
if !(-12..=14).contains(&v) {
return Err(strings.peak_err_tz_range.to_string());
}
}
Ok(())
}
// ── Edit-control helpers ──────────────────────────────────────────────────────
fn set_text(hdlg: HWND, id: i32, text: &str) {
unsafe {
let wide = wide_str(text);
let _ = SetDlgItemTextW(hdlg, id, PCWSTR::from_raw(wide.as_ptr()));
}
}
fn get_text(hdlg: HWND, id: i32) -> String {
unsafe {
let mut buf = [0u16; 64];
let len = GetDlgItemTextW(hdlg, id, &mut buf);
String::from_utf16_lossy(&buf[..len as usize])
.trim()
.to_string()
}
}
// ── In-memory DLGTEMPLATE builder ─────────────────────────────────────────────
struct Buf(Vec<u8>);
impl Buf {
fn new() -> Self {
Self(Vec::with_capacity(512))
}
fn align4(&mut self) {
while !self.0.len().is_multiple_of(4) {
self.0.push(0);
}
}
fn u16v(&mut self, v: u16) {
self.0.extend_from_slice(&v.to_le_bytes());
}
fn u32v(&mut self, v: u32) {
self.0.extend_from_slice(&v.to_le_bytes());
}
fn i16v(&mut self, v: i16) {
self.0.extend_from_slice(&v.to_le_bytes());
}
fn wstr(&mut self, s: &str) {
for c in s.encode_utf16() {
self.u16v(c);
}
self.u16v(0);
}
fn patch_u16(&mut self, offset: usize, v: u16) {
self.0[offset] = (v & 0xFF) as u8;
self.0[offset + 1] = (v >> 8) as u8;
}
}
/// Build the DLGTEMPLATE buffer for the peak-hours dialog.
fn build_template(strings: &Strings) -> Vec<u8> {
// Numeric style constants to avoid extra imports inside this file
const DIALOG_STYLE: u32 = 0x80000000 // WS_POPUP
| 0x00C00000 // WS_CAPTION
| 0x00080000 // WS_SYSMENU
| 0x0040 // DS_SETFONT
| 0x0080 // DS_MODALFRAME
| 0x0800; // DS_CENTER
const WS_VISIBLE: u32 = 0x10000000;
const WS_CHILD: u32 = 0x40000000;
const WS_TABSTOP: u32 = 0x00010000;
const WS_BORDER: u32 = 0x00800000;
const ES_AUTOHSCROLL: u32 = 0x0080;
const BS_DEFPUSHBUTTON: u32 = 0x00000001;
const ATOM_BUTTON: u16 = 0x0080;
const ATOM_EDIT: u16 = 0x0081;
const ATOM_STATIC: u16 = 0x0082;
let mut b = Buf::new();
// ── DLGTEMPLATE header (18 bytes fixed) ─────────────────────────────────
b.u32v(DIALOG_STYLE);
b.u32v(0); // exStyle
let cdit_offset = b.0.len();
b.u16v(0); // cdit – patched below
b.i16v(0); // x (DS_CENTER overrides)
b.i16v(0); // y
b.i16v(210); // cx in dialog units
b.i16v(100); // cy in dialog units
b.u16v(0); // menu: none
b.u16v(0); // class: default dialog class
b.wstr(strings.peak_hours_title);
// DS_SETFONT: point size + typeface name
b.u16v(9);
b.wstr("Segoe UI");
// ── Item helper macro ────────────────────────────────────────────────────
let mut n: u16 = 0;
macro_rules! item {
($style:expr, $x:expr, $y:expr, $cx:expr, $cy:expr,
$id:expr, $atom:expr, $text:expr) => {{
b.align4();
b.u32v($style);
b.u32v(0u32); // exStyle
b.i16v($x as i16);
b.i16v($y as i16);
b.i16v($cx as i16);
b.i16v($cy as i16);
b.u16v($id as u16);
b.u16v(0xFFFF_u16); // atom-class marker
b.u16v($atom);
b.wstr($text);
b.u16v(0u16); // cbExtra
n += 1;
}};
}
let lbl = WS_VISIBLE | WS_CHILD;
let edt = WS_VISIBLE | WS_CHILD | WS_TABSTOP | WS_BORDER | ES_AUTOHSCROLL;
let btn = WS_VISIBLE | WS_CHILD | WS_TABSTOP;
// Row 1 – start time
item!(
lbl,
7,
14,
83,
8,
0xFFFF_u16,
ATOM_STATIC,
strings.peak_start_label
);
item!(edt, 93, 12, 55, 12, IDC_START as u16, ATOM_EDIT, "");
// Row 2 – end time
item!(
lbl,
7,
30,
83,
8,
0xFFFF_u16,
ATOM_STATIC,
strings.peak_end_label
);
item!(edt, 93, 28, 55, 12, IDC_END as u16, ATOM_EDIT, "");
// Row 3 – UTC offset
item!(
lbl,
7,
46,
83,
8,
0xFFFF_u16,
ATOM_STATIC,
strings.peak_tz_label
);
item!(edt, 93, 44, 55, 12, IDC_TZ as u16, ATOM_EDIT, "");
// Hint text
item!(
lbl,
7,
59,
196,
8,
0xFFFF_u16,
ATOM_STATIC,
strings.peak_tz_hint
);
// Buttons
item!(
btn,
7,
80,
42,
14,
IDC_CLEAR as u16,
ATOM_BUTTON,
strings.peak_clear
);
item!(
btn | BS_DEFPUSHBUTTON,
105,
80,
45,
14,
1u16,
ATOM_BUTTON,
"OK"
);
item!(btn, 155, 80, 45, 14, 2u16, ATOM_BUTTON, "Cancel");
b.patch_u16(cdit_offset, n);
b.0
}

View File

@ -417,7 +417,10 @@ fn read_windows_credentials() -> Option<Credentials> {
Err(error) => {
if diagnose::is_enabled() {
diagnose::log_error(
&format!("unable to read Windows credentials at {}", cred_path.display()),
&format!(
"unable to read Windows credentials at {}",
cred_path.display()
),
error,
);
}

View File

@ -1,12 +1,12 @@
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, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY,
NOTIFYICONDATAW, Shell_NotifyIconW,
ExtractIconExW, Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE,
NIM_MODIFY, NOTIFYICONDATAW,
};
use windows::Win32::UI::WindowsAndMessaging::*;
use windows::core::PCWSTR;
use crate::native_interop::{self, Color, WM_APP_TRAY};
@ -107,8 +107,8 @@ pub fn create_icon(percent: Option<f64>) -> HICON {
};
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();
let dib =
CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default();
if dib.is_invalid() {
let _ = DeleteDC(mem_dc);
@ -119,8 +119,7 @@ pub fn create_icon(percent: Option<f64>) -> HICON {
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);
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;
}

View File

@ -9,6 +9,7 @@ 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::SystemInformation::GetLocalTime;
use windows::Win32::System::Threading::CreateMutexW;
use windows::Win32::UI::Accessibility::HWINEVENTHOOK;
use windows::Win32::UI::HiDpi::*;
@ -20,12 +21,13 @@ 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,
self, Color, TIMER_COUNTDOWN, TIMER_PEAK, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK,
WM_APP_TRAY, WM_APP_USAGE_UPDATED,
};
use crate::tray_icon;
use crate::peak_dialog;
use crate::poller;
use crate::theme;
use crate::tray_icon;
use crate::updater::{self, InstallChannel, ReleaseDescriptor, UpdateCheckResult};
/// Wrapper to make HWND sendable across threads (safe for PostMessage usage)
@ -74,6 +76,13 @@ struct AppState {
drag_start_offset: i32,
widget_visible: bool,
/// Peak hours start (hour, minute), or None if not configured.
peak_start: Option<(u8, u8)>,
/// Peak hours end (hour, minute), or None if not configured.
peak_end: Option<(u8, u8)>,
/// UTC offset override in hours. None = use system local time.
peak_tz_offset_hours: Option<i32>,
}
#[derive(Clone, Debug)]
@ -85,6 +94,19 @@ enum UpdateStatus {
Available(ReleaseDescriptor),
}
/// Peak-hours indicator state.
#[derive(Clone, Copy, PartialEq, Eq)]
enum PeakStatus {
/// Peak hours not configured – no icon shown.
NoPeakHours,
/// Current time is outside peak hours (more than 60 min away).
Outside,
/// Within 60 minutes of peak-hours start – warning colour.
Warning,
/// Currently within peak hours – alert colour.
Inside,
}
const RETRY_BASE_MS: u32 = 30_000; // 30 seconds
const POLL_1_MIN: u32 = 60_000;
@ -107,9 +129,16 @@ 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 IDM_PEAK_HOURS: u16 = 50;
const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN
/// Diameter of the peak-hours status circle (px at 96 DPI).
/// Equals 2 × SEGMENT_H + row gap = 2 × 13 + 10 = 36, spanning both meter rows.
const PEAK_ICON_W: i32 = 36;
/// Gap between the right edge of the circle and the first meter label.
const PEAK_ICON_GAP: i32 = 6;
const WM_DPICHANGED_MSG: u32 = 0x02E0;
const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2;
@ -192,6 +221,15 @@ struct SettingsFile {
last_update_check_unix: Option<u64>,
#[serde(default = "default_widget_visible")]
widget_visible: bool,
/// Peak-hours start time in "HH:MM" format, or absent to disable.
#[serde(default, skip_serializing_if = "Option::is_none")]
peak_start: Option<String>,
/// Peak-hours end time in "HH:MM" format.
#[serde(default, skip_serializing_if = "Option::is_none")]
peak_end: Option<String>,
/// UTC offset override in hours (e.g. 10 = UTC+10). Absent = system TZ.
#[serde(default, skip_serializing_if = "Option::is_none")]
peak_tz_offset_hours: Option<i32>,
}
impl Default for SettingsFile {
@ -202,6 +240,9 @@ impl Default for SettingsFile {
language: None,
last_update_check_unix: None,
widget_visible: true,
peak_start: None,
peak_end: None,
peak_tz_offset_hours: None,
}
}
}
@ -243,6 +284,9 @@ fn save_state_settings() {
.map(|language| language.code().to_string()),
last_update_check_unix: s.last_update_check_unix,
widget_visible: s.widget_visible,
peak_start: s.peak_start.map(|(h, m)| format!("{:02}:{:02}", h, m)),
peak_end: s.peak_end.map(|(h, m)| format!("{:02}:{:02}", h, m)),
peak_tz_offset_hours: s.peak_tz_offset_hours,
});
}
}
@ -727,9 +771,35 @@ const TEXT_WIDTH: i32 = 62;
const RIGHT_MARGIN: i32 = 1;
const WIDGET_HEIGHT: i32 = 46;
/// Extra pixels reserved for the peak-hours status circle (0 when not configured).
fn peak_icon_extra_width(has_peak: bool) -> i32 {
if has_peak {
sc(PEAK_ICON_W) + sc(PEAK_ICON_GAP)
} else {
0
}
}
/// Whether peak hours are configured. Acquires the state lock briefly.
fn has_peak_hours() -> bool {
let state = lock_state();
state
.as_ref()
.map(|s| s.peak_start.is_some())
.unwrap_or(false)
}
fn total_widget_width() -> i32 {
widget_width_with_peak(has_peak_hours())
}
/// Compute widget width given whether peak hours are configured.
/// Use this variant when the caller already knows the peak state
/// (e.g. while holding the state lock) to avoid re-acquiring it.
fn widget_width_with_peak(has_peak: bool) -> i32 {
sc(LEFT_DIVIDER_W)
+ sc(DIVIDER_RIGHT_MARGIN)
+ peak_icon_extra_width(has_peak)
+ sc(LABEL_WIDTH)
+ sc(LABEL_RIGHT_MARGIN)
+ (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * SEGMENT_COUNT
@ -739,6 +809,64 @@ fn total_widget_width() -> i32 {
+ sc(RIGHT_MARGIN)
}
/// Compute the current peak-hours status from state.
fn compute_peak_status(state: &AppState) -> PeakStatus {
let (start, end) = match (state.peak_start, state.peak_end) {
(Some(s), Some(e)) => (s, e),
_ => return PeakStatus::NoPeakHours,
};
let (cur_h, cur_m) = get_local_hm(state.peak_tz_offset_hours);
let cur = cur_h as i32 * 60 + cur_m as i32;
let s_m = start.0 as i32 * 60 + start.1 as i32;
let e_m = end.0 as i32 * 60 + end.1 as i32;
// Helper: is `t` in the half-open range [a, b) wrapping midnight?
let in_range = |t: i32, a: i32, b: i32| -> bool {
if a <= b {
t >= a && t < b
} else {
t >= a || t < b
}
};
if in_range(cur, s_m, e_m) {
return PeakStatus::Inside;
}
// Warning: within 60 minutes before peak start (wrapping midnight)
let warn_start = ((s_m - 60) + 1440) % 1440;
if in_range(cur, warn_start, s_m) {
return PeakStatus::Warning;
}
PeakStatus::Outside
}
/// Return (hour, minute) in local time adjusted by the given UTC offset in hours.
/// `None` means use the OS local time via GetLocalTime.
fn get_local_hm(tz_offset_hours: Option<i32>) -> (u8, u8) {
match tz_offset_hours {
None => {
// Use OS local time (handles DST automatically)
unsafe {
let st = GetLocalTime();
(st.wHour as u8, st.wMinute as u8)
}
}
Some(offset_h) => {
use std::time::{SystemTime, UNIX_EPOCH};
let utc_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let local_secs = utc_secs + offset_h as i64 * 3600;
let day_secs = ((local_secs % 86400) + 86400) % 86400;
((day_secs / 3600) as u8, ((day_secs % 3600) / 60) as u8)
}
}
}
pub fn run() {
// Enable Per-Monitor DPI Awareness V2 for crisp rendering at any scale factor
unsafe {
@ -760,7 +888,10 @@ pub fn run() {
h
}
Err(error) => {
diagnose::log_error("startup aborted: unable to create single-instance mutex", error);
diagnose::log_error(
"startup aborted: unable to create single-instance mutex",
error,
);
return;
}
}
@ -862,9 +993,23 @@ pub fn run() {
drag_start_mouse_x: 0,
drag_start_offset: 0,
widget_visible: settings.widget_visible,
peak_start: settings
.peak_start
.as_deref()
.and_then(|s| peak_dialog::parse_time(s).ok()),
peak_end: settings
.peak_end
.as_deref()
.and_then(|s| peak_dialog::parse_time(s).ok()),
peak_tz_offset_hours: settings.peak_tz_offset_hours,
});
}
diagnose::log(format!(
"peak hours config: start={:?} end={:?} tz_hours={:?}",
settings.peak_start, settings.peak_end, settings.peak_tz_offset_hours
));
// Try to embed in taskbar
if let Some(taskbar_hwnd) = native_interop::find_taskbar() {
diagnose::log(format!("taskbar found hwnd={:?}", taskbar_hwnd));
@ -943,6 +1088,9 @@ pub fn run() {
do_poll(send_hwnd);
});
// 1-minute timer to refresh the peak-hours status circle colour.
SetTimer(hwnd, TIMER_PEAK, 60_000, None);
schedule_auto_update_check(hwnd);
let should_check_updates = {
let state = lock_state();
@ -972,7 +1120,17 @@ pub fn run() {
/// 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 (
hwnd_val,
is_dark,
embedded,
strings,
session_pct,
session_text,
weekly_pct,
weekly_text,
peak_status,
) = {
let state = lock_state();
match state.as_ref() {
Some(s) => (
@ -984,6 +1142,7 @@ fn render_layered() {
s.session_text.clone(),
s.weekly_percent,
s.weekly_text.clone(),
compute_peak_status(s),
),
None => return,
}
@ -1066,6 +1225,7 @@ fn render_layered() {
&session_text,
weekly_pct,
&weekly_text,
peak_status,
);
// Background pixels → alpha 1 (nearly invisible but still hittable for right-click).
@ -1129,6 +1289,7 @@ fn paint_content(
session_text: &str,
weekly_pct: f64,
weekly_text: &str,
peak_status: PeakStatus,
) {
unsafe {
let client_rect = RECT {
@ -1179,10 +1340,20 @@ fn paint_content(
FillRect(hdc, &right_rect, right_brush);
let _ = DeleteObject(right_brush);
let content_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN);
let icon_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN);
let peak_extra = if peak_status != PeakStatus::NoPeakHours {
sc(PEAK_ICON_W) + sc(PEAK_ICON_GAP)
} else {
0
};
let content_x = icon_x + peak_extra;
let row2_y = height - sc(5) - sc(SEGMENT_H);
let row1_y = row2_y - sc(10) - sc(SEGMENT_H);
if peak_status != PeakStatus::NoPeakHours {
draw_peak_circle(hdc, icon_x, height, peak_status, is_dark);
}
let _ = SetBkMode(hdc, TRANSPARENT);
let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref()));
@ -1552,6 +1723,10 @@ unsafe extern "system" fn wnd_proc(
TIMER_UPDATE_CHECK => {
begin_update_check(hwnd, false);
}
TIMER_PEAK => {
// Re-evaluate and repaint peak status every minute.
render_layered();
}
_ => {}
}
LRESULT(0)
@ -1634,6 +1809,7 @@ unsafe extern "system" fn wnd_proc(
}
// Clamp: don't go past left edge of taskbar
let has_peak = s.peak_start.is_some();
if let Some(taskbar_hwnd) = s.taskbar_hwnd {
if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) {
let mut tray_left = taskbar_rect.right;
@ -1645,7 +1821,7 @@ unsafe extern "system" fn wnd_proc(
tray_left = tray_rect.left;
}
}
let widget_width = total_widget_width();
let widget_width = widget_width_with_peak(has_peak);
let max_offset = if s.embedded {
tray_left - taskbar_rect.left - widget_width
} else {
@ -1675,7 +1851,7 @@ unsafe extern "system" fn wnd_proc(
tray_left = tray_rect.left;
}
}
let widget_width = total_widget_width();
let widget_width = widget_width_with_peak(has_peak);
let widget_height = sc(WIDGET_HEIGHT);
let y = compute_anchor_y(anchor_top, anchor_height, widget_height);
if s.embedded {
@ -1799,6 +1975,51 @@ unsafe extern "system" fn wnd_proc(
IDM_START_WITH_WINDOWS => {
set_startup_enabled(!is_startup_enabled());
}
IDM_PEAK_HOURS => {
// Read current peak settings for pre-population
let (cur_start, cur_end, cur_tz, strings) = {
let state = lock_state();
match state.as_ref() {
Some(s) => (
s.peak_start
.map(|(h, m)| format!("{:02}:{:02}", h, m))
.unwrap_or_default(),
s.peak_end
.map(|(h, m)| format!("{:02}:{:02}", h, m))
.unwrap_or_default(),
s.peak_tz_offset_hours
.map(|v| v.to_string())
.unwrap_or_default(),
s.language.strings(),
),
None => return LRESULT(0),
}
};
diagnose::log("opening peak hours dialog");
if let Some(result) =
peak_dialog::show(hwnd, &cur_start, &cur_end, &cur_tz, strings)
{
let new_start = peak_dialog::parse_time(&result.start).ok();
let new_end = peak_dialog::parse_time(&result.end).ok();
let new_tz: Option<i32> = result.tz.trim().parse().ok();
{
let mut state = lock_state();
if let Some(s) = state.as_mut() {
s.peak_start = new_start;
s.peak_end = new_end;
s.peak_tz_offset_hours = new_tz;
}
}
diagnose::log(format!(
"peak hours updated: start={:?} end={:?} tz={:?}",
new_start, new_end, new_tz
));
save_state_settings();
position_at_taskbar();
render_layered();
}
}
IDM_FREQ_1MIN | IDM_FREQ_5MIN | IDM_FREQ_15MIN | IDM_FREQ_1HOUR => {
let new_interval = match id {
IDM_FREQ_1MIN => POLL_1_MIN,
@ -1972,6 +2193,14 @@ fn show_context_menu(hwnd: HWND) {
PCWSTR::from_raw(reset_pos_str.as_ptr()),
);
let peak_hours_str = native_interop::wide_str(strings.peak_hours);
let _ = AppendMenuW(
settings_menu,
MENU_ITEM_FLAGS(0),
IDM_PEAK_HOURS as usize,
PCWSTR::from_raw(peak_hours_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() {
@ -2022,8 +2251,10 @@ fn show_context_menu(hwnd: HWND) {
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)
{
let version_flags = if matches!(
update_status,
UpdateStatus::Checking | UpdateStatus::Applying
) {
MF_GRAYED
} else {
MENU_ITEM_FLAGS(0)
@ -2044,7 +2275,11 @@ fn show_context_menu(hwnd: HWND) {
);
let widget_label = native_interop::wide_str(strings.show_widget);
let widget_flags = if widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) };
let widget_flags = if widget_visible {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
};
let _ = AppendMenuW(
menu,
widget_flags,
@ -2072,7 +2307,7 @@ fn show_context_menu(hwnd: HWND) {
/// 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 (is_dark, strings, session_pct, session_text, weekly_pct, weekly_text, peak_status) = {
let state = lock_state();
match state.as_ref() {
Some(s) => (
@ -2082,6 +2317,7 @@ fn paint(hdc: HDC, hwnd: HWND) {
s.session_text.clone(),
s.weekly_percent,
s.weekly_text.clone(),
compute_peak_status(s),
),
None => return,
}
@ -2132,6 +2368,7 @@ fn paint(hdc: HDC, hwnd: HWND) {
&session_text,
weekly_pct,
&weekly_text,
peak_status,
);
let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY);
@ -2253,3 +2490,43 @@ fn draw_rounded_rect(hdc: HDC, rect: &RECT, color: &Color, radius: i32) {
let _ = DeleteObject(brush);
}
}
/// Draw the peak-hours status circle.
///
/// The circle is a filled GDI ellipse whose diameter spans the full height of
/// both meter rows (= PEAK_ICON_W = 36 px at 96 DPI), centred vertically in
/// the widget. Colours adapt to the current theme:
/// - Outside (>60 min until peak): green
/// - Warning (<60 min until peak): amber
/// - Inside (within peak hours): red
fn draw_peak_circle(hdc: HDC, x: i32, height: i32, status: PeakStatus, is_dark: bool) {
let color = match (status, is_dark) {
(PeakStatus::Outside, true) => Color::from_hex("#22C55E"),
(PeakStatus::Outside, false) => Color::from_hex("#16A34A"),
(PeakStatus::Warning, true) => Color::from_hex("#F59E0B"),
(PeakStatus::Warning, false) => Color::from_hex("#D97706"),
(PeakStatus::Inside, true) => Color::from_hex("#EF4444"),
(PeakStatus::Inside, false) => Color::from_hex("#DC2626"),
(PeakStatus::NoPeakHours, _) => return,
};
// Align circle top/bottom with the meter rows.
let row2_y = height - sc(5) - sc(SEGMENT_H);
let row1_y = row2_y - sc(10) - sc(SEGMENT_H);
let top = row1_y;
let bottom = row2_y + sc(SEGMENT_H);
let right = x + sc(PEAK_ICON_W);
unsafe {
let brush = CreateSolidBrush(COLORREF(color.to_colorref()));
let old_brush = SelectObject(hdc, brush);
let null_pen = GetStockObject(NULL_PEN);
let old_pen = SelectObject(hdc, null_pen);
let _ = Ellipse(hdc, x, top, right, bottom);
SelectObject(hdc, old_pen);
SelectObject(hdc, old_brush);
let _ = DeleteObject(brush);
}
}