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 0000000..c3b8007 Binary files /dev/null and b/src/jiangchang_desktop_wake/native/desktop-wake.exe differ 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 0000000..2afe17a Binary files /dev/null and b/src/jiangchang_desktop_wake/native/wecom_wake.dll differ 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()