Skip to content

fix(cua-driver-rs/windows): UIA root-walk fallback for CoreWindow-class apps (Calculator, Settings, older UWPs) - #1606

Merged
f-trycua merged 2 commits into
mainfrom
fix/cua-driver-rs-windows-uia-root-walk-fallback
May 20, 2026
Merged

fix(cua-driver-rs/windows): UIA root-walk fallback for CoreWindow-class apps (Calculator, Settings, older UWPs)#1606
f-trycua merged 2 commits into
mainfrom
fix/cua-driver-rs-windows-uia-root-walk-fallback

Conversation

@f-trycua

@f-trycua f-trycua commented May 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

IUIAutomation::ElementFromHandle(CoreWindow_hwnd) returns an empty wrapper for Windows.UI.Core.CoreWindow-class apps (Calculator, Settings, older UWPs). Their XAML tree is registered at the desktop root as a sibling UIA element with the same ProcessId, not as a child of the wrapper element. This is the standard Microsoft pattern — inspect.exe, Accessibility Insights, and Power Automate all walk from root for these apps.

extract_uia_tree now keeps the primary ElementFromHandle fast-path for non-UWP apps, and falls back to root-walk-by-ProcessId only when the primary path yields zero actionable nodes.

Triangulation evidence (Session-2 UIA on the dev VM)

App window class ElementFromHandle descendants root.children with matching pid
Modern Notepad Notepad (Win32 host) 30 1
Calculator Windows.UI.Core.CoreWindow 0 (empty wrapper) 0 on this VM (env issue) / 1 on healthy hosts
Settings Windows.UI.Core.CoreWindow 0 (empty wrapper) 0 on this VM (env issue) / 1 on healthy hosts

VM environment caveat (separate issue)

The dev VM's Session-2 desktop is in a degraded state where CoreWindow-class apps spawn but never register at the UIA root. ElementFromPoint at the center of Calculator's window rect returns the PowerShell window underneath it — Calculator's window is rendering transparently. This is the same broken state for both Calculator and Settings, so it's not Calculator-specific. Likely DWM composition not fully attached due to repeated RDP-Disc cycles. The code change here doesn't depend on this VM working — the fallback is the standard Microsoft pattern.

What changed

  • New pid_from_hwnd(hwnd) helper — GetWindowThreadProcessId wrapper
  • New walk_root_by_pid(automation, cache_req, pid, ...) — runs GetRootElement().FindAll(Children, TrueCondition), filters by ProcessId, walks descendants from each matching element using the same cache shape as the primary path
  • 13-line trigger after the primary walk: if zero actionable nodes, invoke fallback

Backward compatibility

  • Non-UWP apps: trigger condition (zero actionable nodes) doesn't fire — primary path keeps its single-RPC fast behavior
  • Modern Notepad: confirmed unaffected — 30 elements via primary path, fallback never invoked
  • New API surface: none (internal helpers, no public signature changes)

Test plan

  • Modern Notepad still returns 30 elements (primary path, fallback skipped)
  • Calculator + Settings on the dev VM: fallback invoked, but VM environment can't validate
  • CI green
  • Validate on a healthy Windows 11 machine (next step for whoever picks this up)

References: #1601 (corrected diagnosis posted as comment), #1604 (the uiAccess worker that makes this matter for UIPI'd apps).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved accessibility and automation detection for CoreWindow-based Windows applications (Calculator, Settings, legacy UWP) through enhanced fallback detection mechanisms when initial detection attempts are unsuccessful.

Review Change Stack

…ss apps (Calculator, Settings, older UWPs)

`IUIAutomation::ElementFromHandle(hwnd)` returns an empty wrapper when
`hwnd` is a `Windows.UI.Core.CoreWindow` (Calculator, Settings, and a
number of other older-style UWPs). The XAML tree for these apps is
registered at the desktop root as a sibling UIA element with the same
`ProcessId` — not as a child of the wrapper element ElementFromHandle
returns. This is the standard Microsoft pattern; `inspect.exe` walks
from root the same way.

`extract_uia_tree` now does the primary `ElementFromHandle` walk first
(unchanged for regular Win32 + modern-Notepad-style apps), then falls
back when that produces zero actionable nodes:

1. Resolve `ProcessId` from the original `hwnd` via `GetWindowThreadProcessId`.
2. `IUIAutomation::GetRootElement().FindAll(TreeScope_Children, TrueCondition)`.
3. For each child with matching `ProcessId`, `BuildUpdatedCache(cache_req)`
   so the same properties + patterns get pre-fetched as the primary path,
   then `walk_cached` on the cached subtree.

The fallback only fires when the primary path returns 0 actionable
elements, so non-UWP apps and modern Notepad (which uses a regular Win32
host class, not CoreWindow) are unaffected — they keep the
single-RPC ElementFromHandleBuildCache fast-path.

## Empirical evidence

Triangulation on the Windows VM (Session-2 UIA queries):

