diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..d9640531 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/src/native_interop.rs b/src/native_interop.rs index 6dabe605..6c1da838 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -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 @@ -104,7 +103,7 @@ pub fn find_taskbars() -> Vec { 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 }); } } @@ -143,29 +142,13 @@ pub fn find_child_window(parent: HWND, class_name: &str) -> Option { } } -/// 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 { - 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::() 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 diff --git a/src/window.rs b/src/window.rs index 4325ce06..452e1c52 100644 --- a/src/window.rs +++ b/src/window.rs @@ -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; @@ -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::>(), - ) + 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(); diff --git a/src/window/host_geometry.rs b/src/window/host_geometry.rs index b4a4d365..4bf743f0 100644 --- a/src/window/host_geometry.rs +++ b/src/window/host_geometry.rs @@ -34,7 +34,7 @@ pub(super) fn refresh_theme_host_geometry() { } fn refresh_with(query: impl FnOnce() -> Vec) { - // 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; diff --git a/src/window/message_loop.rs b/src/window/message_loop.rs index f8d7043c..6abcaabb 100644 --- a/src/window/message_loop.rs +++ b/src/window/message_loop.rs @@ -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 => {