WechatOnCloud/bridge/woc_bridge/ui/backends/xdotool.py
Kris 15fc62d478 feat: 完成微信UI自动化新架构全量开发与集成
- 新增六层UI自动化架构:从Backend到Capabilities的完整分层实现
- 添加WeChat 4.0分辨率适配Profile与图像模板资源
- 实现幂等缓存、熔断器、重试策略、链路追踪与监控指标
- 新增头像下载安全校验、发布朋友圈路径白名单防护
- 优化密钥缓存、DB校验逻辑与初始化流程
- 补充完整错误码体系与启动清场机制
2026-07-16 01:19:47 +08:00

319 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""L1 xdotool/scrot 后端实现XdotoolBackend。
通过 xdotool 调用 X11 自动化操作(点击 / 输入 / 按键 / 窗口管理),
scrot 截图输出 stdout 不落盘。所有子进程调用经 _run_managed 上下文管理器,
确保 TimeoutError / CancelledError / 其他异常路径下子进程均被 kill+wait 收尸,
无僵尸进程残留。
分层超时常量:
- _CMD_TIMEOUT_SEC=5.0:单条 xdotool/scrot 命令超时
- _STEP_MIN_TIMEOUT_SEC=7.0:单步 UI 操作最小超时
- _FLOW_TIMEOUT_SEC=30.0:完整发送流程超时
- _HTTP_TIMEOUT_SEC=60.0HTTP 端到端超时
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from contextlib import asynccontextmanager
from typing import AsyncIterator, Optional
from woc_bridge.ui.backends.base import BackendProtocol, WindowGeometry
logger = logging.getLogger("woc-bridge")
# 分层超时常量P1 阶段已落地,供 _run_managed / _step / flow / http 使用)
_CMD_TIMEOUT_SEC = 5.0
_STEP_MIN_TIMEOUT_SEC = 7.0
_FLOW_TIMEOUT_SEC = 30.0
_HTTP_TIMEOUT_SEC = 60.0
class XdotoolBackend(BackendProtocol):
"""xdotool + scrot 后端实现。
所有 UI 操作受 self._ui_lock 串行保护,避免并发点击导致焦点错乱。
asyncio.Lock 必须在事件循环中创建,故在 __init__ 中创建而非模块顶层。
"""
def __init__(self, display: str = ":1") -> None:
"""初始化后端。
Args:
display: X server display 地址,如 ":1"
"""
self.display = display
# asyncio.Lock 必须在事件循环中创建,不在模块顶层
self._ui_lock = asyncio.Lock()
self._debug_shots_enabled = (
os.environ.get("WOC_UI_DEBUG_SHOTS", "false").lower() == "true"
)
self._debug_queue: asyncio.Queue = asyncio.Queue()
self._debug_task: Optional[asyncio.Task] = None
# ------------------------------------------------------------------
# 环境与子进程管理
# ------------------------------------------------------------------
def _env(self) -> dict:
"""返回带 DISPLAY 的环境副本。"""
env = dict(os.environ)
env["DISPLAY"] = self.display
return env
@asynccontextmanager
async def _run_managed(
self, args: list[str]
) -> AsyncIterator[tuple[int, bytes, bytes]]:
"""执行子进程的上下文管理器,确保取消/超时/异常都 kill+wait 收尸。
用法:
async with self._run_managed(args) as (rc, stdout, stderr):
...
任何异常路径均保证 proc.kill() + await proc.wait(),无僵尸残留。
"""
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=self._env(),
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=_CMD_TIMEOUT_SEC
)
except (asyncio.TimeoutError, asyncio.CancelledError):
# 显式 kill + wait再超时仅记日志
proc.kill()
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
logger.error(
"[ui-backend] proc.wait() timeout after kill: %s", args[0]
)
raise
except Exception:
# 兜底:未退出的子进程一律 kill+wait带超时避免无限阻塞 _ui_lock
if proc.returncode is None:
proc.kill()
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
logger.error(
"[ui-backend] proc.wait() timeout after kill: %s",
args[0] if args else "?",
)
raise
yield (proc.returncode, stdout, stderr)
async def _run(self, args: list[str]) -> tuple[int, bytes, bytes]:
"""执行一条命令并返回 (returncode, stdout, stderr)。"""
async with self._run_managed(args) as result:
return result
# ------------------------------------------------------------------
# BackendProtocol 实现
# ------------------------------------------------------------------
async def click(self, x: int, y: int) -> None:
"""合并 mousemove --sync + click 1 为单条 xdotool 命令。
关键:单条命令,避免分两次 fork 之间窗口焦点变化导致点击错位。
"""
async with self._ui_lock:
await self._run(
[
"xdotool",
"mousemove",
"--sync",
str(x),
str(y),
"click",
"1",
]
)
async def type_text(self, text: str) -> None:
"""xdotool type -- <text>:原样输入文本,不解析修饰键组合。"""
async with self._ui_lock:
await self._run(["xdotool", "type", "--", text])
async def key_press(self, key: str, repeat: int = 1) -> None:
"""xdotool key [--repeat N] <key>。"""
async with self._ui_lock:
args = ["xdotool", "key"]
if repeat > 1:
args.extend(["--repeat", str(repeat)])
args.append(key)
await self._run(args)
async def screenshot(self) -> bytes:
"""scrot -o - 输出 PNG 到 stdout不写文件。
超时 5sTimeoutError/CancelledError 时 kill+wait。
"""
async with self._ui_lock:
# scrot -o - 输出到 stdout
proc = await asyncio.create_subprocess_exec(
"scrot",
"-o",
"-",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=self._env(),
)
try:
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=_CMD_TIMEOUT_SEC
)
except (asyncio.TimeoutError, asyncio.CancelledError):
proc.kill()
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
logger.error("[ui-backend] scrot proc.wait() timeout after kill")
raise
except Exception:
if proc.returncode is None:
proc.kill()
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
logger.error(
"[ui-backend] scrot proc.wait() timeout after kill"
)
raise
return stdout
async def get_window_geometry(self, window_title: str) -> WindowGeometry:
"""按标题查窗口并返回几何。容忍多个匹配窗口,取第一个。
Raises:
RuntimeError: 窗口未找到TODO: P3 时换 FlowError(code=WINDOW_NOT_FOUND)
"""
# 1. 查窗口 id容忍多匹配取第一个
rc, stdout, _ = await self._run(
["xdotool", "search", "--name", window_title]
)
if rc != 0:
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
raise RuntimeError(
f"WINDOW_NOT_FOUND: search failed for title={window_title!r}"
)
text = stdout.decode(errors="ignore").strip()
if not text:
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
raise RuntimeError(
f"WINDOW_NOT_FOUND: no window matches title={window_title!r}"
)
first_line = text.splitlines()[0].strip()
try:
window_id = int(first_line)
except ValueError:
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
raise RuntimeError(
f"WINDOW_NOT_FOUND: invalid window id={first_line!r} for title={window_title!r}"
)
# 2. 获取几何
rc, stdout, _ = await self._run(
["xdotool", "getwindowgeometry", "--shell", str(window_id)]
)
if rc != 0:
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
raise RuntimeError(
f"WINDOW_NOT_FOUND: getwindowgeometry failed for id={window_id}"
)
geom: dict[str, str] = {}
for line in stdout.decode(errors="ignore").splitlines():
if "=" in line:
k, v = line.split("=", 1)
geom[k.strip()] = v.strip()
try:
return WindowGeometry(
x=int(geom.get("X", 0)),
y=int(geom.get("Y", 0)),
width=int(geom.get("WIDTH", 0)),
height=int(geom.get("HEIGHT", 0)),
)
except ValueError:
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
raise RuntimeError(
f"WINDOW_NOT_FOUND: invalid geometry for id={window_id}"
)
async def activate_window(self, window_title: str) -> None:
"""激活指定标题的窗口。
Raises:
RuntimeError: 窗口未找到TODO: P3 时换 FlowError(code=WINDOW_NOT_FOUND)
"""
rc, stdout, _ = await self._run(
["xdotool", "search", "--name", window_title]
)
if rc != 0:
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
raise RuntimeError(
f"WINDOW_NOT_FOUND: search failed for title={window_title!r}"
)
text = stdout.decode(errors="ignore").strip()
if not text:
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
raise RuntimeError(
f"WINDOW_NOT_FOUND: no window matches title={window_title!r}"
)
first_line = text.splitlines()[0].strip()
try:
window_id = int(first_line)
except ValueError:
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
raise RuntimeError(
f"WINDOW_NOT_FOUND: invalid window id={first_line!r} for title={window_title!r}"
)
await self._run(["xdotool", "windowactivate", "--sync", str(window_id)])
# ------------------------------------------------------------------
# 调试截图异步队列(生产环境关闭,调试模式后台写盘)
# ------------------------------------------------------------------
async def _debug_screenshot_async(self, step: str, trace_id: str = "") -> None:
"""入队调试截图请求,由后台 worker 串行写盘,不阻塞关键路径。
生产环境WOC_UI_DEBUG_SHOTS=false直接 return。
"""
if not self._debug_shots_enabled:
return
try:
self._debug_queue.put_nowait((step, trace_id, time.time()))
except asyncio.QueueFull:
logger.warning("[ui-backend] debug queue full, drop shot step=%s", step)
return
if self._debug_task is None or self._debug_task.done():
self._debug_task = asyncio.create_task(self._debug_writer())
async def _debug_writer(self) -> None:
"""后台 worker从队列取 (step, trace_id, ts) 写盘到 /tmp/woc_debug/。
文件名格式:{trace_id}_{step}_{ts_ms}.png
异常时记 warning 不抛,避免拖垮整个后端。
"""
while True:
try:
step, trace_id, ts = await self._debug_queue.get()
except asyncio.CancelledError:
return
try:
os.makedirs("/tmp/woc_debug", exist_ok=True)
path = f"/tmp/woc_debug/{trace_id}_{step}_{int(ts * 1000)}.png"
await self._run(["scrot", path])
except Exception as exc:
logger.warning(
"[ui-backend] debug writer failed step=%s: %s", step, exc
)
@property
def capabilities(self) -> set[str]:
"""返回后端支持的能力集合。"""
return {"click", "type", "key", "screenshot", "window"}