Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 15 additions & 4 deletions libs/cua-driver/rust/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;;<guid>]`, 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -95,9 +95,11 @@ fn get_uia() -> Option<IUIAutomation> {
/// 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`.
Expand Down Expand Up @@ -616,7 +618,8 @@ fn extract_shortcut_from_name(name: &str) -> Option<String> {

/// 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<WindowInfo> {
// NativeWindowHandle is an i32-sized handle in UIA; cast to HWND.
let raw = elem.CurrentNativeWindowHandle().ok()?;
Expand All @@ -635,6 +638,15 @@ unsafe fn window_info_from_uia_element(elem: &IUIAutomationElement) -> Option<Wi
}
}

// Apply the SAME listability filter as the EnumWindows path so the two
// enumeration sources can't disagree about a given HWND. That divergence —
// both EnumWindows and UIA filtered on a non-empty title while
// `debug_window_info` did not — was the root cause of trycua/cua#2020.
// Crucially this admits visible, owner-less, empty-caption windows.
if !crate::win32::windows::is_listable_top_level(hwnd) {
return None;
}

// Resolve pid via the standard Win32 path. We deliberately do not trust
// the UIA ProcessId property here because the rest of the driver indexes
// windows by (hwnd, pid) tuples obtained from `GetWindowThreadProcessId`,
Expand All @@ -645,21 +657,12 @@ unsafe fn window_info_from_uia_element(elem: &IUIAutomationElement) -> Option<Wi
return None;
}

// Title — prefer Win32 GetWindowTextW for parity with the EnumWindows path.
// UIA's `CurrentName` sometimes returns the AX-friendly label (e.g. the
// tab title) instead of the OS-level window caption, which would diverge
// from any caller already keyed on the GetWindowText value.
let title_len = GetWindowTextLengthW(hwnd);
if title_len == 0 {
return None;
}
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());
let title = String::from_utf16_lossy(&buf[..len]);
if title.trim().is_empty() {
return None;
}
// Caption — read via the shared Win32 `GetWindowTextW` helper for parity
// with the EnumWindows path. UIA's `CurrentName` sometimes returns the
// AX-friendly label (e.g. a tab title) instead of the OS-level window
// caption, which would diverge from any caller keyed on GetWindowText.
// Empty is fine; the window is still listed.
let title = crate::win32::windows::window_title(hwnd);

let (x, y, w, h) = window_bounds(hwnd);

Expand Down
236 changes: 215 additions & 21 deletions libs/cua-driver/rust/crates/platform-windows/src/win32/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,22 @@
//! `FindAll(TreeScope::Children, ...)` makes no z-order guarantee, so we
//! deliberately do NOT let it reorder anything Win32 already reported.
//!
//! Both sources apply the same filters (visible, non-iconic, non-empty
//! title). The `filter_pid` argument is applied to the merged list so the
//! union/dedupe pipeline runs unconditionally.
//! Both sources apply the same listability filter (`is_listable_top_level`:
//! visible, non-iconic, owner-less, non-cloaked). The title is read for
//! display only and is **not** a filter — empty-caption windows (WPF
//! `HwndWrapper[...]`, borderless / custom-chrome apps) are listed. The
//! `filter_pid` argument is applied to the merged list so the union/dedupe
//! pipeline runs unconditionally.

use std::collections::HashSet;
use std::sync::Mutex;
use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT, TRUE};
use windows::Win32::Graphics::Dwm::{
DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS,
DwmGetWindowAttribute, DWMWA_CLOAKED, DWMWA_EXTENDED_FRAME_BOUNDS,
};
use windows::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetWindowRect, GetWindowTextLengthW, GetWindowTextW,
GetWindowThreadProcessId, IsIconic, IsWindowVisible,
EnumWindows, GetWindow, GetWindowRect, GetWindowTextLengthW, GetWindowTextW,
GetWindowThreadProcessId, IsIconic, IsWindowVisible, GW_OWNER,
};

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -82,9 +85,10 @@ pub fn list_windows(filter_pid: Option<u32>) -> Vec<WindowInfo> {
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<WindowInfo> {
let state = Mutex::new(EnumState { windows: Vec::new() });
let state_ptr = &state as *const Mutex<EnumState> as isize;
Expand All @@ -97,25 +101,23 @@ fn enumerate_via_enum_windows() -> Vec<WindowInfo> {
unsafe extern "system" fn enum_windows_cb(hwnd: HWND, lparam: LPARAM) -> BOOL {
let state = &*(lparam.0 as *const Mutex<EnumState>);

// 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;
}

// Get pid.
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);
Expand All @@ -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;;<guid>]`),
/// 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::<u32>() 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();
Expand All @@ -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;;<guid>]`),
/// 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<u16> = "Cua.Test.EmptyTitleWindow\0".encode_utf16().collect();

let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() 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<u16> = "\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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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"
);
}
}
}