Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on:
push:
branches: [ main, "fix/**", "feat/**" ]
pull_request:
branches: [ main ]

permissions:
contents: read

jobs:
build-and-test:
name: Build, Test & Package
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable

- name: Rust Cache
uses: Swatinem/rust-cache@v2

- name: Check code
run: cargo check --all-targets

- name: Run unit tests
run: cargo test --all

- name: Build release binary
run: cargo build --release

- name: Upload release artifact
uses: actions/upload-artifact@v4
with:
name: claude-code-usage-monitor-release
path: target/release/claude-code-usage-monitor.exe
if-no-files-found: error
31 changes: 7 additions & 24 deletions src/native_interop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ use windows::Win32::Graphics::Gdi::{
EnumDisplayMonitors, GetMonitorInfoW, HDC, HMONITOR, MONITORINFO,
};
use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK};
use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA};
use windows::Win32::UI::WindowsAndMessaging::*;

// Window style constants
Expand Down Expand Up @@ -104,7 +103,7 @@ pub fn find_taskbars() -> Vec<TaskbarWindow> {
if len > 0 {
let class_name = String::from_utf16_lossy(&class_name[..len as usize]);
if class_name == "Shell_TrayWnd" || class_name == "Shell_SecondaryTrayWnd" {
if let Some(rect) = get_taskbar_rect(hwnd).or_else(|| get_window_rect_safe(hwnd)) {
if let Some(rect) = get_taskbar_rect(hwnd) {
taskbars.push(TaskbarWindow { hwnd, rect });
}
}
Expand Down Expand Up @@ -143,29 +142,13 @@ pub fn find_child_window(parent: HWND, class_name: &str) -> Option<HWND> {
}
}

/// Get taskbar position via SHAppBarMessage
/// Get taskbar position safely.
/// We use get_window_rect_safe directly because GetWindowRect is a non-blocking
/// kernel-mode query that returns immediately even if explorer.exe is hung or unresponsive.
/// SHAppBarMessage sends a synchronous LPC message to explorer.exe's UI message loop,
/// which deadlocks the monitor thread if Explorer hangs (Event 1002).
pub fn get_taskbar_rect(taskbar_hwnd: HWND) -> Option<RECT> {
unsafe {
let mut class_name = [0u16; 64];
let len = GetClassNameW(taskbar_hwnd, &mut class_name);
if len > 0 {
let class_name = String::from_utf16_lossy(&class_name[..len as usize]);
if class_name == "Shell_SecondaryTrayWnd" {
return get_window_rect_safe(taskbar_hwnd);
}
}

let mut abd = APPBARDATA {
cbSize: std::mem::size_of::<APPBARDATA>() as u32,
hWnd: taskbar_hwnd,
..Default::default()
};
let result = SHAppBarMessage(ABM_GETTASKBARPOS, &mut abd);
if result == 0 {
return None;
}
Some(abd.rc)
}
get_window_rect_safe(taskbar_hwnd)
}

/// Get the bounding rectangle of a window
Expand Down
34 changes: 20 additions & 14 deletions src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ fn relaunch_self() {
fn spawn_taskbar_watchdog() {
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_secs(TASKBAR_WATCH_INTERVAL_SECS));
let (shell_hosted, windows) = {
let invalid = {
let state = lock_state();
let Some(state) = state.as_ref() else {
continue;
Expand All @@ -422,20 +422,26 @@ fn spawn_taskbar_watchdog() {
)
})
});
(
shell_hosted,
std::iter::once(state.hwnd)
.chain(state.mirror_hwnds.iter().copied())
.chain(state.desktop_hwnds.iter().flatten().copied())
.collect::<Vec<_>>(),
)
if !shell_hosted {
continue;
}
std::iter::once(state.hwnd)
.chain(state.mirror_hwnds.iter().copied())
.chain(state.desktop_hwnds.iter().flatten().copied())
.any(|window| unsafe {
let hwnd = window.to_hwnd();
if !IsWindow(Some(hwnd)).as_bool() {
return true;
}
// When hosted inside a shell window (like Shell_TrayWnd or Progman),
// Windows does not always destroy cross-process child windows when Explorer restarts.
// If this window has a parent that is now destroyed, flag it as invalid.
match GetParent(hwnd).ok() {
Some(p) if !p.is_invalid() => !IsWindow(Some(p)).as_bool(),
_ => false,
}
})
};
if !shell_hosted {
continue;
}
let invalid = windows
.iter()
.any(|window| unsafe { !IsWindow(Some(window.to_hwnd())).as_bool() });
if invalid && !native_interop::find_taskbars().is_empty() {
diagnose::log("watchdog: shell-hosted surface was destroyed -> relaunching");
relaunch_self();
Expand Down
2 changes: 1 addition & 1 deletion src/window/host_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub(super) fn refresh_theme_host_geometry() {
}

fn refresh_with(query: impl FnOnce() -> Vec<ThemeHostGeometry>) {
// SHAppBarMessage can dispatch another layout notification before returning.
// Layout queries can dispatch another layout notification before returning.
// Reentrant readers keep using the last complete snapshot; never wait here.
if REFRESHING.swap(true, Ordering::Acquire) {
return;
Expand Down
1 change: 1 addition & 0 deletions src/window/message_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,7 @@ pub(super) unsafe extern "system" fn wnd_proc(
// and tray-icon-only themes keep their owner HWND, so restore the
// registrations when the shell broadcasts its return.
sync_tray_icon(hwnd);
render_layered();
LRESULT(0)
}
WM_DESTROY => {
Expand Down