feat(desktop-wake): add WeCom UIA warm-tree and ship n2-wake in 1.2.3
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 29s

Expose ensure_ui_ready via DuiLib GetAccessible inject path; package x86 native helpers for host/skill consumers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-02 08:42:38 +08:00
parent 3dc46b0555
commit 71091e38a9
29 changed files with 3088 additions and 3 deletions

6
.gitignore vendored
View File

@@ -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

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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`。

View File

@@ -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)。

View File

@@ -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",
]

View File

@@ -0,0 +1,156 @@
"""CLI:python -m jiangchang_desktop_wake <probe|inject|wake|ensure|profiles>"""
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())

View File

@@ -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 "",
}

View File

@@ -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}",
)

View File

@@ -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}")

View File

@@ -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

View File

@@ -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<hwnd_hex> --mode n2-wake
```
| mode | 用途 |
|---|---|
| `n1` | 仅进程外 UIA(冷树无效) |
| `n2` | 仅注入探针 |
| `n2-wake` | **主路径**:注入 + WakeProvider(ensure 使用) |
| `n2-hook` | 危险,默认禁止 |
验收见 `../ACCEPTANCE.md`。

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,695 @@
// desktop-wake.exe — N1 UIA 客户端 + N2 注入/钩子编排(须 x86 以匹配 WXWork)
// 用法: desktop-wake.exe <profile> <hwnd> [--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 <profile> <hwnd> [--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<string> FindMarkers(AutomationElement root, string[] markers)
{
var found = new List<string>();
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("\"", "\\\"");
}
}
}

View File

@@ -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 <hwnd_hex> --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 补丁。

View File

@@ -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` 不要当主路径

View File

@@ -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 <windows.h>
#include <commctrl.h>
#include <oleacc.h>
#include <UIAutomationClient.h>
#include <stdio.h>
#include <string.h>
#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;
}

View File

@@ -0,0 +1,4 @@
EXPORTS
WakeProvider
WakeTarget
JcCallWndProc

View File

@@ -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."

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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"
]
}

View File

@@ -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"]
}

View File

@@ -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],
}

View File

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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()