Keep the last in-template copy on main so history can restore it; product continues in jiangchang-ui-recorder. Co-authored-by: Cursor <cursoragent@cursor.com>
1712 lines
68 KiB
Python
1712 lines
68 KiB
Python
"""Event-driven Windows action recorder.
|
|
|
|
Input is observed out-of-process with low-level Win32 hooks. Hook callbacks do
|
|
only constant-time queue writes; UIA resolution happens on a separate worker so
|
|
a slow provider (WeCom/SAP/etc.) cannot make Windows remove the hook. The old
|
|
``GetAsyncKeyState`` polling path remains only as a mouse fallback when hook
|
|
installation is unavailable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
import os
|
|
import queue
|
|
import threading
|
|
import time
|
|
from ctypes import wintypes
|
|
from typing import Any, Callable, Optional
|
|
|
|
from .base import RawUserEvent, RecorderBase, RecorderState
|
|
|
|
# Win32 virtual key codes
|
|
_VK_LBUTTON = 0x01
|
|
_VK_RBUTTON = 0x02
|
|
_VK_MBUTTON = 0x04
|
|
|
|
_WH_KEYBOARD_LL = 13
|
|
_WH_MOUSE_LL = 14
|
|
_WM_QUIT = 0x0012
|
|
_WM_KEYDOWN = 0x0100
|
|
_WM_SYSKEYDOWN = 0x0104
|
|
_WM_LBUTTONDOWN = 0x0201
|
|
_WM_LBUTTONUP = 0x0202
|
|
_WM_RBUTTONDOWN = 0x0204
|
|
_WM_RBUTTONUP = 0x0205
|
|
_LLMHF_INJECTED = 0x00000001
|
|
|
|
|
|
class _POINT(ctypes.Structure):
|
|
_fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
|
|
|
|
|
|
class _MSLLHOOKSTRUCT(ctypes.Structure):
|
|
_fields_ = [
|
|
("pt", _POINT),
|
|
("mouseData", wintypes.DWORD),
|
|
("flags", wintypes.DWORD),
|
|
("time", wintypes.DWORD),
|
|
("dwExtraInfo", ctypes.c_size_t),
|
|
]
|
|
|
|
|
|
class _KBDLLHOOKSTRUCT(ctypes.Structure):
|
|
_fields_ = [
|
|
("vkCode", wintypes.DWORD),
|
|
("scanCode", wintypes.DWORD),
|
|
("flags", wintypes.DWORD),
|
|
("time", wintypes.DWORD),
|
|
("dwExtraInfo", ctypes.c_size_t),
|
|
]
|
|
|
|
|
|
_SPECIAL_KEYS = {
|
|
0x08: "Backspace",
|
|
0x09: "Tab",
|
|
0x0D: "Enter",
|
|
0x1B: "Escape",
|
|
0x21: "PageUp",
|
|
0x22: "PageDown",
|
|
0x23: "End",
|
|
0x24: "Home",
|
|
0x25: "ArrowLeft",
|
|
0x26: "ArrowUp",
|
|
0x27: "ArrowRight",
|
|
0x28: "ArrowDown",
|
|
0x2E: "Delete",
|
|
**{0x70 + i: f"F{i + 1}" for i in range(12)},
|
|
}
|
|
|
|
|
|
class WindowsRecorder(RecorderBase):
|
|
"""Observe Windows input and resolve UIA targets outside target processes."""
|
|
|
|
capability_map = {
|
|
"click": "implemented",
|
|
"fill": "implemented",
|
|
"press_key": "implemented",
|
|
"double_click": "schema_only",
|
|
"right_click": "partial",
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
on_event: Optional[Callable[[RawUserEvent], None]] = None,
|
|
exclude_hwnds: Optional[Callable[[], set[int]]] = None,
|
|
poll_sec: float = 0.03,
|
|
focus_value_poll_sec: float = 0.4,
|
|
) -> None:
|
|
super().__init__(on_event=on_event, exclude_hwnds=exclude_hwnds)
|
|
self.poll_sec = poll_sec
|
|
self.focus_value_poll_sec = focus_value_poll_sec
|
|
self._prev_down = False
|
|
self._last_focus_value = ""
|
|
self._last_focus_fp = ""
|
|
self._pending_target: dict[str, Any] = {}
|
|
self._last_uia_point: Optional[tuple[int, int]] = None
|
|
self._last_raw_point: Optional[tuple[int, int]] = None
|
|
self._last_root_hwnd = 0
|
|
self._last_actionable_target: dict[str, Any] = {}
|
|
# Text editors need their own sticky target. A generic "last
|
|
# actionable" target is unsafe here: selecting a contact immediately
|
|
# before typing would otherwise make the contact row the input target.
|
|
self._last_text_entry_target: dict[str, Any] = {}
|
|
self._live_text_control: Any = None
|
|
self._element_atlas: dict[str, list[dict[str, Any]]] = {}
|
|
self._atlas_lock = threading.RLock()
|
|
self._ime_composing = False
|
|
self._last_typing_activity_identity = ""
|
|
self._last_typing_activity_at = 0.0
|
|
self._input_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=1024)
|
|
self._capture_thread: Optional[threading.Thread] = None
|
|
self._uia_thread: Optional[threading.Thread] = None
|
|
self._uia_request = threading.Event()
|
|
self._uia_finished = threading.Event()
|
|
self._snapshot_lock = threading.RLock()
|
|
self._latest_focus_snapshot: dict[str, Any] = {}
|
|
self._latest_focus_sampled_at = 0.0
|
|
self._text_target_generation = 0
|
|
self._uia_bound_target_generation = -1
|
|
self._typing_active = False
|
|
self._last_key_at = 0.0
|
|
self._accept_input = True
|
|
self._startup_ready = threading.Event()
|
|
self._hook_thread_id = 0
|
|
self._mouse_hook = None
|
|
self._keyboard_hook = None
|
|
self._mouse_proc = None
|
|
self._keyboard_proc = None
|
|
self._health_lock = threading.Lock()
|
|
self._health: dict[str, Any] = {
|
|
"backend": "starting",
|
|
"mouse_hook": False,
|
|
"keyboard_hook": False,
|
|
"raw_input_events": 0,
|
|
"mouse_events": 0,
|
|
"keyboard_events": 0,
|
|
"resolved_events": 0,
|
|
"queue_dropped": 0,
|
|
"loop_exceptions": 0,
|
|
"last_error": "",
|
|
"last_event_monotonic": None,
|
|
"undrained_events": 0,
|
|
"shutdown_complete": True,
|
|
"uia_samples": 0,
|
|
}
|
|
|
|
@property
|
|
def capability(self) -> dict[str, str]:
|
|
return dict(self.capability_map)
|
|
|
|
def start(self) -> None:
|
|
if self._thread and self._thread.is_alive():
|
|
self.status = RecorderState.RECORDING
|
|
return
|
|
self._stop.clear()
|
|
self._startup_ready.clear()
|
|
self._uia_finished.clear()
|
|
self._uia_request.clear()
|
|
self._accept_input = True
|
|
self._health_set(shutdown_complete=False, undrained_events=0)
|
|
self.status = RecorderState.RECORDING
|
|
self._capture_thread = threading.Thread(
|
|
target=self._capture_loop,
|
|
name="ui-inspector-win-input-consumer",
|
|
daemon=True,
|
|
)
|
|
self._capture_thread.start()
|
|
self._uia_thread = threading.Thread(
|
|
target=self._uia_sampler_loop,
|
|
name="ui-inspector-win-uia-sampler",
|
|
daemon=True,
|
|
)
|
|
self._uia_thread.start()
|
|
self._thread = threading.Thread(
|
|
target=self._loop, name="ui-inspector-win-recorder", daemon=True
|
|
)
|
|
self._thread.start()
|
|
if not self._startup_ready.wait(timeout=2.0):
|
|
self._record_error("Windows input backend did not complete startup handshake")
|
|
|
|
def stop(self) -> None:
|
|
# Stop accepting hook packets first. The consumer then drains every
|
|
# packet already accepted while the independent UIA sampler performs
|
|
# one final read. Never report a clean shutdown with a hidden tail.
|
|
self._accept_input = False
|
|
self._stop.set()
|
|
self._uia_request.set()
|
|
if self._hook_thread_id:
|
|
try:
|
|
ctypes.windll.user32.PostThreadMessageW(
|
|
int(self._hook_thread_id), _WM_QUIT, 0, 0
|
|
)
|
|
except Exception as exc:
|
|
self._record_error(exc)
|
|
if self._thread and self._thread.is_alive():
|
|
self._thread.join(timeout=2.0)
|
|
listener_alive = bool(self._thread and self._thread.is_alive())
|
|
if not listener_alive:
|
|
self._thread = None
|
|
if self._uia_thread and self._uia_thread.is_alive():
|
|
self._uia_thread.join(timeout=3.0)
|
|
uia_alive = bool(self._uia_thread and self._uia_thread.is_alive())
|
|
if not uia_alive:
|
|
self._uia_thread = None
|
|
if self._capture_thread and self._capture_thread.is_alive():
|
|
self._capture_thread.join(timeout=5.0)
|
|
resolver_alive = bool(self._capture_thread and self._capture_thread.is_alive())
|
|
if not resolver_alive:
|
|
self._capture_thread = None
|
|
undrained = int(self._input_queue.qsize())
|
|
complete = not listener_alive and not resolver_alive and undrained == 0
|
|
self._health_set(
|
|
undrained_events=undrained,
|
|
shutdown_complete=complete,
|
|
uia_sampler_still_running=uia_alive,
|
|
)
|
|
self.status = RecorderState.STOPPED
|
|
if not complete:
|
|
raise RuntimeError(
|
|
"desktop recorder did not shut down cleanly: "
|
|
f"undrained={undrained}, listener_alive={listener_alive}, "
|
|
f"consumer_alive={resolver_alive}"
|
|
)
|
|
|
|
def health(self) -> dict[str, Any]:
|
|
with self._health_lock:
|
|
result = dict(self._health)
|
|
result["listener_thread_alive"] = bool(
|
|
self._thread and self._thread.is_alive()
|
|
)
|
|
result["resolver_thread_alive"] = bool(
|
|
self._capture_thread and self._capture_thread.is_alive()
|
|
)
|
|
result["uia_thread_alive"] = bool(
|
|
self._uia_thread and self._uia_thread.is_alive()
|
|
)
|
|
result["input_queue_size"] = self._input_queue.qsize()
|
|
return result
|
|
|
|
def _health_add(self, key: str, amount: int = 1) -> None:
|
|
with self._health_lock:
|
|
self._health[key] = int(self._health.get(key) or 0) + amount
|
|
|
|
def _health_set(self, **values: Any) -> None:
|
|
with self._health_lock:
|
|
self._health.update(values)
|
|
|
|
def _record_error(self, exc: Any) -> None:
|
|
self._health_add("loop_exceptions")
|
|
self._health_set(last_error=f"{type(exc).__name__}: {exc}" if isinstance(exc, BaseException) else str(exc))
|
|
|
|
def _loop(self) -> None:
|
|
if os.name != "nt":
|
|
self._health_set(backend="unsupported")
|
|
self._startup_ready.set()
|
|
return
|
|
try:
|
|
if self._run_hook_message_loop():
|
|
return
|
|
except Exception as exc:
|
|
self._record_error(exc)
|
|
# Compatibility path: never the preferred backend.
|
|
self._health_set(backend="get_async_key_state_fallback")
|
|
self._startup_ready.set()
|
|
self._poll_mouse_fallback()
|
|
|
|
def _run_hook_message_loop(self) -> bool:
|
|
user32 = ctypes.windll.user32
|
|
kernel32 = ctypes.windll.kernel32
|
|
lresult = ctypes.c_ssize_t
|
|
hook_proc_type = ctypes.WINFUNCTYPE(
|
|
lresult, ctypes.c_int, ctypes.c_size_t, ctypes.c_ssize_t
|
|
)
|
|
user32.SetWindowsHookExW.argtypes = [
|
|
ctypes.c_int, hook_proc_type, ctypes.c_void_p, wintypes.DWORD
|
|
]
|
|
user32.SetWindowsHookExW.restype = ctypes.c_void_p
|
|
user32.CallNextHookEx.argtypes = [
|
|
ctypes.c_void_p, ctypes.c_int, ctypes.c_size_t, ctypes.c_ssize_t
|
|
]
|
|
user32.CallNextHookEx.restype = lresult
|
|
user32.UnhookWindowsHookEx.argtypes = [ctypes.c_void_p]
|
|
user32.UnhookWindowsHookEx.restype = wintypes.BOOL
|
|
kernel32.GetModuleHandleW.restype = ctypes.c_void_p
|
|
|
|
@hook_proc_type
|
|
def mouse_proc(code, wparam, lparam):
|
|
if code >= 0 and wparam in {
|
|
_WM_LBUTTONDOWN, _WM_LBUTTONUP, _WM_RBUTTONDOWN, _WM_RBUTTONUP
|
|
}:
|
|
data = ctypes.cast(
|
|
lparam, ctypes.POINTER(_MSLLHOOKSTRUCT)
|
|
).contents
|
|
self._enqueue_input(
|
|
{
|
|
"kind": "mouse",
|
|
"message": int(wparam),
|
|
"x": int(data.pt.x),
|
|
"y": int(data.pt.y),
|
|
"injected": bool(data.flags & _LLMHF_INJECTED),
|
|
"source_time_ms": int(data.time),
|
|
"coordinate_space": "physical_screen",
|
|
}
|
|
)
|
|
return user32.CallNextHookEx(None, code, wparam, lparam)
|
|
|
|
@hook_proc_type
|
|
def keyboard_proc(code, wparam, lparam):
|
|
if code >= 0 and wparam in {_WM_KEYDOWN, _WM_SYSKEYDOWN}:
|
|
data = ctypes.cast(
|
|
lparam, ctypes.POINTER(_KBDLLHOOKSTRUCT)
|
|
).contents
|
|
self._enqueue_input(
|
|
{
|
|
"kind": "key",
|
|
"vk": int(data.vkCode),
|
|
"scan": int(data.scanCode),
|
|
"injected": bool(data.flags & 0x10),
|
|
"source_time_ms": int(data.time),
|
|
}
|
|
)
|
|
return user32.CallNextHookEx(None, code, wparam, lparam)
|
|
|
|
# Keep callbacks alive for the complete lifetime of the hooks.
|
|
self._mouse_proc = mouse_proc
|
|
self._keyboard_proc = keyboard_proc
|
|
module = kernel32.GetModuleHandleW(None)
|
|
self._hook_thread_id = int(kernel32.GetCurrentThreadId())
|
|
self._mouse_hook = user32.SetWindowsHookExW(
|
|
_WH_MOUSE_LL, mouse_proc, module, 0
|
|
)
|
|
self._keyboard_hook = user32.SetWindowsHookExW(
|
|
_WH_KEYBOARD_LL, keyboard_proc, module, 0
|
|
)
|
|
mouse_ok = bool(self._mouse_hook)
|
|
keyboard_ok = bool(self._keyboard_hook)
|
|
self._health_set(
|
|
backend="low_level_hooks" if mouse_ok else "hook_failed",
|
|
mouse_hook=mouse_ok,
|
|
keyboard_hook=keyboard_ok,
|
|
)
|
|
self._startup_ready.set()
|
|
if not mouse_ok:
|
|
if keyboard_ok:
|
|
user32.UnhookWindowsHookEx(self._keyboard_hook)
|
|
self._keyboard_hook = None
|
|
return False
|
|
|
|
msg = wintypes.MSG()
|
|
try:
|
|
while not self._stop.is_set():
|
|
result = int(user32.GetMessageW(ctypes.byref(msg), None, 0, 0))
|
|
if result <= 0:
|
|
if result < 0:
|
|
self._record_error("GetMessageW failed")
|
|
break
|
|
user32.TranslateMessage(ctypes.byref(msg))
|
|
user32.DispatchMessageW(ctypes.byref(msg))
|
|
finally:
|
|
if self._mouse_hook:
|
|
user32.UnhookWindowsHookEx(self._mouse_hook)
|
|
if self._keyboard_hook:
|
|
user32.UnhookWindowsHookEx(self._keyboard_hook)
|
|
self._mouse_hook = None
|
|
self._keyboard_hook = None
|
|
self._hook_thread_id = 0
|
|
return True
|
|
|
|
def _poll_mouse_fallback(self) -> None:
|
|
user32 = ctypes.windll.user32
|
|
while not self._stop.is_set():
|
|
if self.status != RecorderState.RECORDING:
|
|
time.sleep(0.05)
|
|
continue
|
|
try:
|
|
down = bool(user32.GetAsyncKeyState(_VK_LBUTTON) & 0x8000)
|
|
if down and not self._prev_down:
|
|
x, y = self._cursor_pos(user32)
|
|
self._enqueue_input(
|
|
{"kind": "mouse", "message": _WM_LBUTTONDOWN, "x": x, "y": y, "fallback": True, "coordinate_space": "uia_screen"}
|
|
)
|
|
elif not down and self._prev_down:
|
|
self._enqueue_input({"kind": "mouse", "message": _WM_LBUTTONUP, "fallback": True})
|
|
self._prev_down = down
|
|
except Exception as exc:
|
|
self._record_error(exc)
|
|
time.sleep(self.poll_sec)
|
|
|
|
def _enqueue_input(self, packet: dict[str, Any]) -> None:
|
|
if not self._accept_input:
|
|
self._health_add("input_rejected_after_stop")
|
|
return
|
|
self._health_add("raw_input_events")
|
|
self._health_add("mouse_events" if packet.get("kind") == "mouse" else "keyboard_events")
|
|
self._health_set(last_event_monotonic=time.monotonic())
|
|
try:
|
|
self._input_queue.put_nowait(packet)
|
|
except queue.Full:
|
|
self._health_add("queue_dropped")
|
|
|
|
def _capture_loop(self) -> None:
|
|
# This is deliberately a UIA-free path. Its only job is to preserve
|
|
# input order and turn cached snapshots into semantic events quickly.
|
|
while (
|
|
not self._stop.is_set()
|
|
or not self._input_queue.empty()
|
|
or not self._uia_finished.is_set()
|
|
):
|
|
packet = None
|
|
try:
|
|
packet = self._input_queue.get(timeout=0.05)
|
|
except queue.Empty:
|
|
pass
|
|
try:
|
|
if packet is not None and self.status == RecorderState.RECORDING:
|
|
self._dispatch_input_packet(packet)
|
|
self._health_add("resolved_events")
|
|
if (
|
|
self._typing_active
|
|
and time.monotonic() - self._last_key_at >= 0.25
|
|
):
|
|
self._emit_cached_value_change(force=False)
|
|
except Exception as exc:
|
|
self._record_error(exc)
|
|
finally:
|
|
if packet is not None:
|
|
self._input_queue.task_done()
|
|
self._emit_cached_value_change(force=True)
|
|
|
|
def _uia_sampler_loop(self) -> None:
|
|
"""Own all UIA/COM objects and publish plain-dict snapshots only."""
|
|
try:
|
|
import desktop_uia as uia
|
|
uia.ensure_com_sta()
|
|
except Exception as exc:
|
|
self._record_error(exc)
|
|
try:
|
|
while True:
|
|
self._uia_request.wait(timeout=self.focus_value_poll_sec)
|
|
self._uia_request.clear()
|
|
try:
|
|
with self._snapshot_lock:
|
|
generation = self._text_target_generation
|
|
requested_target = dict(self._last_text_entry_target or {})
|
|
if (
|
|
requested_target
|
|
and generation != self._uia_bound_target_generation
|
|
):
|
|
self._bind_text_target(requested_target, resolve_live=True)
|
|
self._uia_bound_target_generation = generation
|
|
# Once an editor has been bound, read it directly even if a
|
|
# later click has moved OS focus. This preserves the final
|
|
# committed value at transaction boundaries.
|
|
live_info = self._snapshot_live_text_control()
|
|
info = self._focused_control_snapshot()
|
|
if live_info and self._typing_active:
|
|
info = live_info
|
|
if info:
|
|
self._publish_focus_snapshot(info)
|
|
self._health_add("uia_samples")
|
|
except Exception as exc:
|
|
self._record_error(exc)
|
|
if self._stop.is_set():
|
|
break
|
|
finally:
|
|
self._uia_finished.set()
|
|
|
|
def _publish_focus_snapshot(self, info: dict[str, Any]) -> None:
|
|
"""Cross the UIA thread boundary using data, never COM controls."""
|
|
if not info or self._is_transient_input_target(info):
|
|
return
|
|
snap = dict(info)
|
|
with self._snapshot_lock:
|
|
self._latest_focus_snapshot = snap
|
|
self._latest_focus_sampled_at = time.monotonic()
|
|
if self._is_text_entry(snap):
|
|
self._last_text_entry_target = dict(snap)
|
|
|
|
def _cached_focus_snapshot(self) -> dict[str, Any]:
|
|
with self._snapshot_lock:
|
|
return dict(self._latest_focus_snapshot or {})
|
|
|
|
@staticmethod
|
|
def _focus_fingerprint(info: dict[str, Any]) -> str:
|
|
scope = dict(info.get("window_scope") or {})
|
|
return "|".join(
|
|
[
|
|
str(scope.get("hwnd") or 0),
|
|
str(info.get("automation_id") or ""),
|
|
str(info.get("name") or ""),
|
|
str(info.get("control_type") or ""),
|
|
]
|
|
)
|
|
|
|
def _emit_cached_value_change(self, *, force: bool) -> None:
|
|
info = self._cached_focus_snapshot()
|
|
if not info or not self._is_text_entry(info):
|
|
return
|
|
fp = self._focus_fingerprint(info)
|
|
value = str(info.get("value") or "")
|
|
if fp != self._last_focus_fp:
|
|
# A late first sample may already contain the completed text. If
|
|
# keys were observed, preserve it instead of mistaking it for the
|
|
# baseline of a newly focused editor.
|
|
if not (force and self._typing_active and value):
|
|
self._last_focus_fp = fp
|
|
self._last_focus_value = value
|
|
return
|
|
if value == self._last_focus_value:
|
|
return
|
|
self.emit(
|
|
RawUserEvent(
|
|
kind="value_change",
|
|
surface="windows",
|
|
text=value,
|
|
target=info,
|
|
meta={
|
|
"input_type": "password"
|
|
if "password" in str(info.get("name") or "").lower()
|
|
else "",
|
|
"sampled_at": self._latest_focus_sampled_at,
|
|
"causal_flush": bool(force),
|
|
},
|
|
)
|
|
)
|
|
self._last_focus_fp = fp
|
|
self._last_focus_value = value
|
|
self._typing_active = False
|
|
|
|
def _dispatch_input_packet(self, packet: dict[str, Any]) -> None:
|
|
message = int(packet.get("message") or 0)
|
|
if packet.get("kind") == "mouse":
|
|
if message in {_WM_LBUTTONDOWN, _WM_RBUTTONDOWN}:
|
|
self._on_mouse_down_at_fast(
|
|
int(packet.get("x") or 0),
|
|
int(packet.get("y") or 0),
|
|
button="right" if message == _WM_RBUTTONDOWN else "left",
|
|
source_meta=packet,
|
|
)
|
|
elif message in {_WM_LBUTTONUP, _WM_RBUTTONUP}:
|
|
self._on_mouse_up()
|
|
elif packet.get("kind") == "key":
|
|
self._on_key_down(packet)
|
|
|
|
def _on_mouse_down(self, user32) -> None:
|
|
point = self._cursor_pos(user32)
|
|
self._on_mouse_down_at_fast(*point)
|
|
|
|
def _on_mouse_down_at_fast(
|
|
self,
|
|
x: int,
|
|
y: int,
|
|
*,
|
|
button: str = "left",
|
|
source_meta: Optional[dict[str, Any]] = None,
|
|
) -> None:
|
|
"""Resolve a click without touching UIA.
|
|
|
|
Win32 supplies application ownership and the persisted element atlas
|
|
supplies an immediate semantic target. The sampler is then nudged to
|
|
refine focus/value state asynchronously.
|
|
"""
|
|
self._emit_cached_value_change(force=True)
|
|
raw_x, raw_y = int(x), int(y)
|
|
user32 = ctypes.windll.user32
|
|
exclude: set[int] = set()
|
|
try:
|
|
exclude = set(self.exclude_hwnds() or set())
|
|
except Exception:
|
|
pass
|
|
hwnd = 0
|
|
root = 0
|
|
pid = 0
|
|
try:
|
|
point = self._make_point(raw_x, raw_y)
|
|
physical_lookup = getattr(user32, "WindowFromPhysicalPoint", None)
|
|
if physical_lookup:
|
|
physical_lookup.restype = ctypes.c_void_p
|
|
hwnd = int(physical_lookup(point) or 0)
|
|
else:
|
|
user32.WindowFromPoint.restype = ctypes.c_void_p
|
|
hwnd = int(user32.WindowFromPoint(point) or 0)
|
|
root = int(user32.GetAncestor(hwnd, 2) or hwnd) if hwnd else 0
|
|
pid_ref = ctypes.c_ulong()
|
|
if root:
|
|
user32.GetWindowThreadProcessId(root, ctypes.byref(pid_ref))
|
|
pid = int(pid_ref.value or 0)
|
|
if root in exclude or hwnd in exclude or pid == os.getpid():
|
|
return
|
|
overlay = self._top_level_window_at_point(
|
|
raw_x, raw_y, exclude_hwnds=exclude, preferred_pid=pid
|
|
)
|
|
if overlay and overlay != root:
|
|
root = int(overlay)
|
|
pid_ref = ctypes.c_ulong()
|
|
user32.GetWindowThreadProcessId(root, ctypes.byref(pid_ref))
|
|
pid = int(pid_ref.value or 0)
|
|
except Exception as exc:
|
|
self._record_error(exc)
|
|
|
|
scope = self._window_scope_fast(root or hwnd, fallback_pid=pid)
|
|
points = [(raw_x, raw_y)]
|
|
scale = 1.0
|
|
try:
|
|
get_dpi = getattr(user32, "GetDpiForWindow", None)
|
|
dpi = int(get_dpi(int(root or hwnd)) or 96) if get_dpi and (root or hwnd) else 96
|
|
scale = max(1.0, dpi / 96.0)
|
|
if scale > 1.05:
|
|
points.append((round(raw_x / scale), round(raw_y / scale)))
|
|
except Exception:
|
|
pass
|
|
target = self._atlas_hit(str(scope.get("class_name") or ""), points)
|
|
if target:
|
|
target["window_scope"] = dict(scope)
|
|
selection = "persisted_element_atlas"
|
|
else:
|
|
target = {
|
|
"name": str(scope.get("title") or ""),
|
|
"control_type": "WindowControl",
|
|
"class_name": str(scope.get("class_name") or ""),
|
|
"window_scope": dict(scope),
|
|
}
|
|
selection = "win32_window_fallback"
|
|
|
|
self._last_raw_point = (raw_x, raw_y)
|
|
# Atlas coordinates are commonly logical on mixed-DPI desktop apps.
|
|
self._last_uia_point = points[-1]
|
|
self._last_root_hwnd = int(root or hwnd or 0)
|
|
self._pending_target = dict(target)
|
|
if not self._is_root_or_oversized(target):
|
|
self._last_actionable_target = dict(target)
|
|
if self._is_text_entry(target):
|
|
self._set_text_target_fast(target)
|
|
self.emit(
|
|
RawUserEvent(
|
|
kind="click",
|
|
surface="windows",
|
|
button=button,
|
|
x=raw_x,
|
|
y=raw_y,
|
|
target=target,
|
|
meta={
|
|
"input_backend": self.health().get("backend"),
|
|
"injected": bool((source_meta or {}).get("injected")),
|
|
"source_time_ms": (source_meta or {}).get("source_time_ms"),
|
|
"source_coordinate_space": str(
|
|
(source_meta or {}).get("coordinate_space") or "uia_screen"
|
|
),
|
|
"raw_coordinates_physical": {"x": raw_x, "y": raw_y},
|
|
"coordinate_selection": selection,
|
|
"dpi_scale": scale,
|
|
"uia_deferred": True,
|
|
},
|
|
)
|
|
)
|
|
self._uia_request.set()
|
|
|
|
@staticmethod
|
|
def _window_scope_fast(hwnd: int, *, fallback_pid: int = 0) -> dict[str, Any]:
|
|
scope = {"hwnd": int(hwnd or 0), "pid": int(fallback_pid or 0), "title": "", "class_name": ""}
|
|
if not hwnd:
|
|
return scope
|
|
try:
|
|
user32 = ctypes.windll.user32
|
|
title = ctypes.create_unicode_buffer(512)
|
|
klass = ctypes.create_unicode_buffer(256)
|
|
user32.GetWindowTextW(int(hwnd), title, len(title))
|
|
user32.GetClassNameW(int(hwnd), klass, len(klass))
|
|
pid_ref = ctypes.c_ulong()
|
|
user32.GetWindowThreadProcessId(int(hwnd), ctypes.byref(pid_ref))
|
|
scope.update(
|
|
title=title.value,
|
|
class_name=klass.value,
|
|
pid=int(pid_ref.value or fallback_pid or 0),
|
|
)
|
|
except Exception:
|
|
pass
|
|
return scope
|
|
|
|
def _set_text_target_fast(self, info: dict[str, Any]) -> None:
|
|
with self._snapshot_lock:
|
|
self._last_text_entry_target = dict(info)
|
|
self._text_target_generation += 1
|
|
self._last_focus_fp = self._focus_fingerprint(info)
|
|
self._last_focus_value = str(info.get("value") or "")
|
|
with self._snapshot_lock:
|
|
# The atlas target is useful immediately. The sampler will replace
|
|
# it with a live value-bearing snapshot when the provider responds.
|
|
self._latest_focus_snapshot = dict(info)
|
|
self._latest_focus_sampled_at = time.monotonic()
|
|
self._typing_active = False
|
|
self._uia_request.set()
|
|
|
|
def _on_mouse_down_at(
|
|
self,
|
|
x: int,
|
|
y: int,
|
|
*,
|
|
button: str = "left",
|
|
source_meta: Optional[dict[str, Any]] = None,
|
|
) -> None:
|
|
user32 = ctypes.windll.user32
|
|
# Preserve the final value of the previously focused editor before the
|
|
# click changes focus (important for paste-then-click workflows).
|
|
if self._last_focus_fp:
|
|
self._poll_focus_value()
|
|
raw_x, raw_y = int(x), int(y)
|
|
exclude = set()
|
|
try:
|
|
exclude = set(self.exclude_hwnds() or set())
|
|
except Exception:
|
|
exclude = set()
|
|
|
|
# Filter Inspector window
|
|
root = 0
|
|
try:
|
|
point = self._make_point(raw_x, raw_y)
|
|
physical_lookup = getattr(user32, "WindowFromPhysicalPoint", None)
|
|
user32.WindowFromPoint.restype = ctypes.c_void_p
|
|
if physical_lookup:
|
|
physical_lookup.restype = ctypes.c_void_p
|
|
hwnd = int(
|
|
(physical_lookup(point) if physical_lookup else user32.WindowFromPoint(point))
|
|
or 0
|
|
)
|
|
root = int(user32.GetAncestor(hwnd, 2) or hwnd) if hwnd else 0 # GA_ROOT=2
|
|
target_pid = ctypes.c_ulong()
|
|
if root:
|
|
user32.GetWindowThreadProcessId(root, ctypes.byref(target_pid))
|
|
if (
|
|
root in exclude
|
|
or hwnd in exclude
|
|
or int(target_pid.value or 0) == os.getpid()
|
|
):
|
|
self.emit(
|
|
RawUserEvent(
|
|
kind="mouse_down",
|
|
surface="windows",
|
|
x=x,
|
|
y=y,
|
|
from_inspector=True,
|
|
meta={"reason": "inspector_hwnd"},
|
|
)
|
|
)
|
|
return
|
|
overlay = self._top_level_window_at_point(
|
|
raw_x,
|
|
raw_y,
|
|
exclude_hwnds=exclude,
|
|
preferred_pid=int(target_pid.value or 0),
|
|
)
|
|
if overlay and overlay != root:
|
|
root = overlay
|
|
target_pid = ctypes.c_ulong()
|
|
user32.GetWindowThreadProcessId(root, ctypes.byref(target_pid))
|
|
except Exception:
|
|
hwnd = 0
|
|
|
|
uia_x, uia_y, dpi_meta = self._uia_point_for_window(
|
|
root or hwnd, raw_x, raw_y,
|
|
coordinate_space=str((source_meta or {}).get("coordinate_space") or "uia_screen"),
|
|
)
|
|
target, err = self._element_from_point(uia_x, uia_y)
|
|
target_scope_hwnd = int((target.get("window_scope") or {}).get("hwnd") or 0)
|
|
target_scope_root = (
|
|
int(user32.GetAncestor(target_scope_hwnd, 2) or target_scope_hwnd)
|
|
if target_scope_hwnd
|
|
else 0
|
|
)
|
|
if root and target_scope_root and target_scope_root != int(root):
|
|
root_target, root_error = self._element_from_handle(int(root))
|
|
if root_target:
|
|
target = root_target
|
|
err = root_error
|
|
target, uia_x, uia_y, probe_meta = self._probe_mixed_uia_coordinates(
|
|
root or hwnd,
|
|
raw_x,
|
|
raw_y,
|
|
current_point=(uia_x, uia_y),
|
|
current_target=target or {},
|
|
)
|
|
dpi_meta.update(probe_meta)
|
|
target_scope = dict((target or {}).get("window_scope") or {})
|
|
atlas_class = str(target_scope.get("class_name") or "")
|
|
if self._is_root_or_oversized(target or {}) and atlas_class:
|
|
atlas_points = [(int(uia_x), int(uia_y)), (raw_x, raw_y)]
|
|
for candidate in probe_meta.get("uia_coordinate_candidates") or []:
|
|
if candidate.get("x") is not None and candidate.get("y") is not None:
|
|
atlas_points.append((int(candidate["x"]), int(candidate["y"])))
|
|
atlas_target = self._atlas_hit(atlas_class, atlas_points)
|
|
if atlas_target:
|
|
atlas_target["window_scope"] = dict(target_scope)
|
|
target = atlas_target
|
|
dpi_meta["coordinate_selection"] = "persisted_element_atlas"
|
|
dpi_meta["uia_coordinates"] = {"x": uia_x, "y": uia_y}
|
|
self._last_uia_point = (uia_x, uia_y)
|
|
self._last_raw_point = (raw_x, raw_y)
|
|
self._last_root_hwnd = int(root or hwnd or 0)
|
|
self._pending_target = target or {}
|
|
if self._pending_target and not self._is_root_or_oversized(self._pending_target):
|
|
self._last_actionable_target = dict(self._pending_target)
|
|
if self._is_text_entry(self._pending_target):
|
|
self._bind_text_target(self._pending_target)
|
|
self._seed_focus_baseline(self._pending_target)
|
|
meta: dict[str, Any] = {
|
|
"input_backend": self.health().get("backend"),
|
|
"injected": bool((source_meta or {}).get("injected")),
|
|
"source_time_ms": (source_meta or {}).get("source_time_ms"),
|
|
**dpi_meta,
|
|
}
|
|
if err:
|
|
meta["element_from_point_error"] = err
|
|
meta["raw_coordinates"] = {"x": x, "y": y}
|
|
# Coordinates saved as evidence only — never auto primary locator.
|
|
meta["coordinates_are_not_primary_locator"] = True
|
|
self.emit(
|
|
RawUserEvent(
|
|
kind="click",
|
|
surface="windows",
|
|
button=button,
|
|
x=uia_x,
|
|
y=uia_y,
|
|
target=self._pending_target,
|
|
meta=meta,
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _uia_point_for_window(
|
|
hwnd: int,
|
|
x: int,
|
|
y: int,
|
|
*,
|
|
coordinate_space: str,
|
|
) -> tuple[int, int, dict[str, Any]]:
|
|
"""Map WH_*_LL physical coordinates into the DPI-virtualized UIA space."""
|
|
meta: dict[str, Any] = {
|
|
"raw_coordinates_physical": {"x": int(x), "y": int(y)},
|
|
"source_coordinate_space": coordinate_space,
|
|
}
|
|
if coordinate_space != "physical_screen" or not hwnd:
|
|
meta["uia_coordinates"] = {"x": int(x), "y": int(y)}
|
|
return int(x), int(y), meta
|
|
try:
|
|
# UIAutomation providers do not necessarily use the same coordinate
|
|
# space as a DPI-aware WH_MOUSE_LL hook. In particular WeCom can
|
|
# expose a 1440x900 UIA root while the hook and DWM both report a
|
|
# 2880x1800 physical window. Comparing GetWindowRect with DWM alone
|
|
# therefore incorrectly produces a scale of 1. Prefer the actual
|
|
# UIA root rectangle as the destination coordinate space.
|
|
physical = wintypes.RECT()
|
|
hr = ctypes.windll.dwmapi.DwmGetWindowAttribute(
|
|
int(hwnd), 9, ctypes.byref(physical), ctypes.sizeof(physical)
|
|
)
|
|
if hr == 0:
|
|
try:
|
|
import uiautomation as auto
|
|
|
|
root = auto.ControlFromHandle(int(hwnd))
|
|
rr = root.BoundingRectangle if root is not None else None
|
|
rw = int(rr.right - rr.left) if rr is not None else 0
|
|
rh = int(rr.bottom - rr.top) if rr is not None else 0
|
|
pw = int(physical.right - physical.left)
|
|
ph = int(physical.bottom - physical.top)
|
|
if min(rw, rh, pw, ph) > 0:
|
|
ux = round(int(rr.left) + (int(x) - physical.left) * rw / pw)
|
|
uy = round(int(rr.top) + (int(y) - physical.top) * rh / ph)
|
|
sx = pw / rw
|
|
sy = ph / rh
|
|
meta.update(
|
|
{
|
|
"uia_coordinates": {"x": ux, "y": uy},
|
|
"dpi_scale": {"x": round(sx, 4), "y": round(sy, 4)},
|
|
"coordinate_conversion": "dwm_physical_to_uia_root",
|
|
}
|
|
)
|
|
return ux, uy, meta
|
|
except Exception as exc:
|
|
meta["uia_root_coordinate_error"] = f"{type(exc).__name__}: {exc}"
|
|
|
|
logical = wintypes.RECT()
|
|
ctypes.windll.user32.GetWindowRect(int(hwnd), ctypes.byref(logical))
|
|
# Compatibility fallback for providers whose root rectangle cannot
|
|
# be queried. DWMWA_EXTENDED_FRAME_BOUNDS is in physical pixels.
|
|
lw = int(logical.right - logical.left)
|
|
lh = int(logical.bottom - logical.top)
|
|
pw = int(physical.right - physical.left)
|
|
ph = int(physical.bottom - physical.top)
|
|
if hr == 0 and min(lw, lh, pw, ph) > 0:
|
|
sx = pw / lw
|
|
sy = ph / lh
|
|
ux = round(logical.left + (int(x) - physical.left) / sx)
|
|
uy = round(logical.top + (int(y) - physical.top) / sy)
|
|
meta.update(
|
|
{
|
|
"uia_coordinates": {"x": ux, "y": uy},
|
|
"dpi_scale": {"x": round(sx, 4), "y": round(sy, 4)},
|
|
"coordinate_conversion": "dwm_physical_to_getwindowrect_logical",
|
|
}
|
|
)
|
|
return ux, uy, meta
|
|
except Exception as exc:
|
|
meta["coordinate_conversion_error"] = f"{type(exc).__name__}: {exc}"
|
|
meta["uia_coordinates"] = {"x": int(x), "y": int(y)}
|
|
meta["coordinate_conversion"] = "identity_fallback"
|
|
return int(x), int(y), meta
|
|
|
|
@staticmethod
|
|
def _target_quality(target: dict[str, Any]) -> tuple[int, int, int, int, int]:
|
|
bounds = dict(target.get("bounds") or {})
|
|
width = max(0, int(bounds.get("width") or 0))
|
|
height = max(0, int(bounds.get("height") or 0))
|
|
area = width * height
|
|
control_type = str(target.get("control_type") or "")
|
|
text = " ".join(
|
|
str(target.get(key) or "")
|
|
for key in ("name", "automation_id", "class_name", "control_type")
|
|
).lower()
|
|
oversized = control_type == "WindowControl" or area > 1_000_000
|
|
interactive = control_type in {
|
|
"ButtonControl", "EditControl", "TreeItemControl", "ListItemControl",
|
|
"MenuItemControl", "CheckBoxControl", "RadioButtonControl",
|
|
"ComboBoxControl", "TabItemControl", "HyperlinkControl",
|
|
}
|
|
semantic = any(
|
|
token in text
|
|
for token in (
|
|
"search", "edit", "input", "message", "send", "contact",
|
|
"buddy", "result", "comment", "reply",
|
|
)
|
|
)
|
|
return (
|
|
0 if oversized else 1,
|
|
1 if interactive else 0,
|
|
1 if semantic else 0,
|
|
len(target.get("parent_chain") or []),
|
|
-area,
|
|
)
|
|
|
|
def _probe_mixed_uia_coordinates(
|
|
self,
|
|
hwnd: int,
|
|
raw_x: int,
|
|
raw_y: int,
|
|
*,
|
|
current_point: tuple[int, int],
|
|
current_target: dict[str, Any],
|
|
) -> tuple[dict[str, Any], int, int, dict[str, Any]]:
|
|
"""Resolve providers whose root and descendants use different DPI spaces.
|
|
|
|
WeCom exposes a physical-size root but logical-size descendants. Only
|
|
probe alternative points when the direct hit is oversized and belongs
|
|
to a known mixed-space window, so standard desktop applications retain
|
|
their normal UIA hit-testing path.
|
|
"""
|
|
scope = dict(current_target.get("window_scope") or {})
|
|
window_class = str(scope.get("class_name") or current_target.get("class_name") or "")
|
|
if (
|
|
not self._is_root_or_oversized(current_target)
|
|
or window_class not in {"WeWorkWindow", "SearchResultWindow2"}
|
|
):
|
|
return current_target, current_point[0], current_point[1], {}
|
|
|
|
candidates: list[tuple[str, int, int, float]] = [
|
|
("direct", int(current_point[0]), int(current_point[1]), 1.0)
|
|
]
|
|
try:
|
|
get_dpi = getattr(ctypes.windll.user32, "GetDpiForWindow", None)
|
|
dpi = int(get_dpi(int(hwnd)) or 96) if get_dpi else 96
|
|
scale = max(1.0, float(dpi) / 96.0)
|
|
if scale > 1.05:
|
|
candidates.append(
|
|
(
|
|
"window_dpi_absolute",
|
|
round(int(raw_x) / scale),
|
|
round(int(raw_y) / scale),
|
|
scale,
|
|
)
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
unique: list[tuple[str, int, int, float]] = []
|
|
seen: set[tuple[int, int]] = set()
|
|
for item in candidates:
|
|
point = (item[1], item[2])
|
|
if point not in seen:
|
|
seen.add(point)
|
|
unique.append(item)
|
|
|
|
expected_pid = int(scope.get("pid") or 0)
|
|
expected_class = window_class
|
|
best_target = current_target
|
|
best_point = current_point
|
|
best_label = "direct"
|
|
best_scale = 1.0
|
|
best_quality = self._target_quality(current_target)
|
|
attempts: list[dict[str, Any]] = []
|
|
for label, px, py, scale in unique[1:]:
|
|
candidate, _err = self._element_from_point(px, py)
|
|
candidate_scope = dict((candidate or {}).get("window_scope") or {})
|
|
same_scope = bool(
|
|
candidate
|
|
and (not expected_pid or int(candidate_scope.get("pid") or 0) == expected_pid)
|
|
and (
|
|
not expected_class
|
|
or str(candidate_scope.get("class_name") or "") == expected_class
|
|
)
|
|
)
|
|
quality = self._target_quality(candidate or {}) if same_scope else (-1, 0, 0, 0, 0)
|
|
attempts.append(
|
|
{
|
|
"strategy": label,
|
|
"x": px,
|
|
"y": py,
|
|
"scale": round(scale, 4),
|
|
"target": str((candidate or {}).get("name") or ""),
|
|
"control_type": str((candidate or {}).get("control_type") or ""),
|
|
"same_scope": same_scope,
|
|
}
|
|
)
|
|
if same_scope and quality > best_quality:
|
|
best_target = candidate
|
|
best_point = (px, py)
|
|
best_label = label
|
|
best_scale = scale
|
|
best_quality = quality
|
|
|
|
return (
|
|
best_target,
|
|
int(best_point[0]),
|
|
int(best_point[1]),
|
|
{
|
|
"uia_coordinate_candidates": attempts,
|
|
"coordinate_selection": best_label,
|
|
"mixed_coordinate_scale": round(best_scale, 4),
|
|
},
|
|
)
|
|
|
|
def _on_key_down(self, packet: dict[str, Any]) -> None:
|
|
key = _SPECIAL_KEYS.get(int(packet.get("vk") or 0), "")
|
|
# Never call UIA here. Physical keys can arrive every few milliseconds;
|
|
# this consumer must remain faster than the hook producer even when the
|
|
# target accessibility provider is frozen for seconds.
|
|
target = self._cached_focus_snapshot()
|
|
if not self._is_text_entry(target):
|
|
fallback = dict(self._last_text_entry_target or {})
|
|
if fallback and (not target or self._last_point_matches_target(fallback)):
|
|
target = fallback
|
|
if self._ime_composing:
|
|
# Enter/Escape inside an IME candidate/composition window confirms
|
|
# or cancels composition; it is not a business-level key action.
|
|
# Keep the transaction attached to the last real editor.
|
|
if target:
|
|
self._typing_active = True
|
|
self._last_key_at = time.monotonic()
|
|
self._emit_typing_activity(target, packet)
|
|
self._uia_request.set()
|
|
return
|
|
if not key:
|
|
self._typing_active = True
|
|
self._last_key_at = time.monotonic()
|
|
self._emit_typing_activity(target, packet)
|
|
self._uia_request.set()
|
|
return
|
|
if key in {"Backspace", "Delete"} and self._is_text_entry(target):
|
|
# Editing keys are part of the resulting value transaction. The
|
|
# next value poll records one complete fill instead of key spam.
|
|
self._typing_active = True
|
|
self._last_key_at = time.monotonic()
|
|
self._emit_typing_activity(target, packet)
|
|
self._uia_request.set()
|
|
return
|
|
# Enter/Tab/click are transaction boundaries: publish the latest
|
|
# sampled value before the boundary action, preserving causal order.
|
|
self._emit_cached_value_change(force=True)
|
|
self.emit(
|
|
RawUserEvent(
|
|
kind="key",
|
|
surface="windows",
|
|
key=key,
|
|
target=target,
|
|
meta={
|
|
"input_backend": self.health().get("backend"),
|
|
"injected": bool(packet.get("injected")),
|
|
"source_time_ms": packet.get("source_time_ms"),
|
|
},
|
|
)
|
|
)
|
|
self._uia_request.set()
|
|
|
|
def _emit_typing_activity(
|
|
self, target: dict[str, Any], packet: dict[str, Any]
|
|
) -> None:
|
|
"""Journal one marker per text transaction without persisting key text.
|
|
|
|
The marker lets diagnostics distinguish "no typing happened" from
|
|
"typing happened but the provider never exposed a value change". It
|
|
is intentionally not reduced into an Action.
|
|
"""
|
|
target = dict(target or self._last_text_entry_target or {})
|
|
identity = "|".join(
|
|
str(target.get(key) or "")
|
|
for key in ("automation_id", "name", "control_type")
|
|
)
|
|
scope = dict(target.get("window_scope") or {})
|
|
identity = f"{scope.get('hwnd') or 0}|{identity}"
|
|
now = time.monotonic()
|
|
if (
|
|
identity == self._last_typing_activity_identity
|
|
and now - self._last_typing_activity_at < 0.8
|
|
):
|
|
self._last_typing_activity_at = now
|
|
return
|
|
self._last_typing_activity_identity = identity
|
|
self._last_typing_activity_at = now
|
|
self.emit(
|
|
RawUserEvent(
|
|
kind="typing_activity",
|
|
surface="windows",
|
|
target=target,
|
|
meta={
|
|
"input_backend": self.health().get("backend"),
|
|
"injected": bool(packet.get("injected")),
|
|
"source_time_ms": packet.get("source_time_ms"),
|
|
"content_recorded": False,
|
|
"unresolved_target": not bool(target),
|
|
"root_hwnd": int(self._last_root_hwnd or 0),
|
|
"last_uia_point": (
|
|
{"x": self._last_uia_point[0], "y": self._last_uia_point[1]}
|
|
if self._last_uia_point else {}
|
|
),
|
|
},
|
|
)
|
|
)
|
|
|
|
def _focused_control_snapshot(self) -> dict[str, Any]:
|
|
try:
|
|
import desktop_uia as uia
|
|
uia.ensure_com_sta()
|
|
import uiautomation as auto
|
|
ctrl = auto.GetFocusedControl()
|
|
info = self._control_snapshot(ctrl) if ctrl is not None else {}
|
|
self._ime_composing = self._is_transient_input_target(info)
|
|
unusable = self._is_root_or_oversized(info) or self._is_transient_input_target(info)
|
|
if unusable and self._last_uia_point:
|
|
try:
|
|
point_ctrl = auto.ControlFromPoint(*self._last_uia_point)
|
|
point_info = self._control_snapshot(
|
|
point_ctrl, x=self._last_uia_point[0], y=self._last_uia_point[1]
|
|
)
|
|
if not self._is_root_or_oversized(point_info):
|
|
info = point_info
|
|
except Exception:
|
|
pass
|
|
if (
|
|
(self._is_root_or_oversized(info) or self._is_transient_input_target(info))
|
|
and self._last_raw_point
|
|
and self._last_uia_point
|
|
and self._last_root_hwnd
|
|
):
|
|
try:
|
|
probed, _x, _y, _meta = self._probe_mixed_uia_coordinates(
|
|
self._last_root_hwnd,
|
|
self._last_raw_point[0],
|
|
self._last_raw_point[1],
|
|
current_point=self._last_uia_point,
|
|
current_target=info,
|
|
)
|
|
if not self._is_root_or_oversized(probed) and not self._is_transient_input_target(probed):
|
|
info = probed
|
|
except Exception:
|
|
pass
|
|
if (
|
|
(self._is_root_or_oversized(info) or self._is_transient_input_target(info))
|
|
and self._last_text_entry_target
|
|
):
|
|
refreshed = self._snapshot_live_text_control()
|
|
if refreshed:
|
|
info = refreshed
|
|
if (
|
|
(self._is_root_or_oversized(info) or self._is_transient_input_target(info))
|
|
and self._last_actionable_target
|
|
and self._last_point_matches_target(self._last_actionable_target)
|
|
):
|
|
current_scope = dict(info.get("window_scope") or {})
|
|
last_scope = dict(self._last_actionable_target.get("window_scope") or {})
|
|
current_hwnd = int(current_scope.get("hwnd") or self._last_root_hwnd or 0)
|
|
last_hwnd = int(last_scope.get("hwnd") or 0)
|
|
if not current_hwnd or not last_hwnd or current_hwnd == last_hwnd:
|
|
info = dict(self._last_actionable_target)
|
|
if (
|
|
(self._is_root_or_oversized(info) or self._is_transient_input_target(info))
|
|
and self._last_uia_point
|
|
):
|
|
scope = dict(info.get("window_scope") or {})
|
|
class_name = str(scope.get("class_name") or "")
|
|
points = [self._last_uia_point]
|
|
if self._last_raw_point:
|
|
rx, ry = self._last_raw_point
|
|
points.extend(
|
|
[(rx, ry)]
|
|
+ [(round(rx / scale), round(ry / scale)) for scale in (1.25, 1.5, 1.75, 2.0, 2.5, 3.0)]
|
|
)
|
|
atlas_target = self._atlas_hit(class_name, points)
|
|
if atlas_target and self._is_text_entry(atlas_target):
|
|
atlas_target["window_scope"] = dict(scope)
|
|
self._bind_text_target(atlas_target)
|
|
info = self._snapshot_live_text_control() or atlas_target
|
|
if (
|
|
(self._is_root_or_oversized(info) or self._is_transient_input_target(info))
|
|
and self._last_text_entry_target
|
|
):
|
|
current_scope = dict(info.get("window_scope") or {})
|
|
editor_scope = dict(self._last_text_entry_target.get("window_scope") or {})
|
|
current_pid = int(current_scope.get("pid") or 0)
|
|
editor_pid = int(editor_scope.get("pid") or 0)
|
|
# IME windows have a different HWND but normally share the
|
|
# application's PID. Never let the IME HWND replace the
|
|
# business editor just because the provider probe is cold.
|
|
if not current_pid or not editor_pid or current_pid == editor_pid:
|
|
refreshed = self._snapshot_live_text_control()
|
|
info = refreshed or dict(self._last_text_entry_target)
|
|
if info and not self._is_root_or_oversized(info) and not self._is_transient_input_target(info):
|
|
self._last_actionable_target = dict(info)
|
|
if self._is_text_entry(info):
|
|
self._bind_text_target(info, resolve_live=False)
|
|
return info
|
|
# A transient OS surface must never cross the recorder boundary as
|
|
# an application target.
|
|
return {}
|
|
except Exception as exc:
|
|
self._record_error(exc)
|
|
return {}
|
|
|
|
def _on_mouse_up(self) -> None:
|
|
# Click already emitted on down (capture before UI disappears).
|
|
self._pending_target = {}
|
|
|
|
def _poll_focus_value(self) -> None:
|
|
try:
|
|
info = self._focused_control_snapshot()
|
|
if not info:
|
|
return
|
|
# Skip if focused control belongs to Inspector
|
|
hwnd = int(info.get("window_scope", {}).get("hwnd") or 0)
|
|
try:
|
|
exclude = set(self.exclude_hwnds() or set())
|
|
except Exception:
|
|
exclude = set()
|
|
if hwnd and hwnd in exclude:
|
|
return
|
|
fp = "|".join(
|
|
[
|
|
str(info.get("automation_id") or ""),
|
|
str(info.get("name") or ""),
|
|
str(info.get("control_type") or ""),
|
|
]
|
|
)
|
|
val = str(info.get("value") or "")
|
|
if fp == self._last_focus_fp and val == self._last_focus_value:
|
|
return
|
|
if self._last_focus_fp and fp == self._last_focus_fp and val != self._last_focus_value:
|
|
self.emit(
|
|
RawUserEvent(
|
|
kind="value_change",
|
|
surface="windows",
|
|
text=val,
|
|
target=info,
|
|
meta={"input_type": "password" if "password" in (info.get("name") or "").lower() else ""},
|
|
)
|
|
)
|
|
self._last_focus_fp = fp
|
|
self._last_focus_value = val
|
|
info["fingerprint"] = fp
|
|
except Exception:
|
|
return
|
|
|
|
@staticmethod
|
|
def _is_text_entry(info: dict[str, Any]) -> bool:
|
|
text = " ".join(
|
|
str(info.get(key) or "")
|
|
for key in ("name", "automation_id", "class_name", "control_type")
|
|
).lower()
|
|
return str(info.get("control_type") or "") == "EditControl" or any(
|
|
token in text
|
|
for token in ("edit", "input", "search", "compose", "message", "rich")
|
|
)
|
|
|
|
@staticmethod
|
|
def _is_transient_input_target(info: dict[str, Any]) -> bool:
|
|
text = " ".join(
|
|
str(value or "")
|
|
for value in (
|
|
info.get("name"),
|
|
info.get("class_name"),
|
|
(info.get("window_scope") or {}).get("class_name"),
|
|
(info.get("window_scope") or {}).get("title"),
|
|
)
|
|
).lower()
|
|
return any(
|
|
token in text
|
|
for token in (
|
|
"sopy_comp", "msctfime", "candidatewindow", "ime candidate",
|
|
"inputmethod", "input method", "compositionwindow",
|
|
"拼音", "候选词", "输入法",
|
|
)
|
|
)
|
|
|
|
def _last_point_matches_target(self, target: dict[str, Any]) -> bool:
|
|
"""Guard stale focus fallback with the last click's geometry."""
|
|
bounds = dict(target.get("bounds") or {})
|
|
try:
|
|
left = int(bounds.get("left"))
|
|
top = int(bounds.get("top"))
|
|
right = int(bounds.get("right"))
|
|
bottom = int(bounds.get("bottom"))
|
|
except (TypeError, ValueError):
|
|
return False
|
|
points: list[tuple[int, int]] = []
|
|
if self._last_uia_point:
|
|
points.append(self._last_uia_point)
|
|
if self._last_raw_point:
|
|
rx, ry = self._last_raw_point
|
|
points.append((rx, ry))
|
|
# Mixed-DPI providers commonly expose descendants at one of these
|
|
# Windows scale factors even when their root uses physical pixels.
|
|
for scale in (1.25, 1.5, 1.75, 2.0, 2.5, 3.0):
|
|
points.append((round(rx / scale), round(ry / scale)))
|
|
return any(left <= x <= right and top <= y <= bottom for x, y in points)
|
|
|
|
def _seed_focus_baseline(self, info: dict[str, Any]) -> None:
|
|
"""Remember the pre-input value at click time.
|
|
|
|
Without this seed, fast typing can finish before the first 400 ms focus
|
|
poll and the completed text is mistaken for the initial baseline.
|
|
"""
|
|
if not info or not self._is_text_entry(info):
|
|
return
|
|
self._bind_text_target(info)
|
|
self._last_focus_fp = "|".join(
|
|
[
|
|
str(info.get("automation_id") or ""),
|
|
str(info.get("name") or ""),
|
|
str(info.get("control_type") or ""),
|
|
]
|
|
)
|
|
self._last_focus_value = str(info.get("value") or "")
|
|
|
|
def learn_elements(
|
|
self,
|
|
rows: list[dict[str, Any]],
|
|
*,
|
|
window: Optional[dict[str, Any]] = None,
|
|
) -> None:
|
|
"""Publish a bounded, value-free element atlas to the event-time path."""
|
|
win = dict(window or {})
|
|
class_name = str(win.get("class_name") or win.get("chosen_class") or "")
|
|
if not class_name:
|
|
return
|
|
cleaned: list[dict[str, Any]] = []
|
|
for row in rows or []:
|
|
item = dict(row or {})
|
|
bounds = dict(item.get("bounds") or item.get("rect") or {})
|
|
if not bounds:
|
|
continue
|
|
item["bounds"] = bounds
|
|
item.pop("rect", None)
|
|
# Historical atlases are locators, not a store of user content.
|
|
item["value"] = ""
|
|
item["window_scope"] = {
|
|
"hwnd": int(win.get("hwnd") or 0),
|
|
"pid": int(win.get("pid") or 0),
|
|
"title": str(win.get("title") or ""),
|
|
"class_name": class_name,
|
|
}
|
|
cleaned.append(item)
|
|
if cleaned:
|
|
with self._atlas_lock:
|
|
self._element_atlas[class_name] = cleaned[-2500:]
|
|
|
|
def _atlas_hit(
|
|
self, class_name: str, points: list[tuple[int, int]]
|
|
) -> dict[str, Any]:
|
|
with self._atlas_lock:
|
|
rows = list(self._element_atlas.get(class_name) or [])
|
|
candidates: list[tuple[tuple[int, int, int, int], dict[str, Any]]] = []
|
|
for row in rows:
|
|
bounds = dict(row.get("bounds") or {})
|
|
try:
|
|
hit = any(
|
|
int(bounds.get("left")) <= x <= int(bounds.get("right"))
|
|
and int(bounds.get("top")) <= y <= int(bounds.get("bottom"))
|
|
for x, y in points
|
|
)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if not hit or row.get("is_offscreen") is True:
|
|
continue
|
|
name = str(row.get("name") or row.get("automation_id") or "").lower()
|
|
control_type = str(row.get("control_type") or "")
|
|
width = max(0, int(bounds.get("width") or 0))
|
|
height = max(0, int(bounds.get("height") or 0))
|
|
area = width * height
|
|
if not area or width < 4 or height < 4:
|
|
continue
|
|
exact_editor = name in {
|
|
"searchedit", "send_rich_edit", "search_box", "compose_box"
|
|
}
|
|
editor = self._is_text_entry(row) and not name.endswith("_layout")
|
|
semantic = any(
|
|
token in name
|
|
for token in ("search_name", "contact_name", "send", "edit", "input")
|
|
)
|
|
candidates.append(
|
|
((1 if exact_editor else 0, 1 if editor else 0, 1 if semantic else 0, -area), row)
|
|
)
|
|
return dict(max(candidates, default=((0, 0, 0, 0), {}), key=lambda x: x[0])[1])
|
|
|
|
def _bind_text_target(
|
|
self, info: dict[str, Any], *, resolve_live: bool = True
|
|
) -> None:
|
|
if not info or not self._is_text_entry(info):
|
|
return
|
|
self._last_text_entry_target = dict(info)
|
|
if not resolve_live or not self._last_root_hwnd:
|
|
return
|
|
name = str(info.get("name") or "").strip()
|
|
if not name:
|
|
return
|
|
try:
|
|
import uiautomation as auto
|
|
|
|
root = auto.ControlFromHandle(int(self._last_root_hwnd))
|
|
candidate = root.Control(searchDepth=24, Name=name)
|
|
if candidate.Exists(0.35, 0.02):
|
|
self._live_text_control = candidate
|
|
except Exception:
|
|
self._live_text_control = None
|
|
|
|
def _snapshot_live_text_control(self) -> dict[str, Any]:
|
|
if self._live_text_control is None:
|
|
return {}
|
|
try:
|
|
info = self._control_snapshot(self._live_text_control)
|
|
if info and self._is_text_entry(info):
|
|
return info
|
|
except Exception:
|
|
pass
|
|
self._live_text_control = None
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _is_root_or_oversized(info: dict[str, Any]) -> bool:
|
|
bounds = dict(info.get("bounds") or {})
|
|
area = int(bounds.get("width") or 0) * int(bounds.get("height") or 0)
|
|
return str(info.get("control_type") or "") == "WindowControl" or area > 1_000_000
|
|
|
|
def _element_from_point(self, x: int, y: int) -> tuple[dict[str, Any], str]:
|
|
"""Minimal directed read — NO ensure_ui_ready / full warm."""
|
|
try:
|
|
import desktop_uia as uia
|
|
|
|
uia.ensure_com_sta()
|
|
import uiautomation as auto
|
|
|
|
ctrl = auto.ControlFromPoint(int(x), int(y))
|
|
if ctrl is None:
|
|
return {}, "ControlFromPoint returned None"
|
|
return self._control_snapshot(ctrl, x=x, y=y), ""
|
|
except Exception as exc:
|
|
return {}, f"{type(exc).__name__}: {exc}"
|
|
|
|
def _element_from_handle(self, hwnd: int) -> tuple[dict[str, Any], str]:
|
|
try:
|
|
import desktop_uia as uia
|
|
|
|
uia.ensure_com_sta()
|
|
import uiautomation as auto
|
|
|
|
ctrl = auto.ControlFromHandle(int(hwnd))
|
|
if ctrl is None:
|
|
return {}, "ControlFromHandle returned None"
|
|
return self._control_snapshot(ctrl), ""
|
|
except Exception as exc:
|
|
return {}, f"{type(exc).__name__}: {exc}"
|
|
|
|
@staticmethod
|
|
def _is_decorative_window_class(class_name: str) -> bool:
|
|
lowered = str(class_name or "").strip().lower()
|
|
return lowered in {
|
|
"shell_traywnd", "shell_secondarytraywnd", "progman", "workerw",
|
|
"ime", "msctfime ui", "tooltips_class32", "sopy_comp",
|
|
"perryshadowwnd",
|
|
} or any(
|
|
token in lowered
|
|
for token in ("shadowwnd", "dropshadow", "drop_shadow", "windowshadow")
|
|
)
|
|
|
|
@staticmethod
|
|
def _top_level_window_at_point(
|
|
x: int,
|
|
y: int,
|
|
*,
|
|
exclude_hwnds: set[int],
|
|
preferred_pid: int = 0,
|
|
) -> int:
|
|
"""Return the smallest visible top-level overlay containing a point.
|
|
|
|
This is a generic Win32 fallback for applications whose accessibility
|
|
provider reports the owner window from ``WindowFromPoint`` while an
|
|
owned popup/list is visibly on top.
|
|
"""
|
|
if os.name != "nt":
|
|
return 0
|
|
user32 = ctypes.windll.user32
|
|
candidates: list[tuple[int, int]] = []
|
|
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
|
def callback(hwnd, _lparam):
|
|
handle = int(hwnd or 0)
|
|
if not handle or handle in exclude_hwnds or not user32.IsWindowVisible(handle):
|
|
return True
|
|
pid_ref = ctypes.c_ulong()
|
|
user32.GetWindowThreadProcessId(handle, ctypes.byref(pid_ref))
|
|
pid = int(pid_ref.value or 0)
|
|
if pid == os.getpid() or (preferred_pid and pid != preferred_pid):
|
|
return True
|
|
buf = ctypes.create_unicode_buffer(256)
|
|
user32.GetClassNameW(handle, buf, len(buf))
|
|
class_name = buf.value
|
|
if WindowsRecorder._is_decorative_window_class(class_name):
|
|
return True
|
|
rect = wintypes.RECT()
|
|
if not user32.GetWindowRect(handle, ctypes.byref(rect)):
|
|
return True
|
|
if not (rect.left <= int(x) <= rect.right and rect.top <= int(y) <= rect.bottom):
|
|
return True
|
|
width = max(0, int(rect.right - rect.left))
|
|
height = max(0, int(rect.bottom - rect.top))
|
|
if width < 8 or height < 8:
|
|
return True
|
|
candidates.append((width * height, handle))
|
|
return True
|
|
|
|
try:
|
|
user32.EnumWindows(callback, 0)
|
|
except Exception:
|
|
return 0
|
|
return min(candidates, default=(0, 0))[1]
|
|
|
|
def _control_snapshot(
|
|
self, ctrl: Any, *, x: Optional[int] = None, y: Optional[int] = None
|
|
) -> dict[str, Any]:
|
|
def safe(fn, default=""):
|
|
try:
|
|
return fn()
|
|
except Exception:
|
|
return default
|
|
|
|
name = str(safe(lambda: ctrl.Name, "") or "")
|
|
value = ""
|
|
try:
|
|
value = str(ctrl.GetValuePattern().Value)
|
|
except Exception:
|
|
value = str(safe(lambda: getattr(ctrl, "Value", ""), "") or "")
|
|
if not value:
|
|
try:
|
|
value = str(ctrl.GetTextPattern().DocumentRange.GetText(-1) or "")
|
|
except Exception:
|
|
pass
|
|
if not value:
|
|
try:
|
|
value = str(ctrl.GetLegacyIAccessiblePattern().Value or "")
|
|
except Exception:
|
|
pass
|
|
aid = str(safe(lambda: ctrl.AutomationId, "") or "")
|
|
ctype = str(safe(lambda: ctrl.ControlTypeName, "") or "")
|
|
class_name = str(safe(lambda: ctrl.ClassName, "") or "")
|
|
framework = str(safe(lambda: ctrl.FrameworkId, "") or "")
|
|
enabled = safe(lambda: bool(ctrl.IsEnabled), None)
|
|
offscreen = safe(lambda: bool(ctrl.IsOffscreen), None)
|
|
rect: dict[str, int] = {}
|
|
try:
|
|
r = ctrl.BoundingRectangle
|
|
rect = {
|
|
"left": int(r.left),
|
|
"top": int(r.top),
|
|
"right": int(r.right),
|
|
"bottom": int(r.bottom),
|
|
"width": int(r.right - r.left),
|
|
"height": int(r.bottom - r.top),
|
|
}
|
|
except Exception:
|
|
rect = {}
|
|
|
|
parent_chain: list[dict[str, str]] = []
|
|
try:
|
|
p = ctrl.GetParentControl()
|
|
depth = 0
|
|
while p is not None and depth < 8:
|
|
parent_chain.append(
|
|
{
|
|
"name": str(getattr(p, "Name", "") or ""),
|
|
"automation_id": str(getattr(p, "AutomationId", "") or ""),
|
|
"control_type": str(getattr(p, "ControlTypeName", "") or ""),
|
|
"class_name": str(getattr(p, "ClassName", "") or ""),
|
|
}
|
|
)
|
|
p = p.GetParentControl()
|
|
depth += 1
|
|
except Exception:
|
|
pass
|
|
|
|
hwnd = 0
|
|
pid = 0
|
|
wtitle = ""
|
|
wclass = ""
|
|
try:
|
|
hwnd = int(ctrl.NativeWindowHandle or 0)
|
|
if not hwnd:
|
|
# walk up for hwnd
|
|
for item in parent_chain:
|
|
del item
|
|
p = ctrl
|
|
for _ in range(12):
|
|
h = int(getattr(p, "NativeWindowHandle", 0) or 0)
|
|
if h:
|
|
hwnd = h
|
|
break
|
|
p = p.GetParentControl()
|
|
if p is None:
|
|
break
|
|
if hwnd:
|
|
import desktop_uia as uia
|
|
|
|
info = uia.window_info_from_hwnd(hwnd) if hasattr(uia, "window_info_from_hwnd") else None
|
|
if info is None:
|
|
# fallback via GetWindowText
|
|
import ctypes
|
|
|
|
user32 = ctypes.windll.user32
|
|
buf = ctypes.create_unicode_buffer(512)
|
|
user32.GetWindowTextW(hwnd, buf, 512)
|
|
wtitle = buf.value
|
|
user32.GetClassNameW(hwnd, buf, 512)
|
|
wclass = buf.value
|
|
pid_c = ctypes.c_ulong()
|
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid_c))
|
|
pid = int(pid_c.value)
|
|
else:
|
|
wtitle = info.title
|
|
wclass = info.class_name
|
|
pid = info.pid
|
|
hwnd = info.hwnd
|
|
except Exception:
|
|
pass
|
|
|
|
snap = {
|
|
"name": name,
|
|
"value": value,
|
|
"automation_id": aid,
|
|
"control_type": ctype,
|
|
"class_name": class_name,
|
|
"framework_id": framework,
|
|
"is_enabled": enabled,
|
|
"is_offscreen": offscreen,
|
|
"bounds": rect,
|
|
"parent_chain": parent_chain,
|
|
"window_scope": {
|
|
"hwnd": hwnd,
|
|
"pid": pid,
|
|
"title": wtitle,
|
|
"class_name": wclass,
|
|
},
|
|
}
|
|
if x is not None:
|
|
snap["capture_point"] = {"x": x, "y": y}
|
|
return snap
|
|
|
|
@staticmethod
|
|
def _cursor_pos(user32) -> tuple[int, int]:
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
class POINT(ctypes.Structure):
|
|
_fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
|
|
|
|
pt = POINT()
|
|
user32.GetCursorPos(ctypes.byref(pt))
|
|
return int(pt.x), int(pt.y)
|
|
|
|
@staticmethod
|
|
def _make_point(x: int, y: int):
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
class POINT(ctypes.Structure):
|
|
_fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
|
|
|
|
return POINT(int(x), int(y))
|