Files
skill-template/tools/ui_inspector/desktop_uia.py
chendelian 46bc573777
All checks were successful
技能自动化发布 / release (push) Successful in 10s
Release v1.0.66: table default_sort_json for data management
2026-08-02 12:17:55 +08:00

1598 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""UI Automation helpers: attach, control-from-point, tree dump."""
from __future__ import annotations
import ctypes
import time
from ctypes import wintypes
from dataclasses import asdict, dataclass, field
from typing import Any, Optional
user32 = ctypes.windll.user32
WM_GETOBJECT = 0x003D
OBJID_WINDOW = 0x00000000
OBJID_CLIENT = 0xFFFFFFFC
OBJID_SYSMENU = 0xFFFFFFFF
# UIA root object id ((DWORD)-25) — forces UI Automation provider, not just MSAA.
UIA_ROOT_OBJECT_ID = ctypes.c_long(0xFFFFFFE7).value
SW_RESTORE = 9
SW_SHOW = 5
SW_MAXIMIZE = 3
GA_ROOT = 2
GW_HWNDNEXT = 2
GW_OWNER = 4
GWL_EXSTYLE = -20
WS_EX_TOOLWINDOW = 0x00000080
WS_EX_NOACTIVATE = 0x08000000
DWMWA_CLOAKED = 14
@dataclass
class WindowInfo:
hwnd: int
pid: int
title: str
class_name: str
process_name: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass
class ControlInfo:
name: str = ""
value: str = ""
automation_id: str = ""
control_type: str = ""
class_name: str = ""
framework_id: str = ""
localized_control_type: str = ""
is_enabled: Optional[bool] = None
is_offscreen: Optional[bool] = None
rect: dict[str, int] = field(default_factory=dict)
depth: int = 0
parent_chain: list[dict[str, str]] = field(default_factory=list)
path_hint: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def ensure_uia() -> None:
try:
import uiautomation # noqa: F401
except ImportError as exc:
raise RuntimeError(
"缺少 uiautomation。请先: pip install uiautomation>=2.0.18"
) from exc
def ensure_com_sta() -> None:
"""
UIAutomation is STA-friendly. Worker threads must enter an apartment
or FindAll often returns an empty shell (findall=2~3).
"""
try:
import comtypes
try:
comtypes.CoInitializeEx(comtypes.COINIT_APARTMENTTHREADED)
except OSError:
# Already initialized on this thread (or MTA) — ignore.
pass
except Exception:
try:
comtypes.CoInitialize()
except Exception:
pass
except Exception:
pass
def window_area(hwnd: int) -> int:
rect = _window_rect(int(hwnd))
if not rect:
return 0
return max(0, int(rect.get("width") or 0)) * max(0, int(rect.get("height") or 0))
def _force_foreground(hwnd: int) -> bool:
"""AttachThreadInput + SetForeground — more reliable than bare SetForeground."""
hwnd = int(hwnd)
try:
fg = int(user32.GetForegroundWindow() or 0)
tid_fg = int(user32.GetWindowThreadProcessId(fg, None) or 0) if fg else 0
tid_t = int(user32.GetWindowThreadProcessId(hwnd, None) or 0)
attached = False
if tid_fg and tid_t and tid_fg != tid_t:
attached = bool(user32.AttachThreadInput(tid_fg, tid_t, True))
try:
user32.BringWindowToTop(hwnd)
ok = bool(user32.SetForegroundWindow(hwnd))
try:
user32.SetFocus(hwnd)
except Exception:
pass
return ok
finally:
if attached:
user32.AttachThreadInput(tid_fg, tid_t, False)
except Exception:
try:
return bool(user32.SetForegroundWindow(hwnd))
except Exception:
return False
def activate_main_hwnd(hwnd: int, *, maximize: bool = False) -> dict[str, Any]:
"""
Light activation for capture:
- restore only if minimized
- maximize only when explicitly requested (Inspector default: off)
- SetForeground only if not already foreground (avoid flash)
Only call on the chosen main/capture hwnd — never on every child.
"""
hwnd = int(hwnd)
info: dict[str, Any] = {
"hwnd": hwnd,
"iconic": False,
"zoomed_before": False,
"maximized": False,
"foreground": False,
"already_foreground": False,
"maximize_requested": bool(maximize),
}
try:
info["iconic"] = bool(user32.IsIconic(hwnd))
info["zoomed_before"] = bool(user32.IsZoomed(hwnd))
except Exception:
pass
if info["iconic"]:
user32.ShowWindow(hwnd, SW_RESTORE)
time.sleep(0.15)
if maximize and not info["zoomed_before"]:
user32.ShowWindow(hwnd, SW_MAXIMIZE)
time.sleep(0.2)
try:
already = int(user32.GetForegroundWindow() or 0) == hwnd
except Exception:
already = False
info["already_foreground"] = already
if already:
info["foreground"] = True
else:
info["foreground"] = _force_foreground(hwnd)
time.sleep(0.25 if maximize else 0.15)
try:
info["maximized"] = bool(user32.IsZoomed(hwnd))
except Exception:
pass
return info
def is_provider_cold(hwnd: int, *, findall: Optional[int] = None) -> bool:
"""True when WM_GETOBJECT CLIENT is 0 and FindAll is an empty shell."""
hwnd = int(hwnd)
n = int(findall) if findall is not None else findall_count_for_hwnd(hwnd)
if n < 0:
n = 0
if n >= 8:
return False
try:
res = int(user32.SendMessageW(hwnd, WM_GETOBJECT, 0, OBJID_CLIENT) or 0)
except Exception:
res = 0
return res == 0 and n < 8
def is_mainish_window(info: WindowInfo) -> bool:
"""Large primary app window (safe to maximize)."""
cls = info.class_name or ""
if cls in {"SearchResultWindow2", "IME", "MSCTFIME UI", "tooltips_class32"}:
return False
if "SearchResult" in cls:
return False
area = window_area(info.hwnd)
if area >= 500 * 400:
return True
if "WeWork" in cls or "Chrome_WidgetWin" in cls:
return area >= 300 * 200
return bool((info.title or "").strip()) and area >= 600 * 400
def largest_window(candidates: list[WindowInfo]) -> Optional[WindowInfo]:
if not candidates:
return None
return max(candidates, key=lambda c: window_area(c.hwnd))
def _safe_str(value: Any) -> str:
if value is None:
return ""
try:
return str(value).strip()
except Exception:
return ""
def _rect_dict(ctrl) -> dict[str, int]:
try:
r = ctrl.BoundingRectangle
return {
"left": int(r.left),
"top": int(r.top),
"right": int(r.right),
"bottom": int(r.bottom),
"width": int(r.width()),
"height": int(r.height()),
}
except Exception:
return {}
def _read_value(ctrl) -> str:
# Align with production skills: Value often lives in ValueValueProperty.
try:
import uiautomation as auto
raw = ctrl.GetPropertyValue(auto.PropertyId.ValueValueProperty)
s = _safe_str(raw)
if s:
return s
except Exception:
pass
try:
pattern = ctrl.GetValuePattern()
if pattern is not None:
s = _safe_str(pattern.Value)
if s:
return s
except Exception:
pass
for attr in ("Value", "LegacyIAccessibleValue", "HelpText"):
try:
s = _safe_str(getattr(ctrl, attr, None))
if s:
return s
except Exception:
continue
return ""
def _control_type_name(ctrl) -> str:
try:
ct = ctrl.ControlTypeName
if ct:
return _safe_str(ct)
except Exception:
pass
try:
return _safe_str(ctrl.ControlType)
except Exception:
return ""
def describe_control(ctrl, *, depth: int = 0, parent_chain: Optional[list] = None) -> ControlInfo:
info = ControlInfo(
name=_safe_str(getattr(ctrl, "Name", None)),
value=_read_value(ctrl),
automation_id=_safe_str(getattr(ctrl, "AutomationId", None)),
control_type=_control_type_name(ctrl),
class_name=_safe_str(getattr(ctrl, "ClassName", None)),
framework_id=_safe_str(getattr(ctrl, "FrameworkId", None)),
localized_control_type=_safe_str(getattr(ctrl, "LocalizedControlType", None)),
depth=depth,
parent_chain=list(parent_chain or []),
rect=_rect_dict(ctrl),
)
try:
info.is_enabled = bool(ctrl.IsEnabled)
except Exception:
info.is_enabled = None
try:
info.is_offscreen = bool(ctrl.IsOffscreen)
except Exception:
info.is_offscreen = None
bits = []
if info.control_type:
bits.append(info.control_type)
if info.name:
bits.append(f"Name={info.name!r}")
if info.value:
bits.append(f"Value={info.value!r}")
if info.automation_id:
bits.append(f"AutomationId={info.automation_id!r}")
info.path_hint = " | ".join(bits)
return info
def get_toplevel_hwnd(hwnd: int) -> int:
hwnd = int(hwnd)
if not hwnd:
return 0
root = int(user32.GetAncestor(hwnd, GA_ROOT) or 0)
return root or hwnd
def window_info_from_hwnd(hwnd: int) -> WindowInfo:
ensure_uia()
import uiautomation as auto
hwnd = get_toplevel_hwnd(int(hwnd))
if not hwnd or not user32.IsWindow(hwnd):
raise RuntimeError(f"无效窗口 HWND={hwnd}")
title_buf = ctypes.create_unicode_buffer(512)
user32.GetWindowTextW(hwnd, title_buf, 512)
class_buf = ctypes.create_unicode_buffer(256)
user32.GetClassNameW(hwnd, class_buf, 256)
pid = ctypes.c_ulong(0)
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
process_name = ""
try:
root = auto.ControlFromHandle(hwnd)
process_name = _safe_str(getattr(root, "ProcessName", None))
except Exception:
pass
return WindowInfo(
hwnd=hwnd,
pid=int(pid.value),
title=_safe_str(title_buf.value),
class_name=_safe_str(class_buf.value),
process_name=process_name,
)
def get_foreground_window_info() -> WindowInfo:
hwnd = int(user32.GetForegroundWindow())
if not hwnd:
raise RuntimeError("当前没有前台窗口")
return window_info_from_hwnd(hwnd)
def is_inspector_window(info: WindowInfo, *, self_hwnd: int = 0) -> bool:
title = info.title or ""
if self_hwnd and int(info.hwnd) == int(self_hwnd):
return True
if self_hwnd and get_toplevel_hwnd(self_hwnd) == int(info.hwnd):
return True
if "UI Inspector" in title or "OpenClaw UI Inspector" in title:
return True
return False
def is_system_chrome_window(info: WindowInfo) -> bool:
cls = info.class_name or ""
return cls in {
"Shell_TrayWnd",
"Shell_SecondaryTrayWnd",
"NotifyIconOverflowWindow",
"Progman",
"WorkerW",
}
def _window_rect(hwnd: int) -> dict[str, int]:
rect = wintypes.RECT()
if not user32.GetWindowRect(int(hwnd), ctypes.byref(rect)):
return {}
return {
"left": int(rect.left),
"top": int(rect.top),
"right": int(rect.right),
"bottom": int(rect.bottom),
"width": int(rect.right - rect.left),
"height": int(rect.bottom - rect.top),
}
def _is_cloaked(hwnd: int) -> bool:
try:
dwmapi = ctypes.windll.dwmapi
cloaked = ctypes.c_int(0)
hr = dwmapi.DwmGetWindowAttribute(
wintypes.HWND(int(hwnd)),
ctypes.c_int(DWMWA_CLOAKED),
ctypes.byref(cloaked),
ctypes.sizeof(cloaked),
)
return hr == 0 and int(cloaked.value) != 0
except Exception:
return False
def _exstyle(hwnd: int) -> int:
return int(user32.GetWindowLongW(int(hwnd), GWL_EXSTYLE) or 0)
def iter_toplevel_hwnds_zorder():
"""Front-to-back top-level windows (generic Win32 Z-order walk)."""
hwnd = int(user32.GetTopWindow(None) or 0)
seen: set[int] = set()
while hwnd:
if hwnd in seen:
break
seen.add(hwnd)
yield hwnd
hwnd = int(user32.GetWindow(hwnd, GW_HWNDNEXT) or 0)
def list_capturable_candidates(
*,
exclude_hwnds: Optional[set[int]] = None,
min_width: int = 120,
min_height: int = 80,
) -> list[WindowInfo]:
"""Z-order capturable windows; no app-name hardcoding."""
exclude = {int(h) for h in (exclude_hwnds or set()) if h}
# Also exclude top-level of each excluded hwnd
exclude |= {get_toplevel_hwnd(h) for h in list(exclude)}
out: list[WindowInfo] = []
for hwnd in iter_toplevel_hwnds_zorder():
if hwnd in exclude:
continue
if not user32.IsWindowVisible(hwnd):
continue
if user32.IsIconic(hwnd):
continue
if _is_cloaked(hwnd):
continue
rect = _window_rect(hwnd)
if not rect or rect.get("width", 0) < min_width or rect.get("height", 0) < min_height:
continue
try:
info = window_info_from_hwnd(hwnd)
except Exception:
continue
if "UI Inspector" in (info.title or "") or "OpenClaw UI Inspector" in (info.title or ""):
continue
if is_system_chrome_window(info) and info.class_name in {
"Shell_TrayWnd",
"Shell_SecondaryTrayWnd",
"NotifyIconOverflowWindow",
"Progman",
"WorkerW",
}:
continue
if is_system_chrome_window(info) and not (info.title or "").strip():
continue
# Skip untitled toolwindows (IME, ghosts)
ex = _exstyle(hwnd)
if (ex & WS_EX_TOOLWINDOW) and not (info.title or "").strip():
continue
out.append(info)
return out
def _owner_root(hwnd: int) -> int:
cur = int(hwnd)
seen: set[int] = set()
while cur and cur not in seen:
seen.add(cur)
owner = int(user32.GetWindow(cur, GW_OWNER) or 0)
if not owner:
break
cur = owner
return cur or int(hwnd)
def prefer_last_active_popup(info: WindowInfo) -> WindowInfo:
"""If Win32 reports an active owned popup, prefer it (still app-agnostic)."""
try:
root = _owner_root(info.hwnd)
popup = int(user32.GetLastActivePopup(root) or 0)
if not popup or popup == int(info.hwnd):
# also try popup of the candidate itself
popup = int(user32.GetLastActivePopup(int(info.hwnd)) or 0)
if not popup or not user32.IsWindowVisible(popup) or user32.IsIconic(popup):
return info
if _is_cloaked(popup):
return info
prect = _window_rect(popup)
if not prect or prect.get("width", 0) < 80 or prect.get("height", 0) < 60:
return info
popup_info = window_info_from_hwnd(popup)
if is_system_chrome_window(popup_info):
return info
return popup_info
except Exception:
return info
def foreground_toplevel_hwnd(*, exclude_hwnds: Optional[set[int]] = None) -> Optional[int]:
"""True foreground top-level hwnd (GetForegroundWindow), if capturable."""
exclude = {int(h) for h in (exclude_hwnds or set()) if h}
exclude |= {get_toplevel_hwnd(h) for h in list(exclude)}
try:
fg = int(user32.GetForegroundWindow() or 0)
except Exception:
fg = 0
if not fg:
return None
top = get_toplevel_hwnd(fg)
if top in exclude:
return None
if not user32.IsWindowVisible(top) or user32.IsIconic(top) or _is_cloaked(top):
return None
return int(top)
def find_capture_target(
*,
exclude_hwnds: Optional[set[int]] = None,
prefer_pid: Optional[int] = None,
) -> tuple[WindowInfo, list[WindowInfo]]:
"""
Pick the frontmost capturable window.
Priority:
1) GetForegroundWindow (true frontmost), if capturable
2) else Z-order top capturable candidate
Optional prefer_pid only filters when sticky (same front app pid);
never jumps to another app just because it is larger.
"""
candidates = list_capturable_candidates(exclude_hwnds=exclude_hwnds)
if not candidates:
raise RuntimeError(
"未找到可拾取的顶层窗口。请确认目标软件窗口可见且未被最小化。"
)
by_hwnd = {int(c.hwnd): c for c in candidates}
chosen: Optional[WindowInfo] = None
fg = foreground_toplevel_hwnd(exclude_hwnds=exclude_hwnds)
if fg and fg in by_hwnd:
# Frontmost always wins — never replace with a larger/sticky other app.
chosen = by_hwnd[fg]
elif fg:
# Foreground exists but filtered from candidates — still try to describe it.
try:
info = window_info_from_hwnd(fg)
if not is_inspector_window(info) and not (
is_system_chrome_window(info) and not (info.title or "").strip()
):
chosen = info
except Exception:
chosen = None
if chosen is None:
# No usable foreground: Z-order front. prefer_pid only as soft filter here.
if prefer_pid:
for c in candidates:
if int(c.pid) == int(prefer_pid):
chosen = c
break
if chosen is None:
chosen = candidates[0]
# Owned popup of the *same* front window only (e.g. search panel), never another app
popup = prefer_last_active_popup(chosen)
if int(popup.pid) == int(chosen.pid):
chosen = popup
return chosen, candidates
def wait_frontmost_target(
*,
exclude_hwnds: Optional[set[int]] = None,
timeout_sec: float = 12.0,
poll_sec: float = 0.35,
) -> tuple[WindowInfo, list[WindowInfo], dict[str, Any]]:
"""
Wait until the true foreground window is a capturable target
(not the Inspector). Frontmost = the capture target. No size ranking.
"""
ensure_com_sta()
ensure_uia()
exclude = {int(h) for h in (exclude_hwnds or set()) if h}
deadline = time.time() + float(timeout_sec)
meta: dict[str, Any] = {"waited": False, "polls": 0}
last_err = ""
while time.time() < deadline:
meta["polls"] += 1
try:
info, cands = find_capture_target(exclude_hwnds=exclude_hwnds)
except Exception as exc:
last_err = str(exc)
time.sleep(poll_sec)
continue
# If foreground resolved to an excluded hwnd path, candidates[0] is next.
# Reject only when we somehow still landed on Inspector.
if is_inspector_window(info):
time.sleep(poll_sec)
meta["waited"] = True
continue
meta["window"] = info.to_dict()
return info, cands, meta
if last_err:
raise RuntimeError(f"等待最前端目标窗口超时:{last_err}")
raise RuntimeError(
"等待最前端目标窗口超时:请先用鼠标点一下要采集的软件窗口(不要点 Inspector)。"
)
def prepare_frontmost_for_capture(
*,
exclude_hwnds: Optional[set[int]] = None,
timeout_sec: float = 12.0,
) -> dict[str, Any]:
"""
Resolve frontmost target, light-activate if mainish (no force maximize), warmup.
Always returns the frontmost window — findall is informational only,
never a reason to pick another app.
企微:先静默调用 kit ``ensure_ui_ready("wecom")``(与技能同路径),再轻量 poke。
"""
import kit_wake
info, cands, wait_meta = wait_frontmost_target(
exclude_hwnds=exclude_hwnds, timeout_sec=timeout_sec
)
act = None
if is_mainish_window(info):
# Keep operator size/position; only restore if minimized.
act = activate_main_hwnd(info.hwnd, maximize=False)
kit_report = kit_wake.auto_ensure_if_wecom(
class_name=info.class_name or "",
title=info.title or "",
hwnd=info.hwnd,
force=True,
)
warm = warmup_uia_provider(
info.hwnd, interact_window=False, allow_foreground_nudge=False
)
n = int(warm.get("findall_after") or findall_count_for_hwnd(info.hwnd) or 0)
if kit_report.get("ok") and int(kit_report.get("findall") or 0) > n:
n = int(kit_report["findall"])
return {
"ready": True, # frontmost resolved => ready to capture that window
"window": info.to_dict(),
"candidates": len(cands),
"findall": n,
"uia_sparse": n < 8,
"provider_cold": is_provider_cold(info.hwnd, findall=n),
"activate": act,
"warmup": warm,
"kit_wake": kit_report,
"wait": wait_meta,
"strategy": "foreground_only",
}
def enum_child_hwnds(parent: int, *, max_count: int = 80) -> list[int]:
result: list[int] = []
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
def _cb(hwnd, _lparam):
if len(result) >= max_count:
return False
if user32.IsWindowVisible(hwnd):
result.append(int(hwnd))
return True
user32.EnumChildWindows(int(parent), _cb, 0)
return result
def is_window_minimized(hwnd: int) -> bool:
hwnd = int(hwnd)
if user32.IsIconic(hwnd):
return True
rect = _window_rect(hwnd)
# Windows parks minimized windows near (-32000,-32000)
if rect and int(rect.get("left") or 0) <= -30000 and int(rect.get("top") or 0) <= -30000:
return True
return False
def restore_window_if_needed(
hwnd: int,
*,
force_show: bool = False,
bring_foreground: bool = False,
) -> dict[str, Any]:
"""
Only restore when minimized by default.
Avoid unconditional ShowWindow/SetForeground — repeating those on child/tool
HWNDs (esp. WeCom) spawns/flickers many tiny windows under the process.
"""
hwnd = int(hwnd)
info = {
"hwnd": hwnd,
"was_minimized": False,
"restored": False,
"shown": False,
"foreground": False,
}
if is_window_minimized(hwnd):
info["was_minimized"] = True
user32.ShowWindow(hwnd, SW_RESTORE)
time.sleep(0.2)
info["restored"] = True
elif force_show:
try:
user32.ShowWindow(hwnd, SW_SHOW)
info["shown"] = True
except Exception:
pass
if bring_foreground:
try:
if user32.SetForegroundWindow(hwnd):
info["foreground"] = True
except Exception:
pass
time.sleep(0.05)
return info
def findall_count_for_hwnd(hwnd: int) -> int:
try:
from uiautomation.uiautomation import _AutomationClient
root = control_from_hwnd(int(hwnd))
ac = _AutomationClient.instance()
arr = root.Element.FindAll(7, ac.IUIAutomation.CreateTrueCondition())
return int(arr.Length)
except Exception:
return -1
def poke_uia_provider(hwnd: int) -> dict[str, Any]:
"""
Light provider poke: no ShowWindow / no SetForeground.
Safe to call on child or sibling HWNDs without popping tool windows.
"""
ensure_uia()
hwnd = int(hwnd)
report: dict[str, Any] = {"hwnd": hwnd, "mode": "light", "steps": []}
for obj_id, label in (
(UIA_ROOT_OBJECT_ID, "UiaRootObjectId"),
(OBJID_CLIENT, "OBJID_CLIENT"),
):
try:
res = int(user32.SendMessageW(hwnd, WM_GETOBJECT, 0, obj_id) or 0)
report["steps"].append({"wm_getobject": label, "lresult": res})
except Exception as exc:
report["steps"].append({"wm_getobject": label, "error": str(exc)})
try:
from uiautomation.uiautomation import _AutomationClient
import uiautomation as auto
ac = _AutomationClient.instance()
el = ac.IUIAutomation.ElementFromHandle(wintypes.HWND(hwnd))
report["steps"].append({"ElementFromHandle": el is not None})
ctrl = auto.ControlFromHandle(hwnd)
try:
arr = ctrl.Element.FindAll(4, ac.IUIAutomation.CreateTrueCondition())
report["steps"].append({"poke_FindAll_descendants": int(arr.Length)})
except Exception as exc:
report["steps"].append({"poke_FindAll": str(exc)})
except Exception as exc:
report["steps"].append({"uia_refresh": str(exc)})
return report
def _msaa_and_uia_poke(hwnd: int, report: dict[str, Any]) -> None:
"""Shared MSAA/UIA attach steps (no ShowWindow)."""
hwnd = int(hwnd)
for obj_id, label in (
(UIA_ROOT_OBJECT_ID, "UiaRootObjectId"),
(OBJID_CLIENT, "OBJID_CLIENT"),
(OBJID_WINDOW, "OBJID_WINDOW"),
):
try:
res = int(user32.SendMessageW(hwnd, WM_GETOBJECT, 0, obj_id) or 0)
report["steps"].append({"wm_getobject": label, "lresult": res})
except Exception as exc:
report["steps"].append({"wm_getobject": label, "error": str(exc)})
try:
from comtypes import GUID, IUnknown
iid = GUID("{618736e0-3c3d-11cf-810c-00aa00389b71}") # IID_IAccessible
punk = ctypes.c_void_p()
hr = ctypes.windll.oleacc.AccessibleObjectFromWindow(
wintypes.HWND(hwnd),
ctypes.c_long(OBJID_CLIENT),
ctypes.byref(iid),
ctypes.byref(punk),
)
report["steps"].append(
{
"AccessibleObjectFromWindow": int(hr),
"has_punk": bool(punk.value),
}
)
if punk.value:
try:
ctypes.cast(punk, ctypes.POINTER(IUnknown)).Release()
except Exception:
pass
except Exception as exc:
report["steps"].append({"AccessibleObjectFromWindow": str(exc)})
try:
from uiautomation.uiautomation import _AutomationClient
import uiautomation as auto
ac = _AutomationClient.instance()
el = ac.IUIAutomation.ElementFromHandle(wintypes.HWND(hwnd))
report["steps"].append({"ElementFromHandle": el is not None})
ctrl = auto.ControlFromHandle(hwnd)
try:
kids = ctrl.GetChildren()
report["steps"].append({"GetChildren_count": len(kids or [])})
except Exception as exc:
report["steps"].append({"GetChildren": str(exc)})
try:
arr = ctrl.Element.FindAll(4, ac.IUIAutomation.CreateTrueCondition())
report["steps"].append({"poke_FindAll_descendants": int(arr.Length)})
except Exception as exc:
report["steps"].append({"poke_FindAll": str(exc)})
except Exception as exc:
report["steps"].append({"uia_refresh": str(exc)})
def warmup_uia_provider(
hwnd: int,
*,
interact_window: bool = True,
allow_foreground_nudge: bool = True,
sparse_findall_threshold: int = 8,
) -> dict[str, Any]:
"""
Force accessibility provider initialization (Inspect-like).
- Restores minimized main window only (no blanket Show on children).
- If still sparse after poke, optionally SetForeground once on THIS hwnd
(never on child/tool HWNDs — that was the popup storm).
"""
ensure_uia()
hwnd = int(hwnd)
report: dict[str, Any] = {"hwnd": hwnd, "mode": "full", "steps": []}
if interact_window:
report["restore"] = restore_window_if_needed(
hwnd, force_show=False, bring_foreground=False
)
else:
report["restore"] = {"skipped": True}
report["findall_before"] = findall_count_for_hwnd(hwnd)
_msaa_and_uia_poke(hwnd, report)
time.sleep(0.15)
report["findall_after"] = findall_count_for_hwnd(hwnd)
if (
allow_foreground_nudge
and interact_window
and int(report.get("findall_after") or 0) < int(sparse_findall_threshold)
):
# One-shot focus nudge on the capture target only.
nudge = restore_window_if_needed(
hwnd, force_show=False, bring_foreground=True
)
report["foreground_nudge"] = nudge
time.sleep(0.35)
_msaa_and_uia_poke(hwnd, report)
report["findall_after_nudge"] = findall_count_for_hwnd(hwnd)
report["findall_after"] = report["findall_after_nudge"]
report["woke"] = int(report.get("findall_after") or 0) > max(
5, int(report.get("findall_before") or 0) + 2
)
report["provider_cold"] = is_provider_cold(
hwnd, findall=int(report.get("findall_after") or 0)
)
return report
def list_same_pid_toplevel_hwnds(pid: int) -> list[int]:
found: list[int] = []
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
def _cb(hwnd, _lp):
p = ctypes.c_ulong(0)
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(p))
if int(p.value) == int(pid):
found.append(int(hwnd))
return True
user32.EnumWindows(_cb, 0)
return found
def dump_window_tree(
hwnd: int,
*,
max_depth: int = 16,
max_nodes: int = 8000,
only_visible: bool = False,
enrich_child_hwnds: bool = True,
use_findall: bool = True,
warmup: bool = True,
warmup_mode: str = "full",
merge_same_pid_if_sparse: bool = True,
) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]:
"""
Dump UIA tree + flat index + locatable candidates.
warmup_mode:
- full: MSAA/UIA poke on target; restore only if minimized
- light: ElementFromHandle poke only (no ShowWindow)
- off: no provider warmup
Child / same-PID merges never call ShowWindow (light poke at most).
"""
import kit_wake
hwnd = int(hwnd)
mode = (warmup_mode or "full").lower()
if not warmup:
mode = "off"
if mode not in {"full", "light", "off"}:
mode = "full"
kit_report: dict[str, Any] = {"applicable": False, "skipped": True, "reason": "not_checked"}
try:
winfo_for_kit = window_info_from_hwnd(hwnd)
except Exception:
winfo_for_kit = None
if winfo_for_kit is not None:
hint = findall_count_for_hwnd(hwnd)
kit_report = kit_wake.auto_ensure_if_wecom(
class_name=winfo_for_kit.class_name or "",
title=winfo_for_kit.title or "",
hwnd=hwnd,
force=False,
findall_hint=hint,
)
warmup_report: dict[str, Any] = {"mode": mode}
if mode == "full":
try:
winfo = window_info_from_hwnd(hwnd)
except Exception:
winfo = None
if winfo is not None and is_mainish_window(winfo):
# No force maximize — preserve operator window geometry.
act = activate_main_hwnd(hwnd, maximize=False)
warmup_report = warmup_uia_provider(
hwnd, interact_window=False, allow_foreground_nudge=False
)
warmup_report["activate"] = act
else:
warmup_report = warmup_uia_provider(
hwnd, interact_window=True, allow_foreground_nudge=True
)
elif mode == "light":
warmup_report = poke_uia_provider(hwnd)
def _run_dump() -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]:
root_ctrl = control_from_hwnd(hwnd)
t, idx, st = dump_tree(
root_ctrl,
max_depth=max_depth,
max_nodes=max_nodes,
only_visible=only_visible,
)
st = dict(st)
st["enriched_child_hwnds"] = False
st["findall_count"] = 0
st["merged_same_pid_hwnds"] = []
if enrich_child_hwnds:
children_nodes: list[dict[str, Any]] = list(t.get("children") or [])
seen_hwnd = {int(hwnd)}
for child_hwnd in enum_child_hwnds(hwnd, max_count=80):
if child_hwnd in seen_hwnd:
continue
seen_hwnd.add(child_hwnd)
if int(st.get("nodes_kept") or 0) >= max_nodes:
st["truncated"] = True
break
try:
# Never full-warmup / ShowWindow child HWNDs.
child_root = control_from_hwnd(child_hwnd)
except Exception:
continue
remain = max_nodes - int(st.get("nodes_kept") or 0)
sub_tree, sub_index, sub_stats = dump_tree(
child_root,
max_depth=max(2, max_depth - 1),
max_nodes=max(1, remain),
only_visible=only_visible,
)
if int(sub_stats.get("nodes_kept") or 0) <= 1 and not (
(sub_tree.get("name") or "") or (sub_tree.get("value") or "")
):
if int(sub_stats.get("nodes_kept") or 0) <= 0:
continue
children_nodes.append(sub_tree)
idx.extend(sub_index)
st["nodes_kept"] = int(st.get("nodes_kept") or 0) + int(
sub_stats.get("nodes_kept") or 0
)
st["nodes_visited"] = int(st.get("nodes_visited") or 0) + int(
sub_stats.get("nodes_visited") or 0
)
st["enriched_child_hwnds"] = True
if sub_stats.get("truncated"):
st["truncated"] = True
if children_nodes:
t = dict(t)
t["children"] = children_nodes
if use_findall:
found = collect_findall_flat(root_ctrl, max_nodes=max_nodes)
st["findall_count"] = len(found)
existing = {r.get("path_hint") for r in idx}
for row in found:
if row.get("path_hint") in existing:
continue
idx.append(row)
existing.add(row.get("path_hint"))
return t, idx, st
tree, index, stats = _run_dump()
stats["warmup"] = warmup_report
stats["warmup_mode"] = mode
stats["kit_wake"] = kit_report
stats["walker"] = "GetFirstChild/NextSibling (package default RawViewWalker)"
stats["escalated_warmup"] = False
locatable = locatable_from_index(index)
stats["locatable_count"] = len(locatable)
stats["uia_sparse"] = int(stats.get("locatable_count") or 0) < 8
stats["provider_cold"] = is_provider_cold(
hwnd, findall=int(stats.get("findall_count") or 0)
)
# 仍冷且目标为企微:强制再 ensure 一次(绕过短时 cache)后重 dump
if stats["provider_cold"] and winfo_for_kit is not None and kit_wake.looks_like_wecom(
class_name=winfo_for_kit.class_name or "",
title=winfo_for_kit.title or "",
):
kit_report = kit_wake.auto_ensure_if_wecom(
class_name=winfo_for_kit.class_name or "",
title=winfo_for_kit.title or "",
hwnd=hwnd,
force=True,
)
stats["kit_wake"] = kit_report
if kit_report.get("ok"):
tree, index, stats = _run_dump()
stats["warmup"] = warmup_report
stats["warmup_mode"] = mode
stats["kit_wake"] = kit_report
stats["walker"] = "GetFirstChild/NextSibling (package default RawViewWalker)"
stats["escalated_warmup"] = False
stats["kit_wake_retry"] = True
locatable = locatable_from_index(index)
stats["locatable_count"] = len(locatable)
stats["uia_sparse"] = int(stats.get("locatable_count") or 0) < 8
stats["provider_cold"] = is_provider_cold(
hwnd, findall=int(stats.get("findall_count") or 0)
)
# If light dump is sparse, escalate once to full warmup + re-dump (no child ShowWindow).
if stats["uia_sparse"] and mode == "light":
try:
winfo = window_info_from_hwnd(hwnd)
except Exception:
winfo = None
if winfo is not None and is_mainish_window(winfo):
act = activate_main_hwnd(hwnd, maximize=False)
warmup_report = warmup_uia_provider(
hwnd, interact_window=False, allow_foreground_nudge=False
)
warmup_report["activate"] = act
else:
warmup_report = warmup_uia_provider(
hwnd, interact_window=True, allow_foreground_nudge=True
)
tree, index, stats = _run_dump()
stats["warmup"] = warmup_report
stats["warmup_mode"] = "full"
stats["kit_wake"] = kit_report
stats["escalated_warmup"] = True
locatable = locatable_from_index(index)
stats["locatable_count"] = len(locatable)
stats["uia_sparse"] = int(stats.get("locatable_count") or 0) < 8
stats["provider_cold"] = is_provider_cold(
hwnd, findall=int(stats.get("findall_count") or 0)
)
# Sparse fallback: light poke + FindAll on a few large same-pid top-levels.
# Includes WeWorkWindow when current target is a thin popup.
if merge_same_pid_if_sparse and stats["uia_sparse"]:
try:
info = window_info_from_hwnd(hwnd)
merged_n = 0
# Prefer large main-ish windows first
others: list[tuple[int, int, Any]] = []
for other in list_same_pid_toplevel_hwnds(info.pid):
if other == hwnd:
continue
rect = _window_rect(other)
if not rect:
continue
area = int(rect.get("width") or 0) * int(rect.get("height") or 0)
if int(rect.get("width") or 0) < 120 or int(rect.get("height") or 0) < 80:
continue
others.append((area, other, rect))
others.sort(key=lambda x: -x[0])
for _area, other, _rect in others:
if merged_n >= 8:
break
try:
oinfo = window_info_from_hwnd(other)
except Exception:
continue
if is_inspector_window(oinfo):
continue
if oinfo.class_name in {
"Shell_TrayWnd",
"Shell_SecondaryTrayWnd",
"Progman",
"WorkerW",
"IME",
"MSCTFIME UI",
"tooltips_class32",
}:
continue
# Light poke only — never ShowWindow on siblings
poke_uia_provider(other)
try:
oroot = control_from_hwnd(other)
extra = collect_findall_flat(
oroot, max_nodes=max(100, max_nodes - len(index))
)
except Exception:
continue
if len(extra) < 5:
continue
merged_n += 1
stats.setdefault("merged_same_pid_hwnds", []).append(
{
"hwnd": other,
"title": oinfo.title,
"class_name": oinfo.class_name,
"findall": len(extra),
}
)
existing = {r.get("path_hint") for r in index}
for row in extra:
row = dict(row)
row["source"] = f"findall_pid:{other}"
if row.get("path_hint") in existing:
continue
index.append(row)
existing.add(row.get("path_hint"))
locatable = locatable_from_index(index)
stats["locatable_count"] = len(locatable)
stats["findall_count"] = max(
int(stats.get("findall_count") or 0),
sum(1 for r in index if str(r.get("source", "")).startswith("findall")),
)
stats["uia_sparse"] = int(stats.get("locatable_count") or 0) < 8
except Exception as exc:
stats["same_pid_merge_error"] = str(exc)
stats["kit_wake"] = kit_report
return tree, index, stats, locatable
def collect_findall_flat(root_ctrl, *, max_nodes: int = 8000) -> list[dict[str, Any]]:
"""COM FindAll(Subtree) — often finds nodes GetChildren walk misses."""
out: list[dict[str, Any]] = []
try:
from uiautomation.uiautomation import Control, _AutomationClient
ac = _AutomationClient.instance()
cond = ac.IUIAutomation.CreateTrueCondition()
# TreeScope_Subtree = Element|Children|Descendants = 7
arr = root_ctrl.Element.FindAll(7, cond)
length = int(arr.Length)
for i in range(min(length, max_nodes)):
try:
ele = arr.GetElement(i)
ctrl = Control.CreateControlFromElement(ele)
if ctrl is None:
continue
info = describe_control(ctrl, depth=-1, parent_chain=[])
out.append(
{
"depth": -1,
"source": "findall",
"name": info.name,
"value": info.value,
"automation_id": info.automation_id,
"control_type": info.control_type,
"class_name": info.class_name,
"is_enabled": info.is_enabled,
"is_offscreen": info.is_offscreen,
"rect": info.rect,
"path_hint": info.path_hint,
}
)
except Exception:
continue
except Exception:
return out
return out
def locatable_from_index(
index: list[dict[str, Any]], *, limit: int = 300
) -> list[dict[str, Any]]:
"""Nodes useful for locator drafting: non-empty Name / Value / AutomationId."""
rows: list[dict[str, Any]] = []
seen: set[str] = set()
for r in index:
name = (r.get("name") or "").strip()
value = (r.get("value") or "").strip()
aid = (r.get("automation_id") or "").strip()
if not (name or value or aid):
continue
# Skip ultra-generic noise alone
if name in {"", "pane", "Pane"} and not value and not aid:
continue
key = f"{r.get('control_type')}|{name}|{value}|{aid}"
if key in seen:
continue
seen.add(key)
rows.append(r)
if len(rows) >= limit:
break
# Prefer rows with value or automation_id
rows.sort(
key=lambda x: (
0 if (x.get("value") or x.get("automation_id")) else 1,
0 if x.get("name") else 1,
int(x.get("depth") if x.get("depth") is not None and x.get("depth") >= 0 else 99),
)
)
return rows
def format_locatable_text(locatable: list[dict[str, Any]], *, limit: int = 120) -> str:
lines = [
f"可定位候选(Name/Value/AutomationId 非空,共 {len(locatable)},显示前 {min(limit, len(locatable))})",
"",
]
for i, r in enumerate(locatable[:limit]):
lines.append(f"[{i}] {r.get('control_type') or '?'}")
if r.get("name"):
lines.append(f" Name: {r.get('name')}")
if r.get("value"):
lines.append(f" Value: {r.get('value')}")
if r.get("automation_id"):
lines.append(f" AutomationId: {r.get('automation_id')}")
if r.get("class_name"):
lines.append(f" Class: {r.get('class_name')}")
lines.append(
f" offscreen={r.get('is_offscreen')} enabled={r.get('is_enabled')} "
f"src={r.get('source') or 'tree'}"
)
lines.append("")
if not locatable:
lines.append("(无)UIA 可能过稀;请看截图目录中的 screen.png,或换页后再拾取。")
return "\n".join(lines)
def format_tree_text(tree: dict[str, Any], *, max_lines: int = 2000) -> str:
"""Human-readable indented tree for the panel."""
lines: list[str] = []
def walk(node: dict[str, Any], depth: int) -> None:
if len(lines) >= max_lines:
return
indent = " " * depth
ctype = node.get("control_type") or "?"
name = node.get("name") or ""
value = node.get("value") or ""
aid = node.get("automation_id") or ""
bits = [ctype]
if name:
bits.append(f"Name={name!r}")
if value:
bits.append(f"Value={value!r}")
if aid:
bits.append(f"AutomationId={aid!r}")
lines.append(indent + " | ".join(bits))
for child in node.get("children") or []:
if len(lines) >= max_lines:
lines.append(indent + " …(已截断显示)")
return
walk(child, depth + 1)
if tree:
walk(tree, 0)
else:
lines.append("(空树)")
if len(lines) >= max_lines:
lines.append(f"… 显示已截断到 {max_lines} 行;完整内容见 tree.json / index.jsonl")
return "\n".join(lines)
# Back-compat alias used by older app versions
def resolve_capture_target(
*,
self_hwnd: int = 0,
poll_sec: float = 0.15,
wait_sec: float = 2.0,
) -> WindowInfo:
_ = poll_sec, wait_sec
info, _cands = find_capture_target(exclude_hwnds={int(self_hwnd)} if self_hwnd else None)
return info
def hwnd_from_control(ctrl) -> int:
try:
h = int(getattr(ctrl, "NativeWindowHandle", 0) or 0)
if h:
return h
except Exception:
pass
# Walk parents for a native handle
cur = ctrl
for _ in range(24):
try:
h = int(getattr(cur, "NativeWindowHandle", 0) or 0)
if h:
return h
except Exception:
pass
try:
parent = cur.GetParentControl()
except Exception:
break
if parent is None:
break
cur = parent
return 0
def attach_window_by_click(*, timeout_sec: float = 60.0) -> WindowInfo:
"""Minimize race: user clicks anywhere on the target window to attach it."""
x, y = wait_for_left_click(timeout_sec=timeout_sec)
ctrl = control_from_point(x, y)
hwnd = hwnd_from_control(ctrl)
if not hwnd:
# Fallback: window from point via Win32
hwnd = int(user32.WindowFromPoint(wintypes.POINT(x, y)) or 0)
if not hwnd:
raise RuntimeError(f"点击位置 ({x},{y}) 无法解析到窗口")
info = window_info_from_hwnd(hwnd)
if "UI Inspector" in (info.title or ""):
raise RuntimeError("点到了 Inspector 自己,请点目标软件窗口(如企业微信/添加客户)。")
return info
def control_from_hwnd(hwnd: int):
ensure_uia()
import uiautomation as auto
ctrl = auto.ControlFromHandle(int(hwnd))
if ctrl is None:
raise RuntimeError(f"无法从 HWND={hwnd} 取得 UIA 根控件")
return ctrl
def control_from_point(x: int, y: int):
ensure_uia()
import uiautomation as auto
ctrl = auto.ControlFromPoint(int(x), int(y))
if ctrl is None:
raise RuntimeError(f"坐标 ({x},{y}) 处没有 UIA 控件")
return ctrl
def build_parent_chain(ctrl, *, max_ancestors: int = 12) -> list[dict[str, str]]:
chain: list[dict[str, str]] = []
cur = ctrl
for _ in range(max_ancestors):
try:
parent = cur.GetParentControl()
except Exception:
break
if parent is None:
break
chain.append(
{
"name": _safe_str(getattr(parent, "Name", None)),
"value": _read_value(parent),
"automation_id": _safe_str(getattr(parent, "AutomationId", None)),
"control_type": _control_type_name(parent),
"class_name": _safe_str(getattr(parent, "ClassName", None)),
}
)
cur = parent
chain.reverse()
return chain
def dump_tree(
root,
*,
max_depth: int = 16,
max_nodes: int = 8000,
only_visible: bool = False,
) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]:
"""Return (tree, flat_index, stats).
Important: even when a node is offscreen / zero-sized, we still recurse into
its children. Pruning the whole subtree was the main cause of empty dumps.
"""
stats = {
"nodes_visited": 0,
"nodes_kept": 0,
"nodes_skipped_self": 0,
"truncated": False,
"max_depth": max_depth,
"max_nodes": max_nodes,
"only_visible": only_visible,
}
flat: list[dict[str, Any]] = []
def walk(ctrl, depth: int, ancestors: list[dict[str, str]]) -> Optional[dict[str, Any]]:
if stats["nodes_kept"] >= max_nodes:
stats["truncated"] = True
return None
stats["nodes_visited"] += 1
info = describe_control(ctrl, depth=depth, parent_chain=ancestors)
skip_self = False
if only_visible and depth > 0:
rect = info.rect
if info.is_offscreen is True:
skip_self = True
if rect and rect.get("width", 0) <= 0 and rect.get("height", 0) <= 0:
skip_self = True
child_ancestors = ancestors + [
{
"name": info.name,
"value": info.value,
"automation_id": info.automation_id,
"control_type": info.control_type,
"class_name": info.class_name,
}
]
children_out: list[dict[str, Any]] = []
if depth < max_depth and not stats["truncated"]:
try:
children = ctrl.GetChildren()
except Exception:
children = []
for child in children or []:
if stats["truncated"] or stats["nodes_kept"] >= max_nodes:
stats["truncated"] = True
break
child_node = walk(child, depth + 1, child_ancestors)
if child_node is not None:
children_out.append(child_node)
if skip_self:
stats["nodes_skipped_self"] += 1
# Pass children up so hierarchy is not lost
if len(children_out) == 1:
return children_out[0]
if children_out:
return {
"control_type": info.control_type or "SkippedContainer",
"name": info.name,
"value": info.value,
"automation_id": info.automation_id,
"class_name": info.class_name,
"skipped_self": True,
"children": children_out,
}
return None
stats["nodes_kept"] += 1
node = info.to_dict()
flat.append(
{
"depth": depth,
"source": "tree",
"name": info.name,
"value": info.value,
"automation_id": info.automation_id,
"control_type": info.control_type,
"class_name": info.class_name,
"is_enabled": info.is_enabled,
"is_offscreen": info.is_offscreen,
"rect": info.rect,
"path_hint": info.path_hint,
}
)
if children_out:
node["children"] = children_out
return node
tree = walk(root, 0, []) or {}
return tree, flat, stats
def wait_for_left_click(
*,
timeout_sec: float = 60.0,
poll_sec: float = 0.05,
) -> tuple[int, int]:
"""Block until left mouse button is pressed then released; return cursor pos."""
VK_LBUTTON = 0x01
def down() -> bool:
return bool(user32.GetAsyncKeyState(VK_LBUTTON) & 0x8000)
deadline = time.time() + timeout_sec
# Wait until button is up first (avoid consuming the GUI click that started pick)
while time.time() < deadline and down():
time.sleep(poll_sec)
while time.time() < deadline:
if down():
while time.time() < deadline and down():
time.sleep(poll_sec)
point = wintypes.POINT()
user32.GetCursorPos(ctypes.byref(point))
return int(point.x), int(point.y)
time.sleep(poll_sec)
raise TimeoutError(f"等待鼠标左键点击超时({timeout_sec:.0f}s)")
def pick_control_at_cursor_after_click(
*,
timeout_sec: float = 60.0,
attached_hwnd: Optional[int] = None,
) -> tuple[ControlInfo, dict[str, int]]:
x, y = wait_for_left_click(timeout_sec=timeout_sec)
ctrl = control_from_point(x, y)
info_hwnd = hwnd_from_control(ctrl) or int(
user32.WindowFromPoint(wintypes.POINT(int(x), int(y))) or 0
)
top = get_toplevel_hwnd(info_hwnd) if info_hwnd else 0
# Taskbar / desktop chrome — almost never what you want for skill RPA
class_buf = ctypes.create_unicode_buffer(256)
if top:
user32.GetClassNameW(top, class_buf, 256)
top_class = class_buf.value or ""
if top_class in {"Shell_TrayWnd", "Shell_SecondaryTrayWnd", "NotifyIconOverflowWindow"}:
raise RuntimeError(
f"点到了任务栏/系统托盘(class={top_class}),不是业务窗口。\n"
f"请点已附着窗口内部的控件,不要点任务栏上的 Python/Cursor 图标。"
)
if attached_hwnd:
attached_top = get_toplevel_hwnd(int(attached_hwnd))
if top and attached_top and top != attached_top:
title_buf = ctypes.create_unicode_buffer(512)
user32.GetWindowTextW(top, title_buf, 512)
raise RuntimeError(
"拾取点不在已附着窗口内。\n"
f"已附着 hwnd={attached_top};实际点到 title={title_buf.value!r} "
f"class={top_class!r} hwnd={top}。\n"
"请重新「点选附着」到目标软件(如企业微信),再在该窗口内拾取。"
)
chain = build_parent_chain(ctrl)
info = describe_control(ctrl, depth=0, parent_chain=chain)
return info, {"x": x, "y": y}