23 Commits

Author SHA1 Message Date
c9f5c37af8 feat(rpa): add poll_until, env error codes, optional launch timeout (1.2.11)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 19s
Shared readiness wait and thin ERROR:UI_NOT_READY / BROWSER_START_TIMEOUT / ENV_DEGRADED codes so skills can separate slow machines from config failures. Optional browser launch timeout and read-only power/throttle diagnostics (non-blocking).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 10:36:32 +08:00
065d341320 feat(desktop-wake): restore SearchResultWindow2 n2-wake without maximize (1.2.10)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 26s
Re-enable float subclass warm (2->122 verified locally); keep activate_maximize=false.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:07:12 +08:00
24384d70ba fix(desktop-wake): never inject SearchResultWindow2 (1.2.9)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 32s
Soft-wake search popup only; n2-wake on float crashed WeCom via wecom_wake.dll.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:32:23 +08:00
17ac1b4e11 fix(desktop-wake): multi-hwnd subclass + no maximize on search popup (1.2.8)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 37s
SearchResultWindow2 can be warmed after main is already warm; desktop-wake no longer maximizes the popup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:10:15 +08:00
84157758e6 fix(desktop-wake): n2-wake wecom_search via main WeWorkWindow hwnd (1.2.7)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 27s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 19:22:12 +08:00
c75eaa701b feat(desktop-wake): add wecom_search profile for SearchResultWindow2 (1.2.6)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 27s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 11:59:49 +08:00
6d3c353967 fix(desktop-wake): only OBJID_CLIENT DuiLib path to stop UIA core crash (1.2.5)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 25s
Skip UiaRoot MSAA spoofing, sync GETOBJECT, and SPI screen-reader for n2-wake.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 09:28:36 +08:00
9451bef753 fix(desktop-wake): avoid in-process UIA FindAll crash (1.2.4)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 27s
Keep DuiLib subclass wake only with GETOBJECT reentrancy guard; let ensure probe warm out-of-process.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 09:19:42 +08:00
71091e38a9 feat(desktop-wake): add WeCom UIA warm-tree and ship n2-wake in 1.2.3
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 29s
Expose ensure_ui_ready via DuiLib GetAccessible inject path; package x86 native helpers for host/skill consumers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 08:42:38 +08:00
3dc46b0555 fix(config): treat empty .env values with inline # comments as empty
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 32s
2026-07-18 15:53:37 +08:00
54b75c641e feat(release): sync skill detail tabs and publish 1.2.1
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 26s
Package optional TUTORIAL/DEMO/CHANGELOG into skill ZIPs and sync tutorial_md, demo_md, demo_video_url, and versioned changelog to the marketplace API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 11:48:02 +08:00
cf5e74d80a feat(activity): SRCP v1 step gates, pause/resume/stop control (v1.2.0)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:20:37 +08:00
1c51139883 add skill activity stream and job runtime foundation (v1.1.0)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s
Introduce SAS v1 emit/finish/journal API, sibling CLI bridge, RPA video session integration, and tests for the host Job Runner activity timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 19:12:51 +08:00
1632828372 统一运行时环境变量契约,移除 CLAW_* 别名 (v1.0.17)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 19s
2026-06-30 14:41:38 +08:00
f7fc33fa4a fix(config): unify RPA runtime reads through config.get* (v1.0.16)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 21s
Route OPENCLAW_BROWSER_HEADLESS, OPENCLAW_PLAYWRIGHT_STEALTH, OPENCLAW_ARTIFACTS_ON_FAILURE, and OPENCLAW_RECORD_VIDEO (health diagnostics) through jiangchang_skill_core.config.get_bool so user .env and .env.example share the same three-tier priority as the rest of the skill stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 10:21:32 +08:00
5a0b93448c 升级版本到1.0.15
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 25s
2026-06-27 14:53:59 +08:00
367df10755 chore: update skill release readme source
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 26s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 10:59:37 +08:00
857e7e3d73 feat: improve rpa video timing and narration
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 11:50:54 +08:00
0e2b73e1ad feat: improve rpa video audio composition
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 50s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 10:14:31 +08:00
36a411810d ci: publish formal version from pyproject.toml on main push
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 25s
Remove .postN CI versioning, fix runtime version_ge for legacy post releases, and use ASCII issue separators in health output.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 09:26:41 +08:00
c9398451d0 Release v1.0.11: shared runtime diagnostics and media_assets probe helpers
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 23s
2026-06-07 09:06:41 +08:00
2a542c0be6 ci: publish platform kit on main only
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 29s
2026-06-06 16:26:24 +08:00
e59661f2d8 fix(media-assets): download stable release bundle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 16:16:18 +08:00
63 changed files with 7886 additions and 319 deletions

