feat(desktop-wake): add WeCom UIA warm-tree and ship n2-wake in 1.2.3
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 29s
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 29s
Expose ensure_ui_ready via DuiLib GetAccessible inject path; package x86 native helpers for host/skill consumers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
268
src/jiangchang_desktop_wake/uia_backend.py
Normal file
268
src/jiangchang_desktop_wake/uia_backend.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""UIA / MSAA 后端(可选依赖 uiautomation;MSAA 用系统 oleacc)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
from ctypes import wintypes
|
||||
from typing import Callable, Optional, TypeVar
|
||||
|
||||
_logger = logging.getLogger("jiangchang_desktop_wake.uia")
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# 企微 UI 被 N2 钩死后,UIA 会永久阻塞;必须限时
|
||||
_UIA_TIMEOUT_S = 8.0
|
||||
|
||||
|
||||
def _call_with_timeout(fn: Callable[[], T], timeout_s: float = _UIA_TIMEOUT_S) -> T:
|
||||
"""在工作线程跑 UIA;线程内必须 CoInitialize(UIAutomationInitializerInThread)。"""
|
||||
|
||||
def _threaded() -> T:
|
||||
import uiautomation as auto
|
||||
|
||||
# 子线程用 UIA 必须初始化,否则:尚未调用 CoInitialize / UIAutomationCore.dll
|
||||
initializer = getattr(auto, "UIAutomationInitializerInThread", None)
|
||||
if initializer is not None:
|
||||
with initializer(debug=False):
|
||||
return fn()
|
||||
# 极老版本兜底
|
||||
try:
|
||||
ctypes.windll.ole32.CoInitialize(None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
try:
|
||||
ctypes.windll.ole32.CoUninitialize()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
fut = pool.submit(_threaded)
|
||||
try:
|
||||
return fut.result(timeout=timeout_s)
|
||||
except FuturesTimeout as exc:
|
||||
raise TimeoutError(f"uia_timeout_after_{timeout_s}s") from exc
|
||||
|
||||
WM_GETOBJECT = 0x003D
|
||||
OBJID_CLIENT = 0xFFFFFFFC
|
||||
OBJID_WINDOW = 0x00000000
|
||||
|
||||
|
||||
def uia_available() -> bool:
|
||||
try:
|
||||
import uiautomation # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def control_from_hwnd(hwnd: int):
|
||||
import uiautomation as auto
|
||||
|
||||
return auto.ControlFromHandle(int(hwnd))
|
||||
|
||||
|
||||
def count_findall(hwnd: int, *, max_depth: int = 50) -> tuple[int, list[str]]:
|
||||
"""返回 (遍历节点数, 采到的 Name 样本)。超时则 (0, [])。"""
|
||||
if not uia_available():
|
||||
return 0, []
|
||||
|
||||
def _work() -> tuple[int, list[str]]:
|
||||
import uiautomation as auto
|
||||
|
||||
root = control_from_hwnd(hwnd)
|
||||
names: list[str] = []
|
||||
count = 0
|
||||
|
||||
try:
|
||||
if hasattr(auto, "WalkControl"):
|
||||
for c, _depth in auto.WalkControl(
|
||||
root, includeTop=True, maxDepth=max_depth
|
||||
):
|
||||
count += 1
|
||||
try:
|
||||
n = (c.Name or "").strip()
|
||||
if n and len(names) < 200:
|
||||
names.append(n)
|
||||
except Exception:
|
||||
pass
|
||||
return count, names
|
||||
except Exception as exc:
|
||||
_logger.debug("WalkControl failed: %s", exc)
|
||||
|
||||
try:
|
||||
cond = (
|
||||
auto.CreateTrueCondition()
|
||||
if hasattr(auto, "CreateTrueCondition")
|
||||
else None
|
||||
)
|
||||
if cond is not None and hasattr(root, "FindAll"):
|
||||
controls = root.FindAll(max_depth, cond)
|
||||
count = len(controls or [])
|
||||
for c in (controls or [])[:200]:
|
||||
try:
|
||||
n = (c.Name or "").strip()
|
||||
if n:
|
||||
names.append(n)
|
||||
except Exception:
|
||||
pass
|
||||
return count, names
|
||||
except Exception as exc:
|
||||
_logger.debug("FindAll failed: %s", exc)
|
||||
|
||||
queue = [root]
|
||||
depth_map = {id(root): 0}
|
||||
while queue:
|
||||
cur = queue.pop(0)
|
||||
count += 1
|
||||
try:
|
||||
n = (cur.Name or "").strip()
|
||||
if n and len(names) < 200:
|
||||
names.append(n)
|
||||
except Exception:
|
||||
pass
|
||||
d = depth_map.get(id(cur), 0)
|
||||
if d >= max_depth:
|
||||
continue
|
||||
try:
|
||||
children = cur.GetChildren()
|
||||
except Exception:
|
||||
children = []
|
||||
for ch in children or []:
|
||||
depth_map[id(ch)] = d + 1
|
||||
queue.append(ch)
|
||||
return count, names
|
||||
|
||||
try:
|
||||
return _call_with_timeout(_work)
|
||||
except TimeoutError:
|
||||
_logger.warning("count_findall timed out hwnd=%s", hwnd)
|
||||
return 0, []
|
||||
|
||||
|
||||
def markers_present(names: list[str], markers: list[str]) -> tuple[list[str], list[str]]:
|
||||
name_set = set(names)
|
||||
# also allow substring match for AutomationId-like markers appearing in Name
|
||||
found: list[str] = []
|
||||
missing: list[str] = []
|
||||
for m in markers:
|
||||
if m in name_set or any(m == n or m in n for n in names):
|
||||
found.append(m)
|
||||
else:
|
||||
# try live Exists by Name via uiautomation if root available later
|
||||
missing.append(m)
|
||||
return found, missing
|
||||
|
||||
|
||||
def find_markers_on_hwnd(hwnd: int, markers: list[str], *, depth: int = 30) -> tuple[list[str], list[str]]:
|
||||
if not markers:
|
||||
return [], []
|
||||
if not uia_available():
|
||||
return [], list(markers)
|
||||
|
||||
def _work() -> tuple[list[str], list[str]]:
|
||||
return _find_markers_on_hwnd_impl(hwnd, markers, depth=depth)
|
||||
|
||||
try:
|
||||
return _call_with_timeout(_work, timeout_s=6.0)
|
||||
except TimeoutError:
|
||||
_logger.warning("find_markers timed out hwnd=%s", hwnd)
|
||||
return [], list(markers)
|
||||
|
||||
|
||||
def _find_markers_on_hwnd_impl(
|
||||
hwnd: int, markers: list[str], *, depth: int = 30
|
||||
) -> tuple[list[str], list[str]]:
|
||||
root = control_from_hwnd(hwnd)
|
||||
found: list[str] = []
|
||||
missing: list[str] = []
|
||||
for m in markers:
|
||||
try:
|
||||
c = root.Control(searchDepth=depth, Name=m)
|
||||
if c.Exists(0.4, 0.1):
|
||||
found.append(m)
|
||||
else:
|
||||
# AutomationId
|
||||
c2 = root.Control(searchDepth=depth, AutomationId=m)
|
||||
if c2.Exists(0.3, 0.1):
|
||||
found.append(m)
|
||||
else:
|
||||
missing.append(m)
|
||||
except Exception:
|
||||
missing.append(m)
|
||||
return found, missing
|
||||
|
||||
|
||||
def send_wm_getobject(hwnd: int) -> str:
|
||||
user32 = ctypes.windll.user32
|
||||
SMTO_ABORTIFHUNG = 0x0002
|
||||
result = ctypes.c_size_t(0)
|
||||
|
||||
def _one(objid: int) -> int:
|
||||
ok = user32.SendMessageTimeoutW(
|
||||
ctypes.c_void_p(int(hwnd)),
|
||||
WM_GETOBJECT,
|
||||
0,
|
||||
objid,
|
||||
SMTO_ABORTIFHUNG,
|
||||
2000,
|
||||
ctypes.byref(result),
|
||||
)
|
||||
return int(result.value) if ok else -1
|
||||
|
||||
r1 = _one(OBJID_CLIENT)
|
||||
r2 = _one(OBJID_WINDOW)
|
||||
return f"client_lresult={r1} window_lresult={r2}"
|
||||
|
||||
|
||||
def msaa_from_window(hwnd: int) -> str:
|
||||
"""AccessibleObjectFromWindow(OBJID_CLIENT);成功即说明 MSAA 通道有响应。"""
|
||||
oleacc = ctypes.windll.oleacc
|
||||
|
||||
class GUID(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Data1", ctypes.c_ulong),
|
||||
("Data2", ctypes.c_ushort),
|
||||
("Data3", ctypes.c_ushort),
|
||||
("Data4", ctypes.c_ubyte * 8),
|
||||
]
|
||||
|
||||
# IID_IAccessible
|
||||
iid = GUID(
|
||||
0x618736E0,
|
||||
0x3C3D,
|
||||
0x11CF,
|
||||
(ctypes.c_ubyte * 8)(0x81, 0x0C, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71),
|
||||
)
|
||||
punk = ctypes.c_void_p()
|
||||
hr = oleacc.AccessibleObjectFromWindow(
|
||||
wintypes.HWND(int(hwnd)),
|
||||
ctypes.c_ulong(OBJID_CLIENT),
|
||||
ctypes.byref(iid),
|
||||
ctypes.byref(punk),
|
||||
)
|
||||
if hr != 0 or not punk.value:
|
||||
return f"hr={hr} punk=null"
|
||||
try:
|
||||
# IUnknown::Release (vtable[2]) — best-effort,避免 COM 泄漏
|
||||
pobj = ctypes.cast(punk, ctypes.POINTER(ctypes.c_void_p))
|
||||
vtbl = ctypes.cast(pobj[0], ctypes.POINTER(ctypes.c_void_p))
|
||||
release = ctypes.WINFUNCTYPE(ctypes.c_ulong, ctypes.c_void_p)(vtbl[2])
|
||||
release(punk)
|
||||
except Exception as exc:
|
||||
return f"hr={hr} acquired=1 release_err={exc}"
|
||||
return f"hr={hr} acquired=1"
|
||||
|
||||
|
||||
def deep_uia_walk(hwnd: int, *, max_nodes: int = 2000) -> str:
|
||||
"""主动以 UIA 客户端身份深遍历,刺激部分 App 挂 Provider。"""
|
||||
if not uia_available():
|
||||
return "uia_unavailable"
|
||||
count, names = count_findall(hwnd)
|
||||
sample = ",".join(names[:8])
|
||||
return f"walk_count={count} sample={sample!r}"
|
||||
Reference in New Issue
Block a user