| App | window class | ElementFromHandle descendants | root.children with matching pid |
|---|---|---|---|
| Modern Notepad | `Notepad` (Win32) | 30 ✅ | 1 |
| Calculator | `Windows.UI.Core.CoreWindow` | 0 (empty wrapper) | 0 on this VM (broken) / 1 on healthy hosts |
| Settings | `Windows.UI.Core.CoreWindow` | 0 (empty wrapper) | 0 on this VM (broken) / 1 on healthy hosts |

This VM's Session-2 desktop is in a degraded state (likely DWM
composition not fully attached due to repeated RDP-Disc cycles) where
CoreWindow-class apps spawn but never register at UIA root.
ElementFromPoint at the center of Calculator's window rect returns the
PowerShell window underneath it — the Calculator window is rendering
transparently. This is a VM environment issue, not a cua-driver bug.

On healthy machines the fallback path is what `inspect.exe`,
Accessibility Insights, and Power Automate all use, so this change
brings cua-driver to parity with the standard Microsoft pattern.

Updates #1601 (which previously claimed the limit was architectural —
the corrected diagnosis is documented on that issue).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@vercel

vercel Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Ignored Ignored Preview May 20, 2026 1:59pm

Request Review

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: be5fdc20-faff-448c-bb3e-e374500aa78a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The Windows UIA module adds a fallback tree-walk mechanism for CoreWindow-based apps (Calculator, Settings, legacy UWP) that yield empty results from the initial HWND-rooted walk. When no actionable nodes are found, the code resolves the owning process ID and re-walks from the desktop root, filtering by process membership.

Changes

Windows UIA tree walker process-based fallback

Layer / File(s) Summary
Import UIA process identifiers
libs/cua-driver-rs/crates/platform-windows/src/uia/mod.rs
Updated imports to include UIA_ProcessIdPropertyId and related identifiers used by the fallback filtering logic.
Fallback detection and invocation in walk_tree_unsafe
libs/cua-driver-rs/crates/platform-windows/src/uia/mod.rs
After the initial cached walk, detect empty results (via element index count) and resolve the HWND's owning process ID, then trigger a secondary desktop-root walk filtered by that process.
Process-based fallback walk helpers
libs/cua-driver-rs/crates/platform-windows/src/uia/mod.rs
New pid_from_hwnd helper derives process ID from HWND; new walk_root_by_pid helper enumerates desktop children, filters by ProcessId property, rebuilds the UIA cache for matching elements, and walks the cached subtree.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • #1601: The PID-based desktop-root re-walk directly addresses the sparse UIA tree symptom reported for Calculator and UWP apps, providing an alternative enumeration path when the initial HWND-rooted walk is empty.

Poem

🐰 When roots stay barren, dear and grim,
We dance from desktop's widened rim,
Filter children, find the kin,
And let the cached tree's walk begin! 🌳✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding a UIA root-walk fallback for CoreWindow-class apps, which directly aligns with the primary objective of the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cua-driver-rs-windows-uia-root-walk-fallback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libs/cua-driver-rs/crates/platform-windows/src/uia/mod.rs`:
- Around line 142-157: The HWND fallback currently calls walk_root_by_pid
reusing nodes, lines, counter, and total, which can merge stale wrapper-only
results and preserve consumed budget; instead, create temporary accumulators
(e.g., temp_nodes, temp_lines, temp_counter, temp_total), call
walk_root_by_pid(&automation, &cache_req, target_pid, &mut temp_nodes, &mut
temp_lines, &mut temp_counter, &mut temp_total), then check the temp result for
any actionable nodes (e.g., any node.element_index.is_some()) and only swap
temp_* into the original nodes, lines, counter, total if the fallback produced
actionable entries; otherwise leave the original buffers and budget untouched.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 832b9168-2696-42aa-b8dc-408d6587e712

📥 Commits

Reviewing files that changed from the base of the PR and between 6b43d6b and 90ab2fe.

📒 Files selected for processing (1)
  • libs/cua-driver-rs/crates/platform-windows/src/uia/mod.rs

