From 84157758e6fee27b81db516426ee594f18baa63b Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Sun, 2 Aug 2026 19:22:12 +0800 Subject: [PATCH] fix(desktop-wake): n2-wake wecom_search via main WeWorkWindow hwnd (1.2.7) Co-authored-by: Cursor --- pyproject.toml | 2 +- src/jiangchang_desktop_wake/ACCEPTANCE.md | 11 ++-- src/jiangchang_desktop_wake/README.md | 2 +- src/jiangchang_desktop_wake/profiles.py | 3 +- .../profiles/wecom_search.json | 3 +- src/jiangchang_desktop_wake/types.py | 5 ++ src/jiangchang_desktop_wake/wake.py | 58 ++++++++++++++++++- src/jiangchang_desktop_wake/window.py | 20 +++++++ tests/desktop_wake/test_profiles_and_types.py | 8 +++ 9 files changed, 101 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 536cea9..2da8195 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jiangchang-platform-kit" -version = "1.2.6" +version = "1.2.7" description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树" readme = "README.md" requires-python = ">=3.10" diff --git a/src/jiangchang_desktop_wake/ACCEPTANCE.md b/src/jiangchang_desktop_wake/ACCEPTANCE.md index 129b6d0..4386f9a 100644 --- a/src/jiangchang_desktop_wake/ACCEPTANCE.md +++ b/src/jiangchang_desktop_wake/ACCEPTANCE.md @@ -1,13 +1,13 @@ -# jiangchang_desktop_wake 验收(ensure 主路径,1.2.6+) +# jiangchang_desktop_wake 验收(ensure 主路径,1.2.7+) 正式对外路径是 **`ensure`**(内部:`activate` → `desktop-wake --mode n2-wake` → `probe`)。 `n2-wake`:**仅 OBJID_CLIENT** 走 DuiLib;不做进程内 FindAll、不同步 GETOBJECT、不 SPI 读屏;判暖只看进程外 probe。 `c2` / `inject` 仅诊断用。 -| profile | 窗 | 1.2.6 | +| profile | 窗 | 说明 | |---|---|---| | `wecom` | `WeWorkWindow` | 主窗(原有) | -| `wecom_search` | `SearchResultWindow2` | 搜索联系人浮层;不 Maximize;markers=`search_result`,`user_item` | +| `wecom_search` | `SearchResultWindow2` | 搜索浮层;不 Maximize;**n2-wake 注入同 PID 主窗**(`native_hwnd_class=WeWorkWindow`),probe 仍对浮层 | ## 0. 编译(企微先完全退出,含托盘) @@ -77,9 +77,9 @@ python -m jiangchang_desktop_wake probe --profile wecom --- -## C. 搜索浮层 wecom_search(1.2.6+) +## C. 搜索浮层 wecom_search(1.2.7+) -1. 打开企微主窗并登录;在搜索框输入联系人,**保持搜索结果浮层打开**(标题/类名 `SearchResultWindow2`)。 +1. 打开企微主窗并登录;在搜索框输入联系人,**保持搜索结果浮层打开**(类名 `SearchResultWindow2`)。 2. 验收: ```powershell @@ -94,6 +94,7 @@ python -m jiangchang_desktop_wake probe --profile wecom_search |---|---| | ensure 退出码 `0` | 浮层已暖 | | `warm=true` | markers 含 `search_result` / `user_item`(或 findall 足够) | +| 企微 UI | **不白屏**、无红叹号错误页(1.2.6 对浮层 hwnd 注入曾导致) | | `WINDOW_NOT_FOUND` | 浮层未打开——先展开搜索结果再测 | --- diff --git a/src/jiangchang_desktop_wake/README.md b/src/jiangchang_desktop_wake/README.md index 6be9193..d07b159 100644 --- a/src/jiangchang_desktop_wake/README.md +++ b/src/jiangchang_desktop_wake/README.md @@ -42,7 +42,7 @@ print(rs.warm, rs.probe_after.findall_count, rs.probe_after.markers_found) | profile | 窗 | 说明 | |---|---|---| | `wecom` | `WeWorkWindow` | 主窗;markers=`con_op`,`searchedit` | -| `wecom_search` | `SearchResultWindow2` | 搜索联系人浮层;markers=`search_result`,`user_item`;不 Maximize | +| `wecom_search` | `SearchResultWindow2` | 搜索浮层;markers=`search_result`,`user_item`;不 Maximize;**n2-wake 注入同 PID 主窗**(1.2.7+) | | CLI | 退出码 | |---|---| diff --git a/src/jiangchang_desktop_wake/profiles.py b/src/jiangchang_desktop_wake/profiles.py index 3f255be..b2668b5 100644 --- a/src/jiangchang_desktop_wake/profiles.py +++ b/src/jiangchang_desktop_wake/profiles.py @@ -44,11 +44,12 @@ _BUILTIN = { }, "wecom_search": { "id": "wecom_search", - "description": "企业微信搜索结果浮层 SearchResultWindow2", + "description": "企业微信搜索结果浮层 SearchResultWindow2(n2-wake 注入主窗)", "window_class": "SearchResultWindow2", "warm_min_findall": 20, "warm_markers": ["search_result", "user_item"], "activate_maximize": False, + "native_hwnd_class": "WeWorkWindow", "wake_strategies": ["activate", "native_helper"], "native_helper": "desktop-wake.exe", "native_args": ["wecom", "0x{hwnd_hex}", "--mode", "n2-wake"], diff --git a/src/jiangchang_desktop_wake/profiles/wecom_search.json b/src/jiangchang_desktop_wake/profiles/wecom_search.json index b81b3ce..67442e4 100644 --- a/src/jiangchang_desktop_wake/profiles/wecom_search.json +++ b/src/jiangchang_desktop_wake/profiles/wecom_search.json @@ -1,10 +1,11 @@ { "id": "wecom_search", - "description": "企业微信搜索结果浮层 SearchResultWindow2(联系人列表 user_item)", + "description": "企业微信搜索结果浮层 SearchResultWindow2(联系人列表 user_item);n2-wake 注入同 PID 主窗 WeWorkWindow,避免对浮层 hwnd 注入导致白屏", "window_class": "SearchResultWindow2", "warm_min_findall": 20, "warm_markers": ["search_result", "user_item"], "activate_maximize": false, + "native_hwnd_class": "WeWorkWindow", "wake_strategies": [ "activate", "native_helper" diff --git a/src/jiangchang_desktop_wake/types.py b/src/jiangchang_desktop_wake/types.py index 88d5a3a..0b66322 100644 --- a/src/jiangchang_desktop_wake/types.py +++ b/src/jiangchang_desktop_wake/types.py @@ -29,12 +29,16 @@ class Profile: native_args: list[str] = field(default_factory=list) # 浮层窗(如搜索结果)禁止 Maximize,避免打乱搜索态 activate_maximize: bool = True + # native_helper / n2-wake 注入目标窗 class;空则用 probe 窗本身。 + # 搜索浮层应对主窗 WeWorkWindow 注入,避免对 SearchResultWindow2 注入导致白屏。 + native_hwnd_class: Optional[str] = None @classmethod def from_dict(cls, data: dict[str, Any]) -> "Profile": maximize = data.get("activate_maximize") if maximize is None: maximize = True + native_cls = data.get("native_hwnd_class") return cls( id=str(data.get("id") or "unknown"), description=str(data.get("description") or ""), @@ -55,6 +59,7 @@ class Profile: native_helper=data.get("native_helper"), native_args=list(data.get("native_args") or []), activate_maximize=bool(maximize), + native_hwnd_class=str(native_cls).strip() if native_cls else None, ) diff --git a/src/jiangchang_desktop_wake/wake.py b/src/jiangchang_desktop_wake/wake.py index 6c1fada..fa5b78d 100644 --- a/src/jiangchang_desktop_wake/wake.py +++ b/src/jiangchang_desktop_wake/wake.py @@ -15,7 +15,13 @@ from .probe import probe from .profiles import load_profile from .types import Profile, WakeStepResult from . import uia_backend -from .window import activate_hwnd, find_hwnd_for_profile, require_windows +from .window import ( + activate_hwnd, + find_hwnd_for_profile, + find_same_pid_hwnd_by_class, + get_window_pid, + require_windows, +) _logger = logging.getLogger("jiangchang_desktop_wake.wake") @@ -23,6 +29,48 @@ _logger = logging.getLogger("jiangchang_desktop_wake.wake") StrategyFn = Callable[[int, Profile], Tuple[str, str]] +def resolve_native_hwnd(probe_hwnd: int, profile: Profile) -> int: + """native_helper 实际注入 hwnd。 + + 若 profile.native_hwnd_class 已设:优先同 PID 下该 class 窗(如浮层 probe、主窗注入)。 + 找不到则回退 probe_hwnd,并打 warning。 + """ + cls = (getattr(profile, "native_hwnd_class", None) or "").strip() + if not cls: + return int(probe_hwnd) + try: + pid = get_window_pid(int(probe_hwnd)) + hit = find_same_pid_hwnd_by_class(pid=pid, class_name=cls) + except Exception as exc: + _logger.warning( + "resolve_native_hwnd failed profile=%s probe=%s err=%s; fallback probe", + profile.id, + probe_hwnd, + exc, + ) + return int(probe_hwnd) + if not hit: + _logger.warning( + "resolve_native_hwnd miss profile=%s native_hwnd_class=%s pid=%s " + "probe=%s; fallback probe (may crash popup UI)", + profile.id, + cls, + pid, + probe_hwnd, + ) + return int(probe_hwnd) + if int(hit) != int(probe_hwnd): + _logger.info( + "native_hwnd redirect profile=%s probe=%s -> inject=%s class=%s pid=%s", + profile.id, + probe_hwnd, + hit, + cls, + pid, + ) + return int(hit) + + def _strategy_activate(hwnd: int, profile: Profile) -> Tuple[str, str]: maximize = bool(getattr(profile, "activate_maximize", True)) activate_hwnd(hwnd, maximize=maximize) @@ -152,6 +200,7 @@ def wake( """ require_windows() prof = load_profile(profile) if isinstance(profile, str) else profile + # probe / activate 目标(如 SearchResultWindow2) h = find_hwnd_for_profile(prof, hwnd=hwnd) steps: list[WakeStepResult] = [] @@ -163,7 +212,11 @@ def wake( ) continue try: - detail, status = fn(h, prof) + # native_helper:可重定向到同 PID 主窗注入;其余策略仍作用在 probe 窗 + target = resolve_native_hwnd(h, prof) if name == "native_helper" else h + detail, status = fn(target, prof) + if name == "native_helper" and int(target) != int(h): + detail = f"inject_hwnd={target} probe_hwnd={h} | {detail}" steps.append( WakeStepResult( strategy=name, @@ -184,6 +237,7 @@ def wake( time.sleep(pause_s) if stop_when_warm: try: + # 始终对 profile 目标窗(浮层)判暖,不是 inject 窗 pr = probe(prof, hwnd=h) if pr.warm: steps.append( diff --git a/src/jiangchang_desktop_wake/window.py b/src/jiangchang_desktop_wake/window.py index 9d9bdb5..f661819 100644 --- a/src/jiangchang_desktop_wake/window.py +++ b/src/jiangchang_desktop_wake/window.py @@ -49,6 +49,26 @@ def window_area(hwnd: int) -> int: return max(0, rect.right - rect.left) * max(0, rect.bottom - rect.top) +def get_window_pid(hwnd: int) -> int: + require_windows() + pid = wintypes.DWORD() + _user32().GetWindowThreadProcessId(int(hwnd), ctypes.byref(pid)) + return int(pid.value) + + +def find_same_pid_hwnd_by_class( + *, + pid: int, + class_name: str, +) -> Optional[int]: + """同进程下指定 class 的可见顶层窗(多窗取面积最大)。""" + hwnds = enum_hwnds(class_name=class_name) + same = [h for h in hwnds if get_window_pid(h) == int(pid)] + if not same: + return None + return pick_largest(same) if len(same) > 1 else same[0] + + def enum_hwnds( *, class_name: Optional[str] = None, diff --git a/tests/desktop_wake/test_profiles_and_types.py b/tests/desktop_wake/test_profiles_and_types.py index 551497e..cee9e4c 100644 --- a/tests/desktop_wake/test_profiles_and_types.py +++ b/tests/desktop_wake/test_profiles_and_types.py @@ -34,9 +34,17 @@ class ProfileTests(unittest.TestCase): self.assertIn("search_result", p.warm_markers) self.assertIn("user_item", p.warm_markers) self.assertFalse(p.activate_maximize) + self.assertEqual(p.native_hwnd_class, "WeWorkWindow") self.assertIn("native_helper", p.wake_strategies) self.assertIn("n2-wake", p.native_args or []) + def test_resolve_native_hwnd_fallback_without_class(self): + from jiangchang_desktop_wake.wake import resolve_native_hwnd + + p = Profile.from_dict({"id": "x", "window_class": "Foo"}) + self.assertIsNone(p.native_hwnd_class) + self.assertEqual(resolve_native_hwnd(12345, p), 12345) + def test_unknown_profile(self): from jiangchang_desktop_wake.errors import DesktopWakeError