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
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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -6,3 +6,9 @@ __pycache__/
|
|||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
python-runtime/.venv/
|
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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 1. 项目简介
|
## 1. 项目简介
|
||||||
|
|
||||||
匠厂平台共享 Python SDK:在同一发行包中提供 **Skill 侧通用能力**(权益校验、统一日志与运行环境解析)与 **桌面应用自动化能力**(通过 CDP 连接匠厂客户端,驱动聊天与断言)。
|
匠厂平台共享 Python SDK:在同一发行包中提供 **Skill 侧通用能力**(权益校验、统一日志与运行环境解析)、**桌面应用自动化能力**(通过 CDP 连接匠厂客户端),以及 **Windows 桌面 UIA 暖树**(企微等 DuiLib 冷树唤醒)。
|
||||||
|
|
||||||
## 2. 安装方式
|
## 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_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_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. 快速开始
|
## 4. 快速开始
|
||||||
|
|
||||||
@@ -324,8 +325,11 @@ python -m pip install --upgrade \
|
|||||||
```bash
|
```bash
|
||||||
python -c "from jiangchang_desktop_sdk.e2e_helpers import send_prompt_via_composer; print('e2e_helpers OK')"
|
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 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/`)。
|
仓库内还保留 `examples/`、`tools/`、`python-runtime/` 目录及既有 Skill / 前端相关的 GitHub Actions 工作流,与本 PyPI 包的发布彼此独立。`tools/screencast/` 为兼容薄壳,pip 包内实现位于 `screencast` 包(`src/screencast/`)。
|
||||||
|
|
||||||
## v0.2.0 — data-jcid migration (internal refactor)
|
## v0.2.0 — data-jcid migration (internal refactor)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jiangchang-platform-kit"
|
name = "jiangchang-platform-kit"
|
||||||
version = "1.2.2"
|
version = "1.2.3"
|
||||||
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
|
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -21,3 +21,12 @@ Repository = "https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit"
|
|||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
jiangchang_desktop_wake = [
|
||||||
|
"profiles/*.json",
|
||||||
|
"native/*",
|
||||||
|
"native_src/**/*",
|
||||||
|
"README.md",
|
||||||
|
"ACCEPTANCE.md",
|
||||||
|
]
|
||||||
|
|||||||
90
src/jiangchang_desktop_wake/ACCEPTANCE.md
Normal file
90
src/jiangchang_desktop_wake/ACCEPTANCE.md
Normal 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`。
|
||||||
40
src/jiangchang_desktop_wake/README.md
Normal file
40
src/jiangchang_desktop_wake/README.md
Normal 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)。
|
||||||
41
src/jiangchang_desktop_wake/__init__.py
Normal file
41
src/jiangchang_desktop_wake/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
156
src/jiangchang_desktop_wake/__main__.py
Normal file
156
src/jiangchang_desktop_wake/__main__.py
Normal 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())
|
||||||
68
src/jiangchang_desktop_wake/c2.py
Normal file
68
src/jiangchang_desktop_wake/c2.py
Normal 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 "",
|
||||||
|
}
|
||||||
60
src/jiangchang_desktop_wake/ensure.py
Normal file
60
src/jiangchang_desktop_wake/ensure.py
Normal 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}",
|
||||||
|
)
|
||||||
30
src/jiangchang_desktop_wake/errors.py
Normal file
30
src/jiangchang_desktop_wake/errors.py
Normal 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}")
|
||||||
79
src/jiangchang_desktop_wake/inject.py
Normal file
79
src/jiangchang_desktop_wake/inject.py
Normal 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
|
||||||
32
src/jiangchang_desktop_wake/native/README.md
Normal file
32
src/jiangchang_desktop_wake/native/README.md
Normal 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`。
|
||||||
BIN
src/jiangchang_desktop_wake/native/desktop-wake.exe
Normal file
BIN
src/jiangchang_desktop_wake/native/desktop-wake.exe
Normal file
Binary file not shown.
BIN
src/jiangchang_desktop_wake/native/wecom_wake.dll
Normal file
BIN
src/jiangchang_desktop_wake/native/wecom_wake.dll
Normal file
Binary file not shown.
695
src/jiangchang_desktop_wake/native_src/DesktopWake/Program.cs
Normal file
695
src/jiangchang_desktop_wake/native_src/DesktopWake/Program.cs
Normal 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("\"", "\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/jiangchang_desktop_wake/native_src/N2/README.md
Normal file
39
src/jiangchang_desktop_wake/native_src/N2/README.md
Normal 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 补丁。
|
||||||
36
src/jiangchang_desktop_wake/native_src/N2/RESEARCH_C1.md
Normal file
36
src/jiangchang_desktop_wake/native_src/N2/RESEARCH_C1.md
Normal 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` 不要当主路径
|
||||||
600
src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.cpp
Normal file
600
src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.cpp
Normal 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
EXPORTS
|
||||||
|
WakeProvider
|
||||||
|
WakeTarget
|
||||||
|
JcCallWndProc
|
||||||
80
src/jiangchang_desktop_wake/native_src/build.ps1
Normal file
80
src/jiangchang_desktop_wake/native_src/build.ps1
Normal 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."
|
||||||
81
src/jiangchang_desktop_wake/probe.py
Normal file
81
src/jiangchang_desktop_wake/probe.py
Normal 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,
|
||||||
|
)
|
||||||
87
src/jiangchang_desktop_wake/profiles.py
Normal file
87
src/jiangchang_desktop_wake/profiles.py
Normal 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)
|
||||||
13
src/jiangchang_desktop_wake/profiles/generic.json
Normal file
13
src/jiangchang_desktop_wake/profiles/generic.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
15
src/jiangchang_desktop_wake/profiles/wecom.json
Normal file
15
src/jiangchang_desktop_wake/profiles/wecom.json
Normal 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"]
|
||||||
|
}
|
||||||
102
src/jiangchang_desktop_wake/types.py
Normal file
102
src/jiangchang_desktop_wake/types.py
Normal 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],
|
||||||
|
}
|
||||||
268
src/jiangchang_desktop_wake/uia_backend.py
Normal file
268
src/jiangchang_desktop_wake/uia_backend.py
Normal 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}"
|
||||||
225
src/jiangchang_desktop_wake/wake.py
Normal file
225
src/jiangchang_desktop_wake/wake.py
Normal 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
|
||||||
148
src/jiangchang_desktop_wake/window.py
Normal file
148
src/jiangchang_desktop_wake/window.py
Normal 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)
|
||||||
77
tests/desktop_wake/test_profiles_and_types.py
Normal file
77
tests/desktop_wake/test_profiles_and_types.py
Normal 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()
|
||||||
Reference in New Issue
Block a user