View File

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

View File

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

@@ -6,3 +6,9 @@ __pycache__/
build/
dist/
python-runtime/.venv/
@AutomationLog.txt
# desktop_wake:编译换名残留,发版只保留 wecom_wake.dll
src/jiangchang_desktop_wake/native/wecom_wake_*.dll
src/jiangchang_desktop_wake/native/*.exp
src/jiangchang_desktop_wake/native/*.lib

243
README.md
View File

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

View File

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

View File

@@ -23,7 +23,7 @@ __all__ = [
"LaunchError",
"GatewayDownError",
]
__version__ = "1.0.9"
__version__ = "1.0.13"
def __getattr__(name: str):

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

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

View File

@@ -0,0 +1,41 @@
"""jiangchang_desktop_wake — 桌面 UIA Provider 暖树(可整包删除)。
与 ``jiangchang_skill_core`` / ``jiangchang_desktop_sdk`` 平级,互不强制依赖。
用法::
from jiangchang_desktop_wake import ensure_ui_ready, probe
r = ensure_ui_ready("wecom")
print(r.probe_after.findall_count, r.warm)
CLI::
python -m jiangchang_desktop_wake probe --profile wecom
python -m jiangchang_desktop_wake ensure --profile wecom
"""
from __future__ import annotations
from .c2 import wake_c2
from .ensure import ensure_ui_ready
from .errors import DesktopWakeError
from .inject import inject_probe
from .probe import probe
from .profiles import list_profiles, load_profile
from .wake import wake, wake_or_raise
__version__ = "0.5.0"
__all__ = [
"__version__",
"DesktopWakeError",
"ensure_ui_ready",
"inject_probe",
"probe",
"wake",
"wake_c2",
"wake_or_raise",
"load_profile",
"list_profiles",
]

View File

@@ -0,0 +1,156 @@
"""CLI:python -m jiangchang_desktop_wake <probe|inject|wake|ensure|profiles>"""
from __future__ import annotations
import argparse
import json
import sys
def _print_json(obj) -> None:
print(json.dumps(obj, ensure_ascii=False, indent=2))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m jiangchang_desktop_wake",
description="桌面 UIA Provider 暖树(probe / inject / wake / ensure)",
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_profiles = sub.add_parser("profiles", help="列出内置 profile")
p_profiles.set_defaults(func=_cmd_profiles)
p_probe = sub.add_parser("probe", help="探测冷/暖")
p_probe.add_argument("--profile", default="wecom")
p_probe.add_argument("--hwnd", type=lambda s: int(s, 0), default=None)
p_probe.set_defaults(func=_cmd_probe)
p_inject = sub.add_parser(
"inject",
help="阶段 B:安全注入探针(不挂钩;成功看 inject_ok + ui_responsive)",
)
p_inject.add_argument("--profile", default="wecom")
p_inject.add_argument("--hwnd", type=lambda s: int(s, 0), default=None)
p_inject.set_defaults(func=_cmd_inject)
p_c2 = sub.add_parser(
"c2",
help="阶段 C2:SPI + 注入 + WakeProvider,再 probe(冲暖树)",
)
p_c2.add_argument("--profile", default="wecom")
p_c2.add_argument("--hwnd", type=lambda s: int(s, 0), default=None)
p_c2.set_defaults(func=_cmd_c2)
p_wake = sub.add_parser("wake", help="仅跑唤醒策略(不强制成功)")
p_wake.add_argument("--profile", default="wecom")
p_wake.add_argument("--hwnd", type=lambda s: int(s, 0), default=None)
p_wake.set_defaults(func=_cmd_wake)
p_ensure = sub.add_parser(
"ensure",
help="probe→wake→probe;wecom 主路径为 n2-wake(DuiLib 暖树)",
)
p_ensure.add_argument("--profile", default="wecom")
p_ensure.add_argument("--hwnd", type=lambda s: int(s, 0), default=None)
p_ensure.add_argument(
"--no-raise",
action="store_true",
help="仍冷也 exit 0,只打印 JSON",
)
p_ensure.set_defaults(func=_cmd_ensure)
args = parser.parse_args(argv)
try:
return int(args.func(args))
except Exception as exc:
print(str(exc), file=sys.stderr)
return 1
def _cmd_profiles(_args) -> int:
from .profiles import list_profiles, load_profile
out = []
for pid in list_profiles():
p = load_profile(pid)
out.append(
{
"id": p.id,
"description": p.description,
"window_class": p.window_class,
"warm_min_findall": p.warm_min_findall,
"warm_markers": p.warm_markers,
"wake_strategies": p.wake_strategies,
}
)
_print_json(out)
return 0
def _cmd_probe(args) -> int:
from .probe import probe
r = probe(args.profile, hwnd=args.hwnd)
_print_json(r.to_dict())
return 0 if r.warm else 2
def _cmd_inject(args) -> int:
from .inject import inject_probe
r = inject_probe(args.profile, hwnd=args.hwnd)
_print_json(r)
if r.get("inject_ok") and r.get("ui_responsive"):
return 0
if r.get("inject_ok") and not r.get("ui_responsive"):
return 6
return 5
def _cmd_c2(args) -> int:
from .c2 import wake_c2
r = wake_c2(args.profile, hwnd=args.hwnd)
_print_json(r)
if r.get("warm"):
return 0
if r.get("ui_responsive") is False:
return 6
return 4
def _cmd_wake(args) -> int:
from .probe import probe
from .wake import wake
steps = wake(args.profile, hwnd=args.hwnd)
after = probe(args.profile, hwnd=args.hwnd)
_print_json(
{
"steps": [s.to_dict() for s in steps],
"probe_after": after.to_dict(),
}
)
return 0 if after.warm else 2
def _cmd_ensure(args) -> int:
from .ensure import ensure_ui_ready
from .errors import DesktopWakeError
try:
r = ensure_ui_ready(
args.profile,
hwnd=args.hwnd,
raise_on_cold=not args.no_raise,
)
except DesktopWakeError as exc:
print(str(exc), file=sys.stderr)
return 1
_print_json(r.to_dict())
return 0 if r.warm else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,68 @@
"""阶段 C2:安全注入 + WakeProvider 进程内暖树尝试。"""
from __future__ import annotations
import json
import os
import subprocess
import time
from pathlib import Path
from typing import Any, Optional
from .errors import DesktopWakeError, ERR_WAKE_FAILED
from .probe import probe
from .profiles import load_profile
from .wake import native_helper_path
from .window import find_hwnd_for_profile, require_windows
def wake_c2(
profile: str = "wecom",
*,
hwnd: Optional[int] = None,
) -> dict[str, Any]:
"""跑 desktop-wake --mode n2-wake,再 probe。
成功暖树:返回里 ``warm=true``(与 probe 一致)。
"""
require_windows()
prof = load_profile(profile)
h = find_hwnd_for_profile(prof, hwnd=hwnd)
before = probe(prof, hwnd=h)
helper = native_helper_path(prof.native_helper or "desktop-wake.exe")
if helper is None:
raise DesktopWakeError(ERR_WAKE_FAILED, "desktop-wake.exe 缺失,请先 build.ps1")
cmd = [str(helper), prof.id, "0x" + format(int(h), "x"), "--mode", "n2-wake"]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60, check=False)
except subprocess.TimeoutExpired as exc:
raise DesktopWakeError(ERR_WAKE_FAILED, "n2-wake timeout") from exc
out = (proc.stdout or "").strip()
native: dict[str, Any] = {"rc": proc.returncode, "stderr": (proc.stderr or "")[:300]}
if out:
try:
native.update(json.loads(out.splitlines()[-1].strip()))
except json.JSONDecodeError:
native["raw"] = out[:500]
# 进程内 UIA 客户端保持存活后,给外层树一点沉降时间
time.sleep(1.0)
after = probe(prof, hwnd=h)
tdir = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".")
provider = tdir / "jiangchang_desktop_wake_provider.txt"
return {
"hwnd": h,
"profile_id": prof.id,
"cmd": cmd,
"native": native,
"probe_before": before.to_dict(),
"probe_after": after.to_dict(),
"warm": after.warm,
"ui_responsive": bool(native.get("ui_responsive", True)),
"provider_text": provider.read_text(encoding="utf-8", errors="replace")
if provider.is_file()
else "",
}

View File

@@ -0,0 +1,60 @@
"""ensure_ui_ready:probe → wake → probe。"""
from __future__ import annotations
from typing import Optional, Union
from .errors import DesktopWakeError, ERR_PROVIDER_COLD, ERR_WAKE_FAILED
from .probe import probe
from .profiles import load_profile
from .types import EnsureResult, Profile
from .wake import wake
from .window import find_hwnd_for_profile, require_windows
def ensure_ui_ready(
profile: Union[str, Profile] = "wecom",
*,
hwnd: Optional[int] = None,
raise_on_cold: bool = True,
) -> EnsureResult:
"""保证目标窗 UIA 树可用。
已暖则直接返回;否则跑唤醒策略梯再探测。
``raise_on_cold=True``(默认)时仍冷则抛 ``DesktopWakeError``。
"""
require_windows()
prof = load_profile(profile) if isinstance(profile, str) else profile
h = find_hwnd_for_profile(prof, hwnd=hwnd)
before = probe(prof, hwnd=h)
if before.warm:
return EnsureResult(
hwnd=h,
profile_id=prof.id,
warm=True,
probe_before=before,
probe_after=before,
steps=[],
wake_applied=False,
)
steps = wake(prof, hwnd=h, stop_when_warm=True)
after = probe(prof, hwnd=h)
result = EnsureResult(
hwnd=h,
profile_id=prof.id,
warm=after.warm,
probe_before=before,
probe_after=after,
steps=steps,
wake_applied=True,
)
if after.warm or not raise_on_cold:
return result
code = ERR_WAKE_FAILED if steps else ERR_PROVIDER_COLD
raise DesktopWakeError(
code,
f"profile={prof.id} hwnd={h} findall={after.findall_count} "
f"missing={after.markers_missing} notes={after.notes}",
)

View File

@@ -0,0 +1,30 @@
"""桌面 Provider 暖树错误码(stdout / 异常消息前缀)。"""
from __future__ import annotations
ERR_WINDOW_NOT_FOUND = "ERROR:WINDOW_NOT_FOUND"
ERR_UIA_UNAVAILABLE = "ERROR:UIA_UNAVAILABLE"
ERR_PROVIDER_COLD = "ERROR:PROVIDER_COLD"
ERR_WAKE_FAILED = "ERROR:WAKE_FAILED"
ERR_UNSUPPORTED_OS = "ERROR:UNSUPPORTED_OS"
ERR_PROFILE_NOT_FOUND = "ERROR:PROFILE_NOT_FOUND"
__all__ = [
"ERR_WINDOW_NOT_FOUND",
"ERR_UIA_UNAVAILABLE",
"ERR_PROVIDER_COLD",
"ERR_WAKE_FAILED",
"ERR_UNSUPPORTED_OS",
"ERR_PROFILE_NOT_FOUND",
"DesktopWakeError",
]
class DesktopWakeError(RuntimeError):
"""暖树失败;``code`` 为稳定错误码(无 ERROR: 前缀)。"""
def __init__(self, code: str, message: str):
self.code = code
self.message = message
prefix = code if code.startswith("ERROR:") else f"ERROR:{code}"
super().__init__(f"{prefix}: {message}")

View File

@@ -0,0 +1,79 @@
"""阶段 B:安全注入探针(LoadLibrary only,不挂钩 / 不子类化)。"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Any, Optional
from .errors import DesktopWakeError, ERR_WAKE_FAILED
from .profiles import load_profile
from .wake import native_helper_path
from .window import find_hwnd_for_profile, require_windows
def inject_probe(
profile: str = "wecom",
*,
hwnd: Optional[int] = None,
) -> dict[str, Any]:
"""对目标窗进程注入 wecom_wake.dll(仅写 loaded 标记)。
成功标准:``inject_ok=true`` 且 ``ui_responsive=true``(不要求暖树)。
"""
require_windows()
prof = load_profile(profile)
h = find_hwnd_for_profile(prof, hwnd=hwnd)
helper = native_helper_path(prof.native_helper or "desktop-wake.exe")
if helper is None:
raise DesktopWakeError(ERR_WAKE_FAILED, "desktop-wake.exe 缺失,请先 build.ps1")
cmd = [
str(helper),
prof.id,
"0x" + format(int(h), "x"),
"--mode",
"n2",
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=25, check=False)
except subprocess.TimeoutExpired as exc:
raise DesktopWakeError(ERR_WAKE_FAILED, "inject timeout") from exc
out = (proc.stdout or "").strip()
err = (proc.stderr or "").strip()
payload: dict[str, Any] = {
"hwnd": h,
"profile_id": prof.id,
"rc": proc.returncode,
"cmd": cmd,
"stderr": err[:300],
"inject_ok": False,
"ui_responsive": False,
"ok": False,
"detail": "",
"raw": out[:500],
}
if out:
line = out.splitlines()[-1].strip()
try:
data = json.loads(line)
payload.update(data)
payload["inject_ok"] = bool(data.get("inject_ok"))
payload["ui_responsive"] = bool(data.get("ui_responsive"))
payload["ok"] = bool(data.get("ok"))
payload["detail"] = str(data.get("detail") or "")
except json.JSONDecodeError:
payload["detail"] = "non_json_stdout"
import os
tdir = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".")
loaded = tdir / "jiangchang_desktop_wake_loaded.txt"
payload["markers"] = {
"loaded": str(loaded),
"target": str(tdir / "jiangchang_desktop_wake_target.txt"),
"loaded_text": loaded.read_text(encoding="utf-8", errors="replace") if loaded.is_file() else "",
}
return payload

View File

@@ -0,0 +1,32 @@
# native/
发版随 wheel 打包的 x86 产物(匹配 32-bit `WXWork.exe`)。
| 文件 | 说明 |
|---|---|
| `desktop-wake.exe` | 编排:激活 / SPI / 注入 / 调 `WakeProvider` |
| `wecom_wake.dll` | 载荷:DuiLib `GetAccessible` 安全子类化暖树 |
不要把 `wecom_wake_b.dll` 等临时换名文件打进发行包。
## 编译
```powershell
# 先完全退出企微(含托盘),避免 dll 被锁
powershell -File ..\native_src\build.ps1
```
## 用法
```text
desktop-wake.exe wecom 0x<hwnd_hex> --mode n2-wake
```
| mode | 用途 |
|---|---|
| `n1` | 仅进程外 UIA(冷树无效) |
| `n2` | 仅注入探针 |
| `n2-wake` | **主路径**:注入 + WakeProvider(ensure 使用) |
| `n2-hook` | 危险,默认禁止 |
验收见 `../ACCEPTANCE.md`。

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,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("\"", "\\\"");
}
}
}

View File

@@ -0,0 +1,39 @@
# N2 — 企微进程内硬唤醒 PoC
## 现状
- 目标进程:`WXWork.exe`(常见为 **Win32/x86**,安装在 `Program Files (x86)`)
- 产物:`native/wecom_wake.dll` + `native/desktop-wake.exe`(**均须 x86**)
- 手段:
1. `CreateRemoteThread` + `LoadLibraryW`(写 `%TEMP%\jiangchang_desktop_wake_loaded.txt`)
2. `SetWindowsHookEx(WH_CALLWNDPROC)` 在 UI 线程子类化,拦 `WM_GETOBJECT`
## 编译
在 kit 根或本目录:
```powershell
powershell -File ..\build.ps1
# 即:native_src\build.ps1
```
## 手动试
```powershell
# 先 probe 拿 hwnd
python -m jiangchang_desktop_wake probe --profile wecom
.\..\..\native\desktop-wake.exe wecom <hwnd_hex> --mode n2
```
## 标记文件(%TEMP%)
| 文件 | 含义 |
|---|---|
| `jiangchang_desktop_wake_target.txt` | injector 写入的目标 hwnd |
| `jiangchang_desktop_wake_loaded.txt` | DLL 已进目标进程 |
| `jiangchang_desktop_wake_hook.txt` | CALLWNDPROC 钩子触发 |
| `jiangchang_desktop_wake_subclass.txt` | 子类化已安装 |
## 限制
子类化 + `CreateStdAccessibleObject` **不一定**能暖开企微 DirectUI;本阶段先证明注入通道。若 `loaded_marker=True` 而树仍冷,下一步要做更深层 Provider 补丁。

View File

@@ -0,0 +1,36 @@
# C1 — 影刀暖树对照调研清单
目的:在**不抄影刀二进制**的前提下,弄清「暖成功时进程里多了什么」,指导 C2/C3。
## 环境
- 冷基线:退出影刀 + 重启企微 + `probe` 为冷
- 暖对照:影刀拾取一次后 `probe` 为暖
- 记录企微版本:`WXWork` 安装目录版本号 / 关于
## 采集项(冷 vs 暖各做一遍)
| # | 项 | 怎么看 | 记录 |
|---|---|---|---|
| 1 | `probe` JSON | `python -m jiangchang_desktop_wake probe --profile wecom` | findall / markers / hwnd |
| 2 | 模块列表 | Process Explorer / `Listdlls` 看 WXWork.exe | 暖后是否多 DLL |
| 3 | 窗口子类化 | Spy++ 看 WndProc 是否被替换 | 冷/暖差异 |
| 4 | `WM_GETOBJECT` | Inspect 附着时是否突然有树 | 行为变化 |
| 5 | 影刀进程 | 拾取瞬间是否拉起 `misc.exe` / 其它 Native | 命令行参数 |
| 6 | 退出影刀后 | 影刀退出后 `probe` 是否仍暖 | 确认进程内常驻 |
## 产出(填空)
```
企微版本:
冷 findall:
暖 findall:
暖后多出的模块:
疑似入口(misc 参数等):
假设(给 C2):
```
## 约束
- 不把影刀 `misc.exe` 打进发行包
- 卡死过的 `n2-hook` 不要当主路径

View File

@@ -0,0 +1,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;
}

View File

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

View File

@@ -0,0 +1,80 @@
# 编译 N1/N2:x86 desktop-wake.exe + x86 wecom_wake*.dll(匹配 WXWork.exe)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$outDir = (Resolve-Path (Join-Path $here "..\native")).Path
$outExe = Join-Path $outDir "desktop-wake.exe"
$srcCs = Join-Path $here "DesktopWake\Program.cs"
$srcCpp = Join-Path $here "N2\payload\wecom_wake.cpp"
$srcDef = Join-Path $here "N2\payload\wecom_wake.def"
$objPath = Join-Path $env:TEMP "jiangchang_wecom_wake.obj"
$vcvars32 = "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars32.bat"
if (-not (Test-Path $vcvars32)) {
throw "vcvars32.bat not found: $vcvars32"
}
function Test-FileWritable([string]$path) {
try {
if (-not (Test-Path $path)) { return $true }
$fs = [System.IO.File]::Open($path, 'Open', 'ReadWrite', 'None')
$fs.Close()
return $true
} catch { return $false }
}
$dllTarget = $null
$names = @("wecom_wake.dll", "wecom_wake_b.dll", "wecom_wake_c.dll", "wecom_wake_d.dll", "wecom_wake_e.dll")
$names += @("wecom_wake_{0:HHmmss}.dll" -f (Get-Date))
foreach ($name in $names) {
$cand = Join-Path $outDir $name
if (Test-FileWritable $cand) { $dllTarget = $cand; break }
}
if (-not $dllTarget) {
throw "All wecom_wake*.dll locked. Fully quit WXWork.exe (tray too), then rebuild."
}
if ((Split-Path -Leaf $dllTarget) -ne "wecom_wake.dll") {
Write-Host "WARN: primary dll locked; writing $(Split-Path -Leaf $dllTarget). Quit WXWork later to refresh wecom_wake.dll."
}
Write-Host "Building $(Split-Path -Leaf $dllTarget) (x86) ..."
# 用临时 bat + 管道进 cmd,避免 cmd /c 弄坏多行命令
$bat = Join-Path $env:TEMP "jiangchang_build_wecom_wake.bat"
@"
@echo off
call "$vcvars32" || exit /b 1
cl /nologo /LD /O2 /EHsc /DWIN32 /D_WINDOWS /D_USRDLL /utf-8 "$srcCpp" /Fe"$dllTarget" /Fo"$objPath" /link /DEF:"$srcDef" user32.lib ole32.lib oleacc.lib comctl32.lib
exit /b %ERRORLEVEL%
"@ | Set-Content -Path $bat -Encoding ASCII
$buildOut = cmd.exe /c "`"$bat`"" 2>&1
Write-Host ($buildOut | Out-String)
if (-not (Test-Path $dllTarget)) {
throw "wecom_wake dll build failed. cl output above. If locked, quit WXWork and retry."
}
Write-Host "OK $dllTarget"
$csc = Join-Path $env:WINDIR "Microsoft.NET\Framework\v4.0.30319\csc.exe"
if (-not (Test-Path $csc)) { throw "x86 csc.exe not found: $csc" }
$gac = Join-Path $env:WINDIR "Microsoft.NET\assembly\GAC_MSIL"
$uiaClient = Get-ChildItem (Join-Path $gac "UIAutomationClient") -Recurse -Filter "UIAutomationClient.dll" | Select-Object -First 1 -ExpandProperty FullName
$uiaTypes = Get-ChildItem (Join-Path $gac "UIAutomationTypes") -Recurse -Filter "UIAutomationTypes.dll" | Select-Object -First 1 -ExpandProperty FullName
$winBase = Get-ChildItem (Join-Path $gac "WindowsBase") -Recurse -Filter "WindowsBase.dll" | Select-Object -First 1 -ExpandProperty FullName
Write-Host "Building desktop-wake.exe (x86) ..."
& $csc /nologo /target:exe /platform:x86 /optimize+ `
/reference:"$uiaClient" `
/reference:"$uiaTypes" `
/reference:"$winBase" `
/out:"$outExe" `
"$srcCs"
if ($LASTEXITCODE -ne 0) { throw "csc failed: $LASTEXITCODE" }
Remove-Item $objPath -ErrorAction SilentlyContinue
Get-ChildItem $outDir -Filter "wecom_wake*.exp" | Remove-Item -ErrorAction SilentlyContinue
Get-ChildItem $outDir -Filter "wecom_wake*.lib" | Remove-Item -ErrorAction SilentlyContinue
Write-Host "OK built:"
Get-Item $outExe, $dllTarget | Format-Table Name, Length, LastWriteTime -AutoSize
Write-Host "NOTE: WXWork is 32-bit. Fully quit WXWork before a clean N2 retest if dlls were locked."

View File

@@ -0,0 +1,81 @@
"""冷/暖探测。"""
from __future__ import annotations
from typing import Optional, Union
from .errors import DesktopWakeError, ERR_UIA_UNAVAILABLE
from .profiles import load_profile
from .types import ProbeResult, Profile
from . import uia_backend
from .window import (
find_hwnd_for_profile,
get_window_class,
get_window_title,
require_windows,
)
def probe(
profile: Union[str, Profile] = "wecom",
*,
hwnd: Optional[int] = None,
require_uia: bool = False,
) -> ProbeResult:
"""探测目标窗 UIA 是否已暖。
warm 判定:findall >= warm_min_findall,且 warm_markers 全部找到(若配置了 markers)。
"""
require_windows()
prof = load_profile(profile) if isinstance(profile, str) else profile
h = find_hwnd_for_profile(prof, hwnd=hwnd)
title = get_window_title(h)
cls = get_window_class(h)
notes: list[str] = []
available = uia_backend.uia_available()
if require_uia and not available:
raise DesktopWakeError(
ERR_UIA_UNAVAILABLE,
"未安装 uiautomation,请在共享 Python 环境中: pip install uiautomation",
)
findall_count = 0
names: list[str] = []
if available:
findall_count, names = uia_backend.count_findall(h)
if findall_count == 0 and not names:
notes.append("uia_timeout_or_empty")
markers_found, markers_missing = uia_backend.find_markers_on_hwnd(
h, list(prof.warm_markers)
)
# 若 Exists 未命中,用 Name 列表兜底
if markers_missing and names:
fb_found, fb_missing = uia_backend.markers_present(names, markers_missing)
markers_found = list(dict.fromkeys(markers_found + fb_found))
markers_missing = fb_missing
else:
notes.append("uiautomation_not_installed")
markers_found, markers_missing = [], list(prof.warm_markers)
density_ok = findall_count >= int(prof.warm_min_findall)
markers_ok = not prof.warm_markers or not markers_missing
warm = bool(available and density_ok and markers_ok)
if available and not density_ok:
notes.append(f"sparse_findall={findall_count}<{prof.warm_min_findall}")
if available and prof.warm_markers and markers_missing:
notes.append(f"missing_markers={','.join(markers_missing)}")
return ProbeResult(
hwnd=h,
profile_id=prof.id,
findall_count=findall_count,
markers_found=markers_found,
markers_missing=markers_missing,
warm=warm,
window_title=title,
window_class=cls,
uia_available=available,
notes=notes,
)

View File

@@ -0,0 +1,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)

View File

@@ -0,0 +1,13 @@
{
"id": "generic",
"description": "通用桌面窗(需自行传 --hwnd,或扩展 title_contains)",
"window_title_contains": [],
"warm_min_findall": 30,
"warm_markers": [],
"wake_strategies": [
"activate",
"wm_getobject",
"msaa_from_window",
"uia_attach_walk"
]
}

View File

@@ -0,0 +1,15 @@
{
"id": "wecom",
"description": "企业微信桌面主窗",
"window_class": "WeWorkWindow",
"window_title": "企业微信",
"window_title_contains": ["企业微信"],
"warm_min_findall": 80,
"warm_markers": ["con_op", "searchedit"],
"wake_strategies": [
"activate",
"native_helper"
],
"native_helper": "desktop-wake.exe",
"native_args": ["wecom", "0x{hwnd_hex}", "--mode", "n2-wake"]
}

View File

@@ -0,0 +1,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"]
}

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

View File

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

View File

@@ -0,0 +1,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

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

View File

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

View 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

View File

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

View 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"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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 可执行文件路径。

View 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

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

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

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

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

View File

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

View File

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

View File

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

View 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

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

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

View File

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

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

View File

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

View File

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

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