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

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:
2026-08-02 08:42:38 +08:00
parent 3dc46b0555
commit 71091e38a9
29 changed files with 3088 additions and 3 deletions

View 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}"