From 75e50a7ea7cd86f3b1871be7a113c35628c54815 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 20 May 2026 13:04:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(cua-driver-rs/windows):=20type=5Ftext=20?= =?UTF-8?q?=E2=86=92=20UIA=20ValuePattern=20routing=20for=20XAML=20targets?= =?UTF-8?q?=20+=20debug=5Fwindow=5Finfo=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes for CUA-543 (type_text/hotkey doesn't work on modern XAML hosts like Win11 Notepad, Calculator, Settings): 1. **`debug_window_info` MCP tool** (new) — dumps everything the daemon sees about a target pid's top-level windows: Win32 class names, owning .exe basename + path, focused-element name/class/control_type from UIA (when CUIAutomation succeeds), the list of UIA patterns that element supports (Value, Invoke, Text, Toggle, ExpandCollapse, SelectionItem, LegacyIAccessible), and which of the routing predicates match (`xaml_class_match`, `exe_xaml_match`, `xaml_routing_recommended`). Runs in the daemon (Session 2 via autostart kick), so it sees the actual cross-session HWND state that SSH-side PowerShell probes from Session 0 can't reach. 2. **`type_text` UIA routing** for XAML / UWP targets. The new `input::is_xaml_host_hwnd` predicate ORs two signals: - Top-level window class name matches a known XAML host class (`ApplicationFrameWindow`, `WinUIDesktopWin32WindowClass`, `Windows.UI.Core.CoreWindow`, `Microsoft.UI.Content.DesktopChildSiteBridge`). - Owning process .exe basename matches a known XAML-hosted .exe (`notepad.exe`, `calculatorapp.exe`, `applicationframehost.exe`, `photos.exe`, `systemsettings.exe`). When the predicate hits AND `element_index` is supplied, `type_text` routes through `IUIAutomationValuePattern::SetValue` — same code path the existing `set_value` tool uses, verified live to work on modern Notepad. When the predicate hits but no `element_index` is supplied, `type_text` returns a clear actionable error pointing at `get_window_state` (so agents don't get a misleading "✅ Typed" message followed by no characters in the editor). Legacy Win32 stays on the unchanged PostMessage path, preserving the no-focus-steal property. Why not SendInput: an earlier iteration tried SendInput with `AttachThreadInput` + `SetForegroundWindow` and hit `ERROR_ACCESS_DENIED` from UWP AppContainer targets — UIPI blocks input injection even at the same integrity level. UIA Patterns are the right abstraction for XAML / UWP automation; see the CUA-543 ticket for the full investigation log. `hotkey` (Ctrl+S → Save dialog and similar) is intentionally NOT covered here. It's a different routing problem — keyboard shortcuts need to map to UIA `InvokePattern` invocations on the right command element (e.g. the Save button in Notepad's command bar), which is per-app discovery work. Deferred to a follow-up iteration. Diagnostic data behind the routing decisions (captured live on the Windows VM via `debug_window_info`): - Modern Notepad's top-level class is the plain string `"Notepad"`, same as legacy Notepad. EXE-name (`notepad.exe`) is the distinguishing signal. The XAML class list above stays for the apps that DO use those classes (`ApplicationFrameWindow` for older UWP frame hosts, `WinUIDesktopWin32WindowClass` for some WinUI3 desktop apps). - The daemon's `IUIAutomation::GetFocusedElement` returns the OS desktop ("Desktop 1", class `#32769`) when no app has system focus — so the no-`element_index` path can't transparently discover the right element. Hence the actionable error rather than a silent fallback. Tested live on the VM: - `type_text(pid, window_id, element_index=0, text="UIA-ROUTING-FIX-CONFIRMED")` on modern Notepad → driver returns `✅ Wrote 25 char(s) via UIA ValuePattern`, follow-up `get_window_state` shows the text in the Document tree. - `type_text(pid, window_id, text="…")` (no element_index) on the same Notepad → returns the actionable error message pointing at `get_window_state`. - Legacy Win32 path untouched; previously-working `type_text` on classic apps still uses PostMessage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../platform-windows/src/input/keyboard.rs | 96 +++++ .../crates/platform-windows/src/input/mod.rs | 4 +- .../platform-windows/src/tools/impl_.rs | 333 +++++++++++++++++- 3 files changed, 423 insertions(+), 10 deletions(-) diff --git a/libs/cua-driver-rs/crates/platform-windows/src/input/keyboard.rs b/libs/cua-driver-rs/crates/platform-windows/src/input/keyboard.rs index 308fa7427a..e7dc89c489 100644 --- a/libs/cua-driver-rs/crates/platform-windows/src/input/keyboard.rs +++ b/libs/cua-driver-rs/crates/platform-windows/src/input/keyboard.rs @@ -4,6 +4,16 @@ //! WM_KEYDOWN/WM_KEYUP — used for non-printable keys (Enter, Tab, arrows, F-keys, etc.) //! //! All messages are posted async (PostMessageW), so they do NOT steal focus. +//! +//! Modern XAML / WinUI3 / UWP targets reject PostMessage-based keyboard +//! injection because their CoreInput dispatcher only consumes events from +//! the system input queue, not posted messages. This module exposes +//! [`is_xaml_host_hwnd`] so callers (e.g. the `type_text` tool) can route +//! around the PostMessage path for those targets — see CUA-543 and +//! `tools::impl_::TypeTextTool` for the routing logic. The actual UIA +//! `ValuePattern.SetValue` injection lives in the tools layer alongside +//! the existing `set_value` tool; this module deliberately stays +//! Win32-only so the unit tests don't depend on UIA initialisation. use anyhow::{bail, Result}; use std::thread::sleep; @@ -13,9 +23,95 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{ MapVirtualKeyW, MAPVK_VK_TO_VSC, VIRTUAL_KEY, }; use windows::Win32::UI::WindowsAndMessaging::{ + GetClassNameW, GetWindowThreadProcessId, PostMessageW, WM_CHAR, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP, }; +// ── XAML / UWP host detection ──────────────────────────────────────────────── +// +// Two routing signals, OR'd: +// 1. Top-level window class name matches a known XAML host class. +// 2. Owning process .exe basename matches a known XAML-hosted .exe. +// +// The EXE-basename signal is the more reliable of the two: cross-session +// `GetClassNameW` can return nothing, and modern apps like Win 11 Notepad +// keep the legacy `"Notepad"` window class even though they render XAML +// underneath. Diagnostic data captured by `tools::DebugWindowInfoTool` +// (see CUA-543) confirms `notepad.exe` is the reliable signal for modern +// Notepad; class name is not. + +const XAML_HOST_CLASSES: &[&str] = &[ + "ApplicationFrameWindow", + "WinUIDesktopWin32WindowClass", + "Windows.UI.Core.CoreWindow", + "Microsoft.UI.Content.DesktopChildSiteBridge", +]; + +const XAML_HOST_EXES: &[&str] = &[ + "notepad.exe", // Win 11 modern Notepad (UWP-packaged) + "calculatorapp.exe", // UWP Calculator + "calc.exe", // some Win 11 builds expose the stub directly + "applicationframehost.exe", // generic UWP frame host + "photos.exe", // UWP Photos + "systemsettings.exe", // modern Settings +]; + +fn class_name(hwnd: HWND) -> Option { + let mut buf = [0u16; 256]; + let n = unsafe { GetClassNameW(hwnd, &mut buf) }; + if n <= 0 { None } else { Some(String::from_utf16_lossy(&buf[..n as usize])) } +} + +fn owning_exe_basename(hwnd: HWND) -> Option { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, + PROCESS_NAME_FORMAT, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + let mut pid: u32 = 0; + let tid = unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)) }; + if tid == 0 || pid == 0 { + return None; + } + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?; + let mut buf = [0u16; 1024]; + let mut len: u32 = buf.len() as u32; + let result = unsafe { + QueryFullProcessImageNameW(handle, PROCESS_NAME_FORMAT(0), + windows::core::PWSTR(buf.as_mut_ptr()), &mut len) + }; + let _ = unsafe { CloseHandle(handle) }; + if result.is_err() || len == 0 { + return None; + } + let path = String::from_utf16_lossy(&buf[..len as usize]); + let name = path + .rsplit(|c: char| c == '\\' || c == '/') + .next() + .unwrap_or(&path) + .to_ascii_lowercase(); + Some(name) +} + +/// `true` iff the given HWND should bypass the PostMessage keyboard +/// path and route through UIA patterns (or another non-PostMessage +/// mechanism). See module docs + CUA-543 for the routing rationale. +pub fn is_xaml_host_hwnd(hwnd: u64) -> bool { + let h = HWND(hwnd as *mut _); + if let Some(cls) = class_name(h) { + if XAML_HOST_CLASSES.iter().any(|known| cls == *known) { + return true; + } + } + if let Some(exe) = owning_exe_basename(h) { + if XAML_HOST_EXES.iter().any(|known| exe == *known) { + return true; + } + } + false +} + const KEY_DELAY_MS: u64 = 4; /// Post a Unicode character as WM_CHAR. diff --git a/libs/cua-driver-rs/crates/platform-windows/src/input/mod.rs b/libs/cua-driver-rs/crates/platform-windows/src/input/mod.rs index 1244aa4771..49f5f9e542 100644 --- a/libs/cua-driver-rs/crates/platform-windows/src/input/mod.rs +++ b/libs/cua-driver-rs/crates/platform-windows/src/input/mod.rs @@ -13,4 +13,6 @@ pub mod mouse; pub mod keyboard; pub use mouse::{post_click, post_click_screen}; -pub use keyboard::{post_char, post_key, post_type_text, post_type_text_with_delay}; +pub use keyboard::{ + is_xaml_host_hwnd, post_char, post_key, post_type_text, post_type_text_with_delay, +}; diff --git a/libs/cua-driver-rs/crates/platform-windows/src/tools/impl_.rs b/libs/cua-driver-rs/crates/platform-windows/src/tools/impl_.rs index fb3a12ef17..9b7a174357 100644 --- a/libs/cua-driver-rs/crates/platform-windows/src/tools/impl_.rs +++ b/libs/cua-driver-rs/crates/platform-windows/src/tools/impl_.rs @@ -1244,12 +1244,20 @@ impl Tool for TypeTextTool { PostMessage(WM_CHAR) to the focused window. No focus steal.\n\n\ Special keys (Return, Escape, arrows, Tab) go through `press_key` / \ `hotkey` — they are not text.\n\n\ - Optional `element_index` + `window_id` (from the last `get_window_state` \ - snapshot of that window) is accepted for parity but currently no-op on \ - Windows (UIA SetFocus not wired up yet); without `element_index`, the \ - write targets the pid's focused window — same as Swift's no-element path.\n\n\ - `delay_ms` (0–200, default 30) spaces successive characters so \ - autocomplete and IME can keep up.".into(), + **Routing on Windows.** When the target's owning EXE or top-level window \ + class identifies it as a XAML / WinUI3 / UWP host (modern Notepad, \ + Calculator, Photos, Settings, etc.), the tool requires `element_index` \ + + `window_id` and routes through UI Automation's `ValuePattern.SetValue` \ + — same backend as the `set_value` tool. PostMessage WM_CHAR doesn't \ + reach those hosts (their CoreInput dispatcher only consumes events from \ + the system input queue), so the fallback path silently dropped chars. \ + If you call `type_text(pid, text)` on a XAML host without `element_index`, \ + the tool returns an actionable error pointing you at `get_window_state` \ + first. Legacy Win32 apps still use the PostMessage path, preserving the \ + no-focus-steal property.\n\n\ + `delay_ms` (0–200, default 30) spaces successive characters on the \ + PostMessage path so autocomplete and IME can keep up. Ignored on the \ + UIA path (SetValue is atomic).".into(), input_schema: json!({ "type":"object","required":["pid","text"],"properties":{ "pid":{"type":"integer","description":"Target process ID."}, @@ -1294,13 +1302,65 @@ impl Tool for TypeTextTool { } }; let text_len = text.chars().count(); + + // CUA-543 routing: PostMessage WM_CHAR doesn't reach modern + // XAML / WinUI3 hosts. When the target is one of those AND the + // caller has supplied an element_index, route through UIA + // `ValuePattern.SetValue` — same code path the `set_value` tool + // uses, which we've verified works on modern Notepad / WinUI3. + // Legacy Win32 stays on the PostMessage path so the no-focus- + // steal property is preserved. + if crate::input::is_xaml_host_hwnd(hwnd) { + if let Some(idx) = elem_idx { + let idx = idx as usize; + let state = self.state.clone(); + let text_for_uia = text.clone(); + let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let ptr = state.element_cache.get_element_ptr(pid, hwnd, idx) + .ok_or_else(|| anyhow::anyhow!( + "Element {idx} not in cache — call get_window_state(pid={pid}, window_id={hwnd}) first." + ))?; + use windows::Win32::UI::Accessibility::{ + IUIAutomationElement, IUIAutomationValuePattern, UIA_ValuePatternId, + }; + use windows::core::{Interface, BSTR}; + let elem: IUIAutomationElement = + unsafe { IUIAutomationElement::from_raw(ptr as *mut _) }; + let pattern = unsafe { elem.GetCurrentPattern(UIA_ValuePatternId)? }; + std::mem::forget(elem); + let vp: IUIAutomationValuePattern = pattern.cast()?; + unsafe { vp.SetValue(&BSTR::from(text_for_uia.as_str()))? }; + Ok(()) + }).await; + return match result { + Ok(Ok(())) => ToolResult::text(format!( + "✅ Wrote {text_len} char(s) on pid {raw_pid} via UIA ValuePattern \ + (XAML / UWP target, element_index=[{idx}])." + )), + Ok(Err(e)) => ToolResult::error(format!("type_text (UIA path): {e}")), + Err(e) => ToolResult::error(format!("Task error: {e}")), + }; + } else { + // XAML target without element_index: PostMessage will silently + // drop chars. Surface a clear error pointing the agent at the + // right workflow rather than lying with a "✅ Typed" message. + return ToolResult::error(format!( + "type_text on a modern XAML / UWP target (pid {raw_pid}, hwnd {hwnd}) \ + requires `element_index` — its WM_CHAR pipeline ignores PostMessage \ + without keyboard focus. Call `get_window_state(pid={raw_pid}, \ + window_id={hwnd})` to enumerate elements, then re-call \ + `type_text(pid, window_id, element_index, text)`. Or call \ + `set_value(pid, window_id, element_index, value)` directly — same \ + UIA backend." + )); + } + } + + // Legacy Win32 path — PostMessage WM_CHAR, no focus steal. let result = tokio::task::spawn_blocking(move || { crate::input::post_type_text(hwnd, &text) }).await; match result { - // Match Swift's CGEvent-fallback text format 1:1 (Windows always - // takes the synthesis path since AXSelectedText has no UIA - // equivalent wired up here yet). Ok(Ok(())) => ToolResult::text(format!( "✅ Typed {text_len} char(s) on pid {raw_pid} via PostMessage ({_delay_ms}ms delay)." )), @@ -3184,6 +3244,260 @@ impl Tool for KillAppTool { } } +// ── debug_window_info ───────────────────────────────────────────────────────── +// +// Diagnostic tool: dump everything the daemon (Session 2 when launched via +// `cua-driver autostart kick`) sees about a given pid's top-level windows. +// Includes Win32 class names, owning .exe basename + full path, and — when +// CUIAutomation is available — the currently-focused UIA element with the +// list of patterns it supports. +// +// Built for CUA-543 investigation: the routing between PostMessage-based +// input and UIA-pattern-based input depends on what the OS actually reports +// for modern XAML / UWP / WinUI3 targets. SSH-side PowerShell probes from +// Session 0 see nothing (cross-session HWNDs return empty class names); +// this tool gives the same view from inside the daemon process. + +pub struct DebugWindowInfoTool; +static DEBUG_WI_DEF: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Tool for DebugWindowInfoTool { + fn def(&self) -> &ToolDef { + DEBUG_WI_DEF.get_or_init(|| ToolDef { + name: "debug_window_info".into(), + description: "Diagnostic: dump everything cua-driver sees about a pid's \ + top-level windows from the daemon's session perspective. Returns \ + window class names, owning .exe basename + path, and — when \ + CUIAutomation succeeds — the focused UIA element with the list of \ + patterns it supports (ValuePattern, InvokePattern, TextPattern, \ + TogglePattern, etc.). Used to design / debug input routing for \ + XAML / UWP / WinUI3 targets; see CUA-543.".into(), + input_schema: json!({"type":"object","required":["pid"],"properties":{ + "pid":{"type":"integer","description":"PID of the process to inspect."} + },"additionalProperties":false}), + read_only: true, + destructive: false, + idempotent: true, + open_world: false, + }) + } + + async fn invoke(&self, args: Value) -> ToolResult { + let pid_v: u32 = match args.get("pid").and_then(|v| v.as_u64()) { + Some(p) if p > 0 && p <= u32::MAX as u64 => p as u32, + _ => return ToolResult::error("debug_window_info: missing or invalid `pid`".to_string()), + }; + + let outcome = tokio::task::spawn_blocking(move || -> serde_json::Value { + use std::collections::HashSet; + use windows::Win32::Foundation::{CloseHandle, HWND, LPARAM, BOOL, TRUE}; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, + CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, + }; + use windows::Win32::System::RemoteDesktop::ProcessIdToSessionId; + use windows::Win32::System::Threading::{ + GetCurrentProcessId, OpenProcess, + QueryFullProcessImageNameW, PROCESS_NAME_FORMAT, + PROCESS_QUERY_LIMITED_INFORMATION, + }; + use windows::Win32::UI::Accessibility::{ + CUIAutomation, IUIAutomation, + UIA_InvokePatternId, UIA_TextPatternId, UIA_TogglePatternId, + UIA_ValuePatternId, UIA_LegacyIAccessiblePatternId, + UIA_SelectionItemPatternId, UIA_ExpandCollapsePatternId, + }; + use windows::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetClassNameW, GetWindow, GetWindowTextW, + GetWindowThreadProcessId, IsWindowVisible, GW_OWNER, + }; + + // Helper: read class name + title + fn class_name(h: HWND) -> Option { + let mut buf = [0u16; 256]; + let n = unsafe { GetClassNameW(h, &mut buf) }; + if n <= 0 { None } else { Some(String::from_utf16_lossy(&buf[..n as usize])) } + } + fn window_title(h: HWND) -> Option { + let mut buf = [0u16; 512]; + let n = unsafe { GetWindowTextW(h, &mut buf) }; + if n <= 0 { None } else { Some(String::from_utf16_lossy(&buf[..n as usize])) } + } + + // ── Process info: session id + exe path + let mut session_id: u32 = 0; + let _ = unsafe { ProcessIdToSessionId(pid_v, &mut session_id) }; + + let (exe_path, exe_basename) = unsafe { + match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid_v) { + Ok(h) => { + let mut buf = [0u16; 1024]; + let mut len: u32 = buf.len() as u32; + let ok = QueryFullProcessImageNameW( + h, PROCESS_NAME_FORMAT(0), + windows::core::PWSTR(buf.as_mut_ptr()), + &mut len, + ); + let _ = CloseHandle(h); + if ok.is_ok() && len > 0 { + let path = String::from_utf16_lossy(&buf[..len as usize]); + let base = path + .rsplit(|c: char| c == '\\' || c == '/') + .next() + .unwrap_or(&path) + .to_ascii_lowercase(); + (Some(path), Some(base)) + } else { + (None, None) + } + } + Err(_) => (None, None), + } + }; + + // ── Enumerate top-level visible windows owned by this pid + struct Ctx { target_pid: u32, hwnds: Vec } + extern "system" fn enum_cb(hwnd: HWND, lparam: LPARAM) -> BOOL { + unsafe { + let ctx = &mut *(lparam.0 as *mut Ctx); + let mut p: u32 = 0; + GetWindowThreadProcessId(hwnd, Some(&mut p)); + if p == ctx.target_pid && IsWindowVisible(hwnd).as_bool() { + // Only true top-level (no owner) windows. + let owner = GetWindow(hwnd, GW_OWNER); + if owner.unwrap_or_default().is_invalid() { + ctx.hwnds.push(hwnd.0 as isize); + } + } + TRUE + } + } + let mut ctx = Ctx { target_pid: pid_v, hwnds: vec![] }; + let _ = unsafe { + EnumWindows(Some(enum_cb), LPARAM(&mut ctx as *mut Ctx as isize)) + }; + + // ── Init UIA (best-effort; non-fatal if it fails) + let mut focused_info: Option = None; + let coinit_ok = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED).is_ok() }; + if let Ok(uia) = unsafe { + CoCreateInstance::<_, IUIAutomation>(&CUIAutomation, None, CLSCTX_INPROC_SERVER) + } { + if let Ok(focused) = unsafe { uia.GetFocusedElement() } { + // Patterns we care about for input routing + let pattern_ids = [ + (UIA_ValuePatternId.0, "Value"), + (UIA_InvokePatternId.0, "Invoke"), + (UIA_TextPatternId.0, "Text"), + (UIA_TogglePatternId.0, "Toggle"), + (UIA_ExpandCollapsePatternId.0, "ExpandCollapse"), + (UIA_SelectionItemPatternId.0, "SelectionItem"), + (UIA_LegacyIAccessiblePatternId.0, "LegacyIAccessible"), + ]; + let mut patterns: Vec<&str> = vec![]; + for (id, name) in &pattern_ids { + let pid_struct = windows::Win32::UI::Accessibility::UIA_PATTERN_ID(*id); + if unsafe { focused.GetCurrentPattern(pid_struct) }.is_ok() { + patterns.push(name); + } + } + let name = unsafe { focused.CurrentName() } + .ok() + .map(|b| b.to_string()) + .unwrap_or_default(); + let class = unsafe { focused.CurrentClassName() } + .ok() + .map(|b| b.to_string()) + .unwrap_or_default(); + let ctype = unsafe { focused.CurrentControlType() } + .map(|t| t.0) + .unwrap_or(0); + let value_ro = if patterns.contains(&"Value") { + unsafe { + focused.GetCurrentPattern(UIA_ValuePatternId) + .and_then(|p| p.cast::()) + .and_then(|vp| vp.CurrentIsReadOnly()) + .map(|b| b.as_bool()) + .ok() + } + } else { None }; + focused_info = Some(serde_json::json!({ + "name": name, + "class_name": class, + "control_type_id": ctype, + "supported_patterns": patterns, + "value_pattern_is_read_only": value_ro, + })); + } + } + if coinit_ok { + unsafe { CoUninitialize(); } + } + + // ── Build per-window JSON + let xaml_classes: HashSet<&str> = [ + "ApplicationFrameWindow", + "WinUIDesktopWin32WindowClass", + "Windows.UI.Core.CoreWindow", + "Microsoft.UI.Content.DesktopChildSiteBridge", + ].iter().copied().collect(); + let xaml_exes: HashSet<&str> = [ + "notepad.exe", "calculatorapp.exe", "calc.exe", + "applicationframehost.exe", "photos.exe", "systemsettings.exe", + ].iter().copied().collect(); + + let windows_json: Vec = ctx.hwnds.iter() + .map(|&h| { + let hwnd = HWND(h as *mut _); + let cls = class_name(hwnd); + let title = window_title(hwnd); + let xaml_class_match = cls.as_deref() + .map(|c| xaml_classes.contains(c)) + .unwrap_or(false); + serde_json::json!({ + "hwnd": h, + "class_name": cls, + "title": title, + "xaml_class_match": xaml_class_match, + }) + }) + .collect(); + + let exe_xaml_match = exe_basename.as_deref() + .map(|e| xaml_exes.contains(e)) + .unwrap_or(false); + + let daemon_pid = unsafe { GetCurrentProcessId() }; + let mut daemon_session: u32 = 0; + let _ = unsafe { ProcessIdToSessionId(daemon_pid, &mut daemon_session) }; + + serde_json::json!({ + "pid": pid_v, + "session_id": session_id, + "daemon_pid": daemon_pid, + "daemon_session_id": daemon_session, + "exe_path": exe_path, + "exe_basename": exe_basename, + "exe_xaml_match": exe_xaml_match, + "windows": windows_json, + "focused_element": focused_info, + "xaml_routing_recommended": exe_xaml_match + || windows_json.iter() + .any(|w| w.get("xaml_class_match").and_then(|v| v.as_bool()).unwrap_or(false)), + }) + }).await; + + match outcome { + Ok(j) => { + let pretty = serde_json::to_string_pretty(&j).unwrap_or_else(|_| j.to_string()); + ToolResult::text(pretty).with_structured(j) + } + Err(e) => ToolResult::error(format!("debug_window_info: join error: {e}")), + } + } +} + // ── registry builder ────────────────────────────────────────────────────────── pub fn build_registry() -> ToolRegistry { @@ -3194,6 +3508,7 @@ pub fn build_registry() -> ToolRegistry { r.register(Box::new(GetWindowStateTool { state: state.clone() })); r.register(Box::new(LaunchAppTool)); r.register(Box::new(KillAppTool)); + r.register(Box::new(DebugWindowInfoTool)); r.register(Box::new(ClickTool { state: state.clone() })); r.register(Box::new(DoubleClickTool { state: state.clone() })); r.register(Box::new(RightClickTool { state: state.clone() }));