From d2027f8984a514a75cb9065fefcd0ed0a2283568 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 25 Jun 2026 11:15:02 +0800 Subject: [PATCH 1/2] fix(cua-driver)(windows): list empty-/null-title top-level windows (#2020) list_windows dropped any visible top-level window with an empty caption because both enumeration sources (EnumWindows + UIA) filtered on a non-empty title. WPF (HwndWrapper[...]), borderless and custom-chrome apps were therefore untargetable: get_window_state, click and scroll all resolve windows through list_windows, so they failed with "No window with window_id" / "No windows found for pid" even though debug_window_info could still see the window. Replace the non-empty-title proxy with a shared is_listable_top_level predicate (visible + non-iconic + owner-less + non-DWM-cloaked) used by both enumeration sources so they can't drift apart again. The owner check (GW_OWNER null) is what lets us drop the title gate without admitting noise; the EnumWindows path previously had no owner check at all. The title is now read for display only via a shared window_title helper; empty captions are listed (the tool layer already renders "(no title)"). Add an #[ignore] regression test that creates a real empty-title top-level window and asserts list_windows enumerates it. Cross-checked with cargo check --target x86_64-pc-windows-gnu (lib + tests); not yet run on real Windows hardware. --- libs/cua-driver/rust/PARITY.md | 19 +- .../platform-windows/src/uia/windows_enum.rs | 43 ++-- .../platform-windows/src/win32/windows.rs | 236 ++++++++++++++++-- 3 files changed, 253 insertions(+), 45 deletions(-) diff --git a/libs/cua-driver/rust/PARITY.md b/libs/cua-driver/rust/PARITY.md index 3e441fe2ee..ca623d5531 100644 --- a/libs/cua-driver/rust/PARITY.md +++ b/libs/cua-driver/rust/PARITY.md @@ -437,10 +437,21 @@ window manager's canonical top-to-bottom z-order, so it's the authoritative source for both window membership and ordering. It then asks UI Automation for any top-level windows EnumWindows missed (`AutomationElement::RootElement.FindAll(TreeScope::Children, TrueCondition)`, -filtered to `IsOffscreen == false` + non-empty `GetWindowTextW`) and appends -those at the end of the merged list, deduped by HWND. UIA elements contribute -their `NativeWindowHandle` as the canonical HWND so downstream code keyed on -`(pid, window_id)` keeps working unchanged. +filtered to `IsOffscreen == false`) and appends those at the end of the merged +list, deduped by HWND. UIA elements contribute their `NativeWindowHandle` as +the canonical HWND so downstream code keyed on `(pid, window_id)` keeps working +unchanged. + +**Listability filter (shared).** Both sources gate each HWND through the same +`crate::win32::windows::is_listable_top_level` predicate — visible +(`IsWindowVisible`), not minimized (`!IsIconic`), owner-less (`GW_OWNER` null, +so no tool-tips / owned pop-ups) and not DWM-cloaked (`DWMWA_CLOAKED == 0`, so +no suspended-UWP background frames). The window **title is read for display +only and is not a filter**: empty-caption top-level windows (WPF +`HwndWrapper[App.exe;;]`, borderless / custom-chrome apps) are listed. +This fixes trycua/cua#2020, where a non-empty-title check at both sources hid +such windows from the agent even though `debug_window_info` could still see +them. Why UIA at all: modern apps (WebView2-hosted Notepad, packaged-UWP frames, some Electron containers) sometimes hide their visible window inside a host diff --git a/libs/cua-driver/rust/crates/platform-windows/src/uia/windows_enum.rs b/libs/cua-driver/rust/crates/platform-windows/src/uia/windows_enum.rs index 5973f34789..5851dc6ee9 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/uia/windows_enum.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/uia/windows_enum.rs @@ -37,7 +37,7 @@ use windows::Win32::UI::Accessibility::{ }; use windows::core::{BSTR, Interface}; use windows::Win32::UI::WindowsAndMessaging::{ - GetWindowRect, GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, + GetWindowRect, GetWindowThreadProcessId, }; use crate::win32::windows::WindowInfo; @@ -95,9 +95,11 @@ fn get_uia() -> Option { /// Enumerate top-level windows visible to UI Automation. /// /// Returns one `WindowInfo` per non-offscreen child of the UIA desktop root -/// whose `NativeWindowHandle` is non-null and resolves to a window with a -/// non-empty title. Windows whose HWND is zero (pure UIA virtual elements, -/// rare) are skipped because the rest of the driver pipeline keys off HWND. +/// whose `NativeWindowHandle` is non-null and resolves to a listable top-level +/// window (empty captions included — see +/// `crate::win32::windows::is_listable_top_level`). Windows whose HWND is zero +/// (pure UIA virtual elements, rare) are skipped because the rest of the driver +/// pipeline keys off HWND. /// /// Returns an empty vec on any UIA failure — callers should treat UIA as a /// best-effort source and union with `EnumWindows`. @@ -616,7 +618,8 @@ fn extract_shortcut_from_name(name: &str) -> Option { /// Build a `WindowInfo` from a single UIA child element of the desktop root. /// Returns `None` if the element doesn't correspond to a real, on-screen, -/// non-empty-titled HWND. +/// listable top-level HWND. Empty-caption windows ARE listable — see +/// `crate::win32::windows::is_listable_top_level`. unsafe fn window_info_from_uia_element(elem: &IUIAutomationElement) -> Option { // NativeWindowHandle is an i32-sized handle in UIA; cast to HWND. let raw = elem.CurrentNativeWindowHandle().ok()?; @@ -635,6 +638,15 @@ unsafe fn window_info_from_uia_element(elem: &IUIAutomationElement) -> Option Option) -> Vec { merged } -/// Walk `EnumWindows` and collect every visible, non-iconic, non-empty-titled -/// top-level window. No pid filter is applied here — the caller does that on -/// the merged list. +/// Walk `EnumWindows` and collect every listable top-level window (see +/// `is_listable_top_level`). The title is read for display but is not a +/// filter, so empty-caption windows are included. No pid filter is applied +/// here — the caller does that on the merged list. fn enumerate_via_enum_windows() -> Vec { let state = Mutex::new(EnumState { windows: Vec::new() }); let state_ptr = &state as *const Mutex as isize; @@ -97,8 +101,13 @@ fn enumerate_via_enum_windows() -> Vec { unsafe extern "system" fn enum_windows_cb(hwnd: HWND, lparam: LPARAM) -> BOOL { let state = &*(lparam.0 as *const Mutex); - // Skip invisible or minimized windows. - if IsWindowVisible(hwnd).0 == 0 || IsIconic(hwnd).0 != 0 { + // Listable == a real, targetable top-level window. We deliberately do NOT + // gate on the title: a visible, non-iconic, owner-less, non-cloaked window + // is a legitimate target even with an empty caption (WPF, borderless / + // custom-chrome apps). Filtering on a non-empty title used to hide these + // from the agent even though `debug_window_info` could see them — see + // trycua/cua#2020. + if !is_listable_top_level(hwnd) { return TRUE; } @@ -106,16 +115,9 @@ unsafe extern "system" fn enum_windows_cb(hwnd: HWND, lparam: LPARAM) -> BOOL { let mut pid: u32 = 0; GetWindowThreadProcessId(hwnd, Some(&mut pid)); - // Get title (skip empty). - let title_len = GetWindowTextLengthW(hwnd); - if title_len == 0 { return TRUE; } - let mut buf = vec![0u16; (title_len + 1) as usize]; - GetWindowTextW(hwnd, &mut buf); - let title = { - let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); - String::from_utf16_lossy(&buf[..len]) - }; - if title.trim().is_empty() { return TRUE; } + // Read the caption for display only — empty is fine. The tool layer + // already renders "(no title)" for these records. + let title = window_title(hwnd); // Get bounds — prefer DWM extended frame bounds (includes shadow), fallback to GetWindowRect. let (x, y, w, h) = get_window_bounds(hwnd); @@ -133,6 +135,77 @@ unsafe extern "system" fn enum_windows_cb(hwnd: HWND, lparam: LPARAM) -> BOOL { TRUE } +/// Is `hwnd` a real, agent-targetable top-level window? +/// +/// Single source of truth for what `list_windows` exposes, shared by the +/// `EnumWindows` and UI Automation enumeration paths so the two can't drift +/// (that drift was the root cause of trycua/cua#2020). A window qualifies when +/// it is: +/// +/// - visible (`IsWindowVisible`) and not minimized (`!IsIconic`), +/// - a true top-level window — no owner (`GW_OWNER` is null), which excludes +/// tool-tips, owned pop-ups and transient child surfaces, and +/// - not DWM-cloaked (`DWMWA_CLOAKED == 0`), which excludes the hidden +/// background frames of suspended UWP / `ApplicationFrameHost` apps that +/// still report `IsWindowVisible == true`. +/// +/// What is deliberately NOT checked: the window title. An empty caption is not +/// a signal that a window is unreal — WPF apps (`HwndWrapper[App.exe;;]`), +/// borderless / custom-chrome apps, and various splash/tool windows ship +/// visible, owner-less top-level windows with no caption. The owner + cloaked +/// gates here express "is this a real window?" directly, which is what the +/// non-empty-title check was a poor proxy for. +pub(crate) fn is_listable_top_level(hwnd: HWND) -> bool { + unsafe { + if IsWindowVisible(hwnd).0 == 0 || IsIconic(hwnd).0 != 0 { + return false; + } + // Owner-less == genuine top-level. `GetWindow(GW_OWNER)` yields the + // owner HWND, or null/err when there is none. Mirrors the top-level + // test `debug_window_info` uses, so the two tools agree on a given HWND. + if !GetWindow(hwnd, GW_OWNER).unwrap_or_default().is_invalid() { + return false; + } + // Suspended UWP / ApplicationFrameHost shells keep `WS_VISIBLE` but are + // cloaked by DWM (not actually on screen). Drop them. + if is_cloaked(hwnd) { + return false; + } + true + } +} + +/// True iff DWM reports `hwnd` as cloaked — hidden by the compositor even +/// though `WS_VISIBLE` is set (suspended UWP app, window on another virtual +/// desktop, etc.). Returns false if the attribute can't be read. +unsafe fn is_cloaked(hwnd: HWND) -> bool { + let mut cloaked: u32 = 0; + let ok = DwmGetWindowAttribute( + hwnd, + DWMWA_CLOAKED, + &mut cloaked as *mut u32 as *mut _, + std::mem::size_of::() as u32, + ); + ok.is_ok() && cloaked != 0 +} + +/// Read a window's caption via `GetWindowTextW`. Returns an empty string for +/// untitled windows — which are still listed (see `is_listable_top_level`). +/// Shared by the `EnumWindows` and UIA enumeration paths so both report the +/// OS-level caption identically. +pub(crate) fn window_title(hwnd: HWND) -> String { + unsafe { + let title_len = GetWindowTextLengthW(hwnd); + if title_len == 0 { + return String::new(); + } + let mut buf = vec![0u16; (title_len + 1) as usize]; + GetWindowTextW(hwnd, &mut buf); + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf16_lossy(&buf[..len]) + } +} + fn get_window_bounds(hwnd: HWND) -> (i32, i32, i32, i32) { unsafe { let mut rect = RECT::default(); @@ -150,3 +223,124 @@ fn get_window_bounds(hwnd: HWND) -> (i32, i32, i32, i32) { (rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use windows::core::PCWSTR; + use windows::Win32::Foundation::{LRESULT, WPARAM}; + use windows::Win32::System::LibraryLoader::GetModuleHandleW; + use windows::Win32::System::Threading::GetCurrentProcessId; + use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, PeekMessageW, + RegisterClassExW, ShowWindow, TranslateMessage, CS_HREDRAW, CS_VREDRAW, MSG, PM_REMOVE, + SW_SHOWNOACTIVATE, WINDOW_EX_STYLE, WNDCLASSEXW, WS_OVERLAPPEDWINDOW, WS_VISIBLE, + }; + + unsafe extern "system" fn test_wnd_proc(h: HWND, m: u32, w: WPARAM, l: LPARAM) -> LRESULT { + DefWindowProcW(h, m, w, l) + } + + /// Drain the calling thread's message queue a few times so the freshly + /// created window finishes coming up (and DWM settles its cloaked state) + /// before we enumerate. + fn pump_messages(rounds: usize) { + unsafe { + for _ in 0..rounds { + let mut msg = MSG::default(); + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + std::thread::sleep(Duration::from_millis(20)); + } + } + } + + /// Regression test for trycua/cua#2020: a visible, owner-less, + /// **empty-title** top-level window must be enumerated by `list_windows`. + /// + /// Before the fix, both enumeration sources dropped any window with + /// `GetWindowTextLengthW == 0`, so WPF (`HwndWrapper[App.exe;;]`), + /// borderless and custom-chrome apps were invisible to the agent even + /// though `debug_window_info` could list them. + /// + /// `#[ignore]` because it needs an interactive window station to create a + /// visible top-level window; run it via the Windows sandbox harness runner + /// or locally with + /// `cargo test -p platform-windows -- --ignored empty_title`. + #[test] + #[ignore] + fn empty_title_top_level_window_is_listed() { + unsafe { + let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default(); + let class_name: Vec = "Cua.Test.EmptyTitleWindow\0".encode_utf16().collect(); + + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(test_wnd_proc), + hInstance: hinstance.into(), + lpszClassName: PCWSTR(class_name.as_ptr()), + ..Default::default() + }; + // Ignore the return: a re-run in the same process sees the class + // already registered, which is harmless. + RegisterClassExW(&wc); + + // Empty window name == empty caption — the whole point of the test. + let empty_title: Vec = "\0".encode_utf16().collect(); + let hwnd = CreateWindowExW( + WINDOW_EX_STYLE::default(), + PCWSTR(class_name.as_ptr()), + PCWSTR(empty_title.as_ptr()), + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + 100, + 100, + 320, + 80, + None, + None, + hinstance, + None, + ) + .expect("CreateWindowExW failed"); + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + pump_messages(5); + + // Preconditions: the OS really gave us an empty-caption window, and + // the shared predicate accepts it despite the empty title. + assert_eq!( + window_title(hwnd), + "", + "test precondition: the created window must be untitled" + ); + assert!( + is_listable_top_level(hwnd), + "an empty-title visible owner-less top-level window must be listable" + ); + + let pid = GetCurrentProcessId(); + let windows = list_windows(Some(pid)); + let found = windows.iter().find(|w| w.hwnd == hwnd.0 as u64).cloned(); + + // Tear the window down before asserting so a failed assert can't + // leak it onto the desktop for the rest of the test run. + let _ = DestroyWindow(hwnd); + pump_messages(2); + + let found = found.expect( + "empty-title top-level window was dropped by list_windows — #2020 regression", + ); + assert_eq!( + found.title, "", + "listed record should carry the (empty) OS caption verbatim" + ); + assert_eq!( + found.pid, pid, + "listed record pid should match the creating process" + ); + } + } +} From 1924afffcb003d711642730f0fb1478dd18c4472 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 25 Jun 2026 11:31:39 +0800 Subject: [PATCH 2/2] test(cua-driver)(windows): make empty-title test window cleanup panic-safe Wrap the test HWND in a RAII guard so DestroyWindow runs even if a precondition assertion panics before the explicit teardown. Addresses CodeRabbit review feedback on #2021. --- .../platform-windows/src/win32/windows.rs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-windows/src/win32/windows.rs b/libs/cua-driver/rust/crates/platform-windows/src/win32/windows.rs index 73a253f069..8d25a8bce9 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/win32/windows.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/win32/windows.rs @@ -306,6 +306,20 @@ mod tests { None, ) .expect("CreateWindowExW failed"); + + // RAII guard so the visible test window is always destroyed, even + // if a precondition assertion below panics before the explicit + // teardown runs. + struct WindowGuard(HWND); + impl Drop for WindowGuard { + fn drop(&mut self) { + unsafe { + let _ = DestroyWindow(self.0); + } + } + } + let window_guard = WindowGuard(hwnd); + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); pump_messages(5); @@ -325,9 +339,10 @@ mod tests { let windows = list_windows(Some(pid)); let found = windows.iter().find(|w| w.hwnd == hwnd.0 as u64).cloned(); - // Tear the window down before asserting so a failed assert can't - // leak it onto the desktop for the rest of the test run. - let _ = DestroyWindow(hwnd); + // Tear the window down before the final asserts. Dropping the guard + // runs DestroyWindow; if an assertion above already panicked, the + // guard's Drop ran during unwind, so the window is gone either way. + drop(window_guard); pump_messages(2); let found = found.expect(