From 71091e38a992fd96ae2ea3228b887623d8976b98 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Sun, 2 Aug 2026 08:42:38 +0800 Subject: [PATCH] feat(desktop-wake): add WeCom UIA warm-tree and ship n2-wake in 1.2.3 Expose ensure_ui_ready via DuiLib GetAccessible inject path; package x86 native helpers for host/skill consumers. Co-authored-by: Cursor --- .gitignore | 6 + README.md | 6 +- pyproject.toml | 13 +- src/jiangchang_desktop_wake/ACCEPTANCE.md | 90 +++ src/jiangchang_desktop_wake/README.md | 40 + src/jiangchang_desktop_wake/__init__.py | 41 ++ src/jiangchang_desktop_wake/__main__.py | 156 ++++ src/jiangchang_desktop_wake/c2.py | 68 ++ src/jiangchang_desktop_wake/ensure.py | 60 ++ src/jiangchang_desktop_wake/errors.py | 30 + src/jiangchang_desktop_wake/inject.py | 79 ++ src/jiangchang_desktop_wake/native/README.md | 32 + .../native/desktop-wake.exe | Bin 0 -> 18432 bytes .../native/wecom_wake.dll | Bin 0 -> 119296 bytes .../native_src/DesktopWake/Program.cs | 695 ++++++++++++++++++ .../native_src/N2/README.md | 39 + .../native_src/N2/RESEARCH_C1.md | 36 + .../native_src/N2/payload/wecom_wake.cpp | 600 +++++++++++++++ .../native_src/N2/payload/wecom_wake.def | 4 + .../native_src/build.ps1 | 80 ++ src/jiangchang_desktop_wake/probe.py | 81 ++ src/jiangchang_desktop_wake/profiles.py | 87 +++ .../profiles/generic.json | 13 + .../profiles/wecom.json | 15 + src/jiangchang_desktop_wake/types.py | 102 +++ src/jiangchang_desktop_wake/uia_backend.py | 268 +++++++ src/jiangchang_desktop_wake/wake.py | 225 ++++++ src/jiangchang_desktop_wake/window.py | 148 ++++ tests/desktop_wake/test_profiles_and_types.py | 77 ++ 29 files changed, 3088 insertions(+), 3 deletions(-) create mode 100644 src/jiangchang_desktop_wake/ACCEPTANCE.md create mode 100644 src/jiangchang_desktop_wake/README.md create mode 100644 src/jiangchang_desktop_wake/__init__.py create mode 100644 src/jiangchang_desktop_wake/__main__.py create mode 100644 src/jiangchang_desktop_wake/c2.py create mode 100644 src/jiangchang_desktop_wake/ensure.py create mode 100644 src/jiangchang_desktop_wake/errors.py create mode 100644 src/jiangchang_desktop_wake/inject.py create mode 100644 src/jiangchang_desktop_wake/native/README.md create mode 100644 src/jiangchang_desktop_wake/native/desktop-wake.exe create mode 100644 src/jiangchang_desktop_wake/native/wecom_wake.dll create mode 100644 src/jiangchang_desktop_wake/native_src/DesktopWake/Program.cs create mode 100644 src/jiangchang_desktop_wake/native_src/N2/README.md create mode 100644 src/jiangchang_desktop_wake/native_src/N2/RESEARCH_C1.md create mode 100644 src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.cpp create mode 100644 src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.def create mode 100644 src/jiangchang_desktop_wake/native_src/build.ps1 create mode 100644 src/jiangchang_desktop_wake/probe.py create mode 100644 src/jiangchang_desktop_wake/profiles.py create mode 100644 src/jiangchang_desktop_wake/profiles/generic.json create mode 100644 src/jiangchang_desktop_wake/profiles/wecom.json create mode 100644 src/jiangchang_desktop_wake/types.py create mode 100644 src/jiangchang_desktop_wake/uia_backend.py create mode 100644 src/jiangchang_desktop_wake/wake.py create mode 100644 src/jiangchang_desktop_wake/window.py create mode 100644 tests/desktop_wake/test_profiles_and_types.py diff --git a/.gitignore b/.gitignore index 8063b6e..53f3a13 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,9 @@ __pycache__/ build/ dist/ python-runtime/.venv/ +@AutomationLog.txt + +# desktop_wake:编译换名残留,发版只保留 wecom_wake.dll +src/jiangchang_desktop_wake/native/wecom_wake_*.dll +src/jiangchang_desktop_wake/native/*.exp +src/jiangchang_desktop_wake/native/*.lib diff --git a/README.md b/README.md index b66be4e..4580183 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## 1. 项目简介 -匠厂平台共享 Python SDK:在同一发行包中提供 **Skill 侧通用能力**(权益校验、统一日志与运行环境解析)与 **桌面应用自动化能力**(通过 CDP 连接匠厂客户端,驱动聊天与断言)。 +匠厂平台共享 Python SDK:在同一发行包中提供 **Skill 侧通用能力**(权益校验、统一日志与运行环境解析)、**桌面应用自动化能力**(通过 CDP 连接匠厂客户端),以及 **Windows 桌面 UIA 暖树**(企微等 DuiLib 冷树唤醒)。 ## 2. 安装方式 @@ -14,6 +14,7 @@ pip install jiangchang-platform-kit --index-url https://git.jc2009.com/api/packa - **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、**Skill Activity Stream / Job 运行时**(`emit` / `finish` / Run Journal)、兄弟技能 CLI 桥(`sibling_bridge`)、共享媒体资源(`media_assets`)等,供 Skill CLI / 服务进程使用。 - **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API)。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright,以便 `import playwright` 可用。 +- **jiangchang_desktop_wake**:Windows 桌面 UIA 暖树。技能/拾取请用 `ensure_ui_ready("wecom")`(内部 `n2-wake`:注入 + DuiLib GetAccessible)。随包携带 x86 `native/desktop-wake.exe` 与 `wecom_wake.dll`。详见 `src/jiangchang_desktop_wake/README.md`。探测深遍历需另装 `uiautomation`。 ## 4. 快速开始 @@ -324,8 +325,11 @@ python -m pip install --upgrade \ ```bash python -c "from jiangchang_desktop_sdk.e2e_helpers import send_prompt_via_composer; print('e2e_helpers OK')" python -c "from screencast import run_screencast; print('screencast OK')" +python -c "from jiangchang_desktop_wake import ensure_ui_ready; import importlib.resources as r; print('desktop_wake OK', r.files('jiangchang_desktop_wake'))" ``` +企微暖树手工验收见 `src/jiangchang_desktop_wake/ACCEPTANCE.md`(`ensure --profile wecom`)。 + 仓库内还保留 `examples/`、`tools/`、`python-runtime/` 目录及既有 Skill / 前端相关的 GitHub Actions 工作流,与本 PyPI 包的发布彼此独立。`tools/screencast/` 为兼容薄壳,pip 包内实现位于 `screencast` 包(`src/screencast/`)。 ## v0.2.0 — data-jcid migration (internal refactor) diff --git a/pyproject.toml b/pyproject.toml index 9714758..2e42ed6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "jiangchang-platform-kit" -version = "1.2.2" -description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK" +version = "1.2.3" +description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树" readme = "README.md" requires-python = ">=3.10" license = "MIT" @@ -21,3 +21,12 @@ Repository = "https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit" [tool.setuptools.packages.find] where = ["src"] + +[tool.setuptools.package-data] +jiangchang_desktop_wake = [ + "profiles/*.json", + "native/*", + "native_src/**/*", + "README.md", + "ACCEPTANCE.md", +] diff --git a/src/jiangchang_desktop_wake/ACCEPTANCE.md b/src/jiangchang_desktop_wake/ACCEPTANCE.md new file mode 100644 index 0000000..63dee37 --- /dev/null +++ b/src/jiangchang_desktop_wake/ACCEPTANCE.md @@ -0,0 +1,90 @@ +# jiangchang_desktop_wake 验收(阶段 A:ensure 主路径) + +正式对外路径是 **`ensure`**(内部:`activate` → `desktop-wake --mode n2-wake` → `probe`)。 +`c2` / `inject` 仅诊断用。 + +## 0. 编译(企微先完全退出,含托盘) + +```powershell +cd D:\OpenClaw\client-jiangchang\jiangchang-platform-kit +$env:PYTHONPATH = ".\src" +powershell -File src\jiangchang_desktop_wake\native_src\build.ps1 +``` + +期望产物(仅这两个二进制进包): + +- `src\jiangchang_desktop_wake\native\desktop-wake.exe` +- `src\jiangchang_desktop_wake\native\wecom_wake.dll`(约 119KB;勿残留 `_b/_c/...`) + +然后**再打开企微并登录**。 + +--- + +## A. 冷基线 + +退出影刀;若刚暖过请**重启企微**。 + +```powershell +cd D:\OpenClaw\client-jiangchang\jiangchang-platform-kit +$env:PYTHONPATH = ".\src" +python -m jiangchang_desktop_wake probe --profile wecom +``` + +| 期望 | 说明 | +|---|---| +| `warm=false` | 必须是冷树 | +| `findall_count` | 通常 2~3 | + +--- + +## B. 主路径 ensure(必验) + +```powershell +python -m jiangchang_desktop_wake ensure --profile wecom +echo ExitCode=$LASTEXITCODE +type $env:TEMP\jiangchang_desktop_wake_provider.txt +type $env:TEMP\jiangchang_desktop_wake_subclass.txt +python -m jiangchang_desktop_wake probe --profile wecom +``` + +### 通过标准 + +| 检查项 | 通过 | +|---|---| +| ensure 退出码 | **0** | +| JSON `warm` | **true** | +| `probe_after.findall_count` | **≥ 80**(常见 300+) | +| markers | 含 `con_op`、`searchedit` | +| 企微 UI | 可点,无「未响应」 | +| provider | `done=1` `subclass=1` `dui=1` `lr_client≠0` | +| subclass | `method=SetWindowLongPtr`,`ok=1`,`userdata≠0` | + +### 失败时 + +| 现象 | 含义 | +|---|---| +| `ERROR:WINDOW_NOT_FOUND` | 企微未开或未登录出主窗 | +| `skipped:native_helper_missing` / 仍冷 | 未编译 native 或 PYTHONPATH 不对 | +| provider 只有 `nudge=1` 无 `findall=` | 跑到了旧 DLL,退出企微后重编 `wecom_wake.dll` | +| UI「未响应」 | 停测;勿用 `--mode n2-hook` | + +--- + +## C. 单测(不依赖企微窗) + +```powershell +cd D:\OpenClaw\client-jiangchang\jiangchang-platform-kit +$env:PYTHONPATH = ".\src" +python -m unittest discover -s tests/desktop_wake -v +``` + +--- + +## D. 诊断命令(非主路径) + +```powershell +python -m jiangchang_desktop_wake inject --profile wecom +python -m jiangchang_desktop_wake c2 --profile wecom +``` + +禁止默认使用:`desktop-wake.exe ... --mode n2-hook`。 diff --git a/src/jiangchang_desktop_wake/README.md b/src/jiangchang_desktop_wake/README.md new file mode 100644 index 0000000..13a49b8 --- /dev/null +++ b/src/jiangchang_desktop_wake/README.md @@ -0,0 +1,40 @@ +# jiangchang_desktop_wake + +桌面 UIA Provider **暖树**(与 `jiangchang_skill_core` / `jiangchang_desktop_sdk` 平级)。 + +企微主路径:注入 `wecom_wake.dll`,对主窗 `SetWindowLongPtr` 后走 DuiLib +`GetAccessible` / `OnGetObject`(已验收冷树 findall 2→300+)。 + +可整目录删除:`src/jiangchang_desktop_wake` + `tests/desktop_wake`。 + +## 依赖 + +- Windows + 32-bit 企微 +- `pip install uiautomation`(probe 深遍历) +- `native/desktop-wake.exe` + `native/wecom_wake.dll`(`native_src/build.ps1`) + +## 快速试 + +```powershell +$env:PYTHONPATH = ".\src" +powershell -File src\jiangchang_desktop_wake\native_src\build.ps1 +pip install uiautomation + +# 正式 API(技能 / 拾取应调这个) +python -m jiangchang_desktop_wake ensure --profile wecom +``` + +```python +from jiangchang_desktop_wake import ensure_ui_ready, probe + +r = ensure_ui_ready("wecom") # 仍冷则抛 DesktopWakeError +print(r.warm, r.probe_after.findall_count) +``` + +| CLI | 退出码 | +|---|---| +| `ensure` | `0` 已暖;非 0 仍冷或异常 | +| `probe` | `0` 暖;`2` 冷 | +| `inject` / `c2` | 诊断用 | + +完整验收见 [ACCEPTANCE.md](ACCEPTANCE.md)。 diff --git a/src/jiangchang_desktop_wake/__init__.py b/src/jiangchang_desktop_wake/__init__.py new file mode 100644 index 0000000..52c9f82 --- /dev/null +++ b/src/jiangchang_desktop_wake/__init__.py @@ -0,0 +1,41 @@ +"""jiangchang_desktop_wake — 桌面 UIA Provider 暖树(可整包删除)。 + +与 ``jiangchang_skill_core`` / ``jiangchang_desktop_sdk`` 平级,互不强制依赖。 + +用法:: + + from jiangchang_desktop_wake import ensure_ui_ready, probe + + r = ensure_ui_ready("wecom") + print(r.probe_after.findall_count, r.warm) + +CLI:: + + python -m jiangchang_desktop_wake probe --profile wecom + python -m jiangchang_desktop_wake ensure --profile wecom +""" + +from __future__ import annotations + +from .c2 import wake_c2 +from .ensure import ensure_ui_ready +from .errors import DesktopWakeError +from .inject import inject_probe +from .probe import probe +from .profiles import list_profiles, load_profile +from .wake import wake, wake_or_raise + +__version__ = "0.5.0" + +__all__ = [ + "__version__", + "DesktopWakeError", + "ensure_ui_ready", + "inject_probe", + "probe", + "wake", + "wake_c2", + "wake_or_raise", + "load_profile", + "list_profiles", +] diff --git a/src/jiangchang_desktop_wake/__main__.py b/src/jiangchang_desktop_wake/__main__.py new file mode 100644 index 0000000..eee727f --- /dev/null +++ b/src/jiangchang_desktop_wake/__main__.py @@ -0,0 +1,156 @@ +"""CLI:python -m jiangchang_desktop_wake """ + +from __future__ import annotations + +import argparse +import json +import sys + + +def _print_json(obj) -> None: + print(json.dumps(obj, ensure_ascii=False, indent=2)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python -m jiangchang_desktop_wake", + description="桌面 UIA Provider 暖树(probe / inject / wake / ensure)", + ) + sub = parser.add_subparsers(dest="cmd", required=True) + + p_profiles = sub.add_parser("profiles", help="列出内置 profile") + p_profiles.set_defaults(func=_cmd_profiles) + + p_probe = sub.add_parser("probe", help="探测冷/暖") + p_probe.add_argument("--profile", default="wecom") + p_probe.add_argument("--hwnd", type=lambda s: int(s, 0), default=None) + p_probe.set_defaults(func=_cmd_probe) + + p_inject = sub.add_parser( + "inject", + help="阶段 B:安全注入探针(不挂钩;成功看 inject_ok + ui_responsive)", + ) + p_inject.add_argument("--profile", default="wecom") + p_inject.add_argument("--hwnd", type=lambda s: int(s, 0), default=None) + p_inject.set_defaults(func=_cmd_inject) + + p_c2 = sub.add_parser( + "c2", + help="阶段 C2:SPI + 注入 + WakeProvider,再 probe(冲暖树)", + ) + p_c2.add_argument("--profile", default="wecom") + p_c2.add_argument("--hwnd", type=lambda s: int(s, 0), default=None) + p_c2.set_defaults(func=_cmd_c2) + + p_wake = sub.add_parser("wake", help="仅跑唤醒策略(不强制成功)") + p_wake.add_argument("--profile", default="wecom") + p_wake.add_argument("--hwnd", type=lambda s: int(s, 0), default=None) + p_wake.set_defaults(func=_cmd_wake) + + p_ensure = sub.add_parser( + "ensure", + help="probe→wake→probe;wecom 主路径为 n2-wake(DuiLib 暖树)", + ) + p_ensure.add_argument("--profile", default="wecom") + p_ensure.add_argument("--hwnd", type=lambda s: int(s, 0), default=None) + p_ensure.add_argument( + "--no-raise", + action="store_true", + help="仍冷也 exit 0,只打印 JSON", + ) + p_ensure.set_defaults(func=_cmd_ensure) + + args = parser.parse_args(argv) + try: + return int(args.func(args)) + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + + +def _cmd_profiles(_args) -> int: + from .profiles import list_profiles, load_profile + + out = [] + for pid in list_profiles(): + p = load_profile(pid) + out.append( + { + "id": p.id, + "description": p.description, + "window_class": p.window_class, + "warm_min_findall": p.warm_min_findall, + "warm_markers": p.warm_markers, + "wake_strategies": p.wake_strategies, + } + ) + _print_json(out) + return 0 + + +def _cmd_probe(args) -> int: + from .probe import probe + + r = probe(args.profile, hwnd=args.hwnd) + _print_json(r.to_dict()) + return 0 if r.warm else 2 + + +def _cmd_inject(args) -> int: + from .inject import inject_probe + + r = inject_probe(args.profile, hwnd=args.hwnd) + _print_json(r) + if r.get("inject_ok") and r.get("ui_responsive"): + return 0 + if r.get("inject_ok") and not r.get("ui_responsive"): + return 6 + return 5 + + +def _cmd_c2(args) -> int: + from .c2 import wake_c2 + + r = wake_c2(args.profile, hwnd=args.hwnd) + _print_json(r) + if r.get("warm"): + return 0 + if r.get("ui_responsive") is False: + return 6 + return 4 + + +def _cmd_wake(args) -> int: + from .probe import probe + from .wake import wake + + steps = wake(args.profile, hwnd=args.hwnd) + after = probe(args.profile, hwnd=args.hwnd) + _print_json( + { + "steps": [s.to_dict() for s in steps], + "probe_after": after.to_dict(), + } + ) + return 0 if after.warm else 2 + + +def _cmd_ensure(args) -> int: + from .ensure import ensure_ui_ready + from .errors import DesktopWakeError + + try: + r = ensure_ui_ready( + args.profile, + hwnd=args.hwnd, + raise_on_cold=not args.no_raise, + ) + except DesktopWakeError as exc: + print(str(exc), file=sys.stderr) + return 1 + _print_json(r.to_dict()) + return 0 if r.warm else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/jiangchang_desktop_wake/c2.py b/src/jiangchang_desktop_wake/c2.py new file mode 100644 index 0000000..4a251a6 --- /dev/null +++ b/src/jiangchang_desktop_wake/c2.py @@ -0,0 +1,68 @@ +"""阶段 C2:安全注入 + WakeProvider 进程内暖树尝试。""" + +from __future__ import annotations + +import json +import os +import subprocess +import time +from pathlib import Path +from typing import Any, Optional + +from .errors import DesktopWakeError, ERR_WAKE_FAILED +from .probe import probe +from .profiles import load_profile +from .wake import native_helper_path +from .window import find_hwnd_for_profile, require_windows + + +def wake_c2( + profile: str = "wecom", + *, + hwnd: Optional[int] = None, +) -> dict[str, Any]: + """跑 desktop-wake --mode n2-wake,再 probe。 + + 成功暖树:返回里 ``warm=true``(与 probe 一致)。 + """ + require_windows() + prof = load_profile(profile) + h = find_hwnd_for_profile(prof, hwnd=hwnd) + before = probe(prof, hwnd=h) + + helper = native_helper_path(prof.native_helper or "desktop-wake.exe") + if helper is None: + raise DesktopWakeError(ERR_WAKE_FAILED, "desktop-wake.exe 缺失,请先 build.ps1") + + cmd = [str(helper), prof.id, "0x" + format(int(h), "x"), "--mode", "n2-wake"] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60, check=False) + except subprocess.TimeoutExpired as exc: + raise DesktopWakeError(ERR_WAKE_FAILED, "n2-wake timeout") from exc + + out = (proc.stdout or "").strip() + native: dict[str, Any] = {"rc": proc.returncode, "stderr": (proc.stderr or "")[:300]} + if out: + try: + native.update(json.loads(out.splitlines()[-1].strip())) + except json.JSONDecodeError: + native["raw"] = out[:500] + + # 进程内 UIA 客户端保持存活后,给外层树一点沉降时间 + time.sleep(1.0) + after = probe(prof, hwnd=h) + tdir = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") + provider = tdir / "jiangchang_desktop_wake_provider.txt" + return { + "hwnd": h, + "profile_id": prof.id, + "cmd": cmd, + "native": native, + "probe_before": before.to_dict(), + "probe_after": after.to_dict(), + "warm": after.warm, + "ui_responsive": bool(native.get("ui_responsive", True)), + "provider_text": provider.read_text(encoding="utf-8", errors="replace") + if provider.is_file() + else "", + } diff --git a/src/jiangchang_desktop_wake/ensure.py b/src/jiangchang_desktop_wake/ensure.py new file mode 100644 index 0000000..7028ed6 --- /dev/null +++ b/src/jiangchang_desktop_wake/ensure.py @@ -0,0 +1,60 @@ +"""ensure_ui_ready:probe → wake → probe。""" + +from __future__ import annotations + +from typing import Optional, Union + +from .errors import DesktopWakeError, ERR_PROVIDER_COLD, ERR_WAKE_FAILED +from .probe import probe +from .profiles import load_profile +from .types import EnsureResult, Profile +from .wake import wake +from .window import find_hwnd_for_profile, require_windows + + +def ensure_ui_ready( + profile: Union[str, Profile] = "wecom", + *, + hwnd: Optional[int] = None, + raise_on_cold: bool = True, +) -> EnsureResult: + """保证目标窗 UIA 树可用。 + + 已暖则直接返回;否则跑唤醒策略梯再探测。 + ``raise_on_cold=True``(默认)时仍冷则抛 ``DesktopWakeError``。 + """ + require_windows() + prof = load_profile(profile) if isinstance(profile, str) else profile + h = find_hwnd_for_profile(prof, hwnd=hwnd) + before = probe(prof, hwnd=h) + if before.warm: + return EnsureResult( + hwnd=h, + profile_id=prof.id, + warm=True, + probe_before=before, + probe_after=before, + steps=[], + wake_applied=False, + ) + + steps = wake(prof, hwnd=h, stop_when_warm=True) + after = probe(prof, hwnd=h) + result = EnsureResult( + hwnd=h, + profile_id=prof.id, + warm=after.warm, + probe_before=before, + probe_after=after, + steps=steps, + wake_applied=True, + ) + if after.warm or not raise_on_cold: + return result + + code = ERR_WAKE_FAILED if steps else ERR_PROVIDER_COLD + raise DesktopWakeError( + code, + f"profile={prof.id} hwnd={h} findall={after.findall_count} " + f"missing={after.markers_missing} notes={after.notes}", + ) diff --git a/src/jiangchang_desktop_wake/errors.py b/src/jiangchang_desktop_wake/errors.py new file mode 100644 index 0000000..26c79ac --- /dev/null +++ b/src/jiangchang_desktop_wake/errors.py @@ -0,0 +1,30 @@ +"""桌面 Provider 暖树错误码(stdout / 异常消息前缀)。""" + +from __future__ import annotations + +ERR_WINDOW_NOT_FOUND = "ERROR:WINDOW_NOT_FOUND" +ERR_UIA_UNAVAILABLE = "ERROR:UIA_UNAVAILABLE" +ERR_PROVIDER_COLD = "ERROR:PROVIDER_COLD" +ERR_WAKE_FAILED = "ERROR:WAKE_FAILED" +ERR_UNSUPPORTED_OS = "ERROR:UNSUPPORTED_OS" +ERR_PROFILE_NOT_FOUND = "ERROR:PROFILE_NOT_FOUND" + +__all__ = [ + "ERR_WINDOW_NOT_FOUND", + "ERR_UIA_UNAVAILABLE", + "ERR_PROVIDER_COLD", + "ERR_WAKE_FAILED", + "ERR_UNSUPPORTED_OS", + "ERR_PROFILE_NOT_FOUND", + "DesktopWakeError", +] + + +class DesktopWakeError(RuntimeError): + """暖树失败;``code`` 为稳定错误码(无 ERROR: 前缀)。""" + + def __init__(self, code: str, message: str): + self.code = code + self.message = message + prefix = code if code.startswith("ERROR:") else f"ERROR:{code}" + super().__init__(f"{prefix}: {message}") diff --git a/src/jiangchang_desktop_wake/inject.py b/src/jiangchang_desktop_wake/inject.py new file mode 100644 index 0000000..33613fe --- /dev/null +++ b/src/jiangchang_desktop_wake/inject.py @@ -0,0 +1,79 @@ +"""阶段 B:安全注入探针(LoadLibrary only,不挂钩 / 不子类化)。""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any, Optional + +from .errors import DesktopWakeError, ERR_WAKE_FAILED +from .profiles import load_profile +from .wake import native_helper_path +from .window import find_hwnd_for_profile, require_windows + + +def inject_probe( + profile: str = "wecom", + *, + hwnd: Optional[int] = None, +) -> dict[str, Any]: + """对目标窗进程注入 wecom_wake.dll(仅写 loaded 标记)。 + + 成功标准:``inject_ok=true`` 且 ``ui_responsive=true``(不要求暖树)。 + """ + require_windows() + prof = load_profile(profile) + h = find_hwnd_for_profile(prof, hwnd=hwnd) + helper = native_helper_path(prof.native_helper or "desktop-wake.exe") + if helper is None: + raise DesktopWakeError(ERR_WAKE_FAILED, "desktop-wake.exe 缺失,请先 build.ps1") + + cmd = [ + str(helper), + prof.id, + "0x" + format(int(h), "x"), + "--mode", + "n2", + ] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=25, check=False) + except subprocess.TimeoutExpired as exc: + raise DesktopWakeError(ERR_WAKE_FAILED, "inject timeout") from exc + + out = (proc.stdout or "").strip() + err = (proc.stderr or "").strip() + payload: dict[str, Any] = { + "hwnd": h, + "profile_id": prof.id, + "rc": proc.returncode, + "cmd": cmd, + "stderr": err[:300], + "inject_ok": False, + "ui_responsive": False, + "ok": False, + "detail": "", + "raw": out[:500], + } + if out: + line = out.splitlines()[-1].strip() + try: + data = json.loads(line) + payload.update(data) + payload["inject_ok"] = bool(data.get("inject_ok")) + payload["ui_responsive"] = bool(data.get("ui_responsive")) + payload["ok"] = bool(data.get("ok")) + payload["detail"] = str(data.get("detail") or "") + except json.JSONDecodeError: + payload["detail"] = "non_json_stdout" + + import os + + tdir = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") + loaded = tdir / "jiangchang_desktop_wake_loaded.txt" + payload["markers"] = { + "loaded": str(loaded), + "target": str(tdir / "jiangchang_desktop_wake_target.txt"), + "loaded_text": loaded.read_text(encoding="utf-8", errors="replace") if loaded.is_file() else "", + } + return payload diff --git a/src/jiangchang_desktop_wake/native/README.md b/src/jiangchang_desktop_wake/native/README.md new file mode 100644 index 0000000..79eedff --- /dev/null +++ b/src/jiangchang_desktop_wake/native/README.md @@ -0,0 +1,32 @@ +# native/ + +发版随 wheel 打包的 x86 产物(匹配 32-bit `WXWork.exe`)。 + +| 文件 | 说明 | +|---|---| +| `desktop-wake.exe` | 编排:激活 / SPI / 注入 / 调 `WakeProvider` | +| `wecom_wake.dll` | 载荷:DuiLib `GetAccessible` 安全子类化暖树 | + +不要把 `wecom_wake_b.dll` 等临时换名文件打进发行包。 + +## 编译 + +```powershell +# 先完全退出企微(含托盘),避免 dll 被锁 +powershell -File ..\native_src\build.ps1 +``` + +## 用法 + +```text +desktop-wake.exe wecom 0x --mode n2-wake +``` + +| mode | 用途 | +|---|---| +| `n1` | 仅进程外 UIA(冷树无效) | +| `n2` | 仅注入探针 | +| `n2-wake` | **主路径**:注入 + WakeProvider(ensure 使用) | +| `n2-hook` | 危险,默认禁止 | + +验收见 `../ACCEPTANCE.md`。 diff --git a/src/jiangchang_desktop_wake/native/desktop-wake.exe b/src/jiangchang_desktop_wake/native/desktop-wake.exe new file mode 100644 index 0000000000000000000000000000000000000000..c3b800742be74401bbaa32af49c718bea2629b9f GIT binary patch literal 18432 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�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 AsQ>@~ literal 0 HcmV?d00001 diff --git a/src/jiangchang_desktop_wake/native/wecom_wake.dll b/src/jiangchang_desktop_wake/native/wecom_wake.dll new file mode 100644 index 0000000000000000000000000000000000000000..2afe17a3a733248d87d2c7af912bdbe01591c888 GIT binary patch literal 119296 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 URS%{bK~+B-ESd7w|H0RP0I;oS3jhEB literal 0 HcmV?d00001 diff --git a/src/jiangchang_desktop_wake/native_src/DesktopWake/Program.cs b/src/jiangchang_desktop_wake/native_src/DesktopWake/Program.cs new file mode 100644 index 0000000..e0cfc83 --- /dev/null +++ b/src/jiangchang_desktop_wake/native_src/DesktopWake/Program.cs @@ -0,0 +1,695 @@ +// desktop-wake.exe — N1 UIA 客户端 + N2 注入/钩子编排(须 x86 以匹配 WXWork) +// 用法: desktop-wake.exe [--mode auto|n1|n2] +// 退出码: 0暖成功 1参数 2窗无效 3UIA异常 4仍稀疏 5注入失败 +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Windows.Automation; + +namespace JiangchangDesktopWake +{ + internal static class Program + { + private const int WM_GETOBJECT = 0x003D; + private const int WM_NULL = 0x0000; + private const uint OBJID_CLIENT = 0xFFFFFFFC; + private const uint OBJID_WINDOW = 0x00000000; + private const int SW_RESTORE = 9; + private const int SW_MAXIMIZE = 3; + private const int WH_CALLWNDPROC = 4; + private const uint PROCESS_CREATE_THREAD = 0x0002; + private const uint PROCESS_QUERY_INFORMATION = 0x0400; + private const uint PROCESS_VM_OPERATION = 0x0008; + private const uint PROCESS_VM_WRITE = 0x0020; + private const uint PROCESS_VM_READ = 0x0010; + private const uint MEM_COMMIT = 0x1000; + private const uint MEM_RESERVE = 0x2000; + private const uint PAGE_READWRITE = 0x04; + + [DllImport("user32.dll")] + private static extern bool IsWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + private static extern bool IsIconic(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool PostMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool IsHungAppWindow(IntPtr hWnd); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SendMessageTimeout( + IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam, uint fuFlags, uint uTimeout, out IntPtr lpdwResult); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("oleacc.dll")] + private static extern int AccessibleObjectFromWindow( + IntPtr hwnd, uint dwId, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out object ppvObject); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress, UIntPtr dwSize, uint flAllocationType, uint flProtect); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, UIntPtr nSize, out UIntPtr lpNumberOfBytesWritten); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetModuleHandle(string lpModuleName); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)] + private static extern IntPtr GetProcAddress(IntPtr hModule, string procName); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateRemoteThread(IntPtr hProcess, IntPtr lpThreadAttributes, UIntPtr dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeThread(IntPtr hThread, out uint lpExitCode); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SystemParametersInfo(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool VirtualFreeEx(IntPtr hProcess, IntPtr lpAddress, UIntPtr dwSize, uint dwFreeType); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SetWindowsHookEx(int idHook, IntPtr lpfn, IntPtr hMod, uint dwThreadId); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool UnhookWindowsHookEx(IntPtr hhk); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr LoadLibrary(string lpFileName); + + private static readonly Guid IID_IAccessible = new Guid("618736E0-3C3D-11CF-810C-00AA00389B71"); + + private static int Main(string[] args) + { + if (args.Length < 2) + { + Console.Error.WriteLine("usage: desktop-wake.exe [--mode auto|n1|n2|n2-wake|n2-hook]"); + Console.Error.WriteLine(" n1=UIA; n2=inject only; n2-wake=inject+WakeProvider(C2); n2-hook=UNSAFE"); + return 1; + } + + string profile = (args[0] ?? "").Trim().ToLowerInvariant(); + string mode = "auto"; + for (int i = 2; i < args.Length; i++) + { + if (args[i] == "--mode" && i + 1 < args.Length) + { + mode = (args[i + 1] ?? "auto").Trim().ToLowerInvariant(); + i++; + } + } + + IntPtr hwnd; + try { hwnd = ParseHwnd(args[1]); } + catch + { + Console.Error.WriteLine("invalid hwnd: " + args[1]); + return 1; + } + + if (!IsWindow(hwnd)) + { + WriteJson(false, false, false, profile, hwnd, 0, 0, new string[0], "window_invalid", mode, null); + return 2; + } + + int minCount = profile == "wecom" ? 80 : 30; + string[] markers = profile == "wecom" + ? new[] { "con_op", "searchedit" } + : new string[0]; + + string method = "n1_uia_client"; + string detailExtra = ""; + bool n2InjectOk = false; + + try + { + Activate(hwnd); + EnableScreenReaderFlag(); // 进程外 SPI:部分 App 见“读屏器”会挂树 + + // n1/auto:进程外 UIA + // n2:仅注入探针 + // n2-wake:注入 + 远程 WakeProvider(C2,无钩子) + // n2-hook:UNSAFE + bool doN2 = (mode == "n2" || mode == "n2-wake" || mode == "n2-hook") && profile == "wecom"; + bool doN2Hook = (mode == "n2-hook") && profile == "wecom"; + bool doWakeProvider = (mode == "n2-wake") && profile == "wecom"; + // n2-wake:不在 exe 内做 UIA 深扫(易跟着目标进程一起挂);暖树由 Python probe 判定 + bool doN1 = (mode == "n1" || mode == "auto" || mode == "n2-hook"); + + if (doN2) + { + string n2Detail; + IntPtr remoteMod; + n2InjectOk = TryN2(hwnd, doN2Hook, doWakeProvider, out n2Detail, out remoteMod); + detailExtra = n2Detail; + method = n2InjectOk ? (doWakeProvider ? "n2_wake" : "n2_inject") : "n2_inject_failed"; + bool responsive = CheckUiResponsive(hwnd); + detailExtra = detailExtra + ";ui_responsive=" + responsive; + + if (mode == "n2" || mode == "n2-wake") + { + // ok=注入(+provider)成功且 UI 仍响应;是否暖树由调用方 probe + WriteJson( + n2InjectOk && responsive, n2InjectOk, responsive, + profile, hwnd, 0, 0, new string[0], + (n2InjectOk ? (doWakeProvider ? "wake_provider_ok" : "inject_ok") : "inject_failed") + + "|" + detailExtra, + mode, method); + if (!n2InjectOk) return 5; + if (!responsive) return 6; + return 0; + } + + if (!n2InjectOk) + { + WriteJson(false, false, responsive, profile, hwnd, 0, 0, new string[0], n2Detail, mode, method); + return 5; + } + if (!responsive) + { + WriteJson(false, true, false, profile, hwnd, 0, 0, new string[0], + "ui_hung_after_inject|" + detailExtra, mode, method); + return 6; + } + Thread.Sleep(400); + } + + if (doN1) + { + SendGetObject(hwnd); + TryMsaa(hwnd); + var root = AutomationElement.FromHandle(hwnd); + if (root == null) + { + WriteJson(false, n2InjectOk, CheckUiResponsive(hwnd), profile, hwnd, 0, 0, new string[0], + "uia_from_handle_null|" + detailExtra, mode, method); + return 3; + } + try { root.SetFocus(); } catch { } + + int walk1 = WalkCount(root, 64); + int findAll = FindAllCount(root); + Thread.Sleep(200); + SendGetObject(hwnd); + int walk2 = WalkCount(root, 64); + int walk = Math.Max(walk1, Math.Max(walk2, findAll)); + var foundMarkers = FindMarkers(root, markers); + bool warm = walk >= minCount + && (markers.Length == 0 || foundMarkers.Count >= markers.Length); + bool responsive = CheckUiResponsive(hwnd); + + string detail = warm ? "warm" : "still_sparse"; + if (!string.IsNullOrEmpty(detailExtra)) + detail = detail + "|" + detailExtra; + if (mode == "n2-wake") + method = warm ? "n2_wake" : "n2_wake_still_sparse"; + else if (mode == "n2-hook") + method = n2InjectOk ? (warm ? "n2_hook" : "n2_hook_still_sparse") : method; + + WriteJson(warm, n2InjectOk, responsive, profile, hwnd, walk, findAll, + foundMarkers.ToArray(), detail, mode, method); + return warm ? 0 : 4; + } + + WriteJson(false, false, false, profile, hwnd, 0, 0, new string[0], "no_mode", mode, method); + return 1; + } + catch (Exception ex) + { + WriteJson(false, n2InjectOk, false, profile, hwnd, 0, 0, new string[0], + "exception:" + ex.GetType().Name + ":" + ex.Message + "|" + detailExtra, mode, method); + return 3; + } + } + + private static bool CheckUiResponsive(IntPtr hwnd) + { + try + { + if (IsHungAppWindow(hwnd)) return false; + IntPtr result; + IntPtr ok = SendMessageTimeout(hwnd, WM_NULL, IntPtr.Zero, IntPtr.Zero, 0x0002, 2000, out result); + return ok != IntPtr.Zero; + } + catch { return false; } + } + + private static void EnableScreenReaderFlag() + { + try + { + // SPI_SETSCREENREADER=0x46 + SystemParametersInfo(0x0046, 1, IntPtr.Zero, 0x0003); + } + catch { } + } + + private static bool TryN2(IntPtr hwnd, bool enableHook, bool callWakeProvider, out string detail, out IntPtr remoteModule) + { + remoteModule = IntPtr.Zero; + string dll = FindPayloadDll(); + if (dll == null) + { + detail = "n2_dll_missing"; + return false; + } + + uint pid; + uint tid = GetWindowThreadProcessId(hwnd, out pid); + if (pid == 0 || tid == 0) + { + detail = "n2_pid_tid_failed"; + return false; + } + + string targetFile = Path.Combine(Path.GetTempPath(), "jiangchang_desktop_wake_target.txt"); + File.WriteAllText( + targetFile, + "hwnd=0x" + hwnd.ToInt64().ToString("x") + "\r\npid=" + pid + "\r\n", + Encoding.ASCII); + + string loadedFile = Path.Combine(Path.GetTempPath(), "jiangchang_desktop_wake_loaded.txt"); + string subclassFile = Path.Combine(Path.GetTempPath(), "jiangchang_desktop_wake_subclass.txt"); + string hookFile = Path.Combine(Path.GetTempPath(), "jiangchang_desktop_wake_hook.txt"); + string providerFile = Path.Combine(Path.GetTempPath(), "jiangchang_desktop_wake_provider.txt"); + try { if (File.Exists(loadedFile)) File.Delete(loadedFile); } catch { } + try { if (File.Exists(subclassFile)) File.Delete(subclassFile); } catch { } + try { if (File.Exists(hookFile)) File.Delete(hookFile); } catch { } + try { if (File.Exists(providerFile)) File.Delete(providerFile); } catch { } + + string injErr; + bool injected = InjectLoadLibrary(pid, dll, out injErr, out remoteModule); + + string hookErr = enableHook ? "" : "skipped_unsafe_hook"; + bool hooked = false; + if (enableHook) + { + hooked = InstallCallWndHook(dll, tid, hwnd, out hookErr); + PostMessage(hwnd, WM_NULL, IntPtr.Zero, IntPtr.Zero); + Thread.Sleep(200); + } + + string wakeErr = "skipped"; + bool wakeCalled = false; + if (callWakeProvider && injected && remoteModule != IntPtr.Zero) + { + wakeCalled = CallRemoteWakeProvider(pid, dll, remoteModule, hwnd, out wakeErr); + // STA UIA 可能写完 marker 略晚;等 done=1 + for (int i = 0; i < 40; i++) + { + if (File.Exists(providerFile)) + { + try + { + string pt = File.ReadAllText(providerFile); + if (pt.IndexOf("done=1", StringComparison.Ordinal) >= 0) + { + wakeCalled = true; + break; + } + } + catch { } + } + Thread.Sleep(250); + } + if (!wakeCalled && File.Exists(providerFile)) + wakeCalled = true; + } + + bool loadedMarker = false; + if (File.Exists(loadedFile)) + { + try + { + string text = File.ReadAllText(loadedFile); + loadedMarker = text.IndexOf("pid=" + pid, StringComparison.Ordinal) >= 0 + || text.IndexOf("match=1", StringComparison.Ordinal) >= 0; + } + catch { loadedMarker = File.Exists(loadedFile); } + } + bool subclassMarker = File.Exists(subclassFile); + bool hookMarker = File.Exists(hookFile); + bool providerMarker = File.Exists(providerFile); + string providerText = ""; + if (providerMarker) + { + try { providerText = File.ReadAllText(providerFile).Replace("\r", " ").Replace("\n", " ").Trim(); } + catch { } + } + + detail = string.Format( + "pid={0};tid={1};inject={2};hook={3};wake={4};loaded_marker={5};provider_marker={6};provider={7};subclass_marker={8};hook_marker={9};injErr={10};hookErr={11};wakeErr={12};dll={13};remoteMod=0x{14}", + pid, tid, injected, hooked, wakeCalled, loadedMarker, providerMarker, providerText, + subclassMarker, hookMarker, injErr, hookErr, wakeErr, Path.GetFileName(dll), + remoteModule.ToInt64().ToString("x")); + + // DLL 已在目标进程时再次 LoadLibrary 不会重跑 DllMain,故 remoteMod!=0 也算注入成功 + bool injectOk = injected && (loadedMarker || remoteModule != IntPtr.Zero); + // WakeProvider 为 best-effort:注入成功即可,暖树由外层 probe 判定 + return injectOk || (hooked && (hookMarker || subclassMarker)); + } + + private static string FindPayloadDll() + { + try + { + string exe = Process.GetCurrentProcess().MainModule.FileName; + var dir = new DirectoryInfo(Path.GetDirectoryName(exe)); + for (int i = 0; i < 6 && dir != null; i++) + { + string newest = null; + DateTime best = DateTime.MinValue; + string[] searchDirs = new string[] { dir.FullName, Path.Combine(dir.FullName, "native") }; + foreach (var sd in searchDirs) + { + if (!Directory.Exists(sd)) continue; + // 优先正式名 wecom_wake.dll(发版包) + string primary = Path.Combine(sd, "wecom_wake.dll"); + if (File.Exists(primary)) return Path.GetFullPath(primary); + foreach (var f in Directory.GetFiles(sd, "wecom_wake*.dll")) + { + DateTime t = File.GetLastWriteTimeUtc(f); + if (t >= best) { best = t; newest = f; } + } + } + if (newest != null) return Path.GetFullPath(newest); + dir = dir.Parent; + } + } + catch { } + return null; + } + + private static bool InjectLoadLibrary(uint pid, string dllPath, out string err, out IntPtr remoteModule) + { + err = ""; + remoteModule = IntPtr.Zero; + IntPtr hProcess = OpenProcess( + PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, + false, pid); + if (hProcess == IntPtr.Zero) + { + err = "OpenProcess:" + Marshal.GetLastWin32Error(); + return false; + } + try + { + byte[] pathBytes = Encoding.Unicode.GetBytes(dllPath + "\0"); + IntPtr remote = VirtualAllocEx(hProcess, IntPtr.Zero, (UIntPtr)pathBytes.Length, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (remote == IntPtr.Zero) + { + err = "VirtualAllocEx:" + Marshal.GetLastWin32Error(); + return false; + } + UIntPtr written; + if (!WriteProcessMemory(hProcess, remote, pathBytes, (UIntPtr)pathBytes.Length, out written)) + { + err = "WriteProcessMemory:" + Marshal.GetLastWin32Error(); + return false; + } + IntPtr hKernel = GetModuleHandle("kernel32.dll"); + IntPtr loadLib = GetProcAddress(hKernel, "LoadLibraryW"); + if (loadLib == IntPtr.Zero) + { + err = "GetProcAddress LoadLibraryW failed"; + return false; + } + IntPtr hThread = CreateRemoteThread(hProcess, IntPtr.Zero, UIntPtr.Zero, loadLib, remote, 0, IntPtr.Zero); + if (hThread == IntPtr.Zero) + { + err = "CreateRemoteThread:" + Marshal.GetLastWin32Error(); + return false; + } + uint wait = WaitForSingleObject(hThread, 5000); + uint exitCode = 0; + GetExitCodeThread(hThread, out exitCode); + CloseHandle(hThread); + VirtualFreeEx(hProcess, remote, UIntPtr.Zero, 0x8000); + if (wait != 0) + { + err = "LoadLibrary wait timeout/status=" + wait; + return false; + } + if (exitCode == 0) + { + err = "LoadLibrary returned NULL"; + return false; + } + remoteModule = new IntPtr(unchecked((int)exitCode)); // x86 HMODULE + return true; + } + finally + { + CloseHandle(hProcess); + } + } + + private static bool CallRemoteWakeProvider( + uint pid, string dllPath, IntPtr remoteModule, IntPtr hwnd, out string err) + { + err = ""; + IntPtr localMod = LoadLibrary(dllPath); + if (localMod == IntPtr.Zero) + { + err = "local LoadLibrary:" + Marshal.GetLastWin32Error(); + return false; + } + IntPtr localProc = GetProcAddress(localMod, "WakeProvider"); + if (localProc == IntPtr.Zero) + localProc = GetProcAddress(localMod, "_WakeProvider@4"); + if (localProc == IntPtr.Zero) + { + err = "WakeProvider export missing"; + return false; + } + long offset = localProc.ToInt64() - localMod.ToInt64(); + IntPtr remoteProc = new IntPtr(remoteModule.ToInt64() + offset); + + IntPtr hProcess = OpenProcess( + PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, + false, pid); + if (hProcess == IntPtr.Zero) + { + err = "OpenProcess wake:" + Marshal.GetLastWin32Error(); + return false; + } + try + { + IntPtr hThread = CreateRemoteThread( + hProcess, IntPtr.Zero, UIntPtr.Zero, remoteProc, hwnd, 0, IntPtr.Zero); + if (hThread == IntPtr.Zero) + { + err = "CreateRemoteThread WakeProvider:" + Marshal.GetLastWin32Error(); + return false; + } + // STA + 进程内 FindAll 可能较慢 + uint wait = WaitForSingleObject(hThread, 20000); + CloseHandle(hThread); + if (wait != 0) + { + err = "WakeProvider wait=" + wait; + return false; + } + return true; + } + finally + { + CloseHandle(hProcess); + } + } + + private static bool InstallCallWndHook(string dllPath, uint tid, IntPtr hwnd, out string err) + { + err = ""; + IntPtr hMod = LoadLibrary(dllPath); + if (hMod == IntPtr.Zero) + { + err = "LoadLibrary:" + Marshal.GetLastWin32Error(); + return false; + } + // x86 __stdcall 可能是 _JcCallWndProc@12;优先无修饰名(.def 导出) + IntPtr proc = GetProcAddress(hMod, "JcCallWndProc"); + if (proc == IntPtr.Zero) + proc = GetProcAddress(hMod, "_JcCallWndProc@12"); + if (proc == IntPtr.Zero) + { + err = "GetProcAddress JcCallWndProc:" + Marshal.GetLastWin32Error(); + return false; + } + IntPtr hhk = SetWindowsHookEx(WH_CALLWNDPROC, proc, hMod, tid); + if (hhk == IntPtr.Zero) + { + err = "SetWindowsHookEx:" + Marshal.GetLastWin32Error(); + return false; + } + PostMessage(hwnd, WM_NULL, IntPtr.Zero, IntPtr.Zero); + Thread.Sleep(400); + // 保持钩子,避免 DLL 被立刻卸载;进程退出时清理 + GC.KeepAlive(hhk); + return true; + } + + private static IntPtr ParseHwnd(string s) + { + s = (s ?? "").Trim(); + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return new IntPtr(Convert.ToInt64(s, 16)); + // 含 a-f 一定是 hex;纯数字优先按十进制(Python 可传 {hwnd}) + bool hasHexLetter = false; + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if ((c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { hasHexLetter = true; break; } + } + if (hasHexLetter) + return new IntPtr(Convert.ToInt64(s, 16)); + long asDec; + if (long.TryParse(s, out asDec)) + return new IntPtr(asDec); + return new IntPtr(Convert.ToInt64(s, 16)); + } + + private static void Activate(IntPtr hwnd) + { + try + { + if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE); + else ShowWindow(hwnd, SW_MAXIMIZE); + SetForegroundWindow(hwnd); + Thread.Sleep(250); + } + catch { } + } + + private static void SendGetObject(IntPtr hwnd) + { + SendMessage(hwnd, WM_GETOBJECT, IntPtr.Zero, new IntPtr(unchecked((int)OBJID_CLIENT))); + SendMessage(hwnd, WM_GETOBJECT, IntPtr.Zero, new IntPtr(unchecked((int)OBJID_WINDOW))); + } + + private static void TryMsaa(IntPtr hwnd) + { + try + { + Guid iid = IID_IAccessible; + object acc; + int hr = AccessibleObjectFromWindow(hwnd, OBJID_CLIENT, ref iid, out acc); + if (hr == 0 && acc != null) + Marshal.FinalReleaseComObject(acc); + } + catch { } + } + + private static int WalkCount(AutomationElement root, int maxDepth) + { + int count = 0; + var walker = TreeWalker.ControlViewWalker; + Walk(walker, root, 0, maxDepth, ref count); + return count; + } + + private static void Walk(TreeWalker walker, AutomationElement node, int depth, int maxDepth, ref int count) + { + if (node == null || depth > maxDepth) return; + count++; + AutomationElement child; + try { child = walker.GetFirstChild(node); } + catch { return; } + while (child != null) + { + Walk(walker, child, depth + 1, maxDepth, ref count); + try { child = walker.GetNextSibling(child); } + catch { break; } + } + } + + private static int FindAllCount(AutomationElement root) + { + try + { + var all = root.FindAll(TreeScope.Subtree, Condition.TrueCondition); + return all == null ? 0 : all.Count; + } + catch { return 0; } + } + + private static List FindMarkers(AutomationElement root, string[] markers) + { + var found = new List(); + if (markers == null || markers.Length == 0) return found; + foreach (var m in markers) + { + try + { + var el = root.FindFirst(TreeScope.Subtree, new PropertyCondition(AutomationElement.NameProperty, m)); + if (el != null) { found.Add(m); continue; } + el = root.FindFirst(TreeScope.Subtree, new PropertyCondition(AutomationElement.AutomationIdProperty, m)); + if (el != null) found.Add(m); + } + catch { } + } + return found; + } + + private static void WriteJson( + bool ok, bool injectOk, bool uiResponsive, string profile, IntPtr hwnd, + int walk, int findAll, string[] markers, string detail, string mode, string method) + { + var sb = new StringBuilder(); + sb.Append("{"); + sb.Append("\"ok\":").Append(ok ? "true" : "false").Append(","); + sb.Append("\"inject_ok\":").Append(injectOk ? "true" : "false").Append(","); + sb.Append("\"ui_responsive\":").Append(uiResponsive ? "true" : "false").Append(","); + sb.Append("\"profile\":\"").Append(Escape(profile)).Append("\","); + sb.Append("\"hwnd\":\"").Append(hwnd.ToInt64().ToString("x")).Append("\","); + sb.Append("\"mode\":\"").Append(Escape(mode ?? "")).Append("\","); + sb.Append("\"method\":\"").Append(Escape(method ?? "")).Append("\","); + sb.Append("\"walk_count\":").Append(walk).Append(","); + sb.Append("\"findall_count\":").Append(findAll).Append(","); + sb.Append("\"markers_found\":["); + for (int i = 0; i < markers.Length; i++) + { + if (i > 0) sb.Append(","); + sb.Append("\"").Append(Escape(markers[i])).Append("\""); + } + sb.Append("],"); + sb.Append("\"detail\":\"").Append(Escape(detail)).Append("\""); + sb.Append("}"); + Console.WriteLine(sb.ToString()); + } + + private static string Escape(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + return s.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + } +} diff --git a/src/jiangchang_desktop_wake/native_src/N2/README.md b/src/jiangchang_desktop_wake/native_src/N2/README.md new file mode 100644 index 0000000..1f5bc4d --- /dev/null +++ b/src/jiangchang_desktop_wake/native_src/N2/README.md @@ -0,0 +1,39 @@ +# N2 — 企微进程内硬唤醒 PoC + +## 现状 + +- 目标进程:`WXWork.exe`(常见为 **Win32/x86**,安装在 `Program Files (x86)`) +- 产物:`native/wecom_wake.dll` + `native/desktop-wake.exe`(**均须 x86**) +- 手段: + 1. `CreateRemoteThread` + `LoadLibraryW`(写 `%TEMP%\jiangchang_desktop_wake_loaded.txt`) + 2. `SetWindowsHookEx(WH_CALLWNDPROC)` 在 UI 线程子类化,拦 `WM_GETOBJECT` + +## 编译 + +在 kit 根或本目录: + +```powershell +powershell -File ..\build.ps1 +# 即:native_src\build.ps1 +``` + +## 手动试 + +```powershell +# 先 probe 拿 hwnd +python -m jiangchang_desktop_wake probe --profile wecom +.\..\..\native\desktop-wake.exe wecom --mode n2 +``` + +## 标记文件(%TEMP%) + +| 文件 | 含义 | +|---|---| +| `jiangchang_desktop_wake_target.txt` | injector 写入的目标 hwnd | +| `jiangchang_desktop_wake_loaded.txt` | DLL 已进目标进程 | +| `jiangchang_desktop_wake_hook.txt` | CALLWNDPROC 钩子触发 | +| `jiangchang_desktop_wake_subclass.txt` | 子类化已安装 | + +## 限制 + +子类化 + `CreateStdAccessibleObject` **不一定**能暖开企微 DirectUI;本阶段先证明注入通道。若 `loaded_marker=True` 而树仍冷,下一步要做更深层 Provider 补丁。 diff --git a/src/jiangchang_desktop_wake/native_src/N2/RESEARCH_C1.md b/src/jiangchang_desktop_wake/native_src/N2/RESEARCH_C1.md new file mode 100644 index 0000000..1ac2e69 --- /dev/null +++ b/src/jiangchang_desktop_wake/native_src/N2/RESEARCH_C1.md @@ -0,0 +1,36 @@ +# C1 — 影刀暖树对照调研清单 + +目的:在**不抄影刀二进制**的前提下,弄清「暖成功时进程里多了什么」,指导 C2/C3。 + +## 环境 + +- 冷基线:退出影刀 + 重启企微 + `probe` 为冷 +- 暖对照:影刀拾取一次后 `probe` 为暖 +- 记录企微版本:`WXWork` 安装目录版本号 / 关于 + +## 采集项(冷 vs 暖各做一遍) + +| # | 项 | 怎么看 | 记录 | +|---|---|---|---| +| 1 | `probe` JSON | `python -m jiangchang_desktop_wake probe --profile wecom` | findall / markers / hwnd | +| 2 | 模块列表 | Process Explorer / `Listdlls` 看 WXWork.exe | 暖后是否多 DLL | +| 3 | 窗口子类化 | Spy++ 看 WndProc 是否被替换 | 冷/暖差异 | +| 4 | `WM_GETOBJECT` | Inspect 附着时是否突然有树 | 行为变化 | +| 5 | 影刀进程 | 拾取瞬间是否拉起 `misc.exe` / 其它 Native | 命令行参数 | +| 6 | 退出影刀后 | 影刀退出后 `probe` 是否仍暖 | 确认进程内常驻 | + +## 产出(填空) + +``` +企微版本: +冷 findall: +暖 findall: +暖后多出的模块: +疑似入口(misc 参数等): +假设(给 C2): +``` + +## 约束 + +- 不把影刀 `misc.exe` 打进发行包 +- 卡死过的 `n2-hook` 不要当主路径 diff --git a/src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.cpp b/src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.cpp new file mode 100644 index 0000000..c27f5e4 --- /dev/null +++ b/src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.cpp @@ -0,0 +1,600 @@ +/** + * N2/C2+ wecom_wake.dll — 进进程暖树(企微 DuiLib MSAA) + * + * 导出: + * WakeProvider — CreateRemoteThread 入口(主路径) + * WakeTarget — 旧子类化(危险,仅 n2-hook) + * JcCallWndProc — 旧钩子(危险,仅 n2-hook) + * + * C2+ 主路径: + * 1) STA 工作线程 + 进程内 UIA(保持客户端) + * 2) SetWindowSubclass:WM_GETOBJECT 时走 DuiLib 已导出的 + * GetAccessible / OnGetObject(不挂钩、不用 CreateStdAccessibleObject) + */ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "oleacc.lib") +#pragma comment(lib, "ole32.lib") +#pragma comment(lib, "oleaut32.lib") +#pragma comment(lib, "uuid.lib") +#pragma comment(lib, "user32.lib") +#pragma comment(lib, "comctl32.lib") + +#ifndef EVENT_OBJECT_FOCUS +#define EVENT_OBJECT_FOCUS 0x8005 +#endif +#ifndef EVENT_SYSTEM_ALERT +#define EVENT_SYSTEM_ALERT 0x0002 +#endif +#ifndef CHILDID_SELF +#define CHILDID_SELF 0 +#endif +#ifndef ANR_ON +#define ANR_ON 0x00000001 +#endif +#ifndef UiaRootObjectId +#define UiaRootObjectId ((LONG)0xFFFFFFE7) +#endif + +#define WM_JC_WAKE (WM_APP + 0x51) +#define WM_JC_QUIT (WM_APP + 0x52) +#define JC_SUBCLASS_ID 0x4A435701u + +static HMODULE g_self = NULL; +static HWND g_hwnd = NULL; +static WNDPROC g_oldProc = NULL; +static volatile LONG g_getobjectHits = 0; +static volatile LONG g_hookHits = 0; +static volatile LONG g_duiHits = 0; + +static HANDLE g_staThread = NULL; +static HANDLE g_staReady = NULL; +static HWND g_msgHwnd = NULL; +static volatile LONG g_staStarted = 0; + +static IUIAutomation* g_auto = NULL; +static IUIAutomationElement* g_root = NULL; +static IUIAutomationElementArray* g_arr = NULL; +static IAccessible* g_acc = NULL; + +static volatile LONG g_subclassInstalled = 0; + +/* DuiLib thiscall helpers (x86) */ +typedef IAccessible*(__thiscall* FnGetAccessible)(void* self); +typedef LRESULT(__thiscall* FnOnGetObject)( + void* self, UINT uMsg, WPARAM wParam, LPARAM lParam, bool* bHandled); + +static FnGetAccessible g_fnGetAccessible = NULL; +static FnOnGetObject g_fnOnGetObject = NULL; +static HMODULE g_duilib = NULL; + +static void WriteMarker(const char* name, const char* content) +{ + char path[MAX_PATH]; + DWORD n = GetTempPathA(MAX_PATH, path); + if (n == 0 || n >= MAX_PATH) return; + strncat_s(path, MAX_PATH, name, _TRUNCATE); + HANDLE h = CreateFileA(path, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, NULL); + if (h == INVALID_HANDLE_VALUE) return; + DWORD written = 0; + if (content && *content) + WriteFile(h, content, (DWORD)strlen(content), &written, NULL); + CloseHandle(h); +} + +static bool ReadTarget(HWND* outHwnd, DWORD* outPid) +{ + if (outHwnd) *outHwnd = NULL; + if (outPid) *outPid = 0; + char path[MAX_PATH]; + DWORD n = GetTempPathA(MAX_PATH, path); + if (n == 0 || n >= MAX_PATH) return false; + strncat_s(path, MAX_PATH, "jiangchang_desktop_wake_target.txt", _TRUNCATE); + HANDLE h = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (h == INVALID_HANDLE_VALUE) return false; + char buf[256] = {0}; + DWORD rd = 0; + ReadFile(h, buf, sizeof(buf) - 1, &rd, NULL); + CloseHandle(h); + if (rd == 0) return false; + + char* p = buf; + while (*p) { + while (*p == '\r' || *p == '\n' || *p == ' ') p++; + if (!*p) break; + if (_strnicmp(p, "hwnd=", 5) == 0) { + unsigned long long v = _strtoui64(p + 5, NULL, 0); + if (outHwnd) *outHwnd = (HWND)(ULONG_PTR)v; + } else if (_strnicmp(p, "pid=", 4) == 0) { + if (outPid) *outPid = (DWORD)_strtoui64(p + 4, NULL, 0); + } + while (*p && *p != '\n') p++; + } + return (outHwnd && *outHwnd) || (outPid && *outPid); +} + +static HWND ReadTargetHwnd() +{ + HWND hwnd = NULL; + ReadTarget(&hwnd, NULL); + return hwnd; +} + +static void NudgeWinEvents(HWND hwnd) +{ + NotifyWinEvent(EVENT_OBJECT_FOCUS, hwnd, OBJID_CLIENT, CHILDID_SELF); + NotifyWinEvent(EVENT_SYSTEM_ALERT, hwnd, OBJID_CLIENT, CHILDID_SELF); + PostMessageW(hwnd, WM_GETOBJECT, 0, (LPARAM)OBJID_CLIENT); + PostMessageW(hwnd, WM_GETOBJECT, 0, (LPARAM)OBJID_WINDOW); + PostMessageW(hwnd, WM_GETOBJECT, 0, (LPARAM)UiaRootObjectId); + PostMessageW(hwnd, WM_NULL, 0, 0); +} + +static void TryAccUtilityState(HWND hwnd) +{ + HMODULE oleacc = GetModuleHandleW(L"oleacc.dll"); + if (!oleacc) oleacc = LoadLibraryW(L"oleacc.dll"); + if (!oleacc) return; + typedef HRESULT(WINAPI* PFN)(HWND, DWORD, DWORD); + PFN fn = (PFN)GetProcAddress(oleacc, "AccSetRunningUtilityState"); + if (!fn) return; + fn(hwnd, ANR_ON, ANR_ON); +} + +static void ReleaseKeepAlive() +{ + if (g_arr) { g_arr->Release(); g_arr = NULL; } + if (g_root) { g_root->Release(); g_root = NULL; } + if (g_auto) { g_auto->Release(); g_auto = NULL; } + if (g_acc) { g_acc->Release(); g_acc = NULL; } +} + +static bool ResolveDuiLib() +{ + if (g_fnGetAccessible && g_fnOnGetObject) return true; + g_duilib = GetModuleHandleW(L"DuiLib.dll"); + if (!g_duilib) g_duilib = GetModuleHandleW(L"duilib.dll"); + if (!g_duilib) return false; + + g_fnGetAccessible = (FnGetAccessible)GetProcAddress( + g_duilib, "?GetAccessible@CWindowWnd@DuiLib@@UAEPAUIAccessible@@XZ"); + g_fnOnGetObject = (FnOnGetObject)GetProcAddress( + g_duilib, "?OnGetObject@CWindowWnd@DuiLib@@MAEJIIJAA_N@Z"); + return g_fnGetAccessible != NULL || g_fnOnGetObject != NULL; +} + +/* Force DuiLib MSAA for WM_GETOBJECT — no CreateStdAccessibleObject (avoids UI hang). */ +static LRESULT TryDuiLibGetObject(HWND hwnd, WPARAM wParam, LPARAM lParam) +{ + if (!ResolveDuiLib()) + return 0; + + void* self = (void*)GetWindowLongPtrW(hwnd, GWLP_USERDATA); + if (!self) + return 0; + + InterlockedIncrement(&g_duiHits); + + if (g_fnOnGetObject) { + bool handled = false; + LRESULT lr = g_fnOnGetObject(self, WM_GETOBJECT, wParam, lParam, &handled); + if (lr != 0) + return lr; + } + + if (g_fnGetAccessible) { + IAccessible* acc = g_fnGetAccessible(self); + if (acc) { + LRESULT lr = LresultFromObject(IID_IAccessible, wParam, (LPUNKNOWN)acc); + /* LresultFromObject AddRef's; release our ref from GetAccessible */ + acc->Release(); + return lr; + } + } + return 0; +} + +static WNDPROC g_duiOldProc = NULL; + +static LRESULT CALLBACK DuiSubclassProc( + HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, + UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + (void)uIdSubclass; + (void)dwRefData; + if (uMsg == WM_GETOBJECT) { + InterlockedIncrement(&g_getobjectHits); + LRESULT r = DefSubclassProc(hWnd, uMsg, wParam, lParam); + if (r != 0) + return r; + r = TryDuiLibGetObject(hWnd, wParam, lParam); + if (r != 0) + return r; + return 0; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +/* Fallback when comctl SetWindowSubclass unavailable in host */ +static LRESULT CALLBACK DuiWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) +{ + if (msg == WM_GETOBJECT) { + InterlockedIncrement(&g_getobjectHits); + LRESULT r = 0; + if (g_duiOldProc) + r = CallWindowProcW(g_duiOldProc, hwnd, msg, wParam, lParam); + if (r != 0) + return r; + r = TryDuiLibGetObject(hwnd, wParam, lParam); + if (r != 0) + return r; + return 0; + } + if (g_duiOldProc) + return CallWindowProcW(g_duiOldProc, hwnd, msg, wParam, lParam); + return DefWindowProcW(hwnd, msg, wParam, lParam); +} + +static bool InstallDuiSubclass(HWND hwnd) +{ + if (!hwnd || !IsWindow(hwnd)) return false; + if (InterlockedCompareExchange(&g_subclassInstalled, 1, 0) != 0) + return true; + + ResolveDuiLib(); + void* userdata = (void*)GetWindowLongPtrW(hwnd, GWLP_USERDATA); + LONG_PTR accPtr = 0; + LONG_PTR ongetLr = 0; + if (userdata && g_fnGetAccessible) { + IAccessible* acc = g_fnGetAccessible(userdata); + accPtr = (LONG_PTR)acc; + if (acc) { + long kids = -1; + acc->get_accChildCount(&kids); + ongetLr = kids; + /* keep one ref for diagnostics; Release extra if we AddRef'd — GetAccessible owns */ + acc->Release(); + } + } + + /* 企微进程里 SetWindowSubclass 常失败;主用 SetWindowLongPtr(只转发 DuiLib GetAccessible,不挂钩) */ + SetLastError(0); + g_duiOldProc = (WNDPROC)SetWindowLongPtrW( + hwnd, GWLP_WNDPROC, (LONG_PTR)DuiWndProc); + DWORD gle = GetLastError(); + BOOL ok = (g_duiOldProc != NULL); + const char* method = "SetWindowLongPtr"; + + if (!ok) { + INITCOMMONCONTROLSEX icc; + ZeroMemory(&icc, sizeof(icc)); + icc.dwSize = sizeof(icc); + icc.dwICC = ICC_WIN95_CLASSES; + InitCommonControlsEx(&icc); + SetLastError(0); + ok = SetWindowSubclass(hwnd, DuiSubclassProc, JC_SUBCLASS_ID, 0); + gle = GetLastError(); + method = "SetWindowSubclass"; + } + + char line[512]; + sprintf_s(line, + "hwnd=%p ok=%d gle=%lu method=%s userdata=%p " + "getacc_ret=%p getacc_kids=%ld duilib=%p getacc=%p onget=%p\r\n", + hwnd, (int)ok, gle, method, userdata, + (void*)accPtr, (long)ongetLr, g_duilib, + g_fnGetAccessible, g_fnOnGetObject); + WriteMarker("jiangchang_desktop_wake_subclass.txt", line); + if (!ok) { + InterlockedExchange(&g_subclassInstalled, 0); + return false; + } + return true; +} + +static void DoInProcessUiaWake(HWND hwnd) +{ + char line[1024]; + LONG findall = -1; + LONG msaaKids = -1; + HRESULT hrAuto = E_FAIL; + HRESULT hrEl = E_FAIL; + HRESULT hrFind = E_FAIL; + HRESULT hrMsaa = E_FAIL; + LRESULT lrClient = 0; + LRESULT lrUia = 0; + int subclass_ok = 0; + int dui_ok = 0; + + TryAccUtilityState(hwnd); + ResolveDuiLib(); + dui_ok = (g_fnGetAccessible || g_fnOnGetObject) ? 1 : 0; + subclass_ok = InstallDuiSubclass(hwnd) ? 1 : 0; + + NudgeWinEvents(hwnd); + + /* Sync probe WM_GETOBJECT after subclass — goes through our handler */ + lrClient = SendMessageW(hwnd, WM_GETOBJECT, 0, (LPARAM)OBJID_CLIENT); + lrUia = SendMessageW(hwnd, WM_GETOBJECT, 0, (LPARAM)UiaRootObjectId); + + ReleaseKeepAlive(); + + hrAuto = CoCreateInstance( + CLSID_CUIAutomation, NULL, CLSCTX_INPROC_SERVER, + IID_IUIAutomation, (void**)&g_auto); + if (SUCCEEDED(hrAuto) && g_auto) { + hrEl = g_auto->ElementFromHandle(hwnd, &g_root); + if (SUCCEEDED(hrEl) && g_root) { + IUIAutomationCondition* cond = NULL; + if (SUCCEEDED(g_auto->CreateTrueCondition(&cond)) && cond) { + hrFind = g_root->FindAll(TreeScope_Subtree, cond, &g_arr); + cond->Release(); + if (SUCCEEDED(hrFind) && g_arr) { + int len = 0; + if (SUCCEEDED(g_arr->get_Length(&len))) + findall = len; + } + } + } + } + + { + IAccessible* acc = NULL; + hrMsaa = AccessibleObjectFromWindow( + hwnd, OBJID_CLIENT, IID_IAccessible, (void**)&acc); + if (SUCCEEDED(hrMsaa) && acc) { + g_acc = acc; + long kids = 0; + if (SUCCEEDED(acc->get_accChildCount(&kids))) + msaaKids = kids; + } + } + + NudgeWinEvents(hwnd); + + sprintf_s(line, + "hwnd=%p sta_tid=%lu findall=%ld msaa_kids=%ld " + "lr_client=%ld lr_uia=%ld subclass=%d dui=%d " + "dui_hits=%ld getobject_hits=%ld " + "hr_auto=0x%08lx hr_el=0x%08lx hr_find=0x%08lx hr_msaa=0x%08lx " + "keep_auto=%d keep_root=%d keep_arr=%d keep_acc=%d done=1\r\n", + hwnd, GetCurrentThreadId(), (long)findall, (long)msaaKids, + (long)lrClient, (long)lrUia, subclass_ok, dui_ok, + (long)g_duiHits, (long)g_getobjectHits, + (unsigned long)hrAuto, (unsigned long)hrEl, + (unsigned long)hrFind, (unsigned long)hrMsaa, + g_auto ? 1 : 0, g_root ? 1 : 0, g_arr ? 1 : 0, g_acc ? 1 : 0); + WriteMarker("jiangchang_desktop_wake_provider.txt", line); +} + +static LRESULT CALLBACK MsgWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) +{ + if (msg == WM_JC_WAKE) { + HWND target = (HWND)wParam; + if (target && IsWindow(target)) + DoInProcessUiaWake(target); + else + WriteMarker("jiangchang_desktop_wake_provider.txt", "err=bad_hwnd done=1\r\n"); + return 0; + } + if (msg == WM_JC_QUIT) { + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +static DWORD WINAPI StaThreadProc(LPVOID) +{ + HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + bool needUninit = SUCCEEDED(hr) || hr == S_FALSE; + + WNDCLASSW wc; + ZeroMemory(&wc, sizeof(wc)); + wc.lpfnWndProc = MsgWndProc; + wc.hInstance = g_self ? g_self : GetModuleHandleW(NULL); + wc.lpszClassName = L"JiangchangDesktopWakeMsg"; + RegisterClassW(&wc); + + g_msgHwnd = CreateWindowExW( + 0, wc.lpszClassName, L"", 0, + 0, 0, 0, 0, HWND_MESSAGE, NULL, wc.hInstance, NULL); + + if (g_staReady) + SetEvent(g_staReady); + + if (!g_msgHwnd) { + WriteMarker("jiangchang_desktop_wake_provider.txt", "err=no_msg_hwnd done=1\r\n"); + if (needUninit) CoUninitialize(); + return 1; + } + + MSG msg; + while (GetMessageW(&msg, NULL, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + ReleaseKeepAlive(); + if (g_msgHwnd) { + DestroyWindow(g_msgHwnd); + g_msgHwnd = NULL; + } + if (needUninit) CoUninitialize(); + return 0; +} + +static bool EnsureStaThread() +{ + if (InterlockedCompareExchange(&g_staStarted, 1, 0) != 0) { + if (g_staReady) + WaitForSingleObject(g_staReady, 5000); + return g_msgHwnd != NULL; + } + + g_staReady = CreateEventW(NULL, TRUE, FALSE, NULL); + g_staThread = CreateThread(NULL, 0, StaThreadProc, NULL, 0, NULL); + if (!g_staThread) { + InterlockedExchange(&g_staStarted, 0); + WriteMarker("jiangchang_desktop_wake_provider.txt", "err=sta_create done=1\r\n"); + return false; + } + DWORD w = WaitForSingleObject(g_staReady, 5000); + if (w != WAIT_OBJECT_0 || !g_msgHwnd) { + WriteMarker("jiangchang_desktop_wake_provider.txt", "err=sta_ready_timeout done=1\r\n"); + return false; + } + return true; +} + +extern "C" __declspec(dllexport) DWORD WINAPI WakeProvider(LPVOID param) +{ + HWND hwnd = (HWND)param; + if (!hwnd || !IsWindow(hwnd)) + hwnd = ReadTargetHwnd(); + if (!hwnd || !IsWindow(hwnd)) { + WriteMarker("jiangchang_desktop_wake_provider.txt", "err=no_hwnd done=1\r\n"); + return 2; + } + + WriteMarker("jiangchang_desktop_wake_provider.txt", "phase=start\r\n"); + + if (!EnsureStaThread()) + return 3; + + NudgeWinEvents(hwnd); + + DWORD_PTR result = 0; + LRESULT ok = SendMessageTimeoutW( + g_msgHwnd, WM_JC_WAKE, (WPARAM)hwnd, 0, + SMTO_ABORTIFHUNG, 15000, &result); + + if (ok == 0) { + char line[256]; + sprintf_s(line, "err=wake_timeout gle=%lu hwnd=%p done=1\r\n", + GetLastError(), hwnd); + WriteMarker("jiangchang_desktop_wake_provider.txt", line); + return 4; + } + + char path[MAX_PATH]; + DWORD n = GetTempPathA(MAX_PATH, path); + if (n > 0 && n < MAX_PATH) { + strncat_s(path, MAX_PATH, "jiangchang_desktop_wake_provider.txt", _TRUNCATE); + HANDLE h = CreateFileA(path, FILE_APPEND_DATA, FILE_SHARE_READ, NULL, + OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (h != INVALID_HANDLE_VALUE) { + char foot[160]; + sprintf_s(foot, "remote_tid=%lu nudge=1 dui_hits=%ld getobject_hits=%ld\r\n", + GetCurrentThreadId(), (long)g_duiHits, (long)g_getobjectHits); + DWORD written = 0; + WriteFile(h, foot, (DWORD)strlen(foot), &written, NULL); + CloseHandle(h); + } + } + return 0; +} + +/* ---------- 旧危险路径(仅 n2-hook) ---------- */ + +static LRESULT TryStdAccessible(HWND hwnd, WPARAM wParam, LONG objId) +{ + IAccessible* pAcc = NULL; + HRESULT hr = CreateStdAccessibleObject(hwnd, objId, IID_IAccessible, (void**)&pAcc); + if (FAILED(hr) || !pAcc) + return 0; + LRESULT lr = LresultFromObject(IID_IAccessible, wParam, pAcc); + pAcc->Release(); + return lr; +} + +static LRESULT CALLBACK SubclassProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) +{ + if (msg == WM_GETOBJECT) { + InterlockedIncrement(&g_getobjectHits); + LRESULT r = 0; + if (g_oldProc) + r = CallWindowProcW(g_oldProc, hwnd, msg, wParam, lParam); + if (r != 0) + return r; + r = TryDuiLibGetObject(hwnd, wParam, lParam); + if (r != 0) + return r; + LONG objId = (LONG)lParam; + r = TryStdAccessible(hwnd, wParam, objId); + if (r != 0) + return r; + if (objId != (LONG)OBJID_CLIENT) + r = TryStdAccessible(hwnd, wParam, (LONG)OBJID_CLIENT); + return r; + } + if (g_oldProc) + return CallWindowProcW(g_oldProc, hwnd, msg, wParam, lParam); + return DefWindowProcW(hwnd, msg, wParam, lParam); +} + +static void InstallSubclass(HWND hwnd) +{ + if (!hwnd || !IsWindow(hwnd)) return; + if (g_oldProc) return; + g_hwnd = hwnd; + g_oldProc = (WNDPROC)SetWindowLongPtrW(hwnd, GWLP_WNDPROC, (LONG_PTR)SubclassProc); + char line[256]; + sprintf_s(line, "hwnd=%p old=%p err=%lu\r\n", hwnd, g_oldProc, GetLastError()); + WriteMarker("jiangchang_desktop_wake_subclass.txt", line); +} + +extern "C" __declspec(dllexport) int WakeTarget(HWND hwnd) +{ + if (hwnd && IsWindow(hwnd)) + InstallSubclass(hwnd); + else + InstallSubclass(ReadTargetHwnd()); + return g_oldProc ? 0 : 1; +} + +extern "C" __declspec(dllexport) LRESULT CALLBACK JcCallWndProc( + int code, WPARAM wParam, LPARAM lParam) +{ + if (code >= 0) { + InterlockedIncrement(&g_hookHits); + CWPSTRUCT* cwp = (CWPSTRUCT*)lParam; + HWND target = ReadTargetHwnd(); + if (target && cwp && !g_oldProc) + InstallSubclass(target); + if (g_hookHits <= 3) { + char line[128]; + sprintf_s(line, "hook_hits=%ld subclass=%p\r\n", (long)g_hookHits, g_oldProc); + WriteMarker("jiangchang_desktop_wake_hook.txt", line); + } + } + return CallNextHookEx(NULL, code, wParam, lParam); +} + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID reserved) +{ + (void)reserved; + if (reason == DLL_PROCESS_ATTACH) { + g_self = hModule; + DisableThreadLibraryCalls(hModule); + HWND thwnd = NULL; + DWORD tpid = 0; + ReadTarget(&thwnd, &tpid); + DWORD cur = GetCurrentProcessId(); + if (tpid == 0 || tpid == cur) { + char line[256]; + sprintf_s(line, "pid=%lu tid=%lu target_pid=%lu match=%d\r\n", + cur, GetCurrentThreadId(), tpid, (tpid == 0 || tpid == cur) ? 1 : 0); + WriteMarker("jiangchang_desktop_wake_loaded.txt", line); + } + } + return TRUE; +} diff --git a/src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.def b/src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.def new file mode 100644 index 0000000..93c037b --- /dev/null +++ b/src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.def @@ -0,0 +1,4 @@ +EXPORTS + WakeProvider + WakeTarget + JcCallWndProc diff --git a/src/jiangchang_desktop_wake/native_src/build.ps1 b/src/jiangchang_desktop_wake/native_src/build.ps1 new file mode 100644 index 0000000..80811db --- /dev/null +++ b/src/jiangchang_desktop_wake/native_src/build.ps1 @@ -0,0 +1,80 @@ +# 编译 N1/N2:x86 desktop-wake.exe + x86 wecom_wake*.dll(匹配 WXWork.exe) +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$outDir = (Resolve-Path (Join-Path $here "..\native")).Path +$outExe = Join-Path $outDir "desktop-wake.exe" +$srcCs = Join-Path $here "DesktopWake\Program.cs" +$srcCpp = Join-Path $here "N2\payload\wecom_wake.cpp" +$srcDef = Join-Path $here "N2\payload\wecom_wake.def" +$objPath = Join-Path $env:TEMP "jiangchang_wecom_wake.obj" + +$vcvars32 = "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars32.bat" +if (-not (Test-Path $vcvars32)) { + throw "vcvars32.bat not found: $vcvars32" +} + +function Test-FileWritable([string]$path) { + try { + if (-not (Test-Path $path)) { return $true } + $fs = [System.IO.File]::Open($path, 'Open', 'ReadWrite', 'None') + $fs.Close() + return $true + } catch { return $false } +} + +$dllTarget = $null +$names = @("wecom_wake.dll", "wecom_wake_b.dll", "wecom_wake_c.dll", "wecom_wake_d.dll", "wecom_wake_e.dll") +$names += @("wecom_wake_{0:HHmmss}.dll" -f (Get-Date)) +foreach ($name in $names) { + $cand = Join-Path $outDir $name + if (Test-FileWritable $cand) { $dllTarget = $cand; break } +} +if (-not $dllTarget) { + throw "All wecom_wake*.dll locked. Fully quit WXWork.exe (tray too), then rebuild." +} +if ((Split-Path -Leaf $dllTarget) -ne "wecom_wake.dll") { + Write-Host "WARN: primary dll locked; writing $(Split-Path -Leaf $dllTarget). Quit WXWork later to refresh wecom_wake.dll." +} + +Write-Host "Building $(Split-Path -Leaf $dllTarget) (x86) ..." + +# 用临时 bat + 管道进 cmd,避免 cmd /c 弄坏多行命令 +$bat = Join-Path $env:TEMP "jiangchang_build_wecom_wake.bat" +@" +@echo off +call "$vcvars32" || exit /b 1 +cl /nologo /LD /O2 /EHsc /DWIN32 /D_WINDOWS /D_USRDLL /utf-8 "$srcCpp" /Fe"$dllTarget" /Fo"$objPath" /link /DEF:"$srcDef" user32.lib ole32.lib oleacc.lib oleaut32.lib uuid.lib comctl32.lib +exit /b %ERRORLEVEL% +"@ | Set-Content -Path $bat -Encoding ASCII + +$buildOut = cmd.exe /c "`"$bat`"" 2>&1 +Write-Host ($buildOut | Out-String) +if (-not (Test-Path $dllTarget)) { + throw "wecom_wake dll build failed. cl output above. If locked, quit WXWork and retry." +} +Write-Host "OK $dllTarget" + +$csc = Join-Path $env:WINDIR "Microsoft.NET\Framework\v4.0.30319\csc.exe" +if (-not (Test-Path $csc)) { throw "x86 csc.exe not found: $csc" } + +$gac = Join-Path $env:WINDIR "Microsoft.NET\assembly\GAC_MSIL" +$uiaClient = Get-ChildItem (Join-Path $gac "UIAutomationClient") -Recurse -Filter "UIAutomationClient.dll" | Select-Object -First 1 -ExpandProperty FullName +$uiaTypes = Get-ChildItem (Join-Path $gac "UIAutomationTypes") -Recurse -Filter "UIAutomationTypes.dll" | Select-Object -First 1 -ExpandProperty FullName +$winBase = Get-ChildItem (Join-Path $gac "WindowsBase") -Recurse -Filter "WindowsBase.dll" | Select-Object -First 1 -ExpandProperty FullName + +Write-Host "Building desktop-wake.exe (x86) ..." +& $csc /nologo /target:exe /platform:x86 /optimize+ ` + /reference:"$uiaClient" ` + /reference:"$uiaTypes" ` + /reference:"$winBase" ` + /out:"$outExe" ` + "$srcCs" +if ($LASTEXITCODE -ne 0) { throw "csc failed: $LASTEXITCODE" } + +Remove-Item $objPath -ErrorAction SilentlyContinue +Get-ChildItem $outDir -Filter "wecom_wake*.exp" | Remove-Item -ErrorAction SilentlyContinue +Get-ChildItem $outDir -Filter "wecom_wake*.lib" | Remove-Item -ErrorAction SilentlyContinue + +Write-Host "OK built:" +Get-Item $outExe, $dllTarget | Format-Table Name, Length, LastWriteTime -AutoSize +Write-Host "NOTE: WXWork is 32-bit. Fully quit WXWork before a clean N2 retest if dlls were locked." diff --git a/src/jiangchang_desktop_wake/probe.py b/src/jiangchang_desktop_wake/probe.py new file mode 100644 index 0000000..490536d --- /dev/null +++ b/src/jiangchang_desktop_wake/probe.py @@ -0,0 +1,81 @@ +"""冷/暖探测。""" + +from __future__ import annotations + +from typing import Optional, Union + +from .errors import DesktopWakeError, ERR_UIA_UNAVAILABLE +from .profiles import load_profile +from .types import ProbeResult, Profile +from . import uia_backend +from .window import ( + find_hwnd_for_profile, + get_window_class, + get_window_title, + require_windows, +) + + +def probe( + profile: Union[str, Profile] = "wecom", + *, + hwnd: Optional[int] = None, + require_uia: bool = False, +) -> ProbeResult: + """探测目标窗 UIA 是否已暖。 + + warm 判定:findall >= warm_min_findall,且 warm_markers 全部找到(若配置了 markers)。 + """ + require_windows() + prof = load_profile(profile) if isinstance(profile, str) else profile + h = find_hwnd_for_profile(prof, hwnd=hwnd) + title = get_window_title(h) + cls = get_window_class(h) + notes: list[str] = [] + + available = uia_backend.uia_available() + if require_uia and not available: + raise DesktopWakeError( + ERR_UIA_UNAVAILABLE, + "未安装 uiautomation,请在共享 Python 环境中: pip install uiautomation", + ) + + findall_count = 0 + names: list[str] = [] + if available: + findall_count, names = uia_backend.count_findall(h) + if findall_count == 0 and not names: + notes.append("uia_timeout_or_empty") + markers_found, markers_missing = uia_backend.find_markers_on_hwnd( + h, list(prof.warm_markers) + ) + # 若 Exists 未命中,用 Name 列表兜底 + if markers_missing and names: + fb_found, fb_missing = uia_backend.markers_present(names, markers_missing) + markers_found = list(dict.fromkeys(markers_found + fb_found)) + markers_missing = fb_missing + else: + notes.append("uiautomation_not_installed") + markers_found, markers_missing = [], list(prof.warm_markers) + + density_ok = findall_count >= int(prof.warm_min_findall) + markers_ok = not prof.warm_markers or not markers_missing + warm = bool(available and density_ok and markers_ok) + + if available and not density_ok: + notes.append(f"sparse_findall={findall_count}<{prof.warm_min_findall}") + if available and prof.warm_markers and markers_missing: + notes.append(f"missing_markers={','.join(markers_missing)}") + + return ProbeResult( + hwnd=h, + profile_id=prof.id, + findall_count=findall_count, + markers_found=markers_found, + markers_missing=markers_missing, + warm=warm, + window_title=title, + window_class=cls, + uia_available=available, + notes=notes, + ) diff --git a/src/jiangchang_desktop_wake/profiles.py b/src/jiangchang_desktop_wake/profiles.py new file mode 100644 index 0000000..5694ea6 --- /dev/null +++ b/src/jiangchang_desktop_wake/profiles.py @@ -0,0 +1,87 @@ +"""加载 profiles/*.json。""" + +from __future__ import annotations + +import json +from importlib import resources +from pathlib import Path +from typing import Optional + +from .errors import DesktopWakeError, ERR_PROFILE_NOT_FOUND +from .types import Profile + +_BUILTIN = { + "generic": { + "id": "generic", + "description": "通用桌面窗:仅按标题包含匹配,密度阈值较低", + "window_title_contains": [], + "warm_min_findall": 30, + "warm_markers": [], + "wake_strategies": [ + "activate", + "wm_getobject", + "msaa_from_window", + "uia_attach_walk", + ], + }, + "wecom": { + "id": "wecom", + "description": "企业微信桌面主窗", + "window_class": "WeWorkWindow", + "window_title": "企业微信", + "window_title_contains": ["企业微信"], + "warm_min_findall": 80, + "warm_markers": ["con_op", "searchedit"], + "wake_strategies": [ + "activate", + "native_helper", + "wm_getobject", + "msaa_from_window", + "uia_attach_walk", + ], + "native_helper": "desktop-wake.exe", + "native_args": ["wecom", "0x{hwnd_hex}", "--mode", "n1"], + }, +} + + +def _profiles_dir() -> Path: + return Path(__file__).resolve().parent / "profiles" + + +def list_profiles() -> list[str]: + ids = set(_BUILTIN.keys()) + d = _profiles_dir() + if d.is_dir(): + for p in d.glob("*.json"): + ids.add(p.stem) + return sorted(ids) + + +def load_profile(profile_id: str) -> Profile: + pid = (profile_id or "").strip().lower() + if not pid: + raise DesktopWakeError(ERR_PROFILE_NOT_FOUND, "profile 为空") + + path = _profiles_dir() / f"{pid}.json" + data: Optional[dict] = None + if path.is_file(): + data = json.loads(path.read_text(encoding="utf-8")) + else: + try: + pkg = resources.files("jiangchang_desktop_wake").joinpath("profiles", f"{pid}.json") + if pkg.is_file(): + data = json.loads(pkg.read_text(encoding="utf-8")) + except Exception: + data = None + + if data is None: + data = _BUILTIN.get(pid) + if data is None: + raise DesktopWakeError( + ERR_PROFILE_NOT_FOUND, + f"未知 profile={profile_id!r},可用: {', '.join(list_profiles())}", + ) + if "id" not in data: + data = {**data, "id": pid} + return Profile.from_dict(data) diff --git a/src/jiangchang_desktop_wake/profiles/generic.json b/src/jiangchang_desktop_wake/profiles/generic.json new file mode 100644 index 0000000..64a9586 --- /dev/null +++ b/src/jiangchang_desktop_wake/profiles/generic.json @@ -0,0 +1,13 @@ +{ + "id": "generic", + "description": "通用桌面窗(需自行传 --hwnd,或扩展 title_contains)", + "window_title_contains": [], + "warm_min_findall": 30, + "warm_markers": [], + "wake_strategies": [ + "activate", + "wm_getobject", + "msaa_from_window", + "uia_attach_walk" + ] +} diff --git a/src/jiangchang_desktop_wake/profiles/wecom.json b/src/jiangchang_desktop_wake/profiles/wecom.json new file mode 100644 index 0000000..2cd67f0 --- /dev/null +++ b/src/jiangchang_desktop_wake/profiles/wecom.json @@ -0,0 +1,15 @@ +{ + "id": "wecom", + "description": "企业微信桌面主窗", + "window_class": "WeWorkWindow", + "window_title": "企业微信", + "window_title_contains": ["企业微信"], + "warm_min_findall": 80, + "warm_markers": ["con_op", "searchedit"], + "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/types.py b/src/jiangchang_desktop_wake/types.py new file mode 100644 index 0000000..1326f4b --- /dev/null +++ b/src/jiangchang_desktop_wake/types.py @@ -0,0 +1,102 @@ +"""暖树公共数据类型。""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, Optional + + +@dataclass +class Profile: + """App 暖树配置(JSON profile 解析结果)。""" + + id: str + description: str = "" + window_class: Optional[str] = None + window_title: Optional[str] = None + window_title_contains: list[str] = field(default_factory=list) + warm_min_findall: int = 50 + warm_markers: list[str] = field(default_factory=list) + wake_strategies: list[str] = field( + default_factory=lambda: [ + "activate", + "wm_getobject", + "msaa_from_window", + "uia_attach_walk", + ] + ) + native_helper: Optional[str] = None + native_args: list[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Profile": + return cls( + id=str(data.get("id") or "unknown"), + description=str(data.get("description") or ""), + window_class=data.get("window_class"), + window_title=data.get("window_title"), + window_title_contains=list(data.get("window_title_contains") or []), + warm_min_findall=int(data.get("warm_min_findall") or 50), + warm_markers=list(data.get("warm_markers") or []), + wake_strategies=list( + data.get("wake_strategies") + or [ + "activate", + "wm_getobject", + "msaa_from_window", + "uia_attach_walk", + ] + ), + native_helper=data.get("native_helper"), + native_args=list(data.get("native_args") or []), + ) + + +@dataclass +class ProbeResult: + hwnd: int + profile_id: str + findall_count: int + markers_found: list[str] = field(default_factory=list) + markers_missing: list[str] = field(default_factory=list) + warm: bool = False + window_title: str = "" + window_class: str = "" + uia_available: bool = False + notes: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WakeStepResult: + strategy: str + ok: bool + detail: str = "" + status: str = "ok" # ok | fail | skipped + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class EnsureResult: + hwnd: int + profile_id: str + warm: bool + probe_before: ProbeResult + probe_after: ProbeResult + steps: list[WakeStepResult] = field(default_factory=list) + wake_applied: bool = False + + def to_dict(self) -> dict[str, Any]: + return { + "hwnd": self.hwnd, + "profile_id": self.profile_id, + "warm": self.warm, + "wake_applied": self.wake_applied, + "probe_before": self.probe_before.to_dict(), + "probe_after": self.probe_after.to_dict(), + "steps": [s.to_dict() for s in self.steps], + } diff --git a/src/jiangchang_desktop_wake/uia_backend.py b/src/jiangchang_desktop_wake/uia_backend.py new file mode 100644 index 0000000..6c7cfae --- /dev/null +++ b/src/jiangchang_desktop_wake/uia_backend.py @@ -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}" diff --git a/src/jiangchang_desktop_wake/wake.py b/src/jiangchang_desktop_wake/wake.py new file mode 100644 index 0000000..34cfa80 --- /dev/null +++ b/src/jiangchang_desktop_wake/wake.py @@ -0,0 +1,225 @@ +"""软唤醒策略梯 + native helper(N1+)。""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import time +from pathlib import Path +from typing import Callable, Optional, Tuple, Union + +from .errors import DesktopWakeError, ERR_WAKE_FAILED +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 + +_logger = logging.getLogger("jiangchang_desktop_wake.wake") + +# strategy -> (detail, status) status: ok | fail | skipped +StrategyFn = Callable[[int, Profile], Tuple[str, str]] + + +def _strategy_activate(hwnd: int, profile: Profile) -> Tuple[str, str]: + activate_hwnd(hwnd, maximize=True) + return "foreground+maximize", "ok" + + +def _strategy_wm_getobject(hwnd: int, _profile: Profile) -> Tuple[str, str]: + return uia_backend.send_wm_getobject(hwnd), "ok" + + +def _strategy_msaa(hwnd: int, _profile: Profile) -> Tuple[str, str]: + return uia_backend.msaa_from_window(hwnd), "ok" + + +def _strategy_uia_walk(hwnd: int, _profile: Profile) -> Tuple[str, str]: + return uia_backend.deep_uia_walk(hwnd), "ok" + + +def native_helper_path(name: str) -> Optional[Path]: + here = Path(__file__).resolve().parent / "native" / name + if here.is_file(): + return here + env = os.environ.get("JIANGCHANG_DESKTOP_WAKE_NATIVE") + if env: + p = Path(env) + if p.is_file(): + return p + cand = p / name + if cand.is_file(): + return cand + app_root = os.environ.get("JIANGCHANG_APP_ROOT") or os.environ.get("JIANGCHANG_SKILLS_ROOT") + if app_root: + for sub in ( + Path(app_root) / "resources" / "desktop-wake" / name, + Path(app_root) / "desktop-wake" / name, + ): + if sub.is_file(): + return sub + return None + + +def _strategy_native(hwnd: int, profile: Profile) -> Tuple[str, str]: + helper_name = profile.native_helper or "desktop-wake.exe" + path = native_helper_path(helper_name) + if path is None: + return "skipped:native_helper_missing", "skipped" + args = [] + for a in profile.native_args or ["0x{hwnd_hex}"]: + args.append( + a.replace("{hwnd}", str(int(hwnd))) + # 必须带 0x:纯 hex 如 542144 会被 C# 当成十进制,导致 window_invalid + .replace("{hwnd_hex}", format(int(hwnd), "x")) + .replace("{profile}", profile.id) + ) + cmd = [str(path), *args] + mode_n2_wake = "n2-wake" in args + # n2-wake:注入 + DuiLib 子类化,需更长超时;暖树由后续 probe 判定 + timeout_s = 60 if mode_n2_wake else 25 + _logger.info("native_helper cmd=%s timeout=%s", cmd, timeout_s) + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout_s, check=False + ) + except subprocess.TimeoutExpired: + return "timeout", "fail" + except OSError as exc: + return f"spawn_error:{exc}", "fail" + + if mode_n2_wake: + time.sleep(1.0) + + out = (proc.stdout or "").strip() + err = (proc.stderr or "").strip() + detail = f"rc={proc.returncode} out={out[:300]!r} err={err[:120]!r}" + + native_ok = False + if out: + # 取最后一行 JSON + line = out.splitlines()[-1].strip() + try: + payload = json.loads(line) + if mode_n2_wake: + # n2-wake:ok=注入成功且 UI 仍响应;是否暖树看外层 probe + native_ok = bool(payload.get("ok")) and bool( + payload.get("inject_ok", payload.get("ok")) + ) + if payload.get("ui_responsive") is False: + native_ok = False + else: + # n1/auto:ok 表示已暖 + native_ok = bool(payload.get("ok")) + detail = ( + f"rc={proc.returncode} ok={native_ok} mode={'n2-wake' if mode_n2_wake else 'other'} " + f"inject_ok={payload.get('inject_ok')} ui_responsive={payload.get('ui_responsive')} " + f"walk={payload.get('walk_count')} findall={payload.get('findall_count')} " + f"markers={payload.get('markers_found')} detail={payload.get('detail')}" + ) + except json.JSONDecodeError: + native_ok = proc.returncode == 0 + else: + native_ok = proc.returncode == 0 + + return detail, ("ok" if native_ok else "fail") + + +STRATEGIES: dict[str, StrategyFn] = { + "activate": _strategy_activate, + "wm_getobject": _strategy_wm_getobject, + "msaa_from_window": _strategy_msaa, + "uia_attach_walk": _strategy_uia_walk, + "native_helper": _strategy_native, +} + + +def wake( + profile: Union[str, Profile] = "wecom", + *, + hwnd: Optional[int] = None, + stop_when_warm: bool = True, + pause_s: float = 0.4, +) -> list[WakeStepResult]: + """按 profile.wake_strategies 依次尝试唤醒。 + + - ``skipped``:策略未真正执行(如缺 exe),不算成功 + - ``fail``:执行了但 helper/异常失败 + - 每步后若 ``stop_when_warm`` 且 probe 已暖,则提前结束 + """ + require_windows() + prof = load_profile(profile) if isinstance(profile, str) else profile + h = find_hwnd_for_profile(prof, hwnd=hwnd) + steps: list[WakeStepResult] = [] + + for name in prof.wake_strategies: + fn = STRATEGIES.get(name) + if fn is None: + steps.append( + WakeStepResult(strategy=name, ok=False, status="fail", detail="unknown_strategy") + ) + continue + try: + detail, status = fn(h, prof) + steps.append( + WakeStepResult( + strategy=name, + ok=(status == "ok"), + status=status, + detail=detail, + ) + ) + if status == "skipped": + continue + except Exception as exc: + _logger.debug("strategy %s failed: %s", name, exc) + steps.append( + WakeStepResult(strategy=name, ok=False, status="fail", detail=str(exc)) + ) + continue + + time.sleep(pause_s) + if stop_when_warm: + try: + pr = probe(prof, hwnd=h) + if pr.warm: + steps.append( + WakeStepResult( + strategy="probe_check", + ok=True, + status="ok", + detail=f"warm after {name} findall={pr.findall_count}", + ) + ) + break + except Exception as exc: + steps.append( + WakeStepResult( + strategy="probe_check", + ok=False, + status="fail", + detail=str(exc), + ) + ) + + return steps + + +def wake_or_raise( + profile: Union[str, Profile] = "wecom", + *, + hwnd: Optional[int] = None, +) -> list[WakeStepResult]: + steps = wake(profile, hwnd=hwnd) + prof = load_profile(profile) if isinstance(profile, str) else profile + h = find_hwnd_for_profile(prof, hwnd=hwnd) + pr = probe(prof, hwnd=h) + if not pr.warm: + raise DesktopWakeError( + ERR_WAKE_FAILED, + f"唤醒后仍冷 findall={pr.findall_count} missing={pr.markers_missing} " + f"steps={[s.strategy + ':' + s.status for s in steps]}", + ) + return steps diff --git a/src/jiangchang_desktop_wake/window.py b/src/jiangchang_desktop_wake/window.py new file mode 100644 index 0000000..9d9bdb5 --- /dev/null +++ b/src/jiangchang_desktop_wake/window.py @@ -0,0 +1,148 @@ +"""Win32 窗口枚举 / 激活(不依赖 uiautomation)。""" + +from __future__ import annotations + +import ctypes +import os +import time +from ctypes import wintypes +from typing import Optional + +from .errors import DesktopWakeError, ERR_UNSUPPORTED_OS, ERR_WINDOW_NOT_FOUND +from .types import Profile + +SW_RESTORE = 9 +SW_MAXIMIZE = 3 +SW_SHOW = 5 + + +def _user32(): + return ctypes.windll.user32 + + +def require_windows() -> None: + if os.name != "nt": + raise DesktopWakeError(ERR_UNSUPPORTED_OS, "桌面暖树仅支持 Windows") + + +def get_window_title(hwnd: int) -> str: + require_windows() + user32 = _user32() + length = user32.GetWindowTextLengthW(hwnd) + buf = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, buf, length + 1) + return buf.value or "" + + +def get_window_class(hwnd: int) -> str: + require_windows() + buf = ctypes.create_unicode_buffer(256) + _user32().GetClassNameW(hwnd, buf, 256) + return buf.value or "" + + +def window_area(hwnd: int) -> int: + require_windows() + rect = wintypes.RECT() + if not _user32().GetWindowRect(hwnd, ctypes.byref(rect)): + return 0 + return max(0, rect.right - rect.left) * max(0, rect.bottom - rect.top) + + +def enum_hwnds( + *, + class_name: Optional[str] = None, + title: Optional[str] = None, + title_contains: Optional[list[str]] = None, +) -> list[int]: + """按 class / 精确标题 / 标题包含枚举可见顶层窗。""" + require_windows() + user32 = _user32() + found: list[int] = [] + contains = [c for c in (title_contains or []) if c] + + @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + def enum_cb(hwnd, _lparam): + if not user32.IsWindowVisible(hwnd): + return True + if class_name: + cls = ctypes.create_unicode_buffer(256) + user32.GetClassNameW(hwnd, cls, 256) + if cls.value != class_name: + return True + win_title = get_window_title(int(hwnd)) + if title is not None and win_title != title: + return True + if contains and not any(c in win_title for c in contains): + return True + found.append(int(hwnd)) + return True + + user32.EnumWindows(enum_cb, 0) + return found + + +def pick_largest(hwnds: list[int]) -> int: + best = hwnds[0] + best_area = -1 + for h in hwnds: + area = window_area(h) + if area > best_area: + best_area = area + best = h + return best + + +def find_hwnd_for_profile(profile: Profile, hwnd: Optional[int] = None) -> int: + require_windows() + if hwnd is not None: + if not _user32().IsWindow(hwnd): + raise DesktopWakeError(ERR_WINDOW_NOT_FOUND, f"hwnd 无效: {hwnd}") + return int(hwnd) + + has_filter = bool( + profile.window_class + or profile.window_title + or profile.window_title_contains + ) + if not has_filter: + raise DesktopWakeError( + ERR_WINDOW_NOT_FOUND, + f"profile={profile.id} 未配置窗口过滤条件,请传 hwnd=", + ) + + hwnds = enum_hwnds( + class_name=profile.window_class, + title=profile.window_title, + title_contains=profile.window_title_contains or None, + ) + if not hwnds and profile.window_title and profile.window_class: + # 放宽:仅 class + hwnds = enum_hwnds(class_name=profile.window_class) + if not hwnds and profile.window_class: + hwnds = enum_hwnds(class_name=profile.window_class) + if not hwnds and profile.window_title_contains: + hwnds = enum_hwnds(title_contains=profile.window_title_contains) + if not hwnds: + raise DesktopWakeError( + ERR_WINDOW_NOT_FOUND, + f"未找到窗口 profile={profile.id} class={profile.window_class!r} " + f"title={profile.window_title!r}", + ) + return pick_largest(hwnds) if len(hwnds) > 1 else hwnds[0] + + +def activate_hwnd(hwnd: int, *, maximize: bool = False) -> None: + require_windows() + user32 = _user32() + try: + if user32.IsIconic(hwnd): + user32.ShowWindow(hwnd, SW_RESTORE) + elif maximize and not user32.IsZoomed(hwnd): + user32.ShowWindow(hwnd, SW_MAXIMIZE) + else: + user32.ShowWindow(hwnd, SW_SHOW) + except Exception: + pass + user32.SetForegroundWindow(hwnd) + time.sleep(0.35 if maximize else 0.2) diff --git a/tests/desktop_wake/test_profiles_and_types.py b/tests/desktop_wake/test_profiles_and_types.py new file mode 100644 index 0000000..d332191 --- /dev/null +++ b/tests/desktop_wake/test_profiles_and_types.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +"""jiangchang_desktop_wake:profile / 判定逻辑(不依赖真实企微窗)。""" + +from __future__ import annotations + +import unittest + +from jiangchang_desktop_wake.profiles import list_profiles, load_profile +from jiangchang_desktop_wake.types import ProbeResult, Profile +from jiangchang_desktop_wake.uia_backend import markers_present + + +class ProfileTests(unittest.TestCase): + def test_list_contains_wecom(self): + ids = list_profiles() + self.assertIn("wecom", ids) + self.assertIn("generic", ids) + + def test_load_wecom(self): + p = load_profile("wecom") + self.assertEqual(p.id, "wecom") + self.assertEqual(p.window_class, "WeWorkWindow") + self.assertIn("con_op", p.warm_markers) + self.assertGreaterEqual(p.warm_min_findall, 50) + self.assertIn("native_helper", p.wake_strategies) + self.assertIn("n2-wake", p.native_args or []) + + def test_unknown_profile(self): + from jiangchang_desktop_wake.errors import DesktopWakeError + + with self.assertRaises(DesktopWakeError): + load_profile("no-such-app-xyz") + + +class MarkerTests(unittest.TestCase): + def test_markers_present(self): + found, missing = markers_present(["con_op", "foo"], ["con_op", "searchedit"]) + self.assertEqual(found, ["con_op"]) + self.assertEqual(missing, ["searchedit"]) + + +class ProbeWarmLogicTests(unittest.TestCase): + def test_probe_result_dict(self): + r = ProbeResult( + hwnd=1, + profile_id="wecom", + findall_count=3, + warm=False, + uia_available=True, + ) + d = r.to_dict() + self.assertEqual(d["findall_count"], 3) + self.assertFalse(d["warm"]) + + def test_profile_from_dict_defaults(self): + p = Profile.from_dict({"id": "x"}) + self.assertEqual(p.id, "x") + self.assertTrue(p.wake_strategies) + + def test_wecom_prefers_native_helper(self): + p = load_profile("wecom") + self.assertEqual(p.wake_strategies[0], "activate") + self.assertEqual(p.wake_strategies[1], "native_helper") + + def test_native_exe_present(self): + from pathlib import Path + import jiangchang_desktop_wake as pkg + + native = Path(pkg.__file__).resolve().parent / "native" + exe = native / "desktop-wake.exe" + dll = native / "wecom_wake.dll" + self.assertTrue(exe.is_file(), f"missing {exe}; run native_src/build.ps1") + self.assertTrue(dll.is_file(), f"missing {dll}; run native_src/build.ps1") + + +if __name__ == "__main__": + unittest.main()