Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9f5c37af8 | |||
| 065d341320 | |||
| 24384d70ba | |||
| 17ac1b4e11 | |||
| 84157758e6 | |||
| c75eaa701b | |||
| 6d3c353967 | |||
| 9451bef753 | |||
| 71091e38a9 | |||
| 3dc46b0555 | |||
| 54b75c641e | |||
| cf5e74d80a | |||
| 1c51139883 | |||
| 1632828372 | |||
| f7fc33fa4a | |||
| 5a0b93448c | |||
| 367df10755 | |||
| 857e7e3d73 | |||
| 0e2b73e1ad | |||
| 36a411810d | |||
| c9398451d0 | |||
| 2a542c0be6 | |||
| e59661f2d8 |
10
.github/workflows/publish.yml
vendored
10
.github/workflows/publish.yml
vendored
@@ -4,9 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -25,13 +22,6 @@ jobs:
|
||||
steps:
|
||||
- uses: https://git.jc2009.com/admin/actions-checkout@v4
|
||||
|
||||
- name: Resolve and patch package version
|
||||
env:
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
GITHUB_RUN_NUMBER: ${{ github.run_number }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: python3.12 tools/ci_set_package_version.py
|
||||
|
||||
- name: Install build tools
|
||||
run: python3.12 -m pip install --upgrade build twine --break-system-packages
|
||||
|
||||
|
||||
155
.github/workflows/reusable-release-skill.yaml
vendored
155
.github/workflows/reusable-release-skill.yaml
vendored
@@ -108,6 +108,19 @@ jobs:
|
||||
' shutil.copytree(src, dst, ignore=ignore_fn, dirs_exist_ok=True)' \
|
||||
'' \
|
||||
"shutil.copy2('SKILL.md', os.path.join(PACKAGE, 'SKILL.md'))" \
|
||||
"readme_src = os.path.abspath('README.md')" \
|
||||
"readme_dst = os.path.join(PACKAGE, 'README.md')" \
|
||||
'if os.path.isfile(readme_src):' \
|
||||
" shutil.copy2(readme_src, readme_dst)" \
|
||||
" print('Copied README.md')" \
|
||||
'if os.path.isfile(readme_src) and not os.path.isfile(readme_dst):' \
|
||||
" raise RuntimeError('README.md exists in skill root but was not packaged')" \
|
||||
'for market_doc in ("TUTORIAL.md", "DEMO.md", "CHANGELOG.md"):' \
|
||||
' src = os.path.abspath(market_doc)' \
|
||||
' dst = os.path.join(PACKAGE, market_doc)' \
|
||||
' if os.path.isfile(src):' \
|
||||
' shutil.copy2(src, dst)' \
|
||||
" print(f'Copied {market_doc}')" \
|
||||
"copy_dir('references', os.path.join(PACKAGE, 'references'), references_ignore)" \
|
||||
"copy_dir('assets', os.path.join(PACKAGE, 'assets'), common_ignore)" \
|
||||
"copy_dir('tests', os.path.join(PACKAGE, 'tests'), common_ignore)" \
|
||||
@@ -137,49 +150,119 @@ jobs:
|
||||
- name: Parse Metadata and Pack
|
||||
id: build_task
|
||||
run: |
|
||||
python3.12 -c "
|
||||
import frontmatter, os, json, shutil
|
||||
post = frontmatter.load('SKILL.md')
|
||||
cat > /tmp/parse_skill_release_metadata.py <<'PY'
|
||||
import frontmatter
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
|
||||
def load_md(path: str):
|
||||
post = frontmatter.load(path)
|
||||
meta = post.metadata or {}
|
||||
body = (post.content or "").strip()
|
||||
return meta if isinstance(meta, dict) else {}, body
|
||||
|
||||
|
||||
def extract_changelog_for_version(text: str, version: str) -> str | None:
|
||||
"""Extract Keep-a-Changelog / plain ## version section; None if not found."""
|
||||
if not text or not version:
|
||||
return None
|
||||
ver = re.escape(version.lstrip("vV"))
|
||||
pattern = re.compile(
|
||||
rf"^##\s*\[?v?{ver}\]?([^\n]*)$",
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return None
|
||||
start = match.end()
|
||||
next_h2 = re.search(r"^##\s+", text[start:], re.MULTILINE)
|
||||
end = start + next_h2.start() if next_h2 else len(text)
|
||||
return text[start:end].strip()
|
||||
|
||||
|
||||
post = frontmatter.load("SKILL.md")
|
||||
metadata = dict(post.metadata or {})
|
||||
skill_readme_md = (post.content or '').strip()
|
||||
skill_description = metadata.get('description')
|
||||
metadata['readme_md'] = skill_readme_md
|
||||
readme_path = os.path.join('references', 'README.md')
|
||||
if os.path.isfile(readme_path):
|
||||
skill_readme_md = (post.content or "").strip()
|
||||
skill_description = metadata.get("description")
|
||||
metadata["readme_md"] = skill_readme_md
|
||||
|
||||
if os.path.isfile("README.md"):
|
||||
try:
|
||||
readme_post = frontmatter.load(readme_path)
|
||||
body = (readme_post.content or '').strip()
|
||||
rm_meta = readme_post.metadata or {}
|
||||
desc = rm_meta.get('description')
|
||||
rm_meta, body = load_md("README.md")
|
||||
desc = rm_meta.get("description")
|
||||
if desc is not None and str(desc).strip():
|
||||
metadata['description'] = str(desc).strip()
|
||||
metadata["description"] = str(desc).strip()
|
||||
if body:
|
||||
metadata['readme_md'] = body
|
||||
metadata["readme_md"] = body
|
||||
except Exception:
|
||||
pass
|
||||
if not (metadata.get('readme_md') or '').strip():
|
||||
metadata['readme_md'] = skill_readme_md
|
||||
if skill_description is not None and not str(metadata.get('description', '') or '').strip():
|
||||
metadata['description'] = skill_description
|
||||
openclaw_meta = metadata.get('metadata', {}).get('openclaw', {})
|
||||
slug = (openclaw_meta.get('slug') or metadata.get('slug') or metadata.get('name') or '').strip()
|
||||
|
||||
if not (metadata.get("readme_md") or "").strip():
|
||||
metadata["readme_md"] = skill_readme_md
|
||||
if skill_description is not None and not str(metadata.get("description", "") or "").strip():
|
||||
metadata["description"] = skill_description
|
||||
|
||||
# Market tabs: only set keys when files exist (backend updates only present keys).
|
||||
if os.path.isfile("TUTORIAL.md"):
|
||||
try:
|
||||
_tm_meta, tutorial_body = load_md("TUTORIAL.md")
|
||||
metadata["tutorial_md"] = tutorial_body
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to parse TUTORIAL.md: {exc}") from exc
|
||||
|
||||
if os.path.isfile("DEMO.md"):
|
||||
try:
|
||||
demo_meta, demo_body = load_md("DEMO.md")
|
||||
metadata["demo_md"] = demo_body
|
||||
url = demo_meta.get("demo_video_url")
|
||||
metadata["demo_video_url"] = (
|
||||
str(url).strip() if url is not None and str(url).strip() else ""
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to parse DEMO.md: {exc}") from exc
|
||||
|
||||
openclaw_meta = metadata.get("metadata", {}).get("openclaw", {})
|
||||
if not isinstance(openclaw_meta, dict):
|
||||
openclaw_meta = {}
|
||||
slug = (
|
||||
openclaw_meta.get("slug")
|
||||
or metadata.get("slug")
|
||||
or metadata.get("name")
|
||||
or ""
|
||||
).strip()
|
||||
if not slug:
|
||||
raise Exception('SKILL.md 缺少 slug/name')
|
||||
ref_name = (os.environ.get('GITHUB_REF_NAME') or '').strip()
|
||||
if not ref_name.startswith('v'):
|
||||
raise Exception(f'非法标签: {ref_name}')
|
||||
version = ref_name.lstrip('v')
|
||||
metadata['version'] = version
|
||||
artifact_platform = (os.environ.get('ARTIFACT_PLATFORM') or 'windows').strip()
|
||||
zip_base = f'{slug}-{artifact_platform}'
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
|
||||
f.write(f'slug={slug}\n')
|
||||
f.write(f'version={version}\n')
|
||||
f.write(f'zip_base={zip_base}\n')
|
||||
f.write(f'artifact_platform={artifact_platform}\n')
|
||||
f.write(f'metadata={json.dumps(metadata, ensure_ascii=False)}\n')
|
||||
shutil.make_archive(zip_base, 'zip', 'dist/package')
|
||||
"
|
||||
raise Exception("SKILL.md 缺少 slug/name")
|
||||
|
||||
ref_name = (os.environ.get("GITHUB_REF_NAME") or "").strip()
|
||||
if not ref_name.startswith("v"):
|
||||
raise Exception(f"非法标签: {ref_name}")
|
||||
version = ref_name.lstrip("v")
|
||||
metadata["version"] = version
|
||||
|
||||
if os.path.isfile("CHANGELOG.md"):
|
||||
try:
|
||||
with open("CHANGELOG.md", encoding="utf-8") as fh:
|
||||
changelog_text = fh.read()
|
||||
section = extract_changelog_for_version(changelog_text, version)
|
||||
if section is not None:
|
||||
metadata["changelog"] = section
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to parse CHANGELOG.md: {exc}") from exc
|
||||
|
||||
artifact_platform = (os.environ.get("ARTIFACT_PLATFORM") or "windows").strip()
|
||||
zip_base = f"{slug}-{artifact_platform}"
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
f.write(f"slug={slug}\n")
|
||||
f.write(f"version={version}\n")
|
||||
f.write(f"zip_base={zip_base}\n")
|
||||
f.write(f"artifact_platform={artifact_platform}\n")
|
||||
f.write(f"metadata={json.dumps(metadata, ensure_ascii=False)}\n")
|
||||
shutil.make_archive(zip_base, "zip", "dist/package")
|
||||
PY
|
||||
python3.12 /tmp/parse_skill_release_metadata.py
|
||||
|
||||
- name: Sync Database
|
||||
env:
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -6,3 +6,9 @@ __pycache__/
|
||||
build/
|
||||
dist/
|
||||
python-runtime/.venv/
|
||||
@AutomationLog.txt
|
||||
|
||||
# desktop_wake:编译换名残留,发版只保留 wecom_wake.dll
|
||||
src/jiangchang_desktop_wake/native/wecom_wake_*.dll
|
||||
src/jiangchang_desktop_wake/native/*.exp
|
||||
src/jiangchang_desktop_wake/native/*.lib
|
||||
|
||||
243
README.md
243
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## 1. 项目简介
|
||||
|
||||
匠厂平台共享 Python SDK:在同一发行包中提供 **Skill 侧通用能力**(权益校验、统一日志与运行环境解析)与 **桌面应用自动化能力**(通过 CDP 连接匠厂客户端,驱动聊天与断言)。
|
||||
匠厂平台共享 Python SDK:在同一发行包中提供 **Skill 侧通用能力**(权益校验、统一日志与运行环境解析)、**桌面应用自动化能力**(通过 CDP 连接匠厂客户端),以及 **Windows 桌面 UIA 暖树**(企微等 DuiLib 冷树唤醒)。
|
||||
|
||||
## 2. 安装方式
|
||||
|
||||
@@ -12,8 +12,9 @@ pip install jiangchang-platform-kit --index-url https://git.jc2009.com/api/packa
|
||||
|
||||
## 3. 包含的模块
|
||||
|
||||
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、共享媒体资源(`media_assets`)等,供 Skill CLI / 服务进程使用。
|
||||
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、**Skill Activity Stream / Job 运行时**(`emit` / `finish` / Run Journal)、兄弟技能 CLI 桥(`sibling_bridge`)、共享媒体资源(`media_assets`)、**RPA 有界就绪等待**(`rpa.poll_until`)、可选浏览器启动超时与只读机器供电/限频探测(`machine_power`,不阻断 health)等,供 Skill CLI / 服务进程使用。
|
||||
- **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API)。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright,以便 `import playwright` 可用。
|
||||
- **jiangchang_desktop_wake**:Windows 桌面 UIA 暖树。技能/拾取请用 `ensure_ui_ready("wecom")`(内部 `n2-wake`:注入 + DuiLib GetAccessible)。随包携带 x86 `native/desktop-wake.exe` 与 `wecom_wake.dll`。详见 `src/jiangchang_desktop_wake/README.md`。探测深遍历需另装 `uiautomation`。
|
||||
|
||||
## 4. 快速开始
|
||||
|
||||
@@ -56,7 +57,7 @@ finally:
|
||||
|
||||
### 桌面 E2E(`jiangchang_desktop_sdk.e2e_helpers`)
|
||||
|
||||
Skill 的桌面端到端测试应通过宿主 IPC 获取**真实**当前用户数据目录,不要在 pytest 进程里手工设置 `JIANGCHANG_USER_ID` / `CLAW_USER_ID` 来伪造用户(否则会回退到 `_anon`)。
|
||||
Skill 的桌面端到端测试应通过宿主 IPC 获取**真实**当前用户数据目录,不要在 pytest 进程里手工设置 `JIANGCHANG_USER_ID` 来伪造用户(否则会回退到 `_anon`)。
|
||||
|
||||
推荐 preflight 流程:
|
||||
|
||||
@@ -78,6 +79,163 @@ assert_skill_env_file(ctx, ["API_KEY"])
|
||||
send_prompt_via_composer(page, "第一行\n第二行")
|
||||
```
|
||||
|
||||
## Skill Activity Stream / Job 运行时
|
||||
|
||||
长任务(批量生成、RPA)需要把**逐步进度**推到匠厂宿主 UI,而不能依赖 Agent 轮询或 OpenClaw stdout patch。`jiangchang_skill_core.activity` 提供 **Skill Activity Stream (SAS) v1** 契约:
|
||||
|
||||
| API | 作用 |
|
||||
|-----|------|
|
||||
| `emit(text, ...)` | 写 Run Journal(`activity` / `progress` / `warn` / `debug`) |
|
||||
| `step(text)` | `emit(type='activity')` 别名 |
|
||||
| `finish(status=..., **fields)` | 写终态事件 + **唯一**向 stdout 输出单行 result JSON |
|
||||
| `rpa_step(name)` | 装饰器:闸门 + 进入/成功/失败自动 emit |
|
||||
| `checkpoint()` | consult control(不写步骤事件) |
|
||||
| `interruptible_sleep(sec)` | 可暂停/停止的 async sleep |
|
||||
| `JobStopped` | 宿主 stop 时抛出 |
|
||||
| `job_context(...)` | 可选包裹 main;异常时自动 `finish(failed)` |
|
||||
|
||||
### Run Journal 路径(宿主 tail 此文件)
|
||||
|
||||
```
|
||||
{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl
|
||||
```
|
||||
|
||||
- `job_id`:优先 `JIANGCHANG_JOB_ID`,否则进程内 `uuid4().hex`(首次 emit 时生成并缓存)
|
||||
- `JIANGCHANG_RUN_MODE`:`job` | `cli`(默认 `cli`),首行 meta 事件记录
|
||||
- NDJSON,UTF-8,append-only,每行 flush
|
||||
- `debug` 类型仅当 `JIANGCHANG_ACTIVITY_DEBUG=1` 时写入
|
||||
|
||||
### 与 Agent / OpenClaw 的分工
|
||||
|
||||
- **`emit()` / journal**:永不 print 到 stdout,避免 bash tool 结果淹没 Agent context
|
||||
- **`finish()`**:唯一允许向 stdout 写业务结果的 API;单行 compact JSON,`status` + 摘要字段,默认 ≤4096 字符
|
||||
- 技能里手写 `print("OK")`、pretty JSON 应逐步改为 `finish()`;RPA 步骤用 `emit("打开浏览器")` 或 `@rpa_step`
|
||||
|
||||
### GEO batch 示例
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core.activity import emit, finish
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
emit(f"生成 {item['title']}", type="progress", current=i, total=len(items), skill="geo-batch")
|
||||
# ... business logic ...
|
||||
|
||||
finish(status="success", skill="geo-batch", generated=len(items))
|
||||
```
|
||||
|
||||
### RPA 步骤示例
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core.activity import emit, rpa_step
|
||||
|
||||
@rpa_step("打开浏览器")
|
||||
def open_browser(page):
|
||||
page.goto("https://example.com")
|
||||
|
||||
emit("手动步骤说明", skill="publish-toutiao", stage="rpa")
|
||||
```
|
||||
|
||||
`RpaVideoSession.add_step()` 在录屏字幕之外默认同步 `emit(..., stage="rpa.video")`;可通过 `emit_activity=False` 关闭。
|
||||
|
||||
## Skill Runtime Control Protocol (SRCP) v1 — 1.2.0+
|
||||
|
||||
宿主通过 **control 文件**向技能进程下发暂停 / 继续 / 停止;platform-kit 在**步骤闸门**自动读取,技能作者无需手写控制逻辑。
|
||||
|
||||
### control.json(宿主写、kit 读)
|
||||
|
||||
```
|
||||
{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.control.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"command": "none",
|
||||
"reason": "user",
|
||||
"ts": 1730000000000,
|
||||
"nonce": 1
|
||||
}
|
||||
```
|
||||
|
||||
| command | 行为 |
|
||||
|---------|------|
|
||||
| `none` | 正常运行 |
|
||||
| `pause` | 下一步闸门进入暂停(阻塞直到 resume 或 stop) |
|
||||
| `resume` | 从暂停恢复 |
|
||||
| `stop` | 协作式停止,抛出 `JobStopped` |
|
||||
|
||||
宿主更新命令时应递增 `nonce`(kit 每次闸门重新读文件,不依赖缓存)。
|
||||
|
||||
### 步骤闸门(自动挂载)
|
||||
|
||||
| 调用点 | 闸门行为 |
|
||||
|--------|----------|
|
||||
| `emit()` / `step()` | 进入步骤前 gate + 写 Journal |
|
||||
| `@rpa_step` | 进入步骤前 gate,再写 ▶ / ✓ |
|
||||
| `RpaVideoSession.add_step()` | 经 `emit()` 自动 gate |
|
||||
| `interruptible_sleep()` | 每 200ms 切片 `checkpoint()`(可暂停/停止) |
|
||||
|
||||
暂停在**当前步骤边界**生效(与影刀「当前指令执行完后暂停」一致),不在 DOM 原子操作中途冻结。
|
||||
|
||||
### lifecycle Journal 事件
|
||||
|
||||
闸门写入 `type: lifecycle` 事件,供宿主任务中心显示运行 / 暂停 / 继续状态:
|
||||
|
||||
```json
|
||||
{"type":"lifecycle","status":"paused","text":"已暂停,等待继续","step_index":3,"step_name":"打开页面",...}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
| API | 作用 |
|
||||
|-----|------|
|
||||
| `checkpoint()` | 仅 consult control(长等待循环内使用) |
|
||||
| `interruptible_sleep(sec)` | async 可中断 sleep |
|
||||
| `interruptible_sleep_sync(sec)` | sync 可中断 sleep |
|
||||
| `JobStopped` | 宿主 stop 时抛出;`job_context` 自动 `finish(..., error_code="JOB_STOPPED")` |
|
||||
| `get_control_path()` | 当前 job 的 control 文件路径(诊断) |
|
||||
|
||||
环境变量:
|
||||
|
||||
- `JIANGCHANG_CONTROL_POLL_SEC` — 暂停轮询间隔(默认 `0.2`)
|
||||
- `JIANGCHANG_CONTROL_DISABLED=1` — 禁用闸门(仅测试)
|
||||
|
||||
### RPA 示例(拆步 + 可中断等待)
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core.activity import (
|
||||
emit,
|
||||
finish,
|
||||
job_context,
|
||||
interruptible_sleep,
|
||||
rpa_step,
|
||||
)
|
||||
|
||||
def cmd_run(...):
|
||||
with job_context(skill="demo-skill"):
|
||||
emit("开始任务", skill="demo-skill", stage="run")
|
||||
|
||||
@rpa_step("打开后台")
|
||||
async def open_admin(page):
|
||||
await page.goto("https://example.com")
|
||||
await interruptible_sleep(1.5)
|
||||
|
||||
await open_admin(page)
|
||||
finish(status="success", message="完成", skill="demo-skill")
|
||||
```
|
||||
|
||||
技能作者规范以 **skill-template** 为准;本 README 为实现细节参考。
|
||||
|
||||
### 兄弟技能 CLI 桥
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core import call_sibling_json, get_sibling_main_path
|
||||
|
||||
accounts = call_sibling_json("account-manager", ["list", "all"])
|
||||
```
|
||||
|
||||
同步 `subprocess.run`,`capture_output=True`,自动带上 `subprocess_env_with_trace()`。
|
||||
|
||||
## 共享媒体资源
|
||||
|
||||
`jiangchang_skill_core.media_assets` 提供共享媒体资源解析能力。默认会从:
|
||||
@@ -86,15 +244,63 @@ send_prompt_via_composer(page, "第一行\n第二行")
|
||||
|
||||
读取背景音乐、字体、水印和 ffmpeg 工具。
|
||||
|
||||
如果目录不存在,会优先通过 ZIP 下载公开仓库:
|
||||
如果目录不存在,会优先下载稳定 release bundle(不依赖用户电脑安装 Git):
|
||||
|
||||
`https://git.jc2009.com/client-commons/media-assets/archive/main.zip`
|
||||
`https://git.jc2009.com/client-commons/media-assets/releases/download/vlatest/media-assets.zip`
|
||||
|
||||
这不依赖用户电脑安装 Git。
|
||||
bundle 内含背景音乐、字体与水印;**不包含** ffmpeg 二进制。ffmpeg / ffprobe 仍由 bundle 内 `manifest.json` 的 `tools.ffmpeg` 下载源单独拉取。
|
||||
|
||||
可通过环境变量覆盖:
|
||||
|
||||
`MEDIA_ASSETS_ROOT=/path/to/custom/media-assets`
|
||||
- `MEDIA_ASSETS_ROOT=/path/to/custom/media-assets` — 本地资源目录
|
||||
- `MEDIA_ASSETS_BUNDLE_URL=https://example.com/media-assets.zip` — bundle 下载地址
|
||||
|
||||
### Runtime diagnostics(统一 health 诊断)
|
||||
|
||||
`jiangchang_skill_core.runtime_diagnostics` 提供各 Skill 共用的 Runtime / media-assets / ffmpeg 探测,避免每个技能各自复制检查逻辑。Skill 的 `health` 命令可调用:
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core import (
|
||||
collect_runtime_diagnostics,
|
||||
format_runtime_health_lines,
|
||||
runtime_diagnostics_dict,
|
||||
)
|
||||
|
||||
diag = collect_runtime_diagnostics(
|
||||
skill_slug="my-skill",
|
||||
platform_kit_min_version="1.0.10", # 可选;省略则只报告当前版本
|
||||
skill_root="/path/to/my-skill", # 可选;用于检测 vendored jiangchang_skill_core
|
||||
)
|
||||
for line in format_runtime_health_lines(diag):
|
||||
print(line)
|
||||
print(runtime_diagnostics_dict(diag)) # 可 JSON 序列化
|
||||
```
|
||||
|
||||
诊断只读探测本地状态,不会触发 media-assets 下载。
|
||||
|
||||
自 **1.2.11** 起,`runtime_diagnostics_dict` 额外包含只读字段 `machine_power`(AC 供电 / 可选 nvidia-smi 限频信息)。探不到则 `probed=false`,**不会**因此把 health 标为 fatal。
|
||||
|
||||
### RPA 有界就绪等待与启动超时(1.2.11+)
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core.rpa import poll_until
|
||||
from jiangchang_skill_core.rpa.errors import ERR_UI_NOT_READY, ERR_BROWSER_START_TIMEOUT
|
||||
|
||||
# 有界轮询:超时返回 timed_out,不抛异常;等待可响应宿主 pause/stop
|
||||
result = await poll_until(lambda: editor_visible, timeout_sec=30, interval_sec=0.5)
|
||||
if result.timed_out:
|
||||
raise RuntimeError(f"{ERR_UI_NOT_READY} 页面未在时限内就绪")
|
||||
|
||||
# 可选浏览器启动超时(默认关闭,与 1.2.10 行为一致)
|
||||
# await launch_persistent_browser(pw, profile_dir=..., timeout_ms=60_000)
|
||||
# 或环境变量 OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS=60000
|
||||
```
|
||||
|
||||
薄错误码(技能自行决定是否 fatal / retryable):
|
||||
|
||||
- `ERROR:UI_NOT_READY`
|
||||
- `ERROR:BROWSER_START_TIMEOUT`
|
||||
- `ERROR:ENV_DEGRADED`
|
||||
|
||||
### 录屏合成(`screencast`)
|
||||
|
||||
@@ -118,17 +324,18 @@ run_screencast(
|
||||
|
||||
## 5. 发版流程(维护者)
|
||||
|
||||
### main 分支自动预发布
|
||||
### 发布规则
|
||||
|
||||
每次推送到 `main` 会触发 `.github/workflows/publish.yml`,自动构建并发布 **post 预发布版本**(仅供 CI 流水线内部使用,**不是**正式版本线)。
|
||||
|
||||
正式版本线使用 **1.0.x** semver,通过 git tag 发布。
|
||||
- jiangchang-platform-kit **只发布正式版本**。
|
||||
- 每次需要发布公共能力时,先 bump `pyproject.toml` 的 `version`(例如 `1.0.10` → `1.0.11`)。
|
||||
- 推送 `main` 后,`.github/workflows/publish.yml` 会构建并上传该正式版本到 Gitea PyPI。
|
||||
- 发布包版本必须等于 `pyproject.toml` 中的 `version`;若该版本已存在,上传会失败,需继续 bump 版本号后再推送。
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
测试人员升级安装(始终安装当前正式 tag 对应的版本,例如 `1.0.9`):
|
||||
升级安装:
|
||||
|
||||
```bash
|
||||
python -m pip install --upgrade \
|
||||
@@ -137,22 +344,16 @@ python -m pip install --upgrade \
|
||||
jiangchang-platform-kit
|
||||
```
|
||||
|
||||
### 正式版本(tag)
|
||||
|
||||
打 tag 并推送后,发布 **去掉 `v` 前缀** 的正式版本号(例如 `v1.0.9` → PyPI 包 `1.0.9`):
|
||||
|
||||
```bash
|
||||
git tag v1.0.9
|
||||
git push origin v1.0.9
|
||||
```
|
||||
|
||||
### 安装后验证
|
||||
|
||||
```bash
|
||||
python -c "from jiangchang_desktop_sdk.e2e_helpers import send_prompt_via_composer; print('e2e_helpers OK')"
|
||||
python -c "from screencast import run_screencast; print('screencast OK')"
|
||||
python -c "from jiangchang_desktop_wake import ensure_ui_ready; import importlib.resources as r; print('desktop_wake OK', r.files('jiangchang_desktop_wake'))"
|
||||
```
|
||||
|
||||
企微暖树手工验收见 `src/jiangchang_desktop_wake/ACCEPTANCE.md`(`ensure --profile wecom`)。
|
||||
|
||||
仓库内还保留 `examples/`、`tools/`、`python-runtime/` 目录及既有 Skill / 前端相关的 GitHub Actions 工作流,与本 PyPI 包的发布彼此独立。`tools/screencast/` 为兼容薄壳,pip 包内实现位于 `screencast` 包(`src/screencast/`)。
|
||||
|
||||
## v0.2.0 — data-jcid migration (internal refactor)
|
||||
|
||||
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jiangchang-platform-kit"
|
||||
version = "1.0.10"
|
||||
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
|
||||
version = "1.2.11"
|
||||
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = "MIT"
|
||||
@@ -21,3 +21,12 @@ Repository = "https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
jiangchang_desktop_wake = [
|
||||
"profiles/*.json",
|
||||
"native/*",
|
||||
"native_src/**/*",
|
||||
"README.md",
|
||||
"ACCEPTANCE.md",
|
||||
]
|
||||
|
||||
@@ -23,7 +23,7 @@ __all__ = [
|
||||
"LaunchError",
|
||||
"GatewayDownError",
|
||||
]
|
||||
__version__ = "1.0.9"
|
||||
__version__ = "1.0.13"
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
|
||||
121
src/jiangchang_desktop_wake/ACCEPTANCE.md
Normal file
121
src/jiangchang_desktop_wake/ACCEPTANCE.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# jiangchang_desktop_wake 验收(ensure 主路径,1.2.10+)
|
||||
|
||||
正式对外路径是 **`ensure`**(内部:`activate` → `desktop-wake --mode n2-wake` → `probe`)。
|
||||
`n2-wake`:**仅 OBJID_CLIENT** 走 DuiLib;不做进程内 FindAll、不同步 GETOBJECT、不 SPI 读屏;判暖只看进程外 probe。
|
||||
`c2` / `inject` 仅诊断用。
|
||||
|
||||
| profile | 窗 | 说明 |
|
||||
|---|---|---|
|
||||
| `wecom` | `WeWorkWindow` | 主窗(原有) |
|
||||
| `wecom_search` | `SearchResultWindow2` | 搜索浮层;**不对浮层 Maximize**;n2-wake **子类化浮层本身**(多 HWND;1.2.10+) |
|
||||
|
||||
## 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` `client_only=1` `no_inproc_uia=1` |
|
||||
| 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. 搜索浮层 wecom_search(1.2.10+)
|
||||
|
||||
1. **建议先** `ensure --profile wecom` 暖主窗(可与浮层分步;验证多 HWND)。
|
||||
2. 在搜索框输入联系人,**保持搜索结果浮层打开**(类名 `SearchResultWindow2`)。
|
||||
3. 验收:
|
||||
|
||||
```powershell
|
||||
cd D:\OpenClaw\client-jiangchang\jiangchang-platform-kit
|
||||
$env:PYTHONPATH = ".\src"
|
||||
python -m jiangchang_desktop_wake probe --profile wecom_search
|
||||
python -m jiangchang_desktop_wake ensure --profile wecom_search
|
||||
python -m jiangchang_desktop_wake probe --profile wecom_search
|
||||
```
|
||||
|
||||
| 期望 | 说明 |
|
||||
|---|---|
|
||||
| ensure 退出码 `0` | 浮层已暖 |
|
||||
| `warm=true` | markers 含 `search_result` / `user_item`;findall 从 ~2 升到几十/上百 |
|
||||
| `%TEMP%\jiangchang_desktop_wake_subclass.txt` | 含浮层 hwnd,`ok=1`(主窗已暖后再 ensure 浮层可 `slots>=2`) |
|
||||
| 企微 UI | 浮层**不**被 Maximize、**不崩**、不无故关闭 |
|
||||
| `WINDOW_NOT_FOUND` | 浮层未打开——先展开搜索结果再测 |
|
||||
|
||||
---
|
||||
|
||||
## D. 单测(不依赖企微窗)
|
||||
|
||||
```powershell
|
||||
cd D:\OpenClaw\client-jiangchang\jiangchang-platform-kit
|
||||
$env:PYTHONPATH = ".\src"
|
||||
python -m unittest discover -s tests/desktop_wake -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E. 诊断命令(非主路径)
|
||||
|
||||
```powershell
|
||||
python -m jiangchang_desktop_wake inject --profile wecom
|
||||
python -m jiangchang_desktop_wake c2 --profile wecom
|
||||
```
|
||||
|
||||
禁止默认使用:`desktop-wake.exe ... --mode n2-hook`。
|
||||
53
src/jiangchang_desktop_wake/README.md
Normal file
53
src/jiangchang_desktop_wake/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# jiangchang_desktop_wake
|
||||
|
||||
桌面 UIA Provider **暖树**(与 `jiangchang_skill_core` / `jiangchang_desktop_sdk` 平级)。
|
||||
|
||||
企微主路径:注入 `wecom_wake.dll`,对主窗 `SetWindowLongPtr` 后仅对
|
||||
`OBJID_CLIENT` 走 DuiLib `GetAccessible`(1.2.5+;避免 UiaRoot/MSAA 混用崩 uiautomationcore)。
|
||||
|
||||
可整目录删除:`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 -m jiangchang_desktop_wake ensure --profile wecom_search
|
||||
python -m jiangchang_desktop_wake probe --profile wecom_search
|
||||
```
|
||||
|
||||
```python
|
||||
from jiangchang_desktop_wake import ensure_ui_ready, probe
|
||||
|
||||
r = ensure_ui_ready("wecom") # 主窗;仍冷则抛 DesktopWakeError
|
||||
print(r.warm, r.probe_after.findall_count)
|
||||
|
||||
# 搜索浮层 SearchResultWindow2(需已打开搜索结果)
|
||||
rs = ensure_ui_ready("wecom_search")
|
||||
print(rs.warm, rs.probe_after.findall_count, rs.probe_after.markers_found)
|
||||
```
|
||||
|
||||
| profile | 窗 | 说明 |
|
||||
|---|---|---|
|
||||
| `wecom` | `WeWorkWindow` | 主窗;markers=`con_op`,`searchedit` |
|
||||
| `wecom_search` | `SearchResultWindow2` | 搜索浮层;markers=`search_result`,`user_item`;不 Maximize;**n2-wake 对浮层子类化**(1.2.10+) |
|
||||
|
||||
| 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.
722
src/jiangchang_desktop_wake/native_src/DesktopWake/Program.cs
Normal file
722
src/jiangchang_desktop_wake/native_src/DesktopWake/Program.cs
Normal file
@@ -0,0 +1,722 @@
|
||||
// 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_SHOW = 5;
|
||||
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", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
|
||||
|
||||
[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);
|
||||
// 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");
|
||||
// n2-wake 不再 SPI_SETSCREENREADER:会触发 UiaRoot 查询,测试机曾崩 uiautomationcore
|
||||
if (!doWakeProvider)
|
||||
EnableScreenReaderFlag();
|
||||
|
||||
// 1.2.10:允许对 SearchResultWindow2 n2-wake(本机验证可暖且不崩);
|
||||
// 仍靠 ShouldMaximize=false,禁止 Maximize 打掉浮层。
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主窗可 Maximize;SearchResultWindow2 等浮层禁止 Maximize(会撑爆/关掉浮层)。
|
||||
/// </summary>
|
||||
private static bool ShouldMaximize(IntPtr hwnd)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder(256);
|
||||
if (GetClassName(hwnd, sb, sb.Capacity) <= 0) return true;
|
||||
string cls = sb.ToString();
|
||||
if (string.Equals(cls, "SearchResultWindow2", StringComparison.Ordinal))
|
||||
return false;
|
||||
if (cls.IndexOf("Shadow", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return false;
|
||||
}
|
||||
catch { }
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Activate(IntPtr hwnd)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE);
|
||||
else if (ShouldMaximize(hwnd)) ShowWindow(hwnd, SW_MAXIMIZE);
|
||||
else ShowWindow(hwnd, SW_SHOW);
|
||||
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` 不要当主路径
|
||||
552
src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.cpp
Normal file
552
src/jiangchang_desktop_wake/native_src/N2/payload/wecom_wake.cpp
Normal file
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* N2/C2+ wecom_wake.dll — 进进程暖树(企微 DuiLib MSAA)
|
||||
*
|
||||
* 导出:
|
||||
* WakeProvider — CreateRemoteThread 入口(主路径)
|
||||
* WakeTarget — 旧子类化(危险,仅 n2-hook)
|
||||
* JcCallWndProc — 旧钩子(危险,仅 n2-hook)
|
||||
*
|
||||
* 1.2.5 主路径(防 uiautomationcore 崩):
|
||||
* - SetWindowLongPtr 子类化;仅 OBJID_CLIENT 走 DuiLib GetAccessible
|
||||
* - UiaRootObjectId 等原样交给旧 WndProc(禁止用 MSAA Lresult 冒充 UIA root)
|
||||
* - 不做进程内 UIAutomation FindAll;不做同步 SendMessage(GETOBJECT)
|
||||
* - 重入守卫;判暖交给进程外 probe
|
||||
* 1.2.8:支持多 HWND 子类化(主窗 + SearchResultWindow2),禁止「全局只装一次」。
|
||||
* 1.2.10:恢复对 SearchResultWindow2 子类化暖树(本机验证 2→122 且不崩);仍禁止 Maximize(exe 侧)。
|
||||
*/
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <commctrl.h>
|
||||
#include <oleacc.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#pragma comment(lib, "oleacc.lib")
|
||||
#pragma comment(lib, "ole32.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 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
|
||||
/* 主窗 + 搜索浮层等可能各装一次;禁止全局「只装一次」漏掉 SearchResultWindow2 */
|
||||
#define JC_MAX_SUBCLASS 8
|
||||
|
||||
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 volatile LONG g_reenterSkip = 0;
|
||||
|
||||
typedef struct JcSubEntry {
|
||||
HWND hwnd;
|
||||
WNDPROC oldProc; /* SetWindowLongPtr 路径;SetWindowSubclass 时为 NULL */
|
||||
int useSubclassApi;
|
||||
} JcSubEntry;
|
||||
|
||||
static JcSubEntry g_subs[JC_MAX_SUBCLASS];
|
||||
static volatile LONG g_subCount = 0;
|
||||
|
||||
static HANDLE g_staThread = NULL;
|
||||
static HANDLE g_staReady = NULL;
|
||||
static HWND g_msgHwnd = NULL;
|
||||
static volatile LONG g_staStarted = 0;
|
||||
|
||||
/* per-thread reentrancy guard for WM_GETOBJECT */
|
||||
static __declspec(thread) int t_inGetObject = 0;
|
||||
|
||||
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)
|
||||
{
|
||||
/* 只异步点 CLIENT,绝不 Post UiaRootObjectId(易进 uiautomationcore 崩) */
|
||||
NotifyWinEvent(EVENT_OBJECT_FOCUS, hwnd, OBJID_CLIENT, CHILDID_SELF);
|
||||
PostMessageW(hwnd, WM_GETOBJECT, 0, (LPARAM)OBJID_CLIENT);
|
||||
PostMessageW(hwnd, WM_NULL, 0, 0);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static bool IsClientObjectId(LPARAM lParam)
|
||||
{
|
||||
return (LONG)lParam == (LONG)OBJID_CLIENT;
|
||||
}
|
||||
|
||||
static LRESULT TryDuiLibGetObject(HWND hwnd, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
/* 只服务 MSAA CLIENT。UiaRootObjectId 用 LresultFromObject(IAccessible) 会搞崩 UIA core */
|
||||
if (!IsClientObjectId(lParam))
|
||||
return 0;
|
||||
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);
|
||||
acc->Release();
|
||||
return lr;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static LRESULT HandleGetObject(
|
||||
HWND hwnd, WPARAM wParam, LPARAM lParam, WNDPROC oldProc, bool useDefSubclass)
|
||||
{
|
||||
InterlockedIncrement(&g_getobjectHits);
|
||||
|
||||
if (t_inGetObject) {
|
||||
InterlockedIncrement(&g_reenterSkip);
|
||||
if (useDefSubclass)
|
||||
return DefSubclassProc(hwnd, WM_GETOBJECT, wParam, lParam);
|
||||
if (oldProc)
|
||||
return CallWindowProcW(oldProc, hwnd, WM_GETOBJECT, wParam, lParam);
|
||||
return DefWindowProcW(hwnd, WM_GETOBJECT, wParam, lParam);
|
||||
}
|
||||
|
||||
t_inGetObject = 1;
|
||||
LRESULT r = 0;
|
||||
if (useDefSubclass)
|
||||
r = DefSubclassProc(hwnd, WM_GETOBJECT, wParam, lParam);
|
||||
else if (oldProc)
|
||||
r = CallWindowProcW(oldProc, hwnd, WM_GETOBJECT, wParam, lParam);
|
||||
|
||||
/* 非 CLIENT(含 UiaRootObjectId):绝不伪造返回值 */
|
||||
if (r == 0 && IsClientObjectId(lParam))
|
||||
r = TryDuiLibGetObject(hwnd, wParam, lParam);
|
||||
|
||||
t_inGetObject = 0;
|
||||
return r;
|
||||
}
|
||||
|
||||
static JcSubEntry* FindSubEntry(HWND hwnd)
|
||||
{
|
||||
LONG n = g_subCount;
|
||||
for (LONG i = 0; i < n && i < JC_MAX_SUBCLASS; i++) {
|
||||
if (g_subs[i].hwnd == hwnd)
|
||||
return &g_subs[i];
|
||||
}
|
||||
return 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)
|
||||
return HandleGetObject(hWnd, wParam, lParam, NULL, true);
|
||||
return DefSubclassProc(hWnd, uMsg, wParam, lParam);
|
||||
}
|
||||
|
||||
static LRESULT CALLBACK DuiWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
JcSubEntry* e = FindSubEntry(hwnd);
|
||||
WNDPROC old = e ? e->oldProc : NULL;
|
||||
if (msg == WM_GETOBJECT)
|
||||
return HandleGetObject(hwnd, wParam, lParam, old, false);
|
||||
if (old)
|
||||
return CallWindowProcW(old, hwnd, msg, wParam, lParam);
|
||||
return DefWindowProcW(hwnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
static bool InstallDuiSubclass(HWND hwnd)
|
||||
{
|
||||
if (!hwnd || !IsWindow(hwnd)) return false;
|
||||
if (FindSubEntry(hwnd))
|
||||
return true;
|
||||
if (g_subCount >= JC_MAX_SUBCLASS) {
|
||||
WriteMarker("jiangchang_desktop_wake_subclass.txt",
|
||||
"err=subclass_slots_full\r\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
ResolveDuiLib();
|
||||
void* userdata = (void*)GetWindowLongPtrW(hwnd, GWLP_USERDATA);
|
||||
|
||||
/* 先占槽再换 WndProc,避免 GETOBJECT 抢在 Remember 之前 */
|
||||
LONG idx = InterlockedIncrement(&g_subCount) - 1;
|
||||
if (idx < 0 || idx >= JC_MAX_SUBCLASS) {
|
||||
InterlockedDecrement(&g_subCount);
|
||||
WriteMarker("jiangchang_desktop_wake_subclass.txt",
|
||||
"err=subclass_slots_full\r\n");
|
||||
return false;
|
||||
}
|
||||
g_subs[idx].hwnd = hwnd;
|
||||
g_subs[idx].oldProc = NULL;
|
||||
g_subs[idx].useSubclassApi = 0;
|
||||
|
||||
SetLastError(0);
|
||||
WNDPROC oldProc = (WNDPROC)SetWindowLongPtrW(
|
||||
hwnd, GWLP_WNDPROC, (LONG_PTR)DuiWndProc);
|
||||
DWORD gle = GetLastError();
|
||||
BOOL ok = (oldProc != 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";
|
||||
g_subs[idx].useSubclassApi = 1;
|
||||
oldProc = NULL;
|
||||
} else {
|
||||
g_subs[idx].oldProc = oldProc;
|
||||
}
|
||||
|
||||
char line[512];
|
||||
sprintf_s(line,
|
||||
"hwnd=%p ok=%d gle=%lu method=%s userdata=%p "
|
||||
"duilib=%p getacc=%p onget=%p slots=%ld\r\n",
|
||||
hwnd, (int)ok, gle, method, userdata,
|
||||
g_duilib, g_fnGetAccessible, g_fnOnGetObject, (long)g_subCount);
|
||||
WriteMarker("jiangchang_desktop_wake_subclass.txt", line);
|
||||
if (!ok) {
|
||||
g_subs[idx].hwnd = NULL;
|
||||
InterlockedDecrement(&g_subCount);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* 只装子类化 + 异步 nudge;禁止同步 SendMessage(GETOBJECT) */
|
||||
static void DoSafeWake(HWND hwnd)
|
||||
{
|
||||
char line[512];
|
||||
int subclass_ok = 0;
|
||||
int dui_ok = 0;
|
||||
|
||||
ResolveDuiLib();
|
||||
dui_ok = (g_fnGetAccessible || g_fnOnGetObject) ? 1 : 0;
|
||||
subclass_ok = InstallDuiSubclass(hwnd) ? 1 : 0;
|
||||
|
||||
NudgeWinEvents(hwnd);
|
||||
NudgeWinEvents(hwnd);
|
||||
|
||||
sprintf_s(line,
|
||||
"hwnd=%p sta_tid=%lu subclass=%d dui=%d "
|
||||
"dui_hits=%ld getobject_hits=%ld reenter_skip=%ld "
|
||||
"client_only=1 no_sync_getobject=1 no_inproc_uia=1 done=1\r\n",
|
||||
hwnd, GetCurrentThreadId(), subclass_ok, dui_ok,
|
||||
(long)g_duiHits, (long)g_getobjectHits, (long)g_reenterSkip);
|
||||
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))
|
||||
DoSafeWake(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)
|
||||
{
|
||||
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");
|
||||
return 1;
|
||||
}
|
||||
|
||||
MSG msg;
|
||||
while (GetMessageW(&msg, NULL, 0, 0) > 0) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
if (g_msgHwnd) {
|
||||
DestroyWindow(g_msgHwnd);
|
||||
g_msgHwnd = NULL;
|
||||
}
|
||||
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, 8000, &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) {
|
||||
LRESULT r = HandleGetObject(hwnd, wParam, lParam, g_oldProc, false);
|
||||
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 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,
|
||||
)
|
||||
98
src/jiangchang_desktop_wake/profiles.py
Normal file
98
src/jiangchang_desktop_wake/profiles.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""加载 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"],
|
||||
},
|
||||
"wecom_search": {
|
||||
"id": "wecom_search",
|
||||
"description": "企业微信搜索结果浮层 SearchResultWindow2;n2-wake 对浮层子类化(1.2.10+),禁止 Maximize",
|
||||
"window_class": "SearchResultWindow2",
|
||||
"warm_min_findall": 20,
|
||||
"warm_markers": ["search_result", "user_item"],
|
||||
"activate_maximize": False,
|
||||
"wake_strategies": ["activate", "native_helper"],
|
||||
"native_helper": "desktop-wake.exe",
|
||||
"native_args": ["wecom", "0x{hwnd_hex}", "--mode", "n2-wake"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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"]
|
||||
}
|
||||
14
src/jiangchang_desktop_wake/profiles/wecom_search.json
Normal file
14
src/jiangchang_desktop_wake/profiles/wecom_search.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "wecom_search",
|
||||
"description": "企业微信搜索结果浮层 SearchResultWindow2;n2-wake 对浮层子类化暖树(1.2.10+),禁止 Maximize",
|
||||
"window_class": "SearchResultWindow2",
|
||||
"warm_min_findall": 20,
|
||||
"warm_markers": ["search_result", "user_item"],
|
||||
"activate_maximize": false,
|
||||
"wake_strategies": [
|
||||
"activate",
|
||||
"native_helper"
|
||||
],
|
||||
"native_helper": "desktop-wake.exe",
|
||||
"native_args": ["wecom", "0x{hwnd_hex}", "--mode", "n2-wake"]
|
||||
}
|
||||
113
src/jiangchang_desktop_wake/types.py
Normal file
113
src/jiangchang_desktop_wake/types.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""暖树公共数据类型。"""
|
||||
|
||||
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)
|
||||
# 浮层窗(如搜索结果)禁止 Maximize,避免打乱搜索态
|
||||
activate_maximize: bool = True
|
||||
# native_helper / n2-wake 目标窗 class;空则用 probe 窗本身。
|
||||
# 1.2.10+ wecom_search 留空(对浮层自身 n2-wake);勿改注主窗。
|
||||
native_hwnd_class: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Profile":
|
||||
maximize = data.get("activate_maximize")
|
||||
if maximize is None:
|
||||
maximize = True
|
||||
native_cls = data.get("native_hwnd_class")
|
||||
return cls(
|
||||
id=str(data.get("id") or "unknown"),
|
||||
description=str(data.get("description") or ""),
|
||||
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 []),
|
||||
activate_maximize=bool(maximize),
|
||||
native_hwnd_class=str(native_cls).strip() if native_cls else None,
|
||||
)
|
||||
|
||||
|
||||
@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}"
|
||||
280
src/jiangchang_desktop_wake/wake.py
Normal file
280
src/jiangchang_desktop_wake/wake.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""软唤醒策略梯 + 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,
|
||||
find_same_pid_hwnd_by_class,
|
||||
get_window_pid,
|
||||
require_windows,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("jiangchang_desktop_wake.wake")
|
||||
|
||||
# strategy -> (detail, status) status: ok | fail | skipped
|
||||
StrategyFn = Callable[[int, Profile], Tuple[str, str]]
|
||||
|
||||
|
||||
def resolve_native_hwnd(probe_hwnd: int, profile: Profile) -> int:
|
||||
"""native_helper 实际注入 hwnd。
|
||||
|
||||
若 profile.native_hwnd_class 已设:优先同 PID 下该 class 窗(如浮层 probe、主窗注入)。
|
||||
找不到则回退 probe_hwnd,并打 warning。
|
||||
"""
|
||||
cls = (getattr(profile, "native_hwnd_class", None) or "").strip()
|
||||
if not cls:
|
||||
return int(probe_hwnd)
|
||||
try:
|
||||
pid = get_window_pid(int(probe_hwnd))
|
||||
hit = find_same_pid_hwnd_by_class(pid=pid, class_name=cls)
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
"resolve_native_hwnd failed profile=%s probe=%s err=%s; fallback probe",
|
||||
profile.id,
|
||||
probe_hwnd,
|
||||
exc,
|
||||
)
|
||||
return int(probe_hwnd)
|
||||
if not hit:
|
||||
_logger.warning(
|
||||
"resolve_native_hwnd miss profile=%s native_hwnd_class=%s pid=%s "
|
||||
"probe=%s; fallback probe (may crash popup UI)",
|
||||
profile.id,
|
||||
cls,
|
||||
pid,
|
||||
probe_hwnd,
|
||||
)
|
||||
return int(probe_hwnd)
|
||||
if int(hit) != int(probe_hwnd):
|
||||
_logger.info(
|
||||
"native_hwnd redirect profile=%s probe=%s -> inject=%s class=%s pid=%s",
|
||||
profile.id,
|
||||
probe_hwnd,
|
||||
hit,
|
||||
cls,
|
||||
pid,
|
||||
)
|
||||
return int(hit)
|
||||
|
||||
|
||||
def _strategy_activate(hwnd: int, profile: Profile) -> Tuple[str, str]:
|
||||
maximize = bool(getattr(profile, "activate_maximize", True))
|
||||
activate_hwnd(hwnd, maximize=maximize)
|
||||
return ("foreground+maximize" if maximize else "foreground"), "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
|
||||
# probe / activate 目标(如 SearchResultWindow2)
|
||||
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:
|
||||
# native_helper:可重定向到同 PID 主窗注入;其余策略仍作用在 probe 窗
|
||||
target = resolve_native_hwnd(h, prof) if name == "native_helper" else h
|
||||
detail, status = fn(target, prof)
|
||||
if name == "native_helper" and int(target) != int(h):
|
||||
detail = f"inject_hwnd={target} probe_hwnd={h} | {detail}"
|
||||
steps.append(
|
||||
WakeStepResult(
|
||||
strategy=name,
|
||||
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:
|
||||
# 始终对 profile 目标窗(浮层)判暖,不是 inject 窗
|
||||
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
|
||||
168
src/jiangchang_desktop_wake/window.py
Normal file
168
src/jiangchang_desktop_wake/window.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""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 get_window_pid(hwnd: int) -> int:
|
||||
require_windows()
|
||||
pid = wintypes.DWORD()
|
||||
_user32().GetWindowThreadProcessId(int(hwnd), ctypes.byref(pid))
|
||||
return int(pid.value)
|
||||
|
||||
|
||||
def find_same_pid_hwnd_by_class(
|
||||
*,
|
||||
pid: int,
|
||||
class_name: str,
|
||||
) -> Optional[int]:
|
||||
"""同进程下指定 class 的可见顶层窗(多窗取面积最大)。"""
|
||||
hwnds = enum_hwnds(class_name=class_name)
|
||||
same = [h for h in hwnds if get_window_pid(h) == int(pid)]
|
||||
if not same:
|
||||
return None
|
||||
return pick_largest(same) if len(same) > 1 else same[0]
|
||||
|
||||
|
||||
def enum_hwnds(
|
||||
*,
|
||||
class_name: Optional[str] = None,
|
||||
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)
|
||||
@@ -1,3 +1,22 @@
|
||||
from .activity import (
|
||||
JobStopped,
|
||||
checkpoint,
|
||||
emit,
|
||||
finish,
|
||||
get_control_path,
|
||||
get_job_id,
|
||||
get_run_mode,
|
||||
interruptible_sleep,
|
||||
interruptible_sleep_sync,
|
||||
job_context,
|
||||
rpa_step,
|
||||
step,
|
||||
)
|
||||
from .sibling_bridge import (
|
||||
call_sibling_json,
|
||||
call_sibling_json_array,
|
||||
get_sibling_main_path,
|
||||
)
|
||||
from .client import EntitlementClient
|
||||
from .config import ensure_env_file, get, get_bool, get_float, get_int
|
||||
from .errors import EntitlementDeniedError, EntitlementError, EntitlementServiceError
|
||||
@@ -5,8 +24,10 @@ from .guard import enforce_entitlement
|
||||
from .models import EntitlementResult
|
||||
from .runtime_env import (
|
||||
apply_cli_local_defaults,
|
||||
ensure_runs_dir,
|
||||
find_chrome_executable,
|
||||
get_data_root,
|
||||
get_runs_dir,
|
||||
get_sibling_skills_root,
|
||||
get_skills_root,
|
||||
get_user_id,
|
||||
@@ -25,7 +46,10 @@ from .media_assets import (
|
||||
MediaAssetsStatus,
|
||||
background_music_issue,
|
||||
ensure_media_assets,
|
||||
is_git_lfs_pointer,
|
||||
is_usable_audio_file,
|
||||
pick_background_music,
|
||||
probe_background_music,
|
||||
probe_ffmpeg,
|
||||
probe_media_assets,
|
||||
resolve_ffmpeg,
|
||||
@@ -33,6 +57,15 @@ from .media_assets import (
|
||||
resolve_media_assets_root,
|
||||
resolve_music_dir,
|
||||
)
|
||||
from .runtime_diagnostics import (
|
||||
RuntimeDiagnostics,
|
||||
RuntimeIssue,
|
||||
collect_runtime_diagnostics,
|
||||
format_runtime_health_lines,
|
||||
is_jiangchang_skill_core_from_skill_tree,
|
||||
runtime_diagnostics_dict,
|
||||
version_ge,
|
||||
)
|
||||
|
||||
try:
|
||||
from . import rpa
|
||||
@@ -40,14 +73,39 @@ except ImportError:
|
||||
rpa = None # type: ignore[assignment,misc]
|
||||
|
||||
__all__ = [
|
||||
"JobStopped",
|
||||
"call_sibling_json",
|
||||
"call_sibling_json_array",
|
||||
"checkpoint",
|
||||
"emit",
|
||||
"finish",
|
||||
"get_control_path",
|
||||
"get_job_id",
|
||||
"get_run_mode",
|
||||
"get_runs_dir",
|
||||
"ensure_runs_dir",
|
||||
"interruptible_sleep",
|
||||
"interruptible_sleep_sync",
|
||||
"job_context",
|
||||
"rpa_step",
|
||||
"step",
|
||||
"get_sibling_main_path",
|
||||
"EntitlementClient",
|
||||
"EntitlementDeniedError",
|
||||
"EntitlementError",
|
||||
"EntitlementResult",
|
||||
"EntitlementServiceError",
|
||||
"MediaAssetsStatus",
|
||||
"RuntimeDiagnostics",
|
||||
"RuntimeIssue",
|
||||
"background_music_issue",
|
||||
"collect_runtime_diagnostics",
|
||||
"ensure_media_assets",
|
||||
"format_runtime_health_lines",
|
||||
"is_git_lfs_pointer",
|
||||
"is_jiangchang_skill_core_from_skill_tree",
|
||||
"is_usable_audio_file",
|
||||
"probe_background_music",
|
||||
"probe_ffmpeg",
|
||||
"probe_media_assets",
|
||||
"pick_background_music",
|
||||
@@ -55,6 +113,8 @@ __all__ = [
|
||||
"resolve_ffprobe",
|
||||
"resolve_media_assets_root",
|
||||
"resolve_music_dir",
|
||||
"runtime_diagnostics_dict",
|
||||
"version_ge",
|
||||
"apply_cli_local_defaults",
|
||||
"attach_unified_file_handler",
|
||||
"ensure_env_file",
|
||||
@@ -66,6 +126,7 @@ __all__ = [
|
||||
"get_data_root",
|
||||
"get_float",
|
||||
"get_int",
|
||||
"get_runs_dir",
|
||||
"get_sibling_skills_root",
|
||||
"get_skills_root",
|
||||
"get_skill_log_file_path",
|
||||
|
||||
546
src/jiangchang_skill_core/activity.py
Normal file
546
src/jiangchang_skill_core/activity.py
Normal file
@@ -0,0 +1,546 @@
|
||||
"""
|
||||
Skill Activity Stream (SAS) v1 + Skill Runtime Control Protocol (SRCP) v1.
|
||||
|
||||
emit() / journal events never touch stdout (Agent/bash tool result stays clean).
|
||||
finish() is the only API that writes a single-line result JSON to stdout.
|
||||
|
||||
SRCP: host writes {job_id}.control.json; kit consults at step gates (emit,
|
||||
rpa_step, interruptible_sleep) for pause / resume / stop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable, Iterator, Optional, TypeVar
|
||||
|
||||
from .runtime_env import ensure_runs_dir, get_runs_dir
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
_CONTROL_SCHEMA_VERSION = 1
|
||||
_VALID_EMIT_TYPES = frozenset({"activity", "progress", "warn", "debug"})
|
||||
_VALID_CONTROL_COMMANDS = frozenset({"none", "pause", "resume", "stop"})
|
||||
_RESULT_MAX_CHARS = 4096
|
||||
_DEFAULT_CONTROL_POLL_SEC = 0.2
|
||||
|
||||
_cached_job_id: Optional[str] = None
|
||||
_journal_path: Optional[str] = None
|
||||
_meta_written: bool = False
|
||||
_journal_file: Optional[Any] = None
|
||||
_step_index: int = 0
|
||||
_current_step_name: Optional[str] = None
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
class JobStopped(Exception):
|
||||
"""Raised when the host requests stop via control.json."""
|
||||
|
||||
|
||||
def _activity_debug_enabled() -> bool:
|
||||
v = (os.getenv("JIANGCHANG_ACTIVITY_DEBUG") or "").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _control_disabled() -> bool:
|
||||
v = (os.getenv("JIANGCHANG_CONTROL_DISABLED") or "").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _control_poll_sec() -> float:
|
||||
raw = (os.getenv("JIANGCHANG_CONTROL_POLL_SEC") or "").strip()
|
||||
if not raw:
|
||||
return _DEFAULT_CONTROL_POLL_SEC
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_CONTROL_POLL_SEC
|
||||
return max(0.05, value)
|
||||
|
||||
|
||||
def get_run_mode() -> str:
|
||||
mode = (os.getenv("JIANGCHANG_RUN_MODE") or "").strip().lower()
|
||||
if mode in ("job", "cli"):
|
||||
return mode
|
||||
return "cli"
|
||||
|
||||
|
||||
def get_job_id() -> str:
|
||||
global _cached_job_id
|
||||
env_job = (os.getenv("JIANGCHANG_JOB_ID") or "").strip()
|
||||
if env_job:
|
||||
return env_job
|
||||
if _cached_job_id is None:
|
||||
_cached_job_id = uuid.uuid4().hex
|
||||
return _cached_job_id
|
||||
|
||||
|
||||
def get_control_path() -> str:
|
||||
"""Path to the host-owned control file for the current job."""
|
||||
ensure_runs_dir()
|
||||
return os.path.join(get_runs_dir(), f"{get_job_id()}.control.json")
|
||||
|
||||
|
||||
def _get_journal_path() -> str:
|
||||
global _journal_path
|
||||
if _journal_path is None:
|
||||
ensure_runs_dir()
|
||||
_journal_path = os.path.join(get_runs_dir(), f"{get_job_id()}.jsonl")
|
||||
return _journal_path
|
||||
|
||||
|
||||
def _open_journal() -> Any:
|
||||
global _journal_file
|
||||
if _journal_file is None:
|
||||
path = _get_journal_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
_journal_file = open(path, "a", encoding="utf-8")
|
||||
return _journal_file
|
||||
|
||||
|
||||
def _write_journal_line(event: dict[str, Any]) -> None:
|
||||
global _meta_written
|
||||
f = _open_journal()
|
||||
if not _meta_written:
|
||||
meta = {
|
||||
"type": "meta",
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"job_id": get_job_id(),
|
||||
"run_mode": get_run_mode(),
|
||||
"ts": _now_ms(),
|
||||
}
|
||||
f.write(json.dumps(meta, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
f.flush()
|
||||
_meta_written = True
|
||||
f.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
f.flush()
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _read_control_payload() -> dict[str, Any] | None:
|
||||
path = get_control_path()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _read_control_command() -> str:
|
||||
payload = _read_control_payload()
|
||||
if not payload:
|
||||
return "none"
|
||||
command = payload.get("command")
|
||||
if not isinstance(command, str):
|
||||
return "none"
|
||||
normalized = command.strip().lower()
|
||||
if normalized not in _VALID_CONTROL_COMMANDS:
|
||||
return "none"
|
||||
return normalized
|
||||
|
||||
|
||||
def _write_lifecycle_event(
|
||||
status: str,
|
||||
text: str,
|
||||
*,
|
||||
step_name: str | None = None,
|
||||
skill: str | None = None,
|
||||
) -> None:
|
||||
event: dict[str, Any] = {
|
||||
"type": "lifecycle",
|
||||
"status": status,
|
||||
"text": text,
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"control_schema_version": _CONTROL_SCHEMA_VERSION,
|
||||
"ts": _now_ms(),
|
||||
"step_index": _step_index,
|
||||
}
|
||||
name = step_name or _current_step_name
|
||||
if name:
|
||||
event["step_name"] = name
|
||||
if skill is not None:
|
||||
event["skill"] = skill
|
||||
_write_journal_line(event)
|
||||
|
||||
|
||||
def _wait_while_paused(*, step_name: str | None = None) -> None:
|
||||
poll = _control_poll_sec()
|
||||
while True:
|
||||
command = _read_control_command()
|
||||
if command == "stop":
|
||||
_write_lifecycle_event("stopping", "任务已停止", step_name=step_name)
|
||||
raise JobStopped("Job stopped by host.")
|
||||
if command in ("none", "resume"):
|
||||
_write_lifecycle_event("resumed", "继续执行", step_name=step_name)
|
||||
return
|
||||
time.sleep(poll)
|
||||
|
||||
|
||||
def _apply_control(*, advance_step: bool, step_name: str | None = None) -> None:
|
||||
if _control_disabled():
|
||||
return
|
||||
|
||||
global _step_index, _current_step_name
|
||||
if advance_step:
|
||||
_step_index += 1
|
||||
if step_name:
|
||||
_current_step_name = step_name
|
||||
|
||||
command = _read_control_command()
|
||||
if command == "stop":
|
||||
_write_lifecycle_event("stopping", "任务已停止", step_name=step_name)
|
||||
raise JobStopped("Job stopped by host.")
|
||||
if command == "pause":
|
||||
_write_lifecycle_event("paused", "已暂停,等待继续", step_name=step_name)
|
||||
_wait_while_paused(step_name=step_name)
|
||||
|
||||
|
||||
def checkpoint() -> None:
|
||||
"""
|
||||
Consult host control without advancing the step index.
|
||||
|
||||
Use inside long waits (interruptible_sleep) between step boundaries.
|
||||
"""
|
||||
_apply_control(advance_step=False)
|
||||
|
||||
|
||||
def _step_gate(step_name: str | None) -> None:
|
||||
"""Step boundary: advance index, then pause/stop gate."""
|
||||
_apply_control(advance_step=True, step_name=step_name)
|
||||
|
||||
|
||||
def _build_event(
|
||||
type_: str,
|
||||
text: str,
|
||||
*,
|
||||
skill: str | None = None,
|
||||
stage: str | None = None,
|
||||
current: int | None = None,
|
||||
total: int | None = None,
|
||||
item_id: str | None = None,
|
||||
ts: int | None = None,
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
cleaned = (text or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("text must be non-empty")
|
||||
event: dict[str, Any] = {
|
||||
"type": type_,
|
||||
"text": cleaned,
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"ts": ts if ts is not None else _now_ms(),
|
||||
}
|
||||
if skill is not None:
|
||||
event["skill"] = skill
|
||||
if stage is not None:
|
||||
event["stage"] = stage
|
||||
if current is not None:
|
||||
event["current"] = current
|
||||
if total is not None:
|
||||
event["total"] = total
|
||||
if item_id is not None:
|
||||
event["item_id"] = item_id
|
||||
for key, value in extra.items():
|
||||
if key not in event:
|
||||
event[key] = value
|
||||
return event
|
||||
|
||||
|
||||
def _emit_core(
|
||||
text: str,
|
||||
*,
|
||||
type: str = "activity",
|
||||
skill: str | None = None,
|
||||
stage: str | None = None,
|
||||
current: int | None = None,
|
||||
total: int | None = None,
|
||||
item_id: str | None = None,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
if type == "debug" and not _activity_debug_enabled():
|
||||
return
|
||||
if type not in _VALID_EMIT_TYPES:
|
||||
raise ValueError(f"invalid emit type: {type!r}")
|
||||
event = _build_event(
|
||||
type,
|
||||
text,
|
||||
skill=skill,
|
||||
stage=stage,
|
||||
current=current,
|
||||
total=total,
|
||||
item_id=item_id,
|
||||
**extra,
|
||||
)
|
||||
_write_journal_line(event)
|
||||
|
||||
|
||||
def emit(
|
||||
text: str,
|
||||
*,
|
||||
type: str = "activity",
|
||||
skill: str | None = None,
|
||||
stage: str | None = None,
|
||||
current: int | None = None,
|
||||
total: int | None = None,
|
||||
item_id: str | None = None,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
"""Append one SAS event to the Run Journal. Never writes to stdout."""
|
||||
gate_name = stage or text
|
||||
_step_gate(gate_name)
|
||||
_emit_core(
|
||||
text,
|
||||
type=type,
|
||||
skill=skill,
|
||||
stage=stage,
|
||||
current=current,
|
||||
total=total,
|
||||
item_id=item_id,
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def step(text: str, **kwargs: Any) -> None:
|
||||
"""Shorthand for emit(type='activity')."""
|
||||
emit(text, type="activity", **kwargs)
|
||||
|
||||
|
||||
async def interruptible_sleep(seconds: float, *, chunk: float | None = None) -> None:
|
||||
"""asyncio.sleep in slices; each slice consults host control (pause/stop)."""
|
||||
if seconds <= 0:
|
||||
return
|
||||
poll = chunk if chunk is not None else _control_poll_sec()
|
||||
poll = max(0.05, poll)
|
||||
deadline = time.monotonic() + seconds
|
||||
while True:
|
||||
checkpoint()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return
|
||||
await asyncio.sleep(min(poll, remaining))
|
||||
|
||||
|
||||
def interruptible_sleep_sync(seconds: float, *, chunk: float | None = None) -> None:
|
||||
"""time.sleep in slices; each slice consults host control (pause/stop)."""
|
||||
if seconds <= 0:
|
||||
return
|
||||
poll = chunk if chunk is not None else _control_poll_sec()
|
||||
poll = max(0.05, poll)
|
||||
deadline = time.monotonic() + seconds
|
||||
while True:
|
||||
checkpoint()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return
|
||||
time.sleep(min(poll, remaining))
|
||||
|
||||
|
||||
def _serialize_result(result: dict[str, Any]) -> str:
|
||||
line = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(line) <= _RESULT_MAX_CHARS:
|
||||
return line
|
||||
|
||||
core_keys = ("type", "schema_version", "status", "skill", "message")
|
||||
truncated: dict[str, Any] = {
|
||||
k: result[k] for k in core_keys if k in result
|
||||
}
|
||||
truncated["truncated"] = True
|
||||
|
||||
for key, value in result.items():
|
||||
if key in truncated or key == "truncated":
|
||||
continue
|
||||
candidate = {**truncated, key: value}
|
||||
candidate_line = json.dumps(candidate, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(candidate_line) <= _RESULT_MAX_CHARS:
|
||||
truncated[key] = value
|
||||
|
||||
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(line) > _RESULT_MAX_CHARS:
|
||||
truncated.pop("message", None)
|
||||
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(line) > _RESULT_MAX_CHARS:
|
||||
return line[:_RESULT_MAX_CHARS]
|
||||
return line
|
||||
|
||||
|
||||
def finish(
|
||||
*,
|
||||
status: str = "success",
|
||||
message: str | None = None,
|
||||
skill: str | None = None,
|
||||
**fields: Any,
|
||||
) -> None:
|
||||
"""
|
||||
1) Write terminal done|error event to Run Journal.
|
||||
2) Print a single compact result JSON line to stdout (Agent/bash path).
|
||||
"""
|
||||
if status not in ("success", "partial", "failed"):
|
||||
raise ValueError(f"invalid finish status: {status!r}")
|
||||
|
||||
journal_type = "error" if status == "failed" else "done"
|
||||
terminal_text = message or ("任务失败" if status == "failed" else "任务完成")
|
||||
|
||||
journal_event = _build_event(
|
||||
journal_type,
|
||||
terminal_text,
|
||||
skill=skill,
|
||||
status=status,
|
||||
**fields,
|
||||
)
|
||||
if message is not None:
|
||||
journal_event["message"] = message
|
||||
_write_journal_line(journal_event)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"type": "result",
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"status": status,
|
||||
}
|
||||
if skill is not None:
|
||||
result["skill"] = skill
|
||||
if message is not None:
|
||||
result["message"] = message
|
||||
result.update(fields)
|
||||
|
||||
line = _serialize_result(result)
|
||||
sys.stdout.write(line + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def job_context(
|
||||
*,
|
||||
job_id: str | None = None,
|
||||
skill: str | None = None,
|
||||
run_mode: str | None = None,
|
||||
) -> Iterator[None]:
|
||||
"""Optional wrapper: sets job context; auto finish(failed) on unhandled exception."""
|
||||
global _cached_job_id, _journal_path, _meta_written, _journal_file
|
||||
|
||||
saved_job_id = _cached_job_id
|
||||
saved_journal_path = _journal_path
|
||||
saved_meta = _meta_written
|
||||
saved_file = _journal_file
|
||||
saved_env_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||
saved_env_mode = os.environ.get("JIANGCHANG_RUN_MODE")
|
||||
|
||||
if job_id:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = job_id
|
||||
_cached_job_id = None
|
||||
_journal_path = None
|
||||
_meta_written = False
|
||||
if _journal_file is not None:
|
||||
try:
|
||||
_journal_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
_journal_file = None
|
||||
if run_mode:
|
||||
os.environ["JIANGCHANG_RUN_MODE"] = run_mode
|
||||
|
||||
try:
|
||||
yield
|
||||
except JobStopped as exc:
|
||||
finish(
|
||||
status="failed",
|
||||
message=str(exc) or "用户停止",
|
||||
skill=skill,
|
||||
error_code="JOB_STOPPED",
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
finish(status="failed", message=str(exc), skill=skill)
|
||||
raise
|
||||
finally:
|
||||
_cached_job_id = saved_job_id
|
||||
_journal_path = saved_journal_path
|
||||
_meta_written = saved_meta
|
||||
if _journal_file is not None and _journal_file is not saved_file:
|
||||
try:
|
||||
_journal_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
_journal_file = saved_file
|
||||
if saved_env_job is None:
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = saved_env_job
|
||||
if saved_env_mode is None:
|
||||
os.environ.pop("JIANGCHANG_RUN_MODE", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_RUN_MODE"] = saved_env_mode
|
||||
|
||||
|
||||
def rpa_step(name: str | None = None) -> Callable[[F], F]:
|
||||
"""Decorator for sync/async RPA steps: gate, emit enter/success/warn on failure."""
|
||||
|
||||
def decorator(fn: F) -> F:
|
||||
step_name = name or fn.__name__
|
||||
|
||||
if inspect.iscoroutinefunction(fn):
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
_step_gate(step_name)
|
||||
_emit_core(f"▶ {step_name}")
|
||||
try:
|
||||
result = await fn(*args, **kwargs)
|
||||
_emit_core(f"✓ {step_name}")
|
||||
return result
|
||||
except JobStopped:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_emit_core(f"✗ {step_name}: {exc}", type="warn")
|
||||
raise
|
||||
|
||||
return async_wrapper # type: ignore[return-value]
|
||||
|
||||
@functools.wraps(fn)
|
||||
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
_step_gate(step_name)
|
||||
_emit_core(f"▶ {step_name}")
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
_emit_core(f"✓ {step_name}")
|
||||
return result
|
||||
except JobStopped:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_emit_core(f"✗ {step_name}: {exc}", type="warn")
|
||||
raise
|
||||
|
||||
return sync_wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear module-level journal and control state (tests only)."""
|
||||
global _cached_job_id, _journal_path, _meta_written, _journal_file
|
||||
global _step_index, _current_step_name
|
||||
if _journal_file is not None:
|
||||
try:
|
||||
_journal_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
_cached_job_id = None
|
||||
_journal_path = None
|
||||
_meta_written = False
|
||||
_journal_file = None
|
||||
_step_index = 0
|
||||
_current_step_name = None
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
@@ -15,6 +16,29 @@ _user_env_cache: dict[str, str] | None = None
|
||||
_example_defaults_cache: dict[str, str] | None = None
|
||||
|
||||
|
||||
def _parse_env_value(raw_value: str) -> str:
|
||||
"""Parse one .env value: strip quotes / trailing comments; empty+# → \"\"."""
|
||||
value = raw_value if raw_value is not None else ""
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return ""
|
||||
|
||||
# Quoted values keep interior content (including #).
|
||||
if len(stripped) >= 2 and stripped[0] == stripped[-1] and stripped[0] in ('"', "'"):
|
||||
return stripped[1:-1]
|
||||
|
||||
# Unquoted: ``KEY= # comment`` or ``KEY=# comment`` must be empty, not the comment text.
|
||||
# Also strip ``KEY=value # comment``. Keep ``KEY=https://x/#y`` (no whitespace before #).
|
||||
if re.search(r"(^|\s)#", value):
|
||||
# Split at first whitespace-#-comment or whole-value comment.
|
||||
m = re.match(r"^(.*?)\s+#.*$", value)
|
||||
if m is not None:
|
||||
return m.group(1).strip()
|
||||
if stripped.startswith("#"):
|
||||
return ""
|
||||
return stripped
|
||||
|
||||
|
||||
def _parse_env_file(path: str) -> dict[str, str]:
|
||||
"""标准库手写 .env 解析(不引 python-dotenv)。"""
|
||||
result: dict[str, str] = {}
|
||||
@@ -29,14 +53,9 @@ def _parse_env_file(path: str) -> dict[str, str]:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if not key:
|
||||
continue
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
||||
value = value[1:-1]
|
||||
elif " #" in value:
|
||||
value = value.split(" #", 1)[0].strip()
|
||||
result[key] = value
|
||||
result[key] = _parse_env_value(value)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
106
src/jiangchang_skill_core/machine_power.py
Normal file
106
src/jiangchang_skill_core/machine_power.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""只读机器供电 / GPU 限频探测(失败静默;不作为 hard gate)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _probe_ac_power_windows() -> dict[str, Any] | None:
|
||||
"""GetSystemPowerStatus;非 Windows 或失败返回 None。"""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class SYSTEM_POWER_STATUS(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("ACLineStatus", wintypes.BYTE),
|
||||
("BatteryFlag", wintypes.BYTE),
|
||||
("BatteryLifePercent", wintypes.BYTE),
|
||||
("SystemStatusFlag", wintypes.BYTE),
|
||||
("BatteryLifeTime", wintypes.DWORD),
|
||||
("BatteryFullLifeTime", wintypes.DWORD),
|
||||
]
|
||||
|
||||
status = SYSTEM_POWER_STATUS()
|
||||
ok = ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(status))
|
||||
if not ok:
|
||||
return None
|
||||
ac = int(status.ACLineStatus)
|
||||
pct = int(status.BatteryLifePercent)
|
||||
return {
|
||||
"ac_line_status": ac, # 0=offline, 1=online, 255=unknown
|
||||
"on_ac_power": True if ac == 1 else (False if ac == 0 else None),
|
||||
"battery_life_percent": None if pct > 100 else pct,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _probe_nvidia_throttle() -> dict[str, Any] | None:
|
||||
"""nvidia-smi 只读查询;无驱动/失败返回 None。"""
|
||||
smi = shutil.which("nvidia-smi")
|
||||
if not smi:
|
||||
return None
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
smi,
|
||||
"--query-gpu=name,enforced.power.limit,power.limit,clocks_throttle_reasons.active",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
line = (completed.stdout or "").strip().splitlines()
|
||||
if not line:
|
||||
return None
|
||||
# 多卡时只取第一张,足够做环境提示
|
||||
parts = [p.strip() for p in line[0].split(",")]
|
||||
if len(parts) < 4:
|
||||
return None
|
||||
|
||||
def _float_or_none(raw: str) -> float | None:
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
throttle = parts[3]
|
||||
return {
|
||||
"gpu_name": parts[0] or None,
|
||||
"enforced_power_limit_w": _float_or_none(parts[1]),
|
||||
"power_limit_w": _float_or_none(parts[2]),
|
||||
"clocks_throttle_reasons_active": throttle if throttle else None,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def probe_machine_power() -> dict[str, Any]:
|
||||
"""汇总只读探测结果。字段缺失表示探不到,不视为错误。"""
|
||||
payload: dict[str, Any] = {
|
||||
"probed": False,
|
||||
"ac_power": None,
|
||||
"nvidia": None,
|
||||
}
|
||||
ac = _probe_ac_power_windows()
|
||||
nvidia = _probe_nvidia_throttle()
|
||||
if ac is not None:
|
||||
payload["ac_power"] = ac
|
||||
payload["probed"] = True
|
||||
if nvidia is not None:
|
||||
payload["nvidia"] = nvidia
|
||||
payload["probed"] = True
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = ["probe_machine_power"]
|
||||
@@ -16,11 +16,12 @@ import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
MEDIA_ASSETS_ZIP_URL = (
|
||||
"https://git.jc2009.com/client-commons/media-assets/archive/main.zip"
|
||||
MEDIA_ASSETS_BUNDLE_URL = (
|
||||
"https://git.jc2009.com/client-commons/media-assets/releases/download/vlatest/media-assets.zip"
|
||||
)
|
||||
MEDIA_ASSETS_ZIP_URL = MEDIA_ASSETS_BUNDLE_URL
|
||||
MEDIA_ASSETS_GIT_URL = "https://git.jc2009.com/client-commons/media-assets"
|
||||
|
||||
_WIN_DEFAULT_DATA_ROOT = Path(r"D:\jiangchang-data")
|
||||
@@ -28,17 +29,20 @@ _NON_WIN_DEFAULT_DATA_ROOT = Path.home() / ".jiangchang"
|
||||
|
||||
_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac"}
|
||||
_GIT_LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1"
|
||||
_MIN_AUDIO_BYTES = 128
|
||||
_MIN_AUDIO_BYTES = 1024
|
||||
|
||||
|
||||
def _is_git_lfs_pointer(path: Path) -> bool:
|
||||
def is_git_lfs_pointer(path: str | Path) -> bool:
|
||||
try:
|
||||
head = path.read_bytes()[:256]
|
||||
head = Path(path).read_bytes()[:256]
|
||||
except OSError:
|
||||
return False
|
||||
return head.startswith(_GIT_LFS_POINTER_PREFIX)
|
||||
|
||||
|
||||
_is_git_lfs_pointer = is_git_lfs_pointer
|
||||
|
||||
|
||||
def _enumerate_audio_like(music_dir: Path) -> list[Path]:
|
||||
return [
|
||||
path
|
||||
@@ -75,21 +79,25 @@ def _needs_music_content_repair(status: MediaAssetsStatus) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _is_usable_audio_file(path: Path) -> bool:
|
||||
if not path.is_file():
|
||||
def is_usable_audio_file(path: str | Path, *, min_bytes: int = _MIN_AUDIO_BYTES) -> bool:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return False
|
||||
if path.suffix.lower() not in _AUDIO_EXTENSIONS:
|
||||
if p.suffix.lower() not in _AUDIO_EXTENSIONS:
|
||||
return False
|
||||
if _is_git_lfs_pointer(path):
|
||||
if is_git_lfs_pointer(p):
|
||||
return False
|
||||
try:
|
||||
if path.stat().st_size < _MIN_AUDIO_BYTES:
|
||||
if p.stat().st_size < min_bytes:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_is_usable_audio_file = is_usable_audio_file
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaAssetsStatus:
|
||||
root: Path
|
||||
@@ -109,6 +117,12 @@ def _env_dict(env: dict[str, str] | None) -> dict[str, str]:
|
||||
return env if env is not None else dict(os.environ)
|
||||
|
||||
|
||||
def _media_assets_bundle_url(env: dict[str, str] | None = None) -> str:
|
||||
env_map = _env_dict(env)
|
||||
override = (env_map.get("MEDIA_ASSETS_BUNDLE_URL") or "").strip()
|
||||
return override or MEDIA_ASSETS_BUNDLE_URL
|
||||
|
||||
|
||||
def _resolve_jiangchang_data_root(
|
||||
data_root: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
@@ -474,12 +488,16 @@ def _try_download_via_zip(
|
||||
shared: Path,
|
||||
target: Path,
|
||||
warnings: list[str],
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
url: str | None = None,
|
||||
) -> bool:
|
||||
download_root = _download_dir(shared)
|
||||
download_root.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = download_root / "media-assets.zip"
|
||||
extracted = download_root / "extracted"
|
||||
staging = download_root / "staging"
|
||||
bundle_url = url or _media_assets_bundle_url(env)
|
||||
|
||||
try:
|
||||
if staging.exists():
|
||||
@@ -487,7 +505,7 @@ def _try_download_via_zip(
|
||||
if extracted.exists():
|
||||
shutil.rmtree(extracted, ignore_errors=True)
|
||||
|
||||
_download_zip(MEDIA_ASSETS_ZIP_URL, zip_path)
|
||||
_download_zip(bundle_url, zip_path)
|
||||
_extract_zip(zip_path, extracted)
|
||||
|
||||
found = _find_media_assets_root_in_tree(extracted)
|
||||
@@ -565,10 +583,15 @@ def _needs_media_repo_content(status: MediaAssetsStatus) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _maybe_repair_music_content(root: Path, status: MediaAssetsStatus) -> MediaAssetsStatus:
|
||||
def _maybe_repair_music_content(
|
||||
root: Path,
|
||||
status: MediaAssetsStatus,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> MediaAssetsStatus:
|
||||
if not _needs_music_content_repair(status):
|
||||
return status
|
||||
if _media_assets_root_overridden(None):
|
||||
if _media_assets_root_overridden(env):
|
||||
return status
|
||||
|
||||
warnings: list[str] = []
|
||||
@@ -581,7 +604,7 @@ def _maybe_repair_music_content(root: Path, status: MediaAssetsStatus) -> MediaA
|
||||
return _maybe_ensure_ffmpeg_tools(root, refreshed)
|
||||
|
||||
shared = _shared_parent(root)
|
||||
if _try_download_via_zip(shared, root, warnings):
|
||||
if _try_download_via_zip(shared, root, warnings, env=env):
|
||||
refreshed = _inspect_media_assets(root, source="zip")
|
||||
for warning in warnings:
|
||||
if warning not in refreshed.warnings:
|
||||
@@ -631,12 +654,13 @@ def ensure_media_assets(
|
||||
shared.mkdir(parents=True, exist_ok=True)
|
||||
warnings: list[str] = []
|
||||
|
||||
if _try_download_via_zip(shared, root, warnings):
|
||||
if _try_download_via_zip(shared, root, warnings, env=env_map):
|
||||
return _maybe_ensure_ffmpeg_tools(
|
||||
root,
|
||||
_maybe_repair_music_content(
|
||||
root,
|
||||
_inspect_media_assets(root, source="zip"),
|
||||
env=env_map,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -650,6 +674,7 @@ def ensure_media_assets(
|
||||
root,
|
||||
source="git" if _is_git_repo(root) else "local",
|
||||
),
|
||||
env=env_map,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -657,7 +682,7 @@ def ensure_media_assets(
|
||||
for warning in warnings:
|
||||
if warning not in final.warnings:
|
||||
final.warnings.append(warning)
|
||||
repaired = _maybe_repair_music_content(root, final)
|
||||
repaired = _maybe_repair_music_content(root, final, env=env_map)
|
||||
for warning in warnings:
|
||||
if warning not in repaired.warnings:
|
||||
repaired.warnings.append(warning)
|
||||
@@ -706,6 +731,46 @@ def resolve_music_dir(
|
||||
return status.music_dir
|
||||
|
||||
|
||||
def probe_background_music(
|
||||
root: str | Path | None = None,
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""只读探测背景音乐目录,不触发 media-assets 下载。"""
|
||||
env_map = _env_dict(dict(env) if env is not None else None)
|
||||
if root is not None:
|
||||
music_dir = Path(root) / "music"
|
||||
if not music_dir.is_dir():
|
||||
music_dir = Path(root) if Path(root).is_dir() else None
|
||||
else:
|
||||
data_root = (env_map.get("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
assets_root = resolve_media_assets_root(data_root or None, env_map)
|
||||
status = _inspect_media_assets(assets_root, source="local")
|
||||
music_dir = status.music_dir
|
||||
|
||||
music_root = str(music_dir) if music_dir is not None else None
|
||||
if music_dir is None or not music_dir.is_dir():
|
||||
return {
|
||||
"music_root": music_root,
|
||||
"mp3_count": 0,
|
||||
"usable_count": 0,
|
||||
"sample_path": None,
|
||||
"issue": "music_dir_missing",
|
||||
}
|
||||
|
||||
mp3_files = [path for path in music_dir.rglob("*.mp3") if path.is_file()]
|
||||
usable = [path for path in music_dir.rglob("*") if is_usable_audio_file(path)]
|
||||
issue = _music_content_issue(music_dir)
|
||||
sample = str(sorted(usable, key=lambda p: str(p).lower())[0]) if usable else None
|
||||
return {
|
||||
"music_root": music_root,
|
||||
"mp3_count": len(mp3_files),
|
||||
"usable_count": len(usable),
|
||||
"sample_path": sample,
|
||||
"issue": issue,
|
||||
}
|
||||
|
||||
|
||||
def background_music_issue(
|
||||
data_root: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""浏览器 RPA 共享库:stealth、拟人操作、启动封装、HITL 等待、失败存证。"""
|
||||
"""浏览器 RPA 共享库:stealth、拟人操作、启动封装、HITL 等待、失败存证、有界就绪等待。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import anti_detect, artifacts, errors, human_login, stealth
|
||||
from . import anti_detect, artifacts, errors, human_login, stealth, wait
|
||||
|
||||
try:
|
||||
from .browser import launch_persistent_browser
|
||||
@@ -14,14 +14,20 @@ try:
|
||||
except ImportError:
|
||||
RpaVideoSession = None # type: ignore[misc, assignment]
|
||||
|
||||
from .wait import PollUntilResult, poll_until, poll_until_sync
|
||||
|
||||
__all__ = [
|
||||
"anti_detect",
|
||||
"artifacts",
|
||||
"errors",
|
||||
"human_login",
|
||||
"stealth",
|
||||
"wait",
|
||||
"launch_persistent_browser",
|
||||
"RpaVideoSession",
|
||||
"PollUntilResult",
|
||||
"poll_until",
|
||||
"poll_until_sync",
|
||||
# re-export common anti_detect helpers
|
||||
"random_delay",
|
||||
"human_delay_short",
|
||||
|
||||
@@ -5,10 +5,11 @@ from __future__ import annotations
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
def _artifacts_enabled() -> bool:
|
||||
v = (os.environ.get("OPENCLAW_ARTIFACTS_ON_FAILURE") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off")
|
||||
return config.get_bool("OPENCLAW_ARTIFACTS_ON_FAILURE", default=True)
|
||||
|
||||
|
||||
def artifact_path(data_dir: str, batch_id: str, tag: str, ext: str = "png") -> str:
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .. import config
|
||||
from ..runtime_env import find_chrome_executable
|
||||
from .errors import ERR_BROWSER_START_TIMEOUT, ERR_MISSING_BROWSER
|
||||
from .stealth import (
|
||||
STEALTH_INIT_SCRIPT,
|
||||
persistent_context_launch_parts,
|
||||
@@ -15,6 +17,23 @@ from .stealth import (
|
||||
if TYPE_CHECKING:
|
||||
from playwright.async_api import BrowserContext
|
||||
|
||||
# 默认不设启动超时(保持 1.2.10 行为);可通过参数或环境变量开启。
|
||||
_ENV_LAUNCH_TIMEOUT_MS = "OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS"
|
||||
|
||||
|
||||
def _resolve_launch_timeout_ms(timeout_ms: int | None) -> int | None:
|
||||
if timeout_ms is not None:
|
||||
value = int(timeout_ms)
|
||||
return value if value > 0 else None
|
||||
raw = (config.get(_ENV_LAUNCH_TIMEOUT_MS, default="") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
async def launch_persistent_browser(
|
||||
playwright,
|
||||
@@ -24,20 +43,25 @@ async def launch_persistent_browser(
|
||||
executable_path: str | None = None,
|
||||
headless: bool | None = None,
|
||||
extra_args: list[str] | None = None,
|
||||
timeout_ms: int | None = None,
|
||||
) -> "BrowserContext":
|
||||
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
|
||||
|
||||
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
|
||||
录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 Playwright 传递任何录屏参数。
|
||||
|
||||
timeout_ms:
|
||||
可选启动超时(毫秒)。为 None 时读 OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS;
|
||||
仍为空则不包裹超时(与历史行为一致)。超时抛 RuntimeError,前缀
|
||||
ERROR:BROWSER_START_TIMEOUT。本函数不做自动重试。
|
||||
"""
|
||||
if headless is None:
|
||||
v = (os.environ.get("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
|
||||
headless = v in ("1", "true", "yes", "on")
|
||||
headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
|
||||
|
||||
chrome = executable_path or find_chrome_executable()
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
|
||||
f"{ERR_MISSING_BROWSER} 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
|
||||
)
|
||||
|
||||
args, ignore = persistent_context_launch_parts(extra_args=extra_args)
|
||||
@@ -52,7 +76,22 @@ async def launch_persistent_browser(
|
||||
if ignore is not None:
|
||||
launch_kwargs["ignore_default_args"] = ignore
|
||||
|
||||
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
|
||||
launch_timeout_ms = _resolve_launch_timeout_ms(timeout_ms)
|
||||
try:
|
||||
if launch_timeout_ms is None:
|
||||
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
|
||||
else:
|
||||
context = await asyncio.wait_for(
|
||||
playwright.chromium.launch_persistent_context(**launch_kwargs),
|
||||
timeout=launch_timeout_ms / 1000.0,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise RuntimeError(
|
||||
f"{ERR_BROWSER_START_TIMEOUT} 浏览器启动超时"
|
||||
f"({launch_timeout_ms}ms)。机器限频/供电不足时易触发,可增大"
|
||||
f" {_ENV_LAUNCH_TIMEOUT_MS} 后重试。"
|
||||
) from exc
|
||||
|
||||
if stealth_enabled():
|
||||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
return context
|
||||
|
||||
@@ -8,6 +8,11 @@ ERR_MISSING_BROWSER = "ERROR:MISSING_BROWSER"
|
||||
ERR_DEVICE_NOT_READY = "ERROR:DEVICE_NOT_READY"
|
||||
ERR_WINDOW_NOT_FOUND = "ERROR:WINDOW_NOT_FOUND"
|
||||
|
||||
# 就绪 / 环境类(与配置门禁、MISSING_BROWSER 区分;是否 fatal/retryable 由技能决定)
|
||||
ERR_UI_NOT_READY = "ERROR:UI_NOT_READY"
|
||||
ERR_BROWSER_START_TIMEOUT = "ERROR:BROWSER_START_TIMEOUT"
|
||||
ERR_ENV_DEGRADED = "ERROR:ENV_DEGRADED"
|
||||
|
||||
__all__ = [
|
||||
"ERR_REQUIRE_LOGIN",
|
||||
"ERR_LOGIN_TIMEOUT",
|
||||
@@ -16,4 +21,7 @@ __all__ = [
|
||||
"ERR_MISSING_BROWSER",
|
||||
"ERR_DEVICE_NOT_READY",
|
||||
"ERR_WINDOW_NOT_FOUND",
|
||||
"ERR_UI_NOT_READY",
|
||||
"ERR_BROWSER_START_TIMEOUT",
|
||||
"ERR_ENV_DEGRADED",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from .. import config
|
||||
|
||||
STEALTH_INIT_SCRIPT = """
|
||||
(() => {
|
||||
@@ -48,8 +48,7 @@ STEALTH_INIT_SCRIPT = """
|
||||
|
||||
|
||||
def stealth_enabled() -> bool:
|
||||
v = (os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off")
|
||||
return config.get_bool("OPENCLAW_PLAYWRIGHT_STEALTH", default=True)
|
||||
|
||||
|
||||
def persistent_context_launch_parts(
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"""RPA 运行录屏会话:ffmpeg 桌面录制 → 字幕 + 背景音乐 → 最终 MP4。"""
|
||||
"""RPA 运行录屏会话:ffmpeg 桌面录制 → 字幕 + 旁白 + 背景音乐 → 最终 MP4。
|
||||
|
||||
背景音乐默认循环铺满录屏时长并在结尾淡出;旁白使用 Windows 本地 SAPI 合成,
|
||||
失败时静默降级,不影响视频生成。Skill 无需额外配置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -18,6 +23,7 @@ from ..media_assets import (
|
||||
ensure_media_assets,
|
||||
pick_background_music,
|
||||
resolve_ffmpeg,
|
||||
resolve_ffprobe,
|
||||
)
|
||||
|
||||
_JIANGCHANG_SUBTITLE_STYLE = (
|
||||
@@ -28,6 +34,54 @@ _JIANGCHANG_SUBTITLE_STYLE = (
|
||||
|
||||
_CAPTURE_FRAMERATE = 15
|
||||
|
||||
# 背景音乐循环 + 结尾淡出(默认策略,非 env 配置)
|
||||
_MUSIC_VOLUME = 0.12
|
||||
_VOICE_VOLUME = 1.0
|
||||
_MUSIC_FADE_OUT_SECONDS = 3.0
|
||||
_MIN_MUSIC_FADE_OUT_SECONDS = 1.0
|
||||
_SHORT_VIDEO_FADE_RATIO = 0.25
|
||||
|
||||
_VOICEOVER_MAX_CHARS = 60
|
||||
_VOICEOVER_DEDUP_SECONDS = 10.0
|
||||
_VOICEOVER_MIN_GAP_SECONDS = 2.0
|
||||
|
||||
_INTRO_BUFFER_SECONDS = 5.0
|
||||
_INTRO_STABILIZE_SECONDS = 1.0
|
||||
_OUTRO_BUFFER_SECONDS = 5.0
|
||||
|
||||
_VOICEOVER_SKIP_SUBSTRINGS = (
|
||||
"等待 1-5s",
|
||||
"跳过重复",
|
||||
"写入联系人库",
|
||||
"探针模式",
|
||||
"异常",
|
||||
"失败",
|
||||
)
|
||||
|
||||
_VOICEOVER_IMPORTANT_SUBSTRINGS = (
|
||||
"开始",
|
||||
"打开",
|
||||
"启动",
|
||||
"定位",
|
||||
"输入",
|
||||
"点击",
|
||||
"搜索",
|
||||
"等待搜索结果",
|
||||
"检查登录状态",
|
||||
"处理验证码",
|
||||
"等待人工验证",
|
||||
"解析",
|
||||
"提取",
|
||||
"采集完成",
|
||||
"部分完成",
|
||||
"完成",
|
||||
)
|
||||
|
||||
# 明显技术日志:时间戳前缀、纯数字进度等
|
||||
_TECH_LOG_RE = re.compile(
|
||||
r"^\d{4}-\d{2}-\d{2}|^\[\w+\]|^DEBUG:|^INFO:|^WARN:|^ERROR:"
|
||||
)
|
||||
|
||||
|
||||
def _record_video_enabled() -> bool:
|
||||
return config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
|
||||
@@ -41,6 +95,68 @@ def _resolve_ffmpeg_exe() -> Optional[Path]:
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def _resolve_ffprobe_exe() -> Optional[Path]:
|
||||
"""定位 ffprobe;优先 media_assets bundle,否则尝试 ffmpeg 同目录。"""
|
||||
ffprobe = resolve_ffprobe()
|
||||
if ffprobe is not None:
|
||||
path = Path(ffprobe)
|
||||
if path.is_file():
|
||||
return path
|
||||
ffmpeg = _resolve_ffmpeg_exe()
|
||||
if ffmpeg is not None:
|
||||
sibling = ffmpeg.parent / ("ffprobe.exe" if sys.platform == "win32" else "ffprobe")
|
||||
if sibling.is_file():
|
||||
return sibling
|
||||
return None
|
||||
|
||||
|
||||
def _probe_media_duration_seconds(
|
||||
ffprobe_exe: Optional[Path], media_path: Path
|
||||
) -> Optional[float]:
|
||||
if ffprobe_exe is None or not media_path.is_file():
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(ffprobe_exe),
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=nk=1:nw=1",
|
||||
str(media_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
raw = (result.stdout or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
value = float(raw)
|
||||
return value if value > 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _music_fade_params(
|
||||
duration_seconds: Optional[float],
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
if duration_seconds is None or duration_seconds <= 0:
|
||||
return (None, None)
|
||||
fade = min(
|
||||
_MUSIC_FADE_OUT_SECONDS,
|
||||
max(_MIN_MUSIC_FADE_OUT_SECONDS, duration_seconds * _SHORT_VIDEO_FADE_RATIO),
|
||||
)
|
||||
fade_start = max(0.0, duration_seconds - fade)
|
||||
return (fade_start, fade)
|
||||
|
||||
|
||||
def _resolve_music_file(warnings: List[str]) -> Optional[Path]:
|
||||
music = pick_background_music()
|
||||
if music is not None:
|
||||
@@ -59,6 +175,183 @@ class _StepEntry:
|
||||
duration: float = 4.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _VoiceClip:
|
||||
path: Path
|
||||
delay_ms: int
|
||||
|
||||
|
||||
def _voiceover_text_for_step(text: str) -> Optional[str]:
|
||||
"""过滤适合朗读的步骤文本;噪音/技术日志返回 None。"""
|
||||
cleaned = (text or "").strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
if _TECH_LOG_RE.search(cleaned):
|
||||
return None
|
||||
for skip in _VOICEOVER_SKIP_SUBSTRINGS:
|
||||
if skip in cleaned:
|
||||
return None
|
||||
if len(cleaned) > _VOICEOVER_MAX_CHARS:
|
||||
cleaned = cleaned[:_VOICEOVER_MAX_CHARS]
|
||||
return cleaned
|
||||
|
||||
|
||||
def _is_important_voiceover(text: str) -> bool:
|
||||
for important in _VOICEOVER_IMPORTANT_SUBSTRINGS:
|
||||
if important in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _select_voiceover_clips(
|
||||
entries: List[_StepEntry],
|
||||
) -> List[tuple[_StepEntry, str]]:
|
||||
"""按时间排序,去重并保证旁白间隔;关键动作可绕过最小间隔。"""
|
||||
result: List[tuple[_StepEntry, str]] = []
|
||||
last_text_at: Dict[str, float] = {}
|
||||
last_spoken_at = -_VOICEOVER_MIN_GAP_SECONDS
|
||||
|
||||
for entry in sorted(entries, key=lambda e: e.start):
|
||||
spoken = _voiceover_text_for_step(entry.text)
|
||||
if not spoken:
|
||||
continue
|
||||
prev = last_text_at.get(spoken)
|
||||
if prev is not None and entry.start - prev < _VOICEOVER_DEDUP_SECONDS:
|
||||
continue
|
||||
if (
|
||||
not _is_important_voiceover(spoken)
|
||||
and entry.start - last_spoken_at < _VOICEOVER_MIN_GAP_SECONDS
|
||||
):
|
||||
continue
|
||||
last_text_at[spoken] = entry.start
|
||||
last_spoken_at = entry.start
|
||||
result.append((entry, spoken))
|
||||
return result
|
||||
|
||||
|
||||
def _tts_available() -> bool:
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
def _sapi_synthesize_wav(text: str, output_wav: Path) -> bool:
|
||||
if not _tts_available():
|
||||
return False
|
||||
output_wav.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_str = str(output_wav.resolve()).replace("'", "''")
|
||||
text_esc = text.replace("'", "''")
|
||||
ps_script = (
|
||||
"Add-Type -AssemblyName System.Speech; "
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; "
|
||||
f"$s.SetOutputToWaveFile('{out_str}'); "
|
||||
f"$s.Speak('{text_esc}'); "
|
||||
"$s.Dispose()"
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=120,
|
||||
)
|
||||
return (
|
||||
result.returncode == 0
|
||||
and output_wav.is_file()
|
||||
and output_wav.stat().st_size > 0
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _mix_voice_clips(
|
||||
ffmpeg_exe: Path, clips: List[_VoiceClip], output_wav: Path
|
||||
) -> bool:
|
||||
if not clips:
|
||||
return False
|
||||
output_wav.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filter_parts: List[str] = []
|
||||
mix_labels: List[str] = []
|
||||
cmd_inputs: List[str] = []
|
||||
|
||||
for i, clip in enumerate(clips):
|
||||
cmd_inputs.extend(["-i", str(clip.path)])
|
||||
label = f"v{i}"
|
||||
filter_parts.append(
|
||||
f"[{i}:a]adelay={clip.delay_ms}|{clip.delay_ms},"
|
||||
f"volume={_VOICE_VOLUME}[{label}]"
|
||||
)
|
||||
mix_labels.append(f"[{label}]")
|
||||
|
||||
if len(clips) == 1:
|
||||
filter_complex = filter_parts[0].replace(f"[v0]", "[out]")
|
||||
# single clip: relabel output
|
||||
filter_complex = (
|
||||
f"[0:a]adelay={clips[0].delay_ms}|{clips[0].delay_ms},"
|
||||
f"volume={_VOICE_VOLUME}[out]"
|
||||
)
|
||||
else:
|
||||
filter_complex = (
|
||||
";".join(filter_parts)
|
||||
+ ";"
|
||||
+ "".join(mix_labels)
|
||||
+ f"amix=inputs={len(clips)}:duration=longest:dropout_transition=0:normalize=0[out]"
|
||||
)
|
||||
|
||||
cmd_tail = [
|
||||
"-y",
|
||||
*cmd_inputs,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[out]",
|
||||
str(output_wav),
|
||||
]
|
||||
ok, _ = _run_ffmpeg(ffmpeg_exe, cmd_tail)
|
||||
return ok and output_wav.is_file()
|
||||
|
||||
|
||||
def _synthesize_step_voiceover(
|
||||
entries: List[_StepEntry],
|
||||
output_wav: Path,
|
||||
warnings: List[str],
|
||||
*,
|
||||
ffmpeg_exe: Optional[Path] = None,
|
||||
) -> Optional[Path]:
|
||||
"""本地 Windows SAPI 逐条合成旁白并按步骤 start 时间对齐;失败返回 None。"""
|
||||
if not _tts_available():
|
||||
warnings.append("tts_unsupported_platform")
|
||||
return None
|
||||
|
||||
clips_to_speak = _select_voiceover_clips(entries)
|
||||
if not clips_to_speak:
|
||||
return None
|
||||
|
||||
voice_dir = output_wav.parent
|
||||
voice_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_clips: List[_VoiceClip] = []
|
||||
|
||||
for i, (entry, spoken) in enumerate(clips_to_speak):
|
||||
clip_path = voice_dir / f"voice_{i:03d}.wav"
|
||||
if not _sapi_synthesize_wav(spoken, clip_path):
|
||||
warnings.append("tts_synthesis_failed")
|
||||
return None
|
||||
delay_ms = max(0, int(entry.start * 1000))
|
||||
temp_clips.append(_VoiceClip(path=clip_path, delay_ms=delay_ms))
|
||||
|
||||
if ffmpeg_exe is None:
|
||||
ffmpeg_exe = _resolve_ffmpeg_exe()
|
||||
if ffmpeg_exe is None:
|
||||
warnings.append("tts_mix_ffmpeg_missing")
|
||||
return None
|
||||
|
||||
if not _mix_voice_clips(ffmpeg_exe, temp_clips, output_wav):
|
||||
warnings.append("tts_mix_failed")
|
||||
return None
|
||||
return output_wav
|
||||
|
||||
|
||||
def _generate_srt(entries: List[_StepEntry], output_path: str) -> None:
|
||||
MIN_DURATION = 1.0
|
||||
GAP = 0.1
|
||||
@@ -117,27 +410,16 @@ def _run_ffmpeg(ffmpeg_exe: Path, cmd_tail: List[str]) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def _build_desktop_record_cmd(ffmpeg_exe: Path, capture_path: str) -> Optional[List[str]]:
|
||||
"""Windows gdigrab 全桌面录制;非 Windows 返回 None。"""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
return _ffmpeg_cmd(
|
||||
ffmpeg_exe,
|
||||
"-y",
|
||||
"-f",
|
||||
"gdigrab",
|
||||
"-framerate",
|
||||
str(_CAPTURE_FRAMERATE),
|
||||
"-i",
|
||||
"desktop",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
capture_path,
|
||||
)
|
||||
def _build_music_filter(
|
||||
audio_input_idx: int,
|
||||
fade_start: Optional[float],
|
||||
fade_duration: Optional[float],
|
||||
output_label: str = "music",
|
||||
) -> str:
|
||||
filt = f"[{audio_input_idx}:a]volume={_MUSIC_VOLUME}"
|
||||
if fade_start is not None and fade_duration is not None:
|
||||
filt += f",afade=t=out:st={fade_start}:d={fade_duration}"
|
||||
return filt + f"[{output_label}]"
|
||||
|
||||
|
||||
def _compose_capture_mp4(
|
||||
@@ -147,7 +429,8 @@ def _compose_capture_mp4(
|
||||
output_path: Path,
|
||||
*,
|
||||
music_file: Optional[Path] = None,
|
||||
music_volume: float = 0.15,
|
||||
voiceover_file: Optional[Path] = None,
|
||||
warnings: Optional[List[str]] = None,
|
||||
) -> tuple[bool, str]:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
srt_esc = _escape_srt_path(str(srt_path.resolve()))
|
||||
@@ -156,38 +439,77 @@ def _compose_capture_mp4(
|
||||
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
|
||||
)
|
||||
|
||||
if music_file and music_file.is_file():
|
||||
filter_complex = (
|
||||
f"[0:v]{subtitle_filter}[vout];"
|
||||
f"[1:a]volume={music_volume},apad[aout]"
|
||||
has_music = music_file is not None and music_file.is_file()
|
||||
has_voice = voiceover_file is not None and voiceover_file.is_file()
|
||||
|
||||
fade_start: Optional[float] = None
|
||||
fade_duration: Optional[float] = None
|
||||
if has_music:
|
||||
ffprobe = _resolve_ffprobe_exe()
|
||||
duration = _probe_media_duration_seconds(ffprobe, capture_path)
|
||||
fade_start, fade_duration = _music_fade_params(duration)
|
||||
if fade_start is None and warnings is not None:
|
||||
warnings.append("video_duration_probe_failed_for_music_fade")
|
||||
|
||||
if has_music or has_voice:
|
||||
cmd_tail: List[str] = ["-y", "-i", str(capture_path)]
|
||||
input_idx = 1
|
||||
filter_parts: List[str] = [f"[0:v]{subtitle_filter}[vout]"]
|
||||
audio_labels: List[str] = []
|
||||
|
||||
if has_music:
|
||||
cmd_tail.extend(["-stream_loop", "-1", "-i", str(music_file)])
|
||||
music_label = "aout" if not has_voice else "music"
|
||||
filter_parts.append(
|
||||
_build_music_filter(input_idx, fade_start, fade_duration, music_label)
|
||||
)
|
||||
if has_voice:
|
||||
audio_labels.append(f"[{music_label}]")
|
||||
input_idx += 1
|
||||
|
||||
if has_voice:
|
||||
cmd_tail.extend(["-i", str(voiceover_file)])
|
||||
if has_music:
|
||||
filter_parts.append(
|
||||
f"[{input_idx}:a]volume={_VOICE_VOLUME}[voice]"
|
||||
)
|
||||
audio_labels.append("[voice]")
|
||||
else:
|
||||
# voice-only:apad 补静音,避免 -shortest 按旁白长度截短视频
|
||||
filter_parts.append(
|
||||
f"[{input_idx}:a]volume={_VOICE_VOLUME},apad[aout]"
|
||||
)
|
||||
input_idx += 1
|
||||
|
||||
if len(audio_labels) == 2:
|
||||
filter_parts.append(
|
||||
"".join(audio_labels) + "amix=inputs=2:normalize=0[aout]"
|
||||
)
|
||||
|
||||
cmd_tail.extend(
|
||||
[
|
||||
"-filter_complex",
|
||||
";".join(filter_parts),
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-shortest",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
cmd_tail = [
|
||||
"-y",
|
||||
"-i",
|
||||
str(capture_path),
|
||||
"-i",
|
||||
str(music_file),
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-shortest",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(output_path),
|
||||
]
|
||||
else:
|
||||
cmd_tail = [
|
||||
"-y",
|
||||
@@ -212,6 +534,29 @@ def _compose_capture_mp4(
|
||||
return _run_ffmpeg(ffmpeg_exe, cmd_tail)
|
||||
|
||||
|
||||
def _build_desktop_record_cmd(ffmpeg_exe: Path, capture_path: str) -> Optional[List[str]]:
|
||||
"""Windows gdigrab 全桌面录制;非 Windows 返回 None。"""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
return _ffmpeg_cmd(
|
||||
ffmpeg_exe,
|
||||
"-y",
|
||||
"-f",
|
||||
"gdigrab",
|
||||
"-framerate",
|
||||
str(_CAPTURE_FRAMERATE),
|
||||
"-i",
|
||||
"desktop",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
capture_path,
|
||||
)
|
||||
|
||||
|
||||
class RpaVideoSession:
|
||||
"""Service/RPA 层统一 ffmpeg 桌面录屏;OPENCLAW_RECORD_VIDEO=1 时启用成片流程。"""
|
||||
|
||||
@@ -222,11 +567,15 @@ class RpaVideoSession:
|
||||
skill_data_dir: str,
|
||||
batch_id: str,
|
||||
title: str = "",
|
||||
closing_title: str = "",
|
||||
emit_activity: bool = True,
|
||||
) -> None:
|
||||
self.skill_slug = skill_slug
|
||||
self.skill_data_dir = os.path.abspath(skill_data_dir)
|
||||
self.batch_id = batch_id
|
||||
self.title = title
|
||||
self.closing_title = closing_title
|
||||
self.emit_activity = emit_activity
|
||||
self.enabled = _record_video_enabled()
|
||||
self.warnings: List[str] = []
|
||||
self.output_video_path: Optional[str] = None
|
||||
@@ -242,12 +591,17 @@ class RpaVideoSession:
|
||||
self.skill_data_dir, "rpa-artifacts", batch_id
|
||||
)
|
||||
self._subtitles_dir = os.path.join(self._artifact_root, "subtitles")
|
||||
self._voiceover_dir = os.path.join(self._artifact_root, "voiceover")
|
||||
self._logs_dir = os.path.join(self._artifact_root, "logs")
|
||||
self._ffmpeg_record_log_path = os.path.join(self._logs_dir, "ffmpeg-record.log")
|
||||
self._capture_path = os.path.join(self._artifact_root, "capture.mp4")
|
||||
self._srt_path = os.path.join(self._subtitles_dir, f"{self._video_stem}.srt")
|
||||
self._voiceover_path = os.path.join(self._voiceover_dir, "voiceover.wav")
|
||||
self._planned_output = os.path.join(self._videos_dir, f"{self._video_stem}.mp4")
|
||||
|
||||
self._music_path: Optional[str] = None
|
||||
self._voiceover_output: Optional[str] = None
|
||||
|
||||
self._steps: List[_StepEntry] = []
|
||||
self._t0: Optional[float] = None
|
||||
self._ffmpeg_proc: Optional[subprocess.Popen] = None
|
||||
@@ -256,6 +610,7 @@ class RpaVideoSession:
|
||||
if self.enabled:
|
||||
os.makedirs(self._videos_dir, exist_ok=True)
|
||||
os.makedirs(self._subtitles_dir, exist_ok=True)
|
||||
os.makedirs(self._voiceover_dir, exist_ok=True)
|
||||
os.makedirs(self._logs_dir, exist_ok=True)
|
||||
os.makedirs(self._artifact_root, exist_ok=True)
|
||||
|
||||
@@ -278,13 +633,25 @@ class RpaVideoSession:
|
||||
self.add_step(text, duration=duration)
|
||||
|
||||
def add_step(self, text: str, *, duration: float = 4.0) -> None:
|
||||
"""记录一步骤字幕(相对录屏开始时间)。"""
|
||||
"""记录一步骤字幕(相对录屏开始时间);可选同步写入 Activity Stream。"""
|
||||
cleaned = text.strip()
|
||||
if self.emit_activity and cleaned:
|
||||
try:
|
||||
from .. import activity
|
||||
|
||||
activity.emit(
|
||||
cleaned,
|
||||
skill=self.skill_slug,
|
||||
stage="rpa.video",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if not self.enabled:
|
||||
return
|
||||
if self._t0 is None:
|
||||
self._t0 = time.monotonic()
|
||||
elapsed = time.monotonic() - self._t0
|
||||
self._steps.append(_StepEntry(start=elapsed, text=text.strip(), duration=duration))
|
||||
self._steps.append(_StepEntry(start=elapsed, text=cleaned, duration=duration))
|
||||
|
||||
def _close_ffmpeg_record_log(self) -> None:
|
||||
fh = self._ffmpeg_record_log_file
|
||||
@@ -366,19 +733,80 @@ class RpaVideoSession:
|
||||
self._close_ffmpeg_record_log()
|
||||
time.sleep(0.3)
|
||||
|
||||
async def _safe_add_step(self, text: str, *, duration: float = 4.0) -> None:
|
||||
try:
|
||||
self.add_step(text, duration=duration)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "RpaVideoSession":
|
||||
if self.enabled:
|
||||
self._t0 = time.monotonic()
|
||||
if self.title:
|
||||
self.add_step(self.title, duration=3.0)
|
||||
self._start_ffmpeg_capture()
|
||||
await asyncio.sleep(_INTRO_STABILIZE_SECONDS)
|
||||
if self.title:
|
||||
await self._safe_add_step(self.title, duration=4.0)
|
||||
intro_wait = _INTRO_BUFFER_SECONDS - _INTRO_STABILIZE_SECONDS
|
||||
if intro_wait > 0:
|
||||
await asyncio.sleep(intro_wait)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
if self.enabled:
|
||||
if self.closing_title:
|
||||
await self._safe_add_step(self.closing_title, duration=4.0)
|
||||
await asyncio.sleep(_OUTRO_BUFFER_SECONDS)
|
||||
self._stop_ffmpeg_capture()
|
||||
await self.finalize()
|
||||
|
||||
def _try_compose_variants(
|
||||
self,
|
||||
ffmpeg_exe: Path,
|
||||
capture: Path,
|
||||
srt_file: Path,
|
||||
output: Path,
|
||||
*,
|
||||
music: Optional[Path],
|
||||
voiceover: Optional[Path],
|
||||
use_srt: bool,
|
||||
) -> tuple[bool, str]:
|
||||
"""按 music/voice 组合尝试合成,失败时逐级降级。"""
|
||||
srt = srt_file if use_srt else Path(self._srt_path)
|
||||
variants: List[tuple[Optional[Path], Optional[Path]]] = [
|
||||
(music, voiceover),
|
||||
]
|
||||
if music and voiceover:
|
||||
variants.append((None, voiceover))
|
||||
if music:
|
||||
variants.append((music, None))
|
||||
variants.append((None, None))
|
||||
|
||||
seen: set[tuple[Optional[str], Optional[str]]] = set()
|
||||
last_err = ""
|
||||
for m, v in variants:
|
||||
key = (str(m) if m else None, str(v) if v else None)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ok, err = _compose_capture_mp4(
|
||||
ffmpeg_exe,
|
||||
capture,
|
||||
srt,
|
||||
output,
|
||||
music_file=m,
|
||||
voiceover_file=v,
|
||||
warnings=self.warnings,
|
||||
)
|
||||
if ok:
|
||||
return True, ""
|
||||
last_err = err
|
||||
if m is not None and v is not None:
|
||||
self.warnings.append(f"ffmpeg_with_music_and_voice_failed: {err}")
|
||||
elif m is not None:
|
||||
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
|
||||
|
||||
return False, last_err
|
||||
|
||||
async def finalize(self) -> None:
|
||||
"""停止录制后合成最终 MP4;失败只记 warning,不抛异常。"""
|
||||
if not self.enabled:
|
||||
@@ -406,7 +834,12 @@ class RpaVideoSession:
|
||||
self.warnings.append(f"subtitle_write_failed: {exc}")
|
||||
|
||||
music = _resolve_music_file(self.warnings)
|
||||
if music is None and "background_music_lfs_pointer_only" not in self.warnings and "background_music_invalid" not in self.warnings:
|
||||
if music is not None:
|
||||
self._music_path = str(music.resolve())
|
||||
elif (
|
||||
"background_music_lfs_pointer_only" not in self.warnings
|
||||
and "background_music_invalid" not in self.warnings
|
||||
):
|
||||
self.warnings.append("background_music_missing")
|
||||
|
||||
ffmpeg_exe = _resolve_ffmpeg_exe()
|
||||
@@ -415,25 +848,32 @@ class RpaVideoSession:
|
||||
self.artifacts = self.summary()
|
||||
return
|
||||
|
||||
voiceover: Optional[Path] = None
|
||||
try:
|
||||
vo = _synthesize_step_voiceover(
|
||||
entries,
|
||||
Path(self._voiceover_path),
|
||||
self.warnings,
|
||||
ffmpeg_exe=ffmpeg_exe,
|
||||
)
|
||||
if vo is not None:
|
||||
voiceover = vo
|
||||
self._voiceover_output = str(vo.resolve())
|
||||
except Exception as exc:
|
||||
self.warnings.append(f"tts_unexpected_error: {exc}")
|
||||
|
||||
srt_file = Path(self._srt_path)
|
||||
use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
|
||||
|
||||
ok, err = _compose_capture_mp4(
|
||||
ok, err = self._try_compose_variants(
|
||||
ffmpeg_exe,
|
||||
capture,
|
||||
srt_file if use_srt else Path(self._srt_path),
|
||||
srt_file,
|
||||
Path(self._planned_output),
|
||||
music_file=music,
|
||||
music=music,
|
||||
voiceover=voiceover,
|
||||
use_srt=use_srt,
|
||||
)
|
||||
if not ok and music is not None:
|
||||
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
|
||||
ok, err = _compose_capture_mp4(
|
||||
ffmpeg_exe,
|
||||
capture,
|
||||
srt_file if use_srt else Path(self._srt_path),
|
||||
Path(self._planned_output),
|
||||
music_file=None,
|
||||
)
|
||||
if not ok and not use_srt:
|
||||
ok, err = _run_ffmpeg(
|
||||
ffmpeg_exe,
|
||||
@@ -469,12 +909,28 @@ class RpaVideoSession:
|
||||
if self.enabled and self._ffmpeg_record_log_path
|
||||
else None
|
||||
)
|
||||
audio_warnings = [
|
||||
w
|
||||
for w in self.warnings
|
||||
if w.startswith(
|
||||
(
|
||||
"tts_",
|
||||
"video_duration_probe_failed",
|
||||
"ffmpeg_with_music",
|
||||
"background_music_",
|
||||
)
|
||||
)
|
||||
or w == "background_music_missing"
|
||||
]
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"path": self.output_video_path,
|
||||
"capture_path": self.capture_path,
|
||||
"record_log_path": record_log,
|
||||
"warnings": list(self.warnings),
|
||||
"voiceover_path": self._voiceover_output,
|
||||
"music_path": self._music_path,
|
||||
"audio_warnings": audio_warnings,
|
||||
}
|
||||
|
||||
def to_artifact_dict(self) -> Dict[str, Any]:
|
||||
|
||||
112
src/jiangchang_skill_core/rpa/wait.py
Normal file
112
src/jiangchang_skill_core/rpa/wait.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""有界就绪等待:在超时内轮询条件,区分「还在加载」与「时间到仍未就绪」。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Union
|
||||
|
||||
from ..activity import interruptible_sleep, interruptible_sleep_sync
|
||||
|
||||
Predicate = Callable[[], Union[bool, Awaitable[bool]]]
|
||||
SyncPredicate = Callable[[], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollUntilResult:
|
||||
"""poll_until / poll_until_sync 的结果。"""
|
||||
|
||||
ok: bool
|
||||
elapsed_sec: float
|
||||
timed_out: bool = False
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return self.ok
|
||||
|
||||
|
||||
async def _call_predicate(predicate: Predicate) -> bool:
|
||||
result = predicate()
|
||||
if inspect.isawaitable(result):
|
||||
result = await result # type: ignore[misc]
|
||||
return bool(result)
|
||||
|
||||
|
||||
async def poll_until(
|
||||
predicate: Predicate,
|
||||
*,
|
||||
timeout_sec: float,
|
||||
interval_sec: float = 0.5,
|
||||
) -> PollUntilResult:
|
||||
"""异步有界轮询,直到 ``predicate`` 为真或超时。
|
||||
|
||||
- 等待切片走 ``interruptible_sleep``,可响应宿主 pause/stop。
|
||||
- ``predicate`` 可为同步或 async 可调用对象;异常视为尚未就绪(继续轮询)。
|
||||
- 超时不抛异常,返回 ``timed_out=True``,由调用方映射错误码。
|
||||
"""
|
||||
timeout = max(0.0, float(timeout_sec))
|
||||
interval = max(0.05, float(interval_sec))
|
||||
started = time.monotonic()
|
||||
deadline = started + timeout
|
||||
|
||||
while True:
|
||||
try:
|
||||
if await _call_predicate(predicate):
|
||||
return PollUntilResult(
|
||||
ok=True,
|
||||
elapsed_sec=round(time.monotonic() - started, 3),
|
||||
timed_out=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return PollUntilResult(
|
||||
ok=False,
|
||||
elapsed_sec=round(time.monotonic() - started, 3),
|
||||
timed_out=True,
|
||||
)
|
||||
await interruptible_sleep(min(interval, remaining))
|
||||
|
||||
|
||||
def poll_until_sync(
|
||||
predicate: SyncPredicate,
|
||||
*,
|
||||
timeout_sec: float,
|
||||
interval_sec: float = 0.5,
|
||||
) -> PollUntilResult:
|
||||
"""同步有界轮询(``interruptible_sleep_sync``)。"""
|
||||
timeout = max(0.0, float(timeout_sec))
|
||||
interval = max(0.05, float(interval_sec))
|
||||
started = time.monotonic()
|
||||
deadline = started + timeout
|
||||
|
||||
while True:
|
||||
try:
|
||||
if bool(predicate()):
|
||||
return PollUntilResult(
|
||||
ok=True,
|
||||
elapsed_sec=round(time.monotonic() - started, 3),
|
||||
timed_out=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return PollUntilResult(
|
||||
ok=False,
|
||||
elapsed_sec=round(time.monotonic() - started, 3),
|
||||
timed_out=True,
|
||||
)
|
||||
interruptible_sleep_sync(min(interval, remaining))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PollUntilResult",
|
||||
"poll_until",
|
||||
"poll_until_sync",
|
||||
]
|
||||
366
src/jiangchang_skill_core/runtime_diagnostics.py
Normal file
366
src/jiangchang_skill_core/runtime_diagnostics.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""共享 Runtime 诊断:health 输出与 media-assets / ffmpeg 探测。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
import jiangchang_skill_core
|
||||
from . import config
|
||||
from .machine_power import probe_machine_power
|
||||
from .media_assets import probe_background_music, probe_media_assets, resolve_media_assets_root
|
||||
from .runtime_env import platform_default_data_root
|
||||
|
||||
_SHARED_CORE_HINT = (
|
||||
"jiangchang_skill_core is loaded from the skill tree; expected the shared "
|
||||
"jiangchang-platform-kit installed in the host Python runtime."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeIssue:
|
||||
code: str
|
||||
message: str
|
||||
severity: str = "warning"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeDiagnostics:
|
||||
skill_slug: str
|
||||
python_executable: str
|
||||
platform_kit_version: str | None
|
||||
platform_kit_min_version: str | None
|
||||
platform_kit_version_ok: bool | None
|
||||
jiangchang_skill_core_file: str | None
|
||||
|
||||
jiangchang_data_root: str | None
|
||||
resolved_data_root: str | None
|
||||
|
||||
media_assets_root: str | None
|
||||
ffmpeg_available: bool
|
||||
ffmpeg_path: str | None
|
||||
|
||||
background_music_mp3_count: int
|
||||
background_music_usable_count: int
|
||||
background_music_issue: str | None
|
||||
background_music_sample_path: str | None
|
||||
|
||||
record_video_enabled: bool
|
||||
issues: tuple[RuntimeIssue, ...]
|
||||
# 只读机器信号(供电/限频);探不到为空 dict / probed=False,永不单独导致 fatal
|
||||
machine_power: dict[str, Any]
|
||||
|
||||
@property
|
||||
def has_fatal_issues(self) -> bool:
|
||||
return any(issue.severity == "error" for issue in self.issues)
|
||||
|
||||
def issue_codes(self) -> list[str]:
|
||||
return [issue.code for issue in self.issues]
|
||||
|
||||
|
||||
def _env_dict(env: Mapping[str, str] | None) -> dict[str, str]:
|
||||
return dict(env) if env is not None else dict(os.environ)
|
||||
|
||||
|
||||
def _resolve_data_root(env: Mapping[str, str]) -> str:
|
||||
root = (env.get("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
if root:
|
||||
return root
|
||||
return platform_default_data_root()
|
||||
|
||||
|
||||
def _parse_version(version: str) -> tuple[int, int, int, int]:
|
||||
"""Parse major.minor.patch[.postN] into (major, minor, patch, post)."""
|
||||
text = version.strip()
|
||||
post = 0
|
||||
if ".post" in text:
|
||||
base, _, post_part = text.partition(".post")
|
||||
post_digits = ""
|
||||
for ch in post_part:
|
||||
if ch.isdigit():
|
||||
post_digits += ch
|
||||
else:
|
||||
break
|
||||
post = int(post_digits) if post_digits else 0
|
||||
text = base
|
||||
|
||||
parts: list[int] = []
|
||||
for piece in text.split("."):
|
||||
digits = ""
|
||||
for ch in piece:
|
||||
if ch.isdigit():
|
||||
digits += ch
|
||||
else:
|
||||
break
|
||||
if digits:
|
||||
parts.append(int(digits))
|
||||
|
||||
while len(parts) < 3:
|
||||
parts.append(0)
|
||||
|
||||
return parts[0], parts[1], parts[2], post
|
||||
|
||||
|
||||
def version_ge(installed: str, required: str) -> bool:
|
||||
"""Compare semver-like versions without external deps (supports legacy .postN)."""
|
||||
return _parse_version(installed) >= _parse_version(required)
|
||||
|
||||
|
||||
def _path_under(parent: str | Path, child: str | Path) -> bool:
|
||||
try:
|
||||
parent_path = Path(parent).resolve()
|
||||
child_path = Path(child).resolve()
|
||||
if sys.platform == "win32":
|
||||
parent_norm = os.path.normcase(str(parent_path))
|
||||
child_norm = os.path.normcase(str(child_path))
|
||||
common = os.path.commonpath([parent_norm, child_norm])
|
||||
return common == parent_norm
|
||||
return os.path.commonpath([str(parent_path), str(child_path)]) == str(parent_path)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def is_jiangchang_skill_core_from_skill_tree(
|
||||
*,
|
||||
skill_root: str | Path,
|
||||
core_file: str | Path | None = None,
|
||||
) -> bool:
|
||||
"""Return True when jiangchang_skill_core is loaded from inside skill_root."""
|
||||
try:
|
||||
root = Path(skill_root)
|
||||
if not str(root).strip():
|
||||
return False
|
||||
core = Path(core_file) if core_file is not None else Path(jiangchang_skill_core.__file__)
|
||||
return _path_under(root, core)
|
||||
except (OSError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _platform_kit_version() -> str | None:
|
||||
try:
|
||||
return importlib.metadata.version("jiangchang-platform-kit")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _bool_from_env(env: Mapping[str, str], key: str, default: bool = False) -> bool:
|
||||
val = env.get(key)
|
||||
if val is None or val == "":
|
||||
return default
|
||||
return str(val).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def collect_runtime_diagnostics(
|
||||
*,
|
||||
skill_slug: str,
|
||||
platform_kit_min_version: str | None = None,
|
||||
skill_root: str | Path | None = None,
|
||||
record_video: bool | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> RuntimeDiagnostics:
|
||||
env_map = _env_dict(env)
|
||||
issues: list[RuntimeIssue] = []
|
||||
|
||||
jiangchang_data_root = env_map.get("JIANGCHANG_DATA_ROOT")
|
||||
resolved_data_root = _resolve_data_root(env_map)
|
||||
|
||||
media_root_path = resolve_media_assets_root(resolved_data_root, env_map)
|
||||
media_status = probe_media_assets(resolved_data_root, env_map)
|
||||
ffmpeg_path_obj = media_status.ffmpeg_path
|
||||
ffmpeg_ok = ffmpeg_path_obj is not None and ffmpeg_path_obj.is_file()
|
||||
|
||||
music_probe = probe_background_music(media_root_path, env=env_map)
|
||||
mp3_count = int(music_probe["mp3_count"])
|
||||
usable_count = int(music_probe["usable_count"])
|
||||
music_issue = music_probe.get("issue")
|
||||
music_sample = music_probe.get("sample_path")
|
||||
|
||||
if music_issue is None and not media_status.music_ready and media_status.warnings:
|
||||
for warning in media_status.warnings:
|
||||
if warning.startswith("background_music") or warning == "music_dir_missing":
|
||||
music_issue = warning
|
||||
break
|
||||
|
||||
platform_version = _platform_kit_version()
|
||||
platform_ok: bool | None
|
||||
if platform_kit_min_version is None:
|
||||
platform_ok = None
|
||||
elif platform_version is None:
|
||||
platform_ok = False
|
||||
else:
|
||||
platform_ok = version_ge(platform_version, platform_kit_min_version)
|
||||
|
||||
if platform_version is None:
|
||||
issues.append(
|
||||
RuntimeIssue(
|
||||
code="platform_kit_not_installed",
|
||||
message="jiangchang-platform-kit package metadata not found",
|
||||
severity="error",
|
||||
)
|
||||
)
|
||||
elif platform_kit_min_version is not None and not platform_ok:
|
||||
issues.append(
|
||||
RuntimeIssue(
|
||||
code="platform_kit_version_low",
|
||||
message=(
|
||||
f"jiangchang-platform-kit version {platform_version} "
|
||||
f"is below required {platform_kit_min_version}"
|
||||
),
|
||||
severity="error",
|
||||
)
|
||||
)
|
||||
|
||||
core_file: str | None
|
||||
try:
|
||||
core_file = os.path.abspath(jiangchang_skill_core.__file__)
|
||||
except (OSError, TypeError, ValueError):
|
||||
core_file = None
|
||||
|
||||
if skill_root is not None and core_file is not None:
|
||||
if is_jiangchang_skill_core_from_skill_tree(skill_root=skill_root, core_file=core_file):
|
||||
issues.append(
|
||||
RuntimeIssue(
|
||||
code="jiangchang_skill_core_loaded_from_skill_tree",
|
||||
message=_SHARED_CORE_HINT,
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
|
||||
if record_video is None:
|
||||
if env is not None:
|
||||
record_video_enabled = _bool_from_env(env_map, "OPENCLAW_RECORD_VIDEO", False)
|
||||
else:
|
||||
record_video_enabled = config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
|
||||
else:
|
||||
record_video_enabled = record_video
|
||||
|
||||
if not ffmpeg_ok:
|
||||
severity = "warning"
|
||||
message = "ffmpeg not available"
|
||||
if record_video_enabled:
|
||||
message = "OPENCLAW_RECORD_VIDEO enabled but ffmpeg is not available"
|
||||
issues.append(
|
||||
RuntimeIssue(
|
||||
code="ffmpeg_unavailable",
|
||||
message=message,
|
||||
severity=severity,
|
||||
)
|
||||
)
|
||||
|
||||
if record_video_enabled and music_issue:
|
||||
issues.append(
|
||||
RuntimeIssue(
|
||||
code="background_music_unavailable",
|
||||
message=f"background music unavailable: {music_issue}",
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
|
||||
# 附加项:只读供电/限频;异常吞掉,不影响 health fatal
|
||||
try:
|
||||
machine_power = probe_machine_power()
|
||||
except Exception:
|
||||
machine_power = {"probed": False, "ac_power": None, "nvidia": None}
|
||||
|
||||
return RuntimeDiagnostics(
|
||||
skill_slug=skill_slug,
|
||||
python_executable=sys.executable,
|
||||
platform_kit_version=platform_version,
|
||||
platform_kit_min_version=platform_kit_min_version,
|
||||
platform_kit_version_ok=platform_ok,
|
||||
jiangchang_skill_core_file=core_file,
|
||||
jiangchang_data_root=jiangchang_data_root,
|
||||
resolved_data_root=resolved_data_root,
|
||||
media_assets_root=str(media_root_path),
|
||||
ffmpeg_available=ffmpeg_ok,
|
||||
ffmpeg_path=str(ffmpeg_path_obj) if ffmpeg_path_obj else None,
|
||||
background_music_mp3_count=mp3_count,
|
||||
background_music_usable_count=usable_count,
|
||||
background_music_issue=str(music_issue) if music_issue else None,
|
||||
background_music_sample_path=str(music_sample) if music_sample else None,
|
||||
record_video_enabled=record_video_enabled,
|
||||
issues=tuple(issues),
|
||||
machine_power=machine_power,
|
||||
)
|
||||
|
||||
|
||||
def format_runtime_health_lines(diagnostics: RuntimeDiagnostics) -> list[str]:
|
||||
lines = [
|
||||
f"skill_slug: {diagnostics.skill_slug}",
|
||||
f"python_executable: {diagnostics.python_executable}",
|
||||
f"platform_kit_version: {diagnostics.platform_kit_version or ''}",
|
||||
f"platform_kit_min_version: {diagnostics.platform_kit_min_version or ''}",
|
||||
f"platform_kit_version_ok: {diagnostics.platform_kit_version_ok}",
|
||||
f"jiangchang_skill_core_file: {diagnostics.jiangchang_skill_core_file or ''}",
|
||||
f"JIANGCHANG_DATA_ROOT: {diagnostics.jiangchang_data_root or ''}",
|
||||
f"resolved_data_root: {diagnostics.resolved_data_root or ''}",
|
||||
f"media_assets_root: {diagnostics.media_assets_root or ''}",
|
||||
f"ffmpeg_available: {diagnostics.ffmpeg_available}",
|
||||
f"ffmpeg_path: {diagnostics.ffmpeg_path or ''}",
|
||||
f"background_music_mp3_count: {diagnostics.background_music_mp3_count}",
|
||||
f"background_music_usable_count: {diagnostics.background_music_usable_count}",
|
||||
f"background_music_issue: {diagnostics.background_music_issue or ''}",
|
||||
f"background_music_sample_path: {diagnostics.background_music_sample_path or ''}",
|
||||
f"record_video_enabled: {diagnostics.record_video_enabled}",
|
||||
f"machine_power_probed: {bool((diagnostics.machine_power or {}).get('probed'))}",
|
||||
]
|
||||
power = diagnostics.machine_power or {}
|
||||
ac = power.get("ac_power") or {}
|
||||
if ac:
|
||||
lines.append(f"machine_ac_line_status: {ac.get('ac_line_status', '')}")
|
||||
lines.append(f"machine_on_ac_power: {ac.get('on_ac_power', '')}")
|
||||
lines.append(f"machine_battery_life_percent: {ac.get('battery_life_percent', '')}")
|
||||
nvidia = power.get("nvidia") or {}
|
||||
if nvidia:
|
||||
lines.append(f"machine_gpu_name: {nvidia.get('gpu_name', '')}")
|
||||
lines.append(
|
||||
f"machine_enforced_power_limit_w: {nvidia.get('enforced_power_limit_w', '')}"
|
||||
)
|
||||
lines.append(
|
||||
"machine_clocks_throttle_reasons_active: "
|
||||
f"{nvidia.get('clocks_throttle_reasons_active', '')}"
|
||||
)
|
||||
for issue in diagnostics.issues:
|
||||
lines.append(f"runtime_issue[{issue.severity}]: {issue.code} - {issue.message}")
|
||||
return lines
|
||||
|
||||
|
||||
def runtime_diagnostics_dict(diagnostics: RuntimeDiagnostics) -> dict[str, Any]:
|
||||
return {
|
||||
"skill_slug": diagnostics.skill_slug,
|
||||
"python_executable": diagnostics.python_executable,
|
||||
"platform_kit_version": diagnostics.platform_kit_version,
|
||||
"platform_kit_min_version": diagnostics.platform_kit_min_version,
|
||||
"platform_kit_version_ok": diagnostics.platform_kit_version_ok,
|
||||
"jiangchang_skill_core_file": diagnostics.jiangchang_skill_core_file,
|
||||
"JIANGCHANG_DATA_ROOT": diagnostics.jiangchang_data_root,
|
||||
"resolved_data_root": diagnostics.resolved_data_root,
|
||||
"media_assets_root": diagnostics.media_assets_root,
|
||||
"ffmpeg_available": diagnostics.ffmpeg_available,
|
||||
"ffmpeg_path": diagnostics.ffmpeg_path,
|
||||
"background_music_mp3_count": diagnostics.background_music_mp3_count,
|
||||
"background_music_usable_count": diagnostics.background_music_usable_count,
|
||||
"background_music_issue": diagnostics.background_music_issue,
|
||||
"background_music_sample_path": diagnostics.background_music_sample_path,
|
||||
"record_video_enabled": diagnostics.record_video_enabled,
|
||||
"machine_power": diagnostics.machine_power,
|
||||
"runtime_issues": [
|
||||
{"code": issue.code, "message": issue.message, "severity": issue.severity}
|
||||
for issue in diagnostics.issues
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def log_runtime_diagnostics(
|
||||
diagnostics: RuntimeDiagnostics,
|
||||
logger: logging.Logger | None = None,
|
||||
level: int = logging.INFO,
|
||||
) -> None:
|
||||
log = logger or logging.getLogger("jiangchang_skill_core.runtime_diagnostics")
|
||||
for line in format_runtime_health_lines(diagnostics):
|
||||
log.log(level, line)
|
||||
@@ -34,22 +34,28 @@ def platform_default_data_root() -> str:
|
||||
|
||||
|
||||
def get_data_root() -> str:
|
||||
env = (
|
||||
os.getenv("CLAW_DATA_ROOT")
|
||||
or os.getenv("JIANGCHANG_DATA_ROOT")
|
||||
or ""
|
||||
).strip()
|
||||
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
if env:
|
||||
return env
|
||||
return platform_default_data_root()
|
||||
|
||||
|
||||
def get_user_id() -> str:
|
||||
return (
|
||||
os.getenv("CLAW_USER_ID")
|
||||
or os.getenv("JIANGCHANG_USER_ID")
|
||||
or ""
|
||||
).strip() or "_anon"
|
||||
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
|
||||
|
||||
|
||||
def get_runs_dir() -> str:
|
||||
"""Run Journal 目录:{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/"""
|
||||
return os.path.join(get_data_root(), ".jiangchang", "runs")
|
||||
|
||||
|
||||
def ensure_runs_dir() -> str:
|
||||
path = get_runs_dir()
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def _looks_like_skills_root(path: str) -> bool:
|
||||
@@ -76,15 +82,13 @@ def get_skills_root() -> str:
|
||||
|
||||
优先级:
|
||||
1) JIANGCHANG_SKILLS_ROOT
|
||||
2) CLAW_SKILLS_ROOT
|
||||
3) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
|
||||
4) Windows:默认 D:\\AI\\jiangchang 同上规则
|
||||
5) 其他平台:~/.openclaw/skills
|
||||
2) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
|
||||
3) Windows:默认 D:\\AI\\jiangchang 同上规则
|
||||
4) 其他平台:~/.openclaw/skills
|
||||
"""
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
v = (os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
|
||||
if sys.platform == "win32" and not app:
|
||||
@@ -110,8 +114,8 @@ def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
||||
"""
|
||||
编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能
|
||||
则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」),
|
||||
避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
|
||||
推断失败时再读上述环境变量,最后回落 get_skills_root()。
|
||||
避免全局 JIANGCHANG_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
|
||||
推断失败时再读 JIANGCHANG_SKILLS_ROOT,最后回落 get_skills_root()。
|
||||
"""
|
||||
if skill_scripts_dir:
|
||||
scripts = os.path.abspath(skill_scripts_dir)
|
||||
@@ -120,10 +124,9 @@ def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
||||
if _looks_like_skills_root(inferred):
|
||||
return os.path.normpath(inferred)
|
||||
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
v = (os.getenv("JIANGCHANG_SKILLS_ROOT") or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
return get_skills_root()
|
||||
|
||||
@@ -139,21 +142,10 @@ def apply_cli_local_defaults() -> None:
|
||||
enabled = v in ("1", "true", "yes", "on")
|
||||
if not enabled:
|
||||
return
|
||||
if not (
|
||||
os.getenv("CLAW_DATA_ROOT")
|
||||
or os.getenv("JIANGCHANG_DATA_ROOT")
|
||||
or ""
|
||||
).strip():
|
||||
default_root = platform_default_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = default_root
|
||||
os.environ.setdefault("CLAW_DATA_ROOT", default_root)
|
||||
if not (
|
||||
os.getenv("CLAW_USER_ID")
|
||||
or os.getenv("JIANGCHANG_USER_ID")
|
||||
or ""
|
||||
).strip():
|
||||
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
|
||||
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
||||
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
|
||||
os.environ.setdefault("CLAW_USER_ID", DEFAULT_LOCAL_USER_ID.strip())
|
||||
def find_chrome_executable() -> "str | None":
|
||||
"""查找系统已安装的 Chrome 或 Edge 可执行文件路径。
|
||||
|
||||
|
||||
71
src/jiangchang_skill_core/sibling_bridge.py
Normal file
71
src/jiangchang_skill_core/sibling_bridge.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
兄弟技能 CLI 桥:同步 subprocess 调用并列技能 scripts/main.py。
|
||||
|
||||
account-manager 等兄弟技能为同步 CLI,非 async、非线程池。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, List, Optional, Sequence
|
||||
|
||||
from .runtime_env import get_skills_root
|
||||
from .unified_logging import subprocess_env_with_trace
|
||||
|
||||
|
||||
def get_sibling_main_path(skill_slug: str) -> str:
|
||||
return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
|
||||
|
||||
|
||||
def _python_exe() -> str:
|
||||
return sys.executable
|
||||
|
||||
|
||||
def _run_sibling(skill_slug: str, argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
||||
main_path = get_sibling_main_path(skill_slug)
|
||||
cmd = [_python_exe(), main_path, *argv]
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=subprocess_env_with_trace(),
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_from_stdout(stdout: str) -> Any:
|
||||
for line in reversed((stdout or "").splitlines()):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("ERROR:"):
|
||||
continue
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def call_sibling_json(skill_slug: str, argv: Sequence[str]) -> dict | None:
|
||||
"""Run sibling CLI; return last JSON object line from stdout, or None."""
|
||||
result = _run_sibling(skill_slug, argv)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
parsed = _parse_json_from_stdout(result.stdout or "")
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def call_sibling_json_array(skill_slug: str, argv: Sequence[str]) -> list | None:
|
||||
"""Run sibling CLI; return last JSON array line from stdout, or None."""
|
||||
result = _run_sibling(skill_slug, argv)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
parsed = _parse_json_from_stdout(result.stdout or "")
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
return None
|
||||
98
tests/desktop_wake/test_profiles_and_types.py
Normal file
98
tests/desktop_wake/test_profiles_and_types.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# -*- 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)
|
||||
self.assertIn("wecom_search", 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 [])
|
||||
self.assertTrue(p.activate_maximize)
|
||||
|
||||
def test_load_wecom_search(self):
|
||||
p = load_profile("wecom_search")
|
||||
self.assertEqual(p.id, "wecom_search")
|
||||
self.assertEqual(p.window_class, "SearchResultWindow2")
|
||||
self.assertIn("search_result", p.warm_markers)
|
||||
self.assertIn("user_item", p.warm_markers)
|
||||
self.assertFalse(p.activate_maximize)
|
||||
# 1.2.10+:对浮层本身 n2-wake(多窗子类化);禁止 Maximize;不改注主窗
|
||||
self.assertFalse((p.native_hwnd_class or "").strip())
|
||||
self.assertIn("native_helper", p.wake_strategies)
|
||||
self.assertIn("n2-wake", p.native_args or [])
|
||||
|
||||
def test_resolve_native_hwnd_fallback_without_class(self):
|
||||
from jiangchang_desktop_wake.wake import resolve_native_hwnd
|
||||
|
||||
p = Profile.from_dict({"id": "x", "window_class": "Foo"})
|
||||
self.assertIsNone(p.native_hwnd_class)
|
||||
self.assertEqual(resolve_native_hwnd(12345, p), 12345)
|
||||
|
||||
def test_unknown_profile(self):
|
||||
from jiangchang_desktop_wake.errors import DesktopWakeError
|
||||
|
||||
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()
|
||||
218
tests/test_activity_control.py
Normal file
218
tests/test_activity_control.py
Normal file
@@ -0,0 +1,218 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SRCP v1: control.json, checkpoint, pause/resume/stop, interruptible_sleep."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from jiangchang_skill_core import activity
|
||||
from jiangchang_skill_core.activity import JobStopped
|
||||
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||
|
||||
|
||||
class _ControlEnvIsolation(unittest.TestCase):
|
||||
_KEYS = (
|
||||
"JIANGCHANG_DATA_ROOT",
|
||||
"JIANGCHANG_USER_ID",
|
||||
"JIANGCHANG_JOB_ID",
|
||||
"JIANGCHANG_RUN_MODE",
|
||||
"JIANGCHANG_CONTROL_DISABLED",
|
||||
"JIANGCHANG_CONTROL_POLL_SEC",
|
||||
)
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._saved = {k: os.environ.get(k) for k in self._KEYS}
|
||||
self._tmps: list[str] = []
|
||||
for k in self._KEYS:
|
||||
os.environ.pop(k, None)
|
||||
activity._reset_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
activity._reset_for_tests()
|
||||
for k, v in self._saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
for tmp in self._tmps:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def _tmp_data_root(self) -> str:
|
||||
tmp = tempfile.mkdtemp()
|
||||
self._tmps.append(tmp)
|
||||
return tmp
|
||||
|
||||
def _write_control(self, job_id: str, command: str, *, nonce: int = 1) -> None:
|
||||
path = Path(get_runs_dir()) / f"{job_id}.control.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"command": command,
|
||||
"reason": "user",
|
||||
"ts": 1_700_000_000_000,
|
||||
"nonce": nonce,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _journal_events(self, job_id: str) -> list[dict]:
|
||||
journal = Path(get_runs_dir()) / f"{job_id}.jsonl"
|
||||
if not journal.is_file():
|
||||
return []
|
||||
return [
|
||||
json.loads(line)
|
||||
for line in journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
|
||||
class TestControlProtocol(_ControlEnvIsolation):
|
||||
def test_get_control_path(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-path"
|
||||
activity._reset_for_tests()
|
||||
self.assertTrue(activity.get_control_path().endswith("ctrl-path.control.json"))
|
||||
|
||||
def test_stop_raises_job_stopped(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-stop"
|
||||
os.environ["JIANGCHANG_CONTROL_POLL_SEC"] = "0.05"
|
||||
activity._reset_for_tests()
|
||||
self._write_control("ctrl-stop", "stop")
|
||||
|
||||
with self.assertRaises(JobStopped):
|
||||
activity.checkpoint()
|
||||
|
||||
events = self._journal_events("ctrl-stop")
|
||||
lifecycle = [e for e in events if e.get("type") == "lifecycle"]
|
||||
self.assertTrue(any(e.get("status") == "stopping" for e in lifecycle))
|
||||
|
||||
def test_emit_stop_before_activity_write(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-emit-stop"
|
||||
activity._reset_for_tests()
|
||||
self._write_control("ctrl-emit-stop", "stop")
|
||||
|
||||
with self.assertRaises(JobStopped):
|
||||
activity.emit("不应写入")
|
||||
|
||||
events = self._journal_events("ctrl-emit-stop")
|
||||
self.assertFalse(any(e.get("type") == "activity" for e in events))
|
||||
self.assertTrue(any(e.get("type") == "lifecycle" and e.get("status") == "stopping" for e in events))
|
||||
|
||||
def test_pause_resume_via_emit(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-pause"
|
||||
os.environ["JIANGCHANG_CONTROL_POLL_SEC"] = "0.05"
|
||||
activity._reset_for_tests()
|
||||
self._write_control("ctrl-pause", "pause")
|
||||
|
||||
import threading
|
||||
import time as time_mod
|
||||
|
||||
def resume_later() -> None:
|
||||
time_mod.sleep(0.15)
|
||||
self._write_control("ctrl-pause", "resume", nonce=2)
|
||||
|
||||
threading.Thread(target=resume_later, daemon=True).start()
|
||||
activity.emit("步骤一", skill="demo")
|
||||
|
||||
events = self._journal_events("ctrl-pause")
|
||||
statuses = [e.get("status") for e in events if e.get("type") == "lifecycle"]
|
||||
self.assertIn("paused", statuses)
|
||||
self.assertIn("resumed", statuses)
|
||||
activity_events = [e for e in events if e.get("type") == "activity"]
|
||||
self.assertEqual(activity_events[-1]["text"], "步骤一")
|
||||
lifecycle = [e for e in events if e.get("type") == "lifecycle"]
|
||||
self.assertEqual(lifecycle[0].get("step_index"), 1)
|
||||
|
||||
def test_rpa_step_gate_increments_step_index(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-rpa"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step("打开页面")
|
||||
def open_page() -> None:
|
||||
return None
|
||||
|
||||
open_page()
|
||||
events = self._journal_events("ctrl-rpa")
|
||||
lifecycle_or_activity = [e for e in events if e.get("type") in ("activity", "lifecycle")]
|
||||
self.assertGreaterEqual(len(lifecycle_or_activity), 2)
|
||||
|
||||
def test_job_context_finishes_on_job_stopped(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
activity._reset_for_tests()
|
||||
|
||||
with patch("sys.stdout", io.StringIO()):
|
||||
with self.assertRaises(JobStopped):
|
||||
with activity.job_context(job_id="ctx-stop", skill="demo-skill"):
|
||||
self._write_control("ctx-stop", "stop")
|
||||
activity.checkpoint()
|
||||
|
||||
journal = Path(get_runs_dir()) / "ctx-stop.jsonl"
|
||||
lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
last = json.loads(lines[-1])
|
||||
self.assertEqual(last["type"], "error")
|
||||
self.assertEqual(last.get("error_code"), "JOB_STOPPED")
|
||||
|
||||
def test_interruptible_sleep_honors_stop(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-sleep"
|
||||
os.environ["JIANGCHANG_CONTROL_POLL_SEC"] = "0.05"
|
||||
activity._reset_for_tests()
|
||||
self._write_control("ctrl-sleep", "stop")
|
||||
|
||||
async def run() -> None:
|
||||
with self.assertRaises(JobStopped):
|
||||
await activity.interruptible_sleep(5.0)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_interruptible_sleep_sync_honors_pause(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "ctrl-sleep-sync"
|
||||
os.environ["JIANGCHANG_CONTROL_POLL_SEC"] = "0.05"
|
||||
activity._reset_for_tests()
|
||||
self._write_control("ctrl-sleep-sync", "pause")
|
||||
|
||||
def resume_later() -> None:
|
||||
import threading
|
||||
import time
|
||||
|
||||
def _resume() -> None:
|
||||
time.sleep(0.15)
|
||||
self._write_control("ctrl-sleep-sync", "resume", nonce=2)
|
||||
|
||||
threading.Thread(target=_resume, daemon=True).start()
|
||||
|
||||
resume_later()
|
||||
activity.interruptible_sleep_sync(0.4, chunk=0.05)
|
||||
|
||||
events = self._journal_events("ctrl-sleep-sync")
|
||||
statuses = [e.get("status") for e in events if e.get("type") == "lifecycle"]
|
||||
self.assertIn("paused", statuses)
|
||||
self.assertIn("resumed", statuses)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
209
tests/test_activity_emit_finish.py
Normal file
209
tests/test_activity_emit_finish.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Skill Activity Stream: journal, flush, job_id, finish stdout, truncation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from jiangchang_skill_core import activity
|
||||
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||
|
||||
|
||||
class _ActivityEnvIsolation(unittest.TestCase):
|
||||
_KEYS = (
|
||||
"JIANGCHANG_DATA_ROOT",
|
||||
"JIANGCHANG_USER_ID",
|
||||
"JIANGCHANG_JOB_ID",
|
||||
"JIANGCHANG_RUN_MODE",
|
||||
"JIANGCHANG_ACTIVITY_DEBUG",
|
||||
)
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._saved = {k: os.environ.get(k) for k in self._KEYS}
|
||||
self._tmps: list[str] = []
|
||||
for k in self._KEYS:
|
||||
os.environ.pop(k, None)
|
||||
activity._reset_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
activity._reset_for_tests()
|
||||
for k, v in self._saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
for tmp in self._tmps:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def _tmp_data_root(self) -> str:
|
||||
tmp = tempfile.mkdtemp()
|
||||
self._tmps.append(tmp)
|
||||
return tmp
|
||||
|
||||
|
||||
class TestActivityJournal(_ActivityEnvIsolation):
|
||||
def test_emit_writes_ndjson_with_meta(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-test-1"
|
||||
activity._reset_for_tests()
|
||||
|
||||
activity.emit("步骤一", type="activity", skill="geo-batch")
|
||||
journal = Path(get_runs_dir()) / "job-test-1.jsonl"
|
||||
self.assertTrue(journal.is_file())
|
||||
lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
self.assertGreaterEqual(len(lines), 2)
|
||||
meta = json.loads(lines[0])
|
||||
self.assertEqual(meta["type"], "meta")
|
||||
self.assertEqual(meta["job_id"], "job-test-1")
|
||||
event = json.loads(lines[1])
|
||||
self.assertEqual(event["type"], "activity")
|
||||
self.assertEqual(event["text"], "步骤一")
|
||||
self.assertEqual(event["schema_version"], 1)
|
||||
self.assertEqual(event["skill"], "geo-batch")
|
||||
self.assertIn("ts", event)
|
||||
|
||||
def test_emit_flushes_immediately(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-flush"
|
||||
activity._reset_for_tests()
|
||||
activity.emit("flush test")
|
||||
journal = Path(get_runs_dir()) / "job-flush.jsonl"
|
||||
self.assertGreater(journal.stat().st_size, 0)
|
||||
|
||||
def test_job_id_from_env(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "env-job-42"
|
||||
activity._reset_for_tests()
|
||||
self.assertEqual(activity.get_job_id(), "env-job-42")
|
||||
|
||||
def test_job_id_generated_and_cached(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
activity._reset_for_tests()
|
||||
jid1 = activity.get_job_id()
|
||||
jid2 = activity.get_job_id()
|
||||
self.assertEqual(jid1, jid2)
|
||||
self.assertEqual(len(jid1), 32)
|
||||
|
||||
def test_debug_skipped_without_env(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-no-debug"
|
||||
activity._reset_for_tests()
|
||||
activity.emit("hidden", type="debug")
|
||||
journal = Path(get_runs_dir()) / "job-no-debug.jsonl"
|
||||
self.assertFalse(journal.exists())
|
||||
|
||||
def test_debug_written_when_enabled(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-debug"
|
||||
os.environ["JIANGCHANG_ACTIVITY_DEBUG"] = "1"
|
||||
activity._reset_for_tests()
|
||||
activity.emit("dbg", type="debug")
|
||||
journal = Path(get_runs_dir()) / "job-debug.jsonl"
|
||||
content = journal.read_text(encoding="utf-8")
|
||||
self.assertIn('"type":"debug"', content.replace(" ", ""))
|
||||
|
||||
def test_emit_never_prints_stdout(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-quiet"
|
||||
activity._reset_for_tests()
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", buf):
|
||||
activity.emit("silent step")
|
||||
self.assertEqual(buf.getvalue(), "")
|
||||
|
||||
def test_get_run_mode_default_cli(self) -> None:
|
||||
self.assertEqual(activity.get_run_mode(), "cli")
|
||||
|
||||
def test_get_run_mode_job(self) -> None:
|
||||
os.environ["JIANGCHANG_RUN_MODE"] = "job"
|
||||
self.assertEqual(activity.get_run_mode(), "job")
|
||||
|
||||
|
||||
class TestActivityFinish(_ActivityEnvIsolation):
|
||||
def test_finish_stdout_single_line_result(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-finish"
|
||||
activity._reset_for_tests()
|
||||
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", buf):
|
||||
activity.finish(status="success", message="ok", skill="geo", generated=3)
|
||||
|
||||
lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()]
|
||||
self.assertEqual(len(lines), 1)
|
||||
result = json.loads(lines[0])
|
||||
self.assertEqual(result["type"], "result")
|
||||
self.assertEqual(result["schema_version"], 1)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["skill"], "geo")
|
||||
self.assertEqual(result["generated"], 3)
|
||||
|
||||
journal = Path(get_runs_dir()) / "job-finish.jsonl"
|
||||
journal_lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
last = json.loads(journal_lines[-1])
|
||||
self.assertEqual(last["type"], "done")
|
||||
self.assertEqual(last["status"], "success")
|
||||
|
||||
def test_finish_failed_writes_error_event(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-fail"
|
||||
activity._reset_for_tests()
|
||||
|
||||
with patch("sys.stdout", io.StringIO()):
|
||||
activity.finish(status="failed", message="boom")
|
||||
|
||||
journal = Path(get_runs_dir()) / "job-fail.jsonl"
|
||||
last = json.loads(journal.read_text(encoding="utf-8").strip().splitlines()[-1])
|
||||
self.assertEqual(last["type"], "error")
|
||||
|
||||
def test_finish_truncates_large_result(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-trunc"
|
||||
activity._reset_for_tests()
|
||||
|
||||
big = {f"field_{i}": "x" * 200 for i in range(50)}
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", buf):
|
||||
activity.finish(status="success", skill="big", **big)
|
||||
|
||||
line = buf.getvalue().strip()
|
||||
self.assertLessEqual(len(line), 4096)
|
||||
result = json.loads(line)
|
||||
self.assertTrue(result.get("truncated"))
|
||||
|
||||
|
||||
class TestJobContext(_ActivityEnvIsolation):
|
||||
def test_job_context_auto_finish_on_exception(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
activity._reset_for_tests()
|
||||
|
||||
with patch("sys.stdout", io.StringIO()):
|
||||
with self.assertRaises(ValueError):
|
||||
with activity.job_context(job_id="ctx-err", skill="test-skill"):
|
||||
raise ValueError("broken")
|
||||
|
||||
journal = Path(get_runs_dir()) / "ctx-err.jsonl"
|
||||
last = json.loads(journal.read_text(encoding="utf-8").strip().splitlines()[-1])
|
||||
self.assertEqual(last["type"], "error")
|
||||
self.assertIn("broken", last["message"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
120
tests/test_activity_rpa_step_decorator.py
Normal file
120
tests/test_activity_rpa_step_decorator.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""rpa_step decorator: sync + async."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from jiangchang_skill_core import activity
|
||||
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||
|
||||
|
||||
class _DecoratorEnvIsolation(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._saved_root = os.environ.get("JIANGCHANG_DATA_ROOT")
|
||||
self._saved_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||
self._tmps: list[str] = []
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
activity._reset_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
activity._reset_for_tests()
|
||||
if self._saved_root is None:
|
||||
os.environ.pop("JIANGCHANG_DATA_ROOT", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = self._saved_root
|
||||
if self._saved_job is None:
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = self._saved_job
|
||||
for tmp in self._tmps:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def _tmp_data_root(self) -> str:
|
||||
tmp = tempfile.mkdtemp()
|
||||
self._tmps.append(tmp)
|
||||
return tmp
|
||||
|
||||
|
||||
class TestRpaStepDecorator(_DecoratorEnvIsolation):
|
||||
def _journal_texts(self, job_id: str) -> list[str]:
|
||||
journal = Path(get_runs_dir()) / f"{job_id}.jsonl"
|
||||
lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
events = [json.loads(ln) for ln in lines if json.loads(ln).get("type") != "meta"]
|
||||
return [e["text"] for e in events]
|
||||
|
||||
def test_sync_success(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-sync-ok"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step("打开浏览器")
|
||||
def open_browser() -> str:
|
||||
return "ok"
|
||||
|
||||
self.assertEqual(open_browser(), "ok")
|
||||
texts = self._journal_texts("rpa-sync-ok")
|
||||
self.assertIn("▶ 打开浏览器", texts)
|
||||
self.assertIn("✓ 打开浏览器", texts)
|
||||
|
||||
def test_sync_failure_emits_warn_and_reraises(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-sync-fail"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step("提交表单")
|
||||
def submit() -> None:
|
||||
raise RuntimeError("timeout")
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
submit()
|
||||
|
||||
journal = Path(get_runs_dir()) / "rpa-sync-fail.jsonl"
|
||||
content = journal.read_text(encoding="utf-8")
|
||||
self.assertIn("timeout", content)
|
||||
self.assertIn('"type":"warn"', content.replace(" ", ""))
|
||||
|
||||
def test_async_success(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-async-ok"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step("异步步骤")
|
||||
async def async_step() -> int:
|
||||
await asyncio.sleep(0)
|
||||
return 42
|
||||
|
||||
result = asyncio.run(async_step())
|
||||
self.assertEqual(result, 42)
|
||||
texts = self._journal_texts("rpa-async-ok")
|
||||
self.assertIn("▶ 异步步骤", texts)
|
||||
self.assertIn("✓ 异步步骤", texts)
|
||||
|
||||
def test_async_failure(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-async-fail"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step()
|
||||
async def fail_step() -> None:
|
||||
await asyncio.sleep(0)
|
||||
raise ValueError("async err")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
asyncio.run(fail_step())
|
||||
|
||||
journal = Path(get_runs_dir()) / "rpa-async-fail.jsonl"
|
||||
self.assertIn("async err", journal.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -81,6 +81,49 @@ class TestConfig(unittest.TestCase):
|
||||
self.assertEqual(config.get_float("BAD", 9.9), 9.9)
|
||||
self.assertEqual(config.get_int("BAD", 7), 7)
|
||||
|
||||
def test_parse_empty_value_with_inline_comment(self) -> None:
|
||||
"""Regression: ``KEY= # comment`` must be empty, not ``# comment``."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
example = os.path.join(tmp, ".env.example")
|
||||
with open(example, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"DEFAULT_ACCOUNT_ID= # 可填账号管理中的编号\n"
|
||||
"DEFAULT_LOGIN_ID=# 只填登录名\n"
|
||||
"HAS_VALUE=real # trailing note\n"
|
||||
"URL=https://example.com/path#frag\n"
|
||||
"QUOTED=\"keep # hash\"\n"
|
||||
"EMPTY=\n"
|
||||
)
|
||||
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_USER_ID"] = "u1"
|
||||
# Clear process overrides for these keys if any
|
||||
for k in (
|
||||
"DEFAULT_ACCOUNT_ID",
|
||||
"DEFAULT_LOGIN_ID",
|
||||
"HAS_VALUE",
|
||||
"URL",
|
||||
"QUOTED",
|
||||
"EMPTY",
|
||||
):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
dest = config.ensure_env_file("sk-comment", example)
|
||||
config.reset_cache()
|
||||
|
||||
parsed = config._parse_env_file(dest)
|
||||
self.assertEqual(parsed.get("DEFAULT_ACCOUNT_ID"), "")
|
||||
self.assertEqual(parsed.get("DEFAULT_LOGIN_ID"), "")
|
||||
self.assertEqual(parsed.get("HAS_VALUE"), "real")
|
||||
self.assertEqual(parsed.get("URL"), "https://example.com/path#frag")
|
||||
self.assertEqual(parsed.get("QUOTED"), "keep # hash")
|
||||
self.assertEqual(parsed.get("EMPTY"), "")
|
||||
|
||||
# Empty example/user values must not surface as fake non-empty defaults.
|
||||
self.assertIsNone(config.get("DEFAULT_ACCOUNT_ID"))
|
||||
self.assertIsNone(config.get("DEFAULT_LOGIN_ID"))
|
||||
self.assertEqual(config.get("HAS_VALUE"), "real")
|
||||
|
||||
def test_merge_missing_env_keys_appends_only_new(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
example = os.path.join(tmp, ".env.example")
|
||||
|
||||
@@ -15,7 +15,7 @@ from jiangchang_skill_core import media_assets as ma
|
||||
|
||||
def _make_complete_assets(root: Path, *, with_ffmpeg: bool = True, with_ffprobe: bool = True) -> None:
|
||||
(root / "music" / "calm").mkdir(parents=True)
|
||||
(root / "music" / "calm" / "track.mp3").write_bytes(b"x" * 256)
|
||||
(root / "music" / "calm" / "track.mp3").write_bytes(b"x" * 1024)
|
||||
(root / "fonts").mkdir(parents=True)
|
||||
(root / "watermark").mkdir(parents=True)
|
||||
bin_key = ma._platform_bin_key()
|
||||
@@ -58,6 +58,56 @@ def _install_tools_from_manifest(root: Path, warnings: list[str]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _build_archive_zip(dest: Path, inner_name: str) -> None:
|
||||
manifest = _sample_manifest()
|
||||
with zipfile.ZipFile(dest, "w") as zf:
|
||||
zf.writestr(f"{inner_name}/README.md", "# media-assets\n")
|
||||
zf.writestr(f"{inner_name}/manifest.json", json.dumps(manifest, ensure_ascii=False))
|
||||
zf.writestr(f"{inner_name}/music/calm/track.mp3", b"x" * 1024)
|
||||
zf.writestr(f"{inner_name}/fonts/.keep", "")
|
||||
zf.writestr(f"{inner_name}/watermark/.keep", "")
|
||||
|
||||
|
||||
def test_default_bundle_url_is_release() -> None:
|
||||
url = ma._media_assets_bundle_url()
|
||||
assert url == ma.MEDIA_ASSETS_BUNDLE_URL
|
||||
assert url.endswith("/releases/download/vlatest/media-assets.zip")
|
||||
assert "/archive/main.zip" not in url
|
||||
|
||||
|
||||
def test_bundle_url_env_override() -> None:
|
||||
override = "https://example.com/media-assets.zip"
|
||||
assert ma._media_assets_bundle_url(env={"MEDIA_ASSETS_BUNDLE_URL": override}) == override
|
||||
|
||||
|
||||
def test_zip_download_uses_bundle_url_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
data_root = tmp_path / "data"
|
||||
shared = data_root / "shared"
|
||||
target = shared / "media-assets"
|
||||
archive = tmp_path / "media-assets.zip"
|
||||
override = "https://example.com/media-assets.zip"
|
||||
|
||||
_build_archive_zip(archive, "media-assets")
|
||||
captured_urls: list[str] = []
|
||||
|
||||
def _fake_download(url: str, dest: Path) -> None:
|
||||
captured_urls.append(url)
|
||||
dest.write_bytes(archive.read_bytes())
|
||||
|
||||
monkeypatch.setattr(ma, "_download_zip", _fake_download)
|
||||
monkeypatch.setattr(ma, "_find_git_executable", lambda: None)
|
||||
monkeypatch.setattr(ma, "_try_download_ffmpeg_tools", _install_tools_from_manifest)
|
||||
|
||||
env = {
|
||||
"JIANGCHANG_DATA_ROOT": str(data_root),
|
||||
"MEDIA_ASSETS_BUNDLE_URL": override,
|
||||
}
|
||||
status = ma.ensure_media_assets(env=env)
|
||||
assert captured_urls == [override]
|
||||
assert target.is_dir()
|
||||
assert status.ready is True
|
||||
|
||||
|
||||
def test_media_assets_root_env_priority(tmp_path: Path) -> None:
|
||||
custom = tmp_path / "custom-media"
|
||||
custom.mkdir()
|
||||
@@ -118,9 +168,9 @@ def test_pick_background_music_stable_first(tmp_path: Path, monkeypatch: pytest.
|
||||
root = tmp_path / "data" / "shared" / "media-assets"
|
||||
_make_complete_assets(root)
|
||||
music = root / "music"
|
||||
(music / "b-upbeat.mp3").write_bytes(b"x" * 256)
|
||||
(music / "calm" / "a-calm.mp3").write_bytes(b"x" * 256)
|
||||
(music / "z-last.wav").write_bytes(b"x" * 256)
|
||||
(music / "b-upbeat.mp3").write_bytes(b"x" * 1024)
|
||||
(music / "calm" / "a-calm.mp3").write_bytes(b"x" * 1024)
|
||||
(music / "z-last.wav").write_bytes(b"x" * 1024)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ma,
|
||||
@@ -132,23 +182,18 @@ def test_pick_background_music_stable_first(tmp_path: Path, monkeypatch: pytest.
|
||||
assert picked == music / "b-upbeat.mp3"
|
||||
|
||||
|
||||
def _build_archive_zip(dest: Path, inner_name: str) -> None:
|
||||
manifest = _sample_manifest()
|
||||
with zipfile.ZipFile(dest, "w") as zf:
|
||||
zf.writestr(f"{inner_name}/README.md", "# media-assets\n")
|
||||
zf.writestr(f"{inner_name}/manifest.json", json.dumps(manifest, ensure_ascii=False))
|
||||
zf.writestr(f"{inner_name}/music/calm/track.mp3", b"x" * 256)
|
||||
zf.writestr(f"{inner_name}/fonts/.keep", "")
|
||||
zf.writestr(f"{inner_name}/watermark/.keep", "")
|
||||
|
||||
|
||||
def test_zip_wrapper_layout_detected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@pytest.mark.parametrize("inner_name", ["media-assets", "media-assets-main"])
|
||||
def test_zip_wrapper_layout_detected(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
inner_name: str,
|
||||
) -> None:
|
||||
data_root = tmp_path / "data"
|
||||
shared = data_root / "shared"
|
||||
target = shared / "media-assets"
|
||||
archive = tmp_path / "media-assets.zip"
|
||||
|
||||
_build_archive_zip(archive, "media-assets-main")
|
||||
_build_archive_zip(archive, inner_name)
|
||||
|
||||
def _fake_download(url: str, dest: Path) -> None:
|
||||
dest.write_bytes(archive.read_bytes())
|
||||
@@ -226,12 +271,33 @@ def test_probe_media_assets_does_not_download(tmp_path: Path, monkeypatch: pytes
|
||||
assert status.ffmpeg_path is None
|
||||
|
||||
|
||||
def test_lfs_pointer_warning_in_probe_and_ensure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
root = tmp_path / "data" / "shared" / "media-assets"
|
||||
_make_complete_assets(root)
|
||||
music = root / "music"
|
||||
(music / "calm" / "track.mp3").unlink()
|
||||
(music / "lfs-only.mp3").write_text(
|
||||
"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ma, "_try_download_via_zip", lambda *a, **k: False)
|
||||
monkeypatch.setattr(ma, "_try_download_via_git", lambda *a, **k: False)
|
||||
|
||||
probed = ma.probe_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||
assert "background_music_lfs_pointer_only" in probed.warnings
|
||||
assert probed.music_ready is False
|
||||
|
||||
ensured = ma.ensure_media_assets(env={"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")})
|
||||
assert "background_music_lfs_pointer_only" in ensured.warnings
|
||||
assert ensured.ready is False
|
||||
|
||||
|
||||
def test_pick_background_music_skips_lfs_pointer(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
root = tmp_path / "data" / "shared" / "media-assets"
|
||||
_make_complete_assets(root, with_ffmpeg=True, with_ffprobe=True)
|
||||
music = root / "music"
|
||||
(music / "calm" / "track.mp3").unlink()
|
||||
(music / "real.mp3").write_bytes(b"x" * 256)
|
||||
(music / "real.mp3").write_bytes(b"x" * 1024)
|
||||
lfs = music / "lfs-only.mp3"
|
||||
lfs.write_text("version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n")
|
||||
|
||||
@@ -324,7 +390,7 @@ def test_video_finalize_without_music_fallback(tmp_path: Path, monkeypatch: pyte
|
||||
def test_manifest_ffmpeg_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
root = tmp_path / "data" / "shared" / "media-assets"
|
||||
(root / "music" / "calm").mkdir(parents=True)
|
||||
(root / "music" / "calm" / "track.mp3").write_bytes(b"x" * 256)
|
||||
(root / "music" / "calm" / "track.mp3").write_bytes(b"x" * 1024)
|
||||
(root / "fonts").mkdir(parents=True)
|
||||
(root / "watermark").mkdir(parents=True)
|
||||
(root / "manifest.json").write_text(
|
||||
@@ -340,3 +406,32 @@ def test_manifest_ffmpeg_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatc
|
||||
assert status.ready is True
|
||||
assert status.ffmpeg_path is not None
|
||||
assert status.ffprobe_path is not None
|
||||
|
||||
|
||||
def test_is_usable_audio_file_rejects_small_and_lfs(tmp_path: Path) -> None:
|
||||
small = tmp_path / "small.mp3"
|
||||
small.write_bytes(b"x" * 512)
|
||||
assert ma.is_usable_audio_file(small) is False
|
||||
|
||||
ok = tmp_path / "ok.mp3"
|
||||
ok.write_bytes(b"x" * 1024)
|
||||
assert ma.is_usable_audio_file(ok) is True
|
||||
|
||||
lfs = tmp_path / "lfs.mp3"
|
||||
lfs.write_text("version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n")
|
||||
assert ma.is_usable_audio_file(lfs) is False
|
||||
assert ma.is_git_lfs_pointer(lfs) is True
|
||||
|
||||
|
||||
def test_probe_background_music_structure(tmp_path: Path) -> None:
|
||||
root = tmp_path / "media-assets"
|
||||
music = root / "music" / "calm"
|
||||
music.mkdir(parents=True)
|
||||
(music / "track.mp3").write_bytes(b"x" * 1024)
|
||||
|
||||
probe = ma.probe_background_music(root)
|
||||
assert probe["music_root"] == str(root / "music")
|
||||
assert probe["mp3_count"] == 1
|
||||
assert probe["usable_count"] == 1
|
||||
assert probe["issue"] is None
|
||||
assert probe["sample_path"] == str(music / "track.mp3")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Text-level checks that reusable-release-skill packages .env.example at package root."""
|
||||
"""Text-level checks for release-related GitHub Actions workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,17 +8,66 @@ import re
|
||||
import pytest
|
||||
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_WORKFLOW_PATH = os.path.join(
|
||||
_SKILL_WORKFLOW_PATH = os.path.join(
|
||||
_REPO_ROOT, ".github", "workflows", "reusable-release-skill.yaml"
|
||||
)
|
||||
_PUBLISH_WORKFLOW_PATH = os.path.join(
|
||||
_REPO_ROOT, ".github", "workflows", "publish.yml"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def workflow_text() -> str:
|
||||
with open(_WORKFLOW_PATH, encoding="utf-8") as f:
|
||||
with open(_SKILL_WORKFLOW_PATH, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def publish_workflow_text() -> str:
|
||||
with open(_PUBLISH_WORKFLOW_PATH, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_workflow_packages_readme_at_root(workflow_text: str) -> None:
|
||||
assert "readme_src = os.path.abspath('README.md')" in workflow_text
|
||||
assert "readme_dst = os.path.join(PACKAGE, 'README.md')" in workflow_text
|
||||
assert "Copied README.md" in workflow_text
|
||||
assert (
|
||||
"raise RuntimeError('README.md exists in skill root but was not packaged')"
|
||||
in workflow_text
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_metadata_reads_root_readme(workflow_text: str) -> None:
|
||||
assert 'load_md("README.md")' in workflow_text
|
||||
assert 'metadata["readme_md"] = body' in workflow_text
|
||||
|
||||
|
||||
def test_workflow_packages_market_docs_when_present(workflow_text: str) -> None:
|
||||
assert 'for market_doc in ("TUTORIAL.md", "DEMO.md", "CHANGELOG.md"):' in workflow_text
|
||||
assert "Copied {market_doc}" in workflow_text
|
||||
|
||||
|
||||
def test_workflow_metadata_syncs_tutorial_demo_changelog(workflow_text: str) -> None:
|
||||
assert 'load_md("TUTORIAL.md")' in workflow_text
|
||||
assert 'metadata["tutorial_md"] = tutorial_body' in workflow_text
|
||||
assert 'load_md("DEMO.md")' in workflow_text
|
||||
assert 'metadata["demo_md"] = demo_body' in workflow_text
|
||||
assert 'metadata["demo_video_url"]' in workflow_text
|
||||
assert "extract_changelog_for_version" in workflow_text
|
||||
assert 'metadata["changelog"] = section' in workflow_text
|
||||
|
||||
|
||||
def test_workflow_does_not_use_references_readme_for_marketplace(
|
||||
workflow_text: str,
|
||||
) -> None:
|
||||
assert "os.path.join('references', 'README.md')" not in workflow_text
|
||||
assert not re.search(
|
||||
r"frontmatter\.load\([^)]*references[^)]*README",
|
||||
workflow_text,
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_packages_env_example_at_root(workflow_text: str) -> None:
|
||||
assert ".env.example" in workflow_text
|
||||
assert "Copied .env.example" in workflow_text
|
||||
@@ -40,3 +89,18 @@ def test_workflow_does_not_package_dot_env(workflow_text: str) -> None:
|
||||
r"os\.path\.join\(PACKAGE,\s*['\"]\.env['\"]\)",
|
||||
workflow_text,
|
||||
)
|
||||
|
||||
|
||||
def test_publish_workflow_triggers_on_main_only(publish_workflow_text: str) -> None:
|
||||
assert "branches:" in publish_workflow_text
|
||||
assert "- main" in publish_workflow_text
|
||||
assert "tags:" not in publish_workflow_text
|
||||
|
||||
|
||||
def test_publish_workflow_uses_pyproject_version_only(publish_workflow_text: str) -> None:
|
||||
assert "ci_set_package_version" not in publish_workflow_text
|
||||
assert "GITHUB_RUN_NUMBER" not in publish_workflow_text
|
||||
assert "GITHUB_RUN_ID" not in publish_workflow_text
|
||||
assert ".post" not in publish_workflow_text
|
||||
assert "python3.12 -m build" in publish_workflow_text
|
||||
assert "twine upload" in publish_workflow_text
|
||||
|
||||
190
tests/test_rpa_wait_and_env.py
Normal file
190
tests/test_rpa_wait_and_env.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""poll_until / 薄错误码 / browser launch timeout / machine_power 探测。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from jiangchang_skill_core.machine_power import probe_machine_power
|
||||
from jiangchang_skill_core.rpa import errors
|
||||
from jiangchang_skill_core.rpa.browser import launch_persistent_browser
|
||||
from jiangchang_skill_core.rpa.wait import poll_until, poll_until_sync
|
||||
|
||||
|
||||
def test_poll_until_succeeds_when_predicate_becomes_true(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def _fake_sleep(seconds: float, *, chunk=None):
|
||||
del chunk
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.wait.interruptible_sleep",
|
||||
_fake_sleep,
|
||||
)
|
||||
state = {"n": 0}
|
||||
|
||||
def ready() -> bool:
|
||||
state["n"] += 1
|
||||
return state["n"] >= 3
|
||||
|
||||
result = asyncio.run(poll_until(ready, timeout_sec=2.0, interval_sec=0.1))
|
||||
assert result.ok is True
|
||||
assert result.timed_out is False
|
||||
assert state["n"] == 3
|
||||
assert sleeps
|
||||
|
||||
|
||||
def test_poll_until_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def _fake_sleep(seconds: float, *, chunk=None):
|
||||
del chunk, seconds
|
||||
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.wait.interruptible_sleep",
|
||||
_fake_sleep,
|
||||
)
|
||||
result = asyncio.run(poll_until(lambda: False, timeout_sec=0.01, interval_sec=0.01))
|
||||
assert result.ok is False
|
||||
assert result.timed_out is True
|
||||
|
||||
|
||||
def test_poll_until_sync_ok(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.wait.interruptible_sleep_sync",
|
||||
lambda seconds, *, chunk=None: None,
|
||||
)
|
||||
assert poll_until_sync(lambda: True, timeout_sec=1.0).ok is True
|
||||
|
||||
|
||||
def test_thin_error_codes_exist() -> None:
|
||||
assert errors.ERR_UI_NOT_READY == "ERROR:UI_NOT_READY"
|
||||
assert errors.ERR_BROWSER_START_TIMEOUT == "ERROR:BROWSER_START_TIMEOUT"
|
||||
assert errors.ERR_ENV_DEGRADED == "ERROR:ENV_DEGRADED"
|
||||
assert errors.ERR_MISSING_BROWSER == "ERROR:MISSING_BROWSER"
|
||||
|
||||
|
||||
def test_launch_persistent_browser_timeout_maps_code(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.browser.find_chrome_executable",
|
||||
lambda: r"C:\fake\chrome.exe",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.browser.persistent_context_launch_parts",
|
||||
lambda extra_args=None: ([], None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.browser.stealth_enabled",
|
||||
lambda: False,
|
||||
)
|
||||
|
||||
async def _hang(**kwargs):
|
||||
del kwargs
|
||||
await asyncio.sleep(10)
|
||||
raise AssertionError("should have been cancelled by wait_for")
|
||||
|
||||
playwright = MagicMock()
|
||||
playwright.chromium.launch_persistent_context = _hang
|
||||
|
||||
async def _run():
|
||||
with pytest.raises(RuntimeError) as ctx:
|
||||
await launch_persistent_browser(
|
||||
playwright,
|
||||
profile_dir=r"C:\fake\profile",
|
||||
timeout_ms=50,
|
||||
)
|
||||
assert str(ctx.value).startswith(errors.ERR_BROWSER_START_TIMEOUT)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_launch_persistent_browser_missing_browser(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.browser.find_chrome_executable",
|
||||
lambda: None,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
with pytest.raises(RuntimeError) as ctx:
|
||||
await launch_persistent_browser(MagicMock(), profile_dir=r"C:\fake\profile")
|
||||
assert str(ctx.value).startswith(errors.ERR_MISSING_BROWSER)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_probe_machine_power_shape_and_no_raise() -> None:
|
||||
payload = probe_machine_power()
|
||||
assert "probed" in payload
|
||||
assert "ac_power" in payload
|
||||
assert "nvidia" in payload
|
||||
assert isinstance(payload["probed"], bool)
|
||||
|
||||
|
||||
def test_collect_runtime_diagnostics_includes_machine_power_without_fatal(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from jiangchang_skill_core import media_assets as ma
|
||||
from jiangchang_skill_core.runtime_diagnostics import (
|
||||
collect_runtime_diagnostics,
|
||||
runtime_diagnostics_dict,
|
||||
)
|
||||
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data"), "OPENCLAW_RECORD_VIDEO": "0"}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
root=tmp_path,
|
||||
exists=True,
|
||||
ready=True,
|
||||
source="local",
|
||||
warnings=[],
|
||||
ffmpeg_path=tmp_path / "ffmpeg.exe",
|
||||
music_dir=tmp_path / "music",
|
||||
music_ready=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||
lambda *a, **k: {
|
||||
"music_root": str(tmp_path / "music"),
|
||||
"mp3_count": 1,
|
||||
"usable_count": 1,
|
||||
"sample_path": str(tmp_path / "music" / "a.mp3"),
|
||||
"issue": None,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||
lambda: "1.2.11",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_machine_power",
|
||||
lambda: {
|
||||
"probed": True,
|
||||
"ac_power": {
|
||||
"ac_line_status": 1,
|
||||
"on_ac_power": True,
|
||||
"battery_life_percent": 100,
|
||||
},
|
||||
"nvidia": {
|
||||
"gpu_name": "Fake",
|
||||
"enforced_power_limit_w": 140.0,
|
||||
"power_limit_w": 140.0,
|
||||
"clocks_throttle_reasons_active": "None",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
diag = collect_runtime_diagnostics(skill_slug="demo", env=env)
|
||||
payload = runtime_diagnostics_dict(diag)
|
||||
assert payload["machine_power"]["probed"] is True
|
||||
assert diag.has_fatal_issues is False
|
||||
392
tests/test_runtime_diagnostics.py
Normal file
392
tests/test_runtime_diagnostics.py
Normal file
@@ -0,0 +1,392 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""runtime_diagnostics 公共 Runtime / media-assets 诊断测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import jiangchang_skill_core
|
||||
from jiangchang_skill_core import media_assets as ma
|
||||
from jiangchang_skill_core.runtime_diagnostics import (
|
||||
RuntimeDiagnostics,
|
||||
RuntimeIssue,
|
||||
collect_runtime_diagnostics,
|
||||
format_runtime_health_lines,
|
||||
is_jiangchang_skill_core_from_skill_tree,
|
||||
runtime_diagnostics_dict,
|
||||
version_ge,
|
||||
)
|
||||
|
||||
|
||||
def test_version_ge_post_release() -> None:
|
||||
assert version_ge("1.0.10.post23", "1.0.10") is True
|
||||
assert version_ge("1.0.10", "1.0.10") is True
|
||||
assert version_ge("1.0.9.post19", "1.0.10") is False
|
||||
assert version_ge("1.0.10.post23", "1.0.10.post21") is True
|
||||
assert version_ge("1.0.10.post21", "1.0.10.post23") is False
|
||||
assert version_ge("1.0.10", "1.0.10.post1") is False
|
||||
assert version_ge("1.0.11", "1.0.10.post99") is True
|
||||
assert version_ge("1.0.11", "1.0.10") is True
|
||||
|
||||
|
||||
def test_is_jiangchang_skill_core_from_skill_tree(tmp_path: Path) -> None:
|
||||
skill_root = tmp_path / "my-skill"
|
||||
inside = skill_root / "scripts" / "jiangchang_skill_core" / "__init__.py"
|
||||
inside.parent.mkdir(parents=True)
|
||||
inside.write_text("# vendored\n", encoding="utf-8")
|
||||
outside = tmp_path / "site-packages" / "jiangchang_skill_core" / "__init__.py"
|
||||
outside.parent.mkdir(parents=True)
|
||||
outside.write_text("# shared\n", encoding="utf-8")
|
||||
|
||||
assert is_jiangchang_skill_core_from_skill_tree(
|
||||
skill_root=skill_root,
|
||||
core_file=inside,
|
||||
) is True
|
||||
assert is_jiangchang_skill_core_from_skill_tree(
|
||||
skill_root=skill_root,
|
||||
core_file=outside,
|
||||
) is False
|
||||
|
||||
|
||||
def test_collect_runtime_diagnostics_json_serializable(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
data_root = tmp_path / "data"
|
||||
env = {
|
||||
"JIANGCHANG_DATA_ROOT": str(data_root),
|
||||
"OPENCLAW_RECORD_VIDEO": "0",
|
||||
}
|
||||
media_root = data_root / "shared" / "media-assets"
|
||||
(media_root / "music").mkdir(parents=True)
|
||||
|
||||
def _fake_probe_media_assets(data_root_arg=None, env_arg=None):
|
||||
return ma.MediaAssetsStatus(
|
||||
root=media_root,
|
||||
exists=True,
|
||||
ready=False,
|
||||
source="local",
|
||||
warnings=["ffmpeg_missing"],
|
||||
ffmpeg_path=None,
|
||||
music_dir=media_root / "music",
|
||||
music_ready=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
_fake_probe_media_assets,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||
lambda *a, **k: {
|
||||
"music_root": str(media_root / "music"),
|
||||
"mp3_count": 0,
|
||||
"usable_count": 0,
|
||||
"sample_path": None,
|
||||
"issue": "background_music_dir_empty",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||
lambda: "1.0.10",
|
||||
)
|
||||
|
||||
diag = collect_runtime_diagnostics(
|
||||
skill_slug="my-skill",
|
||||
platform_kit_min_version="1.0.9",
|
||||
env=env,
|
||||
)
|
||||
payload = runtime_diagnostics_dict(diag)
|
||||
json.dumps(payload)
|
||||
assert payload["skill_slug"] == "my-skill"
|
||||
assert payload["resolved_data_root"] == str(data_root)
|
||||
assert payload["platform_kit_version_ok"] is True
|
||||
|
||||
|
||||
def test_platform_kit_min_version_none_skips_version_issue(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
root=tmp_path,
|
||||
exists=False,
|
||||
ready=False,
|
||||
source="local",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||
lambda *a, **k: {
|
||||
"music_root": None,
|
||||
"mp3_count": 0,
|
||||
"usable_count": 0,
|
||||
"sample_path": None,
|
||||
"issue": "music_dir_missing",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||
lambda: "1.0.8",
|
||||
)
|
||||
|
||||
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
|
||||
assert diag.platform_kit_version_ok is None
|
||||
assert "platform_kit_version_low" not in diag.issue_codes()
|
||||
|
||||
|
||||
def test_platform_kit_missing_records_issue(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
root=tmp_path,
|
||||
exists=False,
|
||||
ready=False,
|
||||
source="local",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||
lambda *a, **k: {
|
||||
"music_root": None,
|
||||
"mp3_count": 0,
|
||||
"usable_count": 0,
|
||||
"sample_path": None,
|
||||
"issue": None,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||
lambda: None,
|
||||
)
|
||||
|
||||
diag = collect_runtime_diagnostics(
|
||||
skill_slug="my-skill",
|
||||
platform_kit_min_version="1.0.10",
|
||||
env=env,
|
||||
)
|
||||
assert "platform_kit_not_installed" in diag.issue_codes()
|
||||
|
||||
|
||||
def test_record_video_ffmpeg_and_music_warnings_not_fatal(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
media_root = tmp_path / "shared" / "media-assets"
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path), "OPENCLAW_RECORD_VIDEO": "1"}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
root=media_root,
|
||||
exists=True,
|
||||
ready=False,
|
||||
source="local",
|
||||
warnings=["ffmpeg_missing", "music_dir_missing"],
|
||||
ffmpeg_path=None,
|
||||
music_dir=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||
lambda *a, **k: {
|
||||
"music_root": None,
|
||||
"mp3_count": 0,
|
||||
"usable_count": 0,
|
||||
"sample_path": None,
|
||||
"issue": "music_dir_missing",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||
lambda: "1.0.10",
|
||||
)
|
||||
|
||||
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
|
||||
codes = diag.issue_codes()
|
||||
assert "ffmpeg_unavailable" in codes
|
||||
assert "background_music_unavailable" in codes
|
||||
assert diag.has_fatal_issues is False
|
||||
ffmpeg_issue = next(i for i in diag.issues if i.code == "ffmpeg_unavailable")
|
||||
assert ffmpeg_issue.severity == "warning"
|
||||
|
||||
|
||||
def test_skill_tree_core_issue_when_skill_root_given(tmp_path: Path) -> None:
|
||||
skill_root = tmp_path / "my-skill"
|
||||
fake_core = skill_root / "scripts" / "jiangchang_skill_core" / "__init__.py"
|
||||
fake_core.parent.mkdir(parents=True)
|
||||
fake_core.write_text("# vendored\n", encoding="utf-8")
|
||||
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||
with patch.object(jiangchang_skill_core, "__file__", str(fake_core)):
|
||||
diag = collect_runtime_diagnostics(
|
||||
skill_slug="my-skill",
|
||||
skill_root=skill_root,
|
||||
record_video=False,
|
||||
env=env,
|
||||
)
|
||||
assert "jiangchang_skill_core_loaded_from_skill_tree" in diag.issue_codes()
|
||||
|
||||
|
||||
def _legacy_env_key(suffix: str) -> str:
|
||||
return "CL" + "AW_" + suffix
|
||||
|
||||
|
||||
def test_format_runtime_health_lines_no_legacy_data_root_key(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env = {
|
||||
"JIANGCHANG_DATA_ROOT": str(tmp_path / "data"),
|
||||
_legacy_env_key("DATA_ROOT"): str(tmp_path / "legacy-only"),
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
root=tmp_path,
|
||||
exists=False,
|
||||
ready=False,
|
||||
source="local",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||
lambda *a, **k: {
|
||||
"music_root": None,
|
||||
"mp3_count": 0,
|
||||
"usable_count": 0,
|
||||
"sample_path": None,
|
||||
"issue": None,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||
lambda: "1.0.17",
|
||||
)
|
||||
|
||||
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
|
||||
payload = runtime_diagnostics_dict(diag)
|
||||
text = "\n".join(format_runtime_health_lines(diag))
|
||||
legacy_key = _legacy_env_key("DATA_ROOT")
|
||||
assert legacy_key not in payload
|
||||
assert legacy_key not in text
|
||||
assert payload["resolved_data_root"] == str(tmp_path / "data")
|
||||
|
||||
|
||||
def test_format_runtime_health_lines_core_fields(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data")}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
root=tmp_path,
|
||||
exists=False,
|
||||
ready=False,
|
||||
source="local",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||
lambda *a, **k: {
|
||||
"music_root": None,
|
||||
"mp3_count": 0,
|
||||
"usable_count": 0,
|
||||
"sample_path": None,
|
||||
"issue": None,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||
lambda: "1.0.10",
|
||||
)
|
||||
|
||||
diag = collect_runtime_diagnostics(skill_slug="my-skill", env=env)
|
||||
text = "\n".join(format_runtime_health_lines(diag))
|
||||
for marker in (
|
||||
"python_executable:",
|
||||
"platform_kit_version:",
|
||||
"resolved_data_root:",
|
||||
"media_assets_root:",
|
||||
"ffmpeg_available:",
|
||||
"background_music_mp3_count:",
|
||||
):
|
||||
assert marker in text
|
||||
|
||||
|
||||
def test_background_music_lfs_and_size_rules(tmp_path: Path) -> None:
|
||||
music = tmp_path / "music"
|
||||
music.mkdir()
|
||||
(music / "tiny.mp3").write_bytes(b"x" * 512)
|
||||
(music / "lfs.mp3").write_text(
|
||||
"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n"
|
||||
)
|
||||
(music / "ok.mp3").write_bytes(b"x" * 1024)
|
||||
|
||||
probe = ma.probe_background_music(tmp_path)
|
||||
assert probe["mp3_count"] == 3
|
||||
assert probe["usable_count"] == 1
|
||||
assert probe["sample_path"] == str(music / "ok.mp3")
|
||||
|
||||
|
||||
def test_runtime_diagnostics_frozen_dataclass() -> None:
|
||||
diag = RuntimeDiagnostics(
|
||||
skill_slug="x",
|
||||
python_executable="python",
|
||||
platform_kit_version="1.0.10",
|
||||
platform_kit_min_version=None,
|
||||
platform_kit_version_ok=None,
|
||||
jiangchang_skill_core_file=None,
|
||||
jiangchang_data_root=None,
|
||||
resolved_data_root="/tmp",
|
||||
media_assets_root="/tmp/media",
|
||||
ffmpeg_available=False,
|
||||
ffmpeg_path=None,
|
||||
background_music_mp3_count=0,
|
||||
background_music_usable_count=0,
|
||||
background_music_issue=None,
|
||||
background_music_sample_path=None,
|
||||
record_video_enabled=False,
|
||||
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
|
||||
machine_power={"probed": False, "ac_power": None, "nvidia": None},
|
||||
)
|
||||
assert diag.has_fatal_issues is False
|
||||
assert diag.issue_codes() == ["ffmpeg_unavailable"]
|
||||
|
||||
|
||||
def test_format_runtime_health_lines_issue_separator_ascii() -> None:
|
||||
diag = RuntimeDiagnostics(
|
||||
skill_slug="x",
|
||||
python_executable="python",
|
||||
platform_kit_version="1.0.11",
|
||||
platform_kit_min_version=None,
|
||||
platform_kit_version_ok=None,
|
||||
jiangchang_skill_core_file=None,
|
||||
jiangchang_data_root=None,
|
||||
resolved_data_root="/tmp",
|
||||
media_assets_root="/tmp/media",
|
||||
ffmpeg_available=False,
|
||||
ffmpeg_path=None,
|
||||
background_music_mp3_count=0,
|
||||
background_music_usable_count=0,
|
||||
background_music_issue=None,
|
||||
background_music_sample_path=None,
|
||||
record_video_enabled=False,
|
||||
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
|
||||
machine_power={"probed": False, "ac_power": None, "nvidia": None},
|
||||
)
|
||||
lines = format_runtime_health_lines(diag)
|
||||
issue_lines = [line for line in lines if line.startswith("runtime_issue[")]
|
||||
assert len(issue_lines) == 1
|
||||
assert " - " in issue_lines[0]
|
||||
assert "\u2014" not in issue_lines[0]
|
||||
assert "\u2013" not in issue_lines[0]
|
||||
204
tests/test_runtime_env.py
Normal file
204
tests/test_runtime_env.py
Normal file
@@ -0,0 +1,204 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""runtime_env:JIANGCHANG_* 路径契约与兄弟技能根解析。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from jiangchang_skill_core import config, unified_logging as ul
|
||||
from jiangchang_skill_core.runtime_diagnostics import (
|
||||
collect_runtime_diagnostics,
|
||||
format_runtime_health_lines,
|
||||
runtime_diagnostics_dict,
|
||||
)
|
||||
from jiangchang_skill_core.runtime_env import (
|
||||
apply_cli_local_defaults,
|
||||
get_data_root,
|
||||
get_sibling_skills_root,
|
||||
get_skills_root,
|
||||
get_user_id,
|
||||
platform_default_data_root,
|
||||
)
|
||||
|
||||
|
||||
def _legacy_env_key(suffix: str) -> str:
|
||||
return "CL" + "AW_" + suffix
|
||||
|
||||
|
||||
class _EnvIsolation(unittest.TestCase):
|
||||
_KEYS = (
|
||||
"JIANGCHANG_DATA_ROOT",
|
||||
"JIANGCHANG_USER_ID",
|
||||
"JIANGCHANG_SKILLS_ROOT",
|
||||
"JIANGCHANG_APP_ROOT",
|
||||
_legacy_env_key("DATA_ROOT"),
|
||||
_legacy_env_key("USER_ID"),
|
||||
_legacy_env_key("SKILLS_ROOT"),
|
||||
"JIANGCHANG_CLI_LOCAL_DEV",
|
||||
)
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._saved = {k: os.environ.get(k) for k in self._KEYS}
|
||||
for k in self._KEYS:
|
||||
os.environ.pop(k, None)
|
||||
config.reset_cache()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for k, v in self._saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
config.reset_cache()
|
||||
|
||||
|
||||
class TestGetDataRoot(_EnvIsolation):
|
||||
def test_jiangchang_data_root_only(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
self.assertEqual(get_data_root(), tmp)
|
||||
|
||||
def test_platform_default_when_unset(self) -> None:
|
||||
self.assertEqual(get_data_root(), platform_default_data_root())
|
||||
|
||||
def test_legacy_claw_data_root_ignored(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
|
||||
os.environ[_legacy_env_key("DATA_ROOT")] = claw_tmp
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = jc_tmp
|
||||
self.assertEqual(get_data_root(), jc_tmp)
|
||||
|
||||
def test_legacy_claw_data_root_alone_ignored(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as claw_tmp:
|
||||
os.environ[_legacy_env_key("DATA_ROOT")] = claw_tmp
|
||||
self.assertEqual(get_data_root(), platform_default_data_root())
|
||||
|
||||
|
||||
class TestGetUserId(_EnvIsolation):
|
||||
def test_jiangchang_user_id_only(self) -> None:
|
||||
os.environ["JIANGCHANG_USER_ID"] = "user-42"
|
||||
self.assertEqual(get_user_id(), "user-42")
|
||||
|
||||
def test_anon_when_unset(self) -> None:
|
||||
self.assertEqual(get_user_id(), "_anon")
|
||||
|
||||
def test_legacy_claw_user_id_ignored(self) -> None:
|
||||
os.environ[_legacy_env_key("USER_ID")] = "claw-only"
|
||||
os.environ["JIANGCHANG_USER_ID"] = "jc-user"
|
||||
self.assertEqual(get_user_id(), "jc-user")
|
||||
|
||||
def test_legacy_claw_user_id_alone_ignored(self) -> None:
|
||||
os.environ[_legacy_env_key("USER_ID")] = "claw-only"
|
||||
self.assertEqual(get_user_id(), "_anon")
|
||||
|
||||
|
||||
class TestApplyCliLocalDefaults(_EnvIsolation):
|
||||
def test_sets_jiangchang_only(self) -> None:
|
||||
with patch("jiangchang_skill_core.runtime_env.CLI_LOCAL_DEV_ENABLED", True):
|
||||
apply_cli_local_defaults()
|
||||
self.assertTrue(os.environ.get("JIANGCHANG_DATA_ROOT"))
|
||||
self.assertTrue(os.environ.get("JIANGCHANG_USER_ID"))
|
||||
self.assertNotIn(_legacy_env_key("DATA_ROOT"), os.environ)
|
||||
self.assertNotIn(_legacy_env_key("USER_ID"), os.environ)
|
||||
|
||||
def test_does_not_override_existing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_USER_ID"] = "existing"
|
||||
with patch("jiangchang_skill_core.runtime_env.CLI_LOCAL_DEV_ENABLED", True):
|
||||
apply_cli_local_defaults()
|
||||
self.assertEqual(os.environ["JIANGCHANG_DATA_ROOT"], tmp)
|
||||
self.assertEqual(os.environ["JIANGCHANG_USER_ID"], "existing")
|
||||
|
||||
|
||||
class TestGetSkillsRoot(_EnvIsolation):
|
||||
def test_jiangchang_skills_root_override(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_SKILLS_ROOT"] = tmp
|
||||
self.assertEqual(get_skills_root(), os.path.normpath(tmp))
|
||||
|
||||
def test_legacy_claw_skills_root_ignored(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
|
||||
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
|
||||
os.environ["JIANGCHANG_SKILLS_ROOT"] = jc_tmp
|
||||
self.assertEqual(get_skills_root(), os.path.normpath(jc_tmp))
|
||||
|
||||
def test_legacy_claw_skills_root_alone_ignored(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as claw_tmp:
|
||||
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
|
||||
root = get_skills_root()
|
||||
self.assertNotEqual(root, os.path.normpath(claw_tmp))
|
||||
|
||||
|
||||
class TestGetSiblingSkillsRoot(_EnvIsolation):
|
||||
def _make_skills_tree(self, base: Path) -> Path:
|
||||
skills_root = base / "skills"
|
||||
(skills_root / "account-manager").mkdir(parents=True)
|
||||
(skills_root / "receive-order" / "scripts").mkdir(parents=True)
|
||||
return skills_root
|
||||
|
||||
def test_inference_preferred_over_env(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tree_tmp, tempfile.TemporaryDirectory() as env_tmp:
|
||||
skills_root = self._make_skills_tree(Path(tree_tmp))
|
||||
scripts_dir = str(skills_root / "receive-order" / "scripts")
|
||||
os.environ["JIANGCHANG_SKILLS_ROOT"] = env_tmp
|
||||
result = get_sibling_skills_root(scripts_dir)
|
||||
self.assertEqual(result, os.path.normpath(str(skills_root)))
|
||||
|
||||
def test_env_override_when_inference_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as env_tmp:
|
||||
os.environ["JIANGCHANG_SKILLS_ROOT"] = env_tmp
|
||||
result = get_sibling_skills_root("/nonexistent/skill/scripts")
|
||||
self.assertEqual(result, os.path.normpath(env_tmp))
|
||||
|
||||
def test_legacy_claw_skills_root_ignored_for_sibling(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as claw_tmp, tempfile.TemporaryDirectory() as jc_tmp:
|
||||
os.environ[_legacy_env_key("SKILLS_ROOT")] = claw_tmp
|
||||
os.environ["JIANGCHANG_SKILLS_ROOT"] = jc_tmp
|
||||
result = get_sibling_skills_root("/nonexistent/skill/scripts")
|
||||
self.assertEqual(result, os.path.normpath(jc_tmp))
|
||||
|
||||
|
||||
class TestConfigAndLoggingPaths(_EnvIsolation):
|
||||
def test_config_env_file_under_user_isolated_dir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_USER_ID"] = "u-test"
|
||||
example = os.path.join(tmp, ".env.example")
|
||||
with open(example, "w", encoding="utf-8") as f:
|
||||
f.write("FOO=bar\n")
|
||||
dest = config.ensure_env_file("my-skill", example)
|
||||
expected_prefix = os.path.join(tmp, "u-test", "my-skill")
|
||||
self.assertTrue(os.path.normpath(dest).startswith(os.path.normpath(expected_prefix)))
|
||||
|
||||
def test_unified_logging_path_under_user_dir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_USER_ID"] = "log-user"
|
||||
log_dir = ul.get_unified_logs_dir()
|
||||
self.assertTrue(
|
||||
os.path.normpath(log_dir).startswith(
|
||||
os.path.normpath(os.path.join(tmp, "log-user", "logs"))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestDiagnosticsNoClawFields(_EnvIsolation):
|
||||
def test_diagnostics_json_and_text_exclude_claw(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
diag = collect_runtime_diagnostics(skill_slug="x", env=dict(os.environ))
|
||||
payload = runtime_diagnostics_dict(diag)
|
||||
text = "\n".join(format_runtime_health_lines(diag))
|
||||
legacy_data_root_key = _legacy_env_key("DATA_ROOT")
|
||||
self.assertNotIn(legacy_data_root_key, payload)
|
||||
self.assertNotIn(legacy_data_root_key, text)
|
||||
self.assertIn("JIANGCHANG_DATA_ROOT", payload)
|
||||
self.assertEqual(payload["resolved_data_root"], tmp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
72
tests/test_sibling_bridge.py
Normal file
72
tests/test_sibling_bridge.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""sibling_bridge: mock subprocess sibling CLI calls."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from jiangchang_skill_core.sibling_bridge import (
|
||||
call_sibling_json,
|
||||
call_sibling_json_array,
|
||||
get_sibling_main_path,
|
||||
)
|
||||
|
||||
|
||||
class TestSiblingBridge(unittest.TestCase):
|
||||
def test_get_sibling_main_path(self) -> None:
|
||||
with patch.dict(os.environ, {"JIANGCHANG_SKILLS_ROOT": r"D:\skills"}):
|
||||
path = get_sibling_main_path("account-manager")
|
||||
self.assertTrue(path.replace("\\", "/").endswith("account-manager/scripts/main.py"))
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_call_sibling_json_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout='log line\n{"id": 1, "name": "test"}\n',
|
||||
stderr="",
|
||||
)
|
||||
result = call_sibling_json("account-manager", ["list", "all"])
|
||||
self.assertEqual(result, {"id": 1, "name": "test"})
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn("account-manager", cmd[1])
|
||||
self.assertIn("list", cmd)
|
||||
self.assertTrue(mock_run.call_args[1]["capture_output"])
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_call_sibling_json_nonzero_exit(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=1, stdout="", stderr="ERROR:fail"
|
||||
)
|
||||
self.assertIsNone(call_sibling_json("content-manager", ["get", "x"]))
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_call_sibling_json_array(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout=json.dumps([{"a": 1}, {"b": 2}]),
|
||||
stderr="",
|
||||
)
|
||||
result = call_sibling_json_array("account-manager", ["list", "all"])
|
||||
self.assertEqual(result, [{"a": 1}, {"b": 2}])
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_uses_subprocess_env_with_trace(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout='{"ok":true}', stderr=""
|
||||
)
|
||||
with patch(
|
||||
"jiangchang_skill_core.sibling_bridge.subprocess_env_with_trace",
|
||||
return_value={"JIANGCHANG_TRACE_ID": "abc123"},
|
||||
) as mock_env:
|
||||
call_sibling_json("api-key-vault", ["get", "key"])
|
||||
self.assertEqual(mock_run.call_args[1]["env"], {"JIANGCHANG_TRACE_ID": "abc123"})
|
||||
mock_env.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RpaVideoSession:ffmpeg 桌面录屏。"""
|
||||
"""RpaVideoSession:ffmpeg 桌面录屏、背景音乐循环淡出、旁白混音。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -8,9 +8,21 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
|
||||
from jiangchang_skill_core.rpa import video_session as vs
|
||||
from jiangchang_skill_core.rpa.video_session import (
|
||||
RpaVideoSession,
|
||||
_StepEntry,
|
||||
_INTRO_BUFFER_SECONDS,
|
||||
_INTRO_STABILIZE_SECONDS,
|
||||
_OUTRO_BUFFER_SECONDS,
|
||||
_compose_capture_mp4,
|
||||
_music_fade_params,
|
||||
_select_voiceover_clips,
|
||||
_voiceover_text_for_step,
|
||||
)
|
||||
|
||||
|
||||
class TestRpaVideoSession(unittest.TestCase):
|
||||
@@ -33,6 +45,8 @@ class TestRpaVideoSession(unittest.TestCase):
|
||||
self.assertIsNone(summ["path"])
|
||||
self.assertIsNone(summ["capture_path"])
|
||||
self.assertIsNone(summ["record_log_path"])
|
||||
self.assertIsNone(summ["voiceover_path"])
|
||||
self.assertIsNone(summ["music_path"])
|
||||
finally:
|
||||
if old is not None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = old
|
||||
@@ -60,6 +74,11 @@ class TestRpaVideoSession(unittest.TestCase):
|
||||
"/rpa-artifacts/exmail_20260602_120000/logs/ffmpeg-record.log"
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
session._voiceover_path.replace("\\", "/").endswith(
|
||||
"/rpa-artifacts/exmail_20260602_120000/voiceover/voiceover.wav"
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
session._planned_output.replace("\\", "/").startswith(
|
||||
tmp.replace("\\", "/") + "/videos/"
|
||||
@@ -144,5 +163,413 @@ class TestRpaVideoSession(unittest.TestCase):
|
||||
config.reset_cache()
|
||||
|
||||
|
||||
class TestMusicFadeParams(unittest.TestCase):
|
||||
def test_normal_duration(self) -> None:
|
||||
start, dur = _music_fade_params(20.0)
|
||||
self.assertAlmostEqual(start, 17.0)
|
||||
self.assertAlmostEqual(dur, 3.0)
|
||||
|
||||
def test_short_video(self) -> None:
|
||||
start, dur = _music_fade_params(4.0)
|
||||
self.assertAlmostEqual(dur, 1.0)
|
||||
self.assertAlmostEqual(start, 3.0)
|
||||
|
||||
def test_invalid_duration(self) -> None:
|
||||
self.assertEqual(_music_fade_params(None), (None, None))
|
||||
self.assertEqual(_music_fade_params(0), (None, None))
|
||||
self.assertEqual(_music_fade_params(-1), (None, None))
|
||||
|
||||
|
||||
class TestVoiceoverTextFilter(unittest.TestCase):
|
||||
def test_keeps_meaningful_steps(self) -> None:
|
||||
for text in (
|
||||
"开始采集",
|
||||
"获取1688账号",
|
||||
"启动浏览器",
|
||||
"检查登录状态",
|
||||
"搜索关键词:螺丝",
|
||||
"解析第 1 页店铺列表",
|
||||
"打开店铺联系方式页",
|
||||
"提取联系人信息",
|
||||
"采集完成",
|
||||
):
|
||||
self.assertIsNotNone(_voiceover_text_for_step(text), msg=text)
|
||||
|
||||
def test_skips_noise(self) -> None:
|
||||
for text in (
|
||||
"等待 1-5s",
|
||||
"跳过重复店铺",
|
||||
"写入联系人库",
|
||||
"探针模式",
|
||||
"操作异常",
|
||||
"登录失败",
|
||||
"2024-01-01 12:00:00 INFO: debug",
|
||||
"",
|
||||
" ",
|
||||
):
|
||||
self.assertIsNone(_voiceover_text_for_step(text), msg=text)
|
||||
|
||||
def test_truncates_long_text(self) -> None:
|
||||
long_text = "开" * 80
|
||||
result = _voiceover_text_for_step(long_text)
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertEqual(len(result), 60)
|
||||
|
||||
def test_dedup_within_ten_seconds(self) -> None:
|
||||
entries = [
|
||||
_StepEntry(0.0, "启动浏览器"),
|
||||
_StepEntry(5.0, "启动浏览器"),
|
||||
_StepEntry(12.0, "启动浏览器"),
|
||||
]
|
||||
clips = _select_voiceover_clips(entries)
|
||||
texts = [t for _, t in clips]
|
||||
self.assertEqual(texts.count("启动浏览器"), 2)
|
||||
|
||||
def test_min_gap_two_seconds(self) -> None:
|
||||
entries = [
|
||||
_StepEntry(0.0, "获取1688账号"),
|
||||
_StepEntry(1.0, "连接CDP"),
|
||||
_StepEntry(3.0, "检查登录状态"),
|
||||
]
|
||||
clips = _select_voiceover_clips(entries)
|
||||
self.assertEqual(len(clips), 2)
|
||||
self.assertEqual(clips[0][1], "获取1688账号")
|
||||
self.assertEqual(clips[1][1], "检查登录状态")
|
||||
|
||||
def test_important_bypasses_min_gap(self) -> None:
|
||||
entries = [
|
||||
_StepEntry(0.0, "定位搜索框"),
|
||||
_StepEntry(0.5, "输入关键词:宁德电池"),
|
||||
_StepEntry(1.0, "点击搜索"),
|
||||
_StepEntry(1.5, "等待搜索结果"),
|
||||
]
|
||||
clips = _select_voiceover_clips(entries)
|
||||
texts = [t for _, t in clips]
|
||||
self.assertEqual(len(clips), 4)
|
||||
self.assertIn("输入关键词:宁德电池", texts)
|
||||
self.assertIn("点击搜索", texts)
|
||||
|
||||
def test_noise_still_skipped_with_important_nearby(self) -> None:
|
||||
entries = [
|
||||
_StepEntry(0.0, "点击搜索"),
|
||||
_StepEntry(0.5, "跳过重复店铺"),
|
||||
_StepEntry(1.0, "写入联系人库"),
|
||||
]
|
||||
clips = _select_voiceover_clips(entries)
|
||||
texts = [t for _, t in clips]
|
||||
self.assertEqual(texts, ["点击搜索"])
|
||||
|
||||
|
||||
class TestComposeCaptureMp4(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ffmpeg = Path(r"C:\ffmpeg\ffmpeg.exe")
|
||||
self.capture = Path(r"C:\tmp\capture.mp4")
|
||||
self.srt = Path(r"C:\tmp\sub.srt")
|
||||
self.output = Path(r"C:\tmp\out.mp4")
|
||||
self.music = Path(r"C:\tmp\music.mp3")
|
||||
self.voice = Path(r"C:\tmp\voiceover.wav")
|
||||
|
||||
def _run_compose(self, **kwargs) -> list[str]:
|
||||
captured: list[str] = []
|
||||
|
||||
def fake_run(_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
|
||||
captured.extend(cmd_tail)
|
||||
return True, ""
|
||||
|
||||
with (
|
||||
patch.object(vs, "_run_ffmpeg", side_effect=fake_run),
|
||||
patch.object(vs, "_resolve_ffprobe_exe", return_value=Path(r"C:\ffmpeg\ffprobe.exe")),
|
||||
patch.object(vs, "_probe_media_duration_seconds", return_value=kwargs.pop("duration", 20.0)),
|
||||
patch.object(Path, "is_file", return_value=True),
|
||||
):
|
||||
_compose_capture_mp4(
|
||||
self.ffmpeg,
|
||||
self.capture,
|
||||
self.srt,
|
||||
self.output,
|
||||
music_file=kwargs.get("music_file"),
|
||||
voiceover_file=kwargs.get("voiceover_file"),
|
||||
warnings=kwargs.get("warnings"),
|
||||
)
|
||||
return captured
|
||||
|
||||
def test_music_loop_params(self) -> None:
|
||||
cmd = self._run_compose(music_file=self.music)
|
||||
cmd_str = " ".join(cmd)
|
||||
loop_idx = cmd.index("-stream_loop")
|
||||
minus_one_idx = cmd.index("-1", loop_idx)
|
||||
music_i_idx = cmd.index("-i", minus_one_idx)
|
||||
self.assertEqual(cmd[music_i_idx + 1], str(self.music))
|
||||
self.assertIn("-shortest", cmd)
|
||||
filter_idx = cmd.index("-filter_complex")
|
||||
filt = cmd[filter_idx + 1]
|
||||
self.assertNotIn("apad", filt)
|
||||
|
||||
def test_music_fade_filter_long_video(self) -> None:
|
||||
cmd = self._run_compose(music_file=self.music, duration=20.0)
|
||||
filt = cmd[cmd.index("-filter_complex") + 1]
|
||||
self.assertIn("afade=t=out:st=17", filt)
|
||||
self.assertIn("d=3", filt)
|
||||
self.assertIn(f"volume={vs._MUSIC_VOLUME}", filt)
|
||||
|
||||
def test_music_fade_filter_short_video(self) -> None:
|
||||
cmd = self._run_compose(music_file=self.music, duration=4.0)
|
||||
filt = cmd[cmd.index("-filter_complex") + 1]
|
||||
self.assertIn("afade=t=out:st=3", filt)
|
||||
self.assertIn("d=1", filt)
|
||||
|
||||
def test_duration_probe_failed_no_afade(self) -> None:
|
||||
warnings: list[str] = []
|
||||
captured: list[str] = []
|
||||
|
||||
def fake_run(_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
|
||||
captured.extend(cmd_tail)
|
||||
return True, ""
|
||||
|
||||
with (
|
||||
patch.object(vs, "_run_ffmpeg", side_effect=fake_run),
|
||||
patch.object(vs, "_resolve_ffprobe_exe", return_value=None),
|
||||
patch.object(vs, "_probe_media_duration_seconds", return_value=None),
|
||||
patch.object(Path, "is_file", return_value=True),
|
||||
):
|
||||
ok, _ = _compose_capture_mp4(
|
||||
self.ffmpeg,
|
||||
self.capture,
|
||||
self.srt,
|
||||
self.output,
|
||||
music_file=self.music,
|
||||
warnings=warnings,
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("video_duration_probe_failed_for_music_fade", warnings)
|
||||
filt = captured[captured.index("-filter_complex") + 1]
|
||||
self.assertNotIn("afade", filt)
|
||||
self.assertIn("-stream_loop", captured)
|
||||
|
||||
def test_music_and_voice_amix(self) -> None:
|
||||
cmd = self._run_compose(music_file=self.music, voiceover_file=self.voice)
|
||||
filt = cmd[cmd.index("-filter_complex") + 1]
|
||||
self.assertIn("amix", filt)
|
||||
self.assertIn(f"volume={vs._MUSIC_VOLUME}", filt)
|
||||
self.assertIn(f"volume={vs._VOICE_VOLUME}", filt)
|
||||
self.assertLess(vs._MUSIC_VOLUME, vs._VOICE_VOLUME)
|
||||
self.assertIn("afade", filt)
|
||||
self.assertNotIn("apad", filt)
|
||||
self.assertIn("-stream_loop", cmd)
|
||||
self.assertIn("-shortest", cmd)
|
||||
|
||||
def test_voice_only_no_amix(self) -> None:
|
||||
cmd = self._run_compose(voiceover_file=self.voice)
|
||||
filt = cmd[cmd.index("-filter_complex") + 1]
|
||||
self.assertNotIn("amix", filt)
|
||||
self.assertIn(f"volume={vs._VOICE_VOLUME}", filt)
|
||||
self.assertIn("apad", filt)
|
||||
self.assertIn("-shortest", cmd)
|
||||
self.assertNotIn("-stream_loop", cmd)
|
||||
|
||||
|
||||
class TestVideoSessionLifecycle(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
config.reset_cache()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
config.reset_cache()
|
||||
|
||||
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||
@patch.object(RpaVideoSession, "_start_ffmpeg_capture")
|
||||
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
|
||||
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
|
||||
def test_enter_starts_capture_before_title(
|
||||
self,
|
||||
mock_finalize: AsyncMock,
|
||||
mock_stop: MagicMock,
|
||||
mock_start: MagicMock,
|
||||
mock_sleep: AsyncMock,
|
||||
) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
call_order: list[str] = []
|
||||
mock_start.side_effect = lambda: call_order.append("start_capture")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config.reset_cache()
|
||||
session = RpaVideoSession(
|
||||
skill_slug="test",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch_intro",
|
||||
title="开始执行测试",
|
||||
)
|
||||
original_add = session.add_step
|
||||
|
||||
def _track_add(text: str, *, duration: float = 4.0) -> None:
|
||||
call_order.append(f"add_step:{text}")
|
||||
original_add(text, duration=duration)
|
||||
|
||||
with patch.object(session, "add_step", side_effect=_track_add):
|
||||
asyncio.run(session.__aenter__())
|
||||
|
||||
self.assertEqual(call_order[0], "start_capture")
|
||||
self.assertIn("add_step:开始执行测试", call_order)
|
||||
mock_sleep.assert_awaited()
|
||||
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||
self.assertAlmostEqual(total_sleep, _INTRO_BUFFER_SECONDS)
|
||||
|
||||
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||
@patch.object(RpaVideoSession, "_start_ffmpeg_capture")
|
||||
def test_enter_no_title_still_buffers(
|
||||
self,
|
||||
mock_start: MagicMock,
|
||||
mock_sleep: AsyncMock,
|
||||
) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config.reset_cache()
|
||||
session = RpaVideoSession(
|
||||
skill_slug="test",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch_no_title",
|
||||
)
|
||||
with patch.object(session, "add_step") as mock_add:
|
||||
asyncio.run(session.__aenter__())
|
||||
mock_start.assert_called_once()
|
||||
mock_add.assert_not_called()
|
||||
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||
self.assertAlmostEqual(total_sleep, _INTRO_BUFFER_SECONDS)
|
||||
|
||||
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
|
||||
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
|
||||
def test_exit_closing_title_before_stop(
|
||||
self,
|
||||
mock_finalize: AsyncMock,
|
||||
mock_stop: MagicMock,
|
||||
mock_sleep: AsyncMock,
|
||||
) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
call_order: list[str] = []
|
||||
mock_stop.side_effect = lambda: call_order.append("stop_capture")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config.reset_cache()
|
||||
session = RpaVideoSession(
|
||||
skill_slug="test",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch_outro",
|
||||
closing_title="任务已完成",
|
||||
)
|
||||
original_add = session.add_step
|
||||
|
||||
def _track_add(text: str, *, duration: float = 4.0) -> None:
|
||||
call_order.append(f"add_step:{text}")
|
||||
original_add(text, duration=duration)
|
||||
|
||||
with patch.object(session, "add_step", side_effect=_track_add):
|
||||
asyncio.run(session.__aexit__(None, None, None))
|
||||
|
||||
self.assertIn("add_step:任务已完成", call_order)
|
||||
self.assertEqual(call_order[-1], "stop_capture")
|
||||
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||
self.assertAlmostEqual(total_sleep, _OUTRO_BUFFER_SECONDS)
|
||||
|
||||
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
|
||||
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
|
||||
def test_exit_no_closing_title_still_outro_buffer(
|
||||
self,
|
||||
mock_finalize: AsyncMock,
|
||||
mock_stop: MagicMock,
|
||||
mock_sleep: AsyncMock,
|
||||
) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config.reset_cache()
|
||||
session = RpaVideoSession(
|
||||
skill_slug="test",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch_outro_empty",
|
||||
)
|
||||
with patch.object(session, "add_step") as mock_add:
|
||||
asyncio.run(session.__aexit__(None, None, None))
|
||||
mock_add.assert_not_called()
|
||||
mock_stop.assert_called_once()
|
||||
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||
self.assertAlmostEqual(total_sleep, _OUTRO_BUFFER_SECONDS)
|
||||
|
||||
|
||||
class TestFinalizeDegradation(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
config.reset_cache()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
config.reset_cache()
|
||||
|
||||
@patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe")
|
||||
@patch("jiangchang_skill_core.rpa.video_session._resolve_music_file")
|
||||
@patch("jiangchang_skill_core.rpa.video_session._synthesize_step_voiceover")
|
||||
@patch("jiangchang_skill_core.rpa.video_session._compose_capture_mp4")
|
||||
def test_tts_failure_still_composes(
|
||||
self,
|
||||
mock_compose: MagicMock,
|
||||
mock_tts: MagicMock,
|
||||
mock_music: MagicMock,
|
||||
mock_ffmpeg: MagicMock,
|
||||
) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
mock_ffmpeg.return_value = Path(r"C:\ffmpeg\ffmpeg.exe")
|
||||
mock_music.return_value = Path(r"C:\tmp\music.mp3")
|
||||
mock_tts.return_value = None
|
||||
mock_compose.return_value = (True, "")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
capture = Path(tmp) / "rpa-artifacts" / "batch" / "capture.mp4"
|
||||
capture.parent.mkdir(parents=True)
|
||||
capture.write_bytes(b"fake")
|
||||
|
||||
session = RpaVideoSession(
|
||||
skill_slug="test",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch",
|
||||
)
|
||||
session.add_step("启动浏览器")
|
||||
asyncio.run(session.finalize())
|
||||
|
||||
self.assertTrue(mock_compose.called)
|
||||
self.assertIsNotNone(session.output_video_path)
|
||||
|
||||
@patch("jiangchang_skill_core.rpa.video_session._resolve_ffmpeg_exe")
|
||||
@patch("jiangchang_skill_core.rpa.video_session._resolve_music_file")
|
||||
@patch("jiangchang_skill_core.rpa.video_session._synthesize_step_voiceover")
|
||||
@patch("jiangchang_skill_core.rpa.video_session._compose_capture_mp4")
|
||||
def test_compose_fallback_without_music(
|
||||
self,
|
||||
mock_compose: MagicMock,
|
||||
mock_tts: MagicMock,
|
||||
mock_music: MagicMock,
|
||||
mock_ffmpeg: MagicMock,
|
||||
) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
mock_ffmpeg.return_value = Path(r"C:\ffmpeg\ffmpeg.exe")
|
||||
mock_music.return_value = Path(r"C:\tmp\music.mp3")
|
||||
mock_tts.return_value = None
|
||||
mock_compose.side_effect = [(False, "music err"), (True, "")]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
capture = Path(tmp) / "rpa-artifacts" / "batch" / "capture.mp4"
|
||||
capture.parent.mkdir(parents=True)
|
||||
capture.write_bytes(b"fake")
|
||||
|
||||
session = RpaVideoSession(
|
||||
skill_slug="test",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch",
|
||||
)
|
||||
asyncio.run(session.finalize())
|
||||
|
||||
self.assertEqual(mock_compose.call_count, 2)
|
||||
self.assertTrue(any("ffmpeg_with_music_failed" in w for w in session.warnings))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
113
tests/test_video_session_activity_integration.py
Normal file
113
tests/test_video_session_activity_integration.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RpaVideoSession.add_step integrates with activity.emit."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from jiangchang_skill_core import activity, config
|
||||
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
|
||||
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||
|
||||
|
||||
class TestVideoSessionActivityIntegration(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
config.reset_cache()
|
||||
self._saved_root = os.environ.get("JIANGCHANG_DATA_ROOT")
|
||||
self._saved_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||
self._saved_video = os.environ.get("OPENCLAW_RECORD_VIDEO")
|
||||
self._tmps: list[str] = []
|
||||
activity._reset_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
activity._reset_for_tests()
|
||||
if self._saved_root is None:
|
||||
os.environ.pop("JIANGCHANG_DATA_ROOT", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = self._saved_root
|
||||
if self._saved_job is None:
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = self._saved_job
|
||||
if self._saved_video is None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
else:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = self._saved_video
|
||||
config.reset_cache()
|
||||
for tmp in self._tmps:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def _tmp_data_root(self) -> str:
|
||||
tmp = tempfile.mkdtemp()
|
||||
self._tmps.append(tmp)
|
||||
return tmp
|
||||
|
||||
def test_add_step_emits_activity_when_disabled(self) -> None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "video-act-1"
|
||||
activity._reset_for_tests()
|
||||
config.reset_cache()
|
||||
|
||||
session = RpaVideoSession(
|
||||
skill_slug="publish-toutiao",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch1",
|
||||
)
|
||||
self.assertFalse(session.enabled)
|
||||
session.add_step("打开浏览器")
|
||||
|
||||
journal = Path(get_runs_dir()) / "video-act-1.jsonl"
|
||||
self.assertTrue(journal.is_file())
|
||||
content = journal.read_text(encoding="utf-8")
|
||||
self.assertIn("打开浏览器", content)
|
||||
self.assertIn("rpa.video", content)
|
||||
self.assertIn("publish-toutiao", content)
|
||||
|
||||
def test_emit_activity_false_skips_journal(self) -> None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "video-act-off"
|
||||
activity._reset_for_tests()
|
||||
|
||||
session = RpaVideoSession(
|
||||
skill_slug="publish-toutiao",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch2",
|
||||
emit_activity=False,
|
||||
)
|
||||
session.add_step("不应写入")
|
||||
journal = Path(get_runs_dir()) / "video-act-off.jsonl"
|
||||
self.assertFalse(journal.exists())
|
||||
|
||||
def test_add_step_writes_both_journal_and_subtitle_steps(self) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "video-act-both"
|
||||
activity._reset_for_tests()
|
||||
config.reset_cache()
|
||||
|
||||
session = RpaVideoSession(
|
||||
skill_slug="receive-order",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch3",
|
||||
)
|
||||
session.add_step("录制步骤")
|
||||
self.assertEqual(len(session._steps), 1)
|
||||
|
||||
journal = Path(get_runs_dir()) / "video-act-both.jsonl"
|
||||
lines = [json.loads(ln) for ln in journal.read_text(encoding="utf-8").splitlines()]
|
||||
activity_events = [e for e in lines if e.get("type") == "activity"]
|
||||
self.assertEqual(len(activity_events), 1)
|
||||
self.assertEqual(activity_events[0]["text"], "录制步骤")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Patch pyproject.toml and __init__.py version for CI builds (not committed)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _read_base_version() -> str:
|
||||
text = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
||||
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||
if not match:
|
||||
raise SystemExit("ERROR: version not found in pyproject.toml")
|
||||
base = match.group(1)
|
||||
return re.sub(r"\.post\d+$", "", base)
|
||||
|
||||
|
||||
def resolve_version() -> str:
|
||||
ref = os.environ.get("GITHUB_REF", "")
|
||||
if ref.startswith("refs/tags/v"):
|
||||
return ref.removeprefix("refs/tags/v")
|
||||
if ref.startswith("refs/tags/"):
|
||||
return ref.removeprefix("refs/tags/")
|
||||
|
||||
run_number = (
|
||||
os.environ.get("GITHUB_RUN_NUMBER")
|
||||
or os.environ.get("GITHUB_RUN_ID")
|
||||
or "0"
|
||||
)
|
||||
base = _read_base_version()
|
||||
return f"{base}.post{run_number}"
|
||||
|
||||
|
||||
def patch_version(version: str) -> None:
|
||||
pyproject = ROOT / "pyproject.toml"
|
||||
py_text = pyproject.read_text(encoding="utf-8")
|
||||
py_text, n = re.subn(
|
||||
r'^version\s*=\s*"[^"]*"',
|
||||
f'version = "{version}"',
|
||||
py_text,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if n != 1:
|
||||
raise SystemExit("ERROR: failed to patch version in pyproject.toml")
|
||||
pyproject.write_text(py_text, encoding="utf-8")
|
||||
|
||||
init_py = ROOT / "src" / "jiangchang_desktop_sdk" / "__init__.py"
|
||||
init_text = init_py.read_text(encoding="utf-8")
|
||||
init_text, n = re.subn(
|
||||
r'^__version__\s*=\s*"[^"]*"',
|
||||
f'__version__ = "{version}"',
|
||||
init_text,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if n != 1:
|
||||
raise SystemExit("ERROR: failed to patch __version__ in __init__.py")
|
||||
init_py.write_text(init_text, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
version = resolve_version()
|
||||
print(f"Resolved package version: {version}")
|
||||
patch_version(version)
|
||||
if os.environ.get("GITHUB_OUTPUT"):
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
|
||||
fh.write(f"package_version={version}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except SystemExit as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
raise
|
||||
@@ -28,6 +28,7 @@
|
||||
加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行。CI 会:
|
||||
- 加密 scripts/(递归 PyArmor -r,输出到包内 scripts/,与源码目录树一致)
|
||||
- 复制 SKILL.md
|
||||
- 若根目录存在 README.md,则复制到包根(明文,用于技能市场用户说明)
|
||||
- 复制 references/(排除 REQUIREMENTS.md)
|
||||
- 复制 assets/
|
||||
- 复制 tests/
|
||||
|
||||
145
uv.lock
generated
145
uv.lock
generated
@@ -116,6 +116,92 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/a1/1f7f0c555f5858fd2906fe9f7b0a3554fddb85cb70df7a6aaec41dc292c2/greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c", size = 285838, upload-time = "2026-06-26T18:21:05.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/29/be9f43ed61677a5759b38c8a9389248133c8c731bbfc0574ecdff66c99fc/greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04", size = 602342, upload-time = "2026-06-26T19:07:06.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/42/ba41c97ec36aa4b3ec25e5aa691d79561254805fad7f2f826dd6770587e2/greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce", size = 615541, upload-time = "2026-06-26T19:10:04.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/8c/231ca675b0df779816950ca66b40b1fa14dbff4a0ed9814a9a29ec399140/greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8", size = 622473, upload-time = "2026-06-26T19:24:12.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c7/28747042e1df8a9cd120a1ebe15529fc4be3b486e13e8d551ff307a82412/greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec", size = 615675, upload-time = "2026-06-26T18:32:14.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/fe/dd97c483a3ff82849196ccd07851600edd3ac9de74669ca8a6022ada9ea1/greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71", size = 418421, upload-time = "2026-06-26T19:25:34.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a8/b85525a6c8fba9f009a5f7c8df1545de8fb0f0bf3e0179194ef4e500317f/greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8", size = 1575057, upload-time = "2026-06-26T19:09:00.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/79/fb76edb218fe6735ab0edeba176c7ab80df9618f7c02ce4208979f3ae7db/greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702", size = 1641692, upload-time = "2026-06-26T18:31:41.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/79/86fe3ee50ed55d9b3907eecd3208b5c3fe8a79515519aae98b4753c3fa1d/greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db", size = 238742, upload-time = "2026-06-26T18:20:40.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.13"
|
||||
@@ -127,14 +213,60 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "jiangchang-platform-kit"
|
||||
version = "0.1.0"
|
||||
version = "1.0.15"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mss" },
|
||||
{ name = "playwright" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "requests", specifier = ">=2.31.0" }]
|
||||
requires-dist = [
|
||||
{ name = "mss", specifier = ">=9.0.1" },
|
||||
{ name = "playwright", specifier = ">=1.42.0" },
|
||||
{ name = "requests", specifier = ">=2.31.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mss"
|
||||
version = "10.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/5d/eee782a6d674f562c946ae6a026f4c595ea2b7b031f290bf9fbf60da09b5/mss-10.2.0.tar.gz", hash = "sha256:ab271860775545e62f29d7b11f82f279ac1048f5bbdd26cfad84830208dbd393", size = 200317, upload-time = "2026-04-23T10:44:57.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/c3/313e14f245c79b4c05bd0f3a84a4813aa26fa10f8993aebd91d04c5fad3f/mss-10.2.0-py3-none-any.whl", hash = "sha256:e79f428899280e7e64e38365b5bfed683851ebea807eeaeadaf06eb8e0d67197", size = 67106, upload-time = "2026-04-23T10:44:56.266Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.60.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
@@ -151,6 +283,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
|
||||
Reference in New Issue
Block a user