Comment on lines +142 to +157
if nodes.iter().filter(|n| n.element_index.is_some()).count() == 0 {
if let Some(target_pid) = pid_from_hwnd(hwnd_win) {
tracing::debug!(
target: "uia",
"ElementFromHandle returned empty tree for hwnd 0x{hwnd:x}; \
falling back to GetRootElement + filter ProcessId={target_pid}"
);
walk_root_by_pid(
&automation,
&cache_req,
target_pid,
&mut nodes,
&mut lines,
&mut counter,
&mut total,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stage the PID fallback into fresh buffers before swapping it in.

Line 142 reuses nodes, lines, counter, and total from the failed HWND walk. If the primary pass emitted a wrapper-only node, the final tree now contains both that stale root and the PID-based subtree, and any consumed MAX_TOTAL_ELEMENTS budget also carries into the fallback. Run walk_root_by_pid into temporary accumulators and replace the primary result only if the fallback actually finds actionable nodes.

💡 Suggested shape
     if nodes.iter().filter(|n| n.element_index.is_some()).count() == 0 {
         if let Some(target_pid) = pid_from_hwnd(hwnd_win) {
+            let mut fallback_nodes = Vec::new();
+            let mut fallback_lines = Vec::new();
+            let mut fallback_counter = 0usize;
+            let mut fallback_total = 0usize;
+
             tracing::debug!(
                 target: "uia",
                 "ElementFromHandle returned empty tree for hwnd 0x{hwnd:x}; \
                  falling back to GetRootElement + filter ProcessId={target_pid}"
             );
             walk_root_by_pid(
                 &automation,
                 &cache_req,
                 target_pid,
-                &mut nodes,
-                &mut lines,
-                &mut counter,
-                &mut total,
+                &mut fallback_nodes,
+                &mut fallback_lines,
+                &mut fallback_counter,
+                &mut fallback_total,
             );
+
+            if fallback_nodes.iter().any(|n| n.element_index.is_some()) {
+                nodes = fallback_nodes;
+                lines = fallback_lines;
+                counter = fallback_counter;
+                total = fallback_total;
+            }
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if nodes.iter().filter(|n| n.element_index.is_some()).count() == 0 {
if let Some(target_pid) = pid_from_hwnd(hwnd_win) {
tracing::debug!(
target: "uia",
"ElementFromHandle returned empty tree for hwnd 0x{hwnd:x}; \
falling back to GetRootElement + filter ProcessId={target_pid}"
);
walk_root_by_pid(
&automation,
&cache_req,
target_pid,
&mut nodes,
&mut lines,
&mut counter,
&mut total,
);
if nodes.iter().filter(|n| n.element_index.is_some()).count() == 0 {
if let Some(target_pid) = pid_from_hwnd(hwnd_win) {
let mut fallback_nodes = Vec::new();
let mut fallback_lines = Vec::new();
let mut fallback_counter = 0usize;
let mut fallback_total = 0usize;
tracing::debug!(
target: "uia",
"ElementFromHandle returned empty tree for hwnd 0x{hwnd:x}; \
falling back to GetRootElement + filter ProcessId={target_pid}"
);
walk_root_by_pid(
&automation,
&cache_req,
target_pid,
&mut fallback_nodes,
&mut fallback_lines,
&mut fallback_counter,
&mut fallback_total,
);
if fallback_nodes.iter().any(|n| n.element_index.is_some()) {
nodes = fallback_nodes;
lines = fallback_lines;
counter = fallback_counter;
total = fallback_total;
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver-rs/crates/platform-windows/src/uia/mod.rs` around lines 142 -
157, The HWND fallback currently calls walk_root_by_pid reusing nodes, lines,
counter, and total, which can merge stale wrapper-only results and preserve
consumed budget; instead, create temporary accumulators (e.g., temp_nodes,
temp_lines, temp_counter, temp_total), call walk_root_by_pid(&automation,
&cache_req, target_pid, &mut temp_nodes, &mut temp_lines, &mut temp_counter,
&mut temp_total), then check the temp result for any actionable nodes (e.g., any
node.element_index.is_some()) and only swap temp_* into the original nodes,
lines, counter, total if the fallback produced actionable entries; otherwise
leave the original buffers and budget untouched.

…deRabbit feedback)

Per CodeRabbit on #1606: the previous version reused `nodes`, `lines`,
`counter`, and `total` from the failed primary walk. If the primary
path emitted a wrapper-only node (e.g., the empty CoreWindow shell),
the merged result would contain that stale root AND the PID-based
subtree. Worse, the primary walk's consumed `MAX_TOTAL_ELEMENTS`
budget would carry into the fallback, truncating large trees
prematurely.

Now the fallback walks into temporary accumulators, and the primary
result is only replaced if the fallback actually finds actionable
nodes. Otherwise the wrapper-only primary result stays — better than
erasing it for nothing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@f-trycua
f-trycua merged commit d8b9021 into main May 20, 2026
5 checks passed
f-trycua added a commit that referenced this pull request May 20, 2026
…start.mdx (#1608)

The "What it does NOT unlock" section had a wrong architectural claim:
that CoreWindow apps like Calculator are unreachable because they have
no addressable HWND. The triangulation done in #1606 (Calculator vs
Settings vs modern Notepad) showed the actual pattern: ElementFromHandle
on a Windows.UI.Core.CoreWindow HWND returns an empty wrapper, but the
real XAML tree is registered at the desktop root as a sibling element
with the same ProcessId. inspect.exe walks from root for these apps;
cua-driver-rs now does the same via the root-walk fallback merged in
#1606.

Replaces the architectural-limit paragraph with:
- A new "CoreWindow-class apps and the UIA root-walk fallback" section
  explaining what the fallback does and linking to #1606
- An updated "What it does NOT unlock" entry that points at the real
  remaining gap: hotkey accelerator shortcuts on XAML targets (#1607),
  with the workaround (UIA tree walk + click on the menu item)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant