本次提交包含多项核心更新: 1. 新增微信4.0浅色主题UI模板资源与说明文档,补充了联系人图标、新的朋友入口等四个UI元素的图像匹配模板 2. 重构UI驱动层,将原2375行的单文件拆分为5个职责清晰的子驱动类,提升代码可维护性 3. 新增批量群发消息与聊天记录导出的完整数据模型、路由与后台协程,支持幂等校验与风控配置 4. 为所有业务接口新增post_verify校验逻辑,补充了verified字段返回操作结果 5. 优化媒体文件解密逻辑,修复了导出功能中的数据库错误处理与分辨率硬编码问题 6. 更新版本配置,将媒体发送功能标记为已落地,调整了能力列表与配置项
275 lines
11 KiB
Python
275 lines
11 KiB
Python
"""UI driver 基类:抽取 _step / _parse_window_geometry / _click_at 公共能力。
|
||
|
||
设计原则:
|
||
- 优先委托 XdotoolBackend(若已构造),复用其 _ui_lock 串行保护
|
||
- legacy 模式自建轻量 _run_managed(兼容 ui_backend=legacy 场景)
|
||
- 子类继承 BaseDriver 后只需实现业务方法,无需重复造轮子
|
||
|
||
消除的重复(源自 xdotool_driver.py 单类 2375 行):
|
||
- 11 处内联 `_step` 闭包:deadline 计算 + max(_STEP_MIN_TIMEOUT_SEC, remaining)
|
||
+ asyncio.wait_for 超时包装 + coro.close() 兜底
|
||
- 7+ 处 `getwindowgeometry --shell` 输出解析:geom = {} + split("=", 1) + int()
|
||
- _click_at 链式命令(mousemove --sync X Y click 1)的重复实现
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import time
|
||
from dataclasses import dataclass
|
||
from typing import Any, Awaitable, Optional
|
||
|
||
logger = logging.getLogger("woc-bridge")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 分层超时常量(与 backends/xdotool.py / xdotool_driver.py 保持一致)
|
||
# L1 _CMD_TIMEOUT_SEC : 单条 xdotool/pgrep/kill 子进程命令超时
|
||
# L2 _STEP_MIN_TIMEOUT_SEC : 单个 UI 步骤(_step)超时下限 = L1 + 2s
|
||
# L2 必须 > L1,避免 _step 的 wait_for 先于 _run 内的 L1 触发导致子进程
|
||
# 未被 kill 而泄漏。
|
||
# ---------------------------------------------------------------------------
|
||
_CMD_TIMEOUT_SEC = 5.0
|
||
_STEP_MIN_TIMEOUT_SEC = 7.0
|
||
|
||
|
||
@dataclass
|
||
class WindowGeom:
|
||
"""窗口几何信息(对应 xdotool getwindowgeometry --shell 输出)。
|
||
|
||
与 backends/base.py 的 WindowGeometry 区别:本类额外携带 window_id,
|
||
供 driver 层在多次操作间复用窗口 ID,避免重复 search。
|
||
"""
|
||
|
||
window_id: int
|
||
x: int
|
||
y: int
|
||
width: int
|
||
height: int
|
||
|
||
|
||
class BaseDriver:
|
||
"""UI driver 抽象基类。
|
||
|
||
子类(WindowDriver / LoginDriver / MessageDriver / MomentDriver /
|
||
ContactDriver)继承本类,自动获得 _step / _parse_window_geometry /
|
||
_click_at 能力,无需重复造轮子。
|
||
|
||
使用方式:
|
||
# 优先委托 XdotoolBackend(推荐,复用其 _ui_lock 串行保护)
|
||
backend = XdotoolBackend(display=":1")
|
||
driver = MyDriver(display=":1", backend=backend)
|
||
|
||
# legacy 模式(无 backend,自建 _ui_lock + _run_managed)
|
||
driver = MyDriver(display=":1")
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
display: str,
|
||
backend: Optional[Any] = None,
|
||
) -> None:
|
||
"""初始化 driver。
|
||
|
||
Args:
|
||
display: X server display 地址,如 ":1"
|
||
backend: 可选 XdotoolBackend 实例;提供时复用其 _ui_lock 串行保护
|
||
与 click/type/key 等能力,None 时走 legacy _run_managed
|
||
"""
|
||
self._display = display
|
||
self._backend = backend
|
||
# legacy 模式自建 _ui_lock;若 backend 已存在则复用其 _ui_lock
|
||
# (确保 driver 与 backend 共享同一把锁,避免并发点击焦点错乱)
|
||
if backend is not None and hasattr(backend, "_ui_lock"):
|
||
self._ui_lock = backend._ui_lock
|
||
else:
|
||
self._ui_lock = asyncio.Lock()
|
||
|
||
# ------------------------------------------------------------------
|
||
# 环境与子进程管理
|
||
# ------------------------------------------------------------------
|
||
def _env(self) -> dict:
|
||
"""返回带 DISPLAY 的环境副本。"""
|
||
env = dict(os.environ)
|
||
env["DISPLAY"] = self._display
|
||
return env
|
||
|
||
async def _run_managed(self, cmd: list[str]) -> tuple[int, bytes, bytes]:
|
||
"""legacy 模式执行 xdotool 命令(无 backend 时使用)。
|
||
|
||
- 持有 _ui_lock 串行保护(与 XdotoolBackend 行为一致)
|
||
- asyncio.create_subprocess_exec 异步执行,不阻塞 event loop
|
||
- L1 (_CMD_TIMEOUT_SEC) 超时兜底,超时/取消时 kill+wait 收尸,
|
||
避免僵尸进程残留占住管道/资源
|
||
- 返回 (returncode, stdout, stderr);超时抛 asyncio.TimeoutError
|
||
(由调用方包装为 BridgeError)
|
||
|
||
Args:
|
||
cmd: 命令及其参数列表,如 ["xdotool", "search", "--name", "微信"]
|
||
"""
|
||
async with self._ui_lock:
|
||
proc = await asyncio.create_subprocess_exec(
|
||
*cmd,
|
||
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 子进程再收尸,避免僵尸进程残留占住管道/资源
|
||
proc.kill()
|
||
try:
|
||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||
except asyncio.TimeoutError:
|
||
logger.error(
|
||
"[ui-driver] proc.wait() 超时,孤儿进程残留: %s",
|
||
cmd[0] if cmd else "?",
|
||
)
|
||
# 抛原始 TimeoutError/CancelledError,交由调用方包装
|
||
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-driver] proc.wait() 超时: %s",
|
||
cmd[0] if cmd else "?",
|
||
)
|
||
raise
|
||
return (proc.returncode, stdout, stderr)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 公共能力:_step / _parse_window_geometry / _click_at
|
||
# ------------------------------------------------------------------
|
||
async def _step(
|
||
self,
|
||
coro: Awaitable,
|
||
desc: str,
|
||
deadline: Optional[float] = None,
|
||
) -> Any:
|
||
"""通用超时包装方法(替换 11 处内联 _step 闭包)。
|
||
|
||
语义对齐 xdotool_driver.py 中的内联闭包:
|
||
- deadline 为绝对时间戳(time.monotonic() 返回值),None 时使用
|
||
_STEP_MIN_TIMEOUT_SEC 作为固定超时
|
||
- 剩余时间 <= 0 时关闭未启动的协程并抛 asyncio.TimeoutError,
|
||
避免 "coroutine was never awaited" 警告
|
||
- 剩余时间 < _STEP_MIN_TIMEOUT_SEC 时取下限(max 兜底),
|
||
避免 L2 先于 _run 内的 L1 触发导致子进程未被 kill 而泄漏
|
||
- 超时抛 asyncio.TimeoutError(由调用方包装为 BridgeError,
|
||
保持基类与 models.BridgeError 解耦)
|
||
|
||
Args:
|
||
coro: 待执行的协程/可等待对象
|
||
desc: 步骤描述,用于日志与错误信息
|
||
deadline: 绝对超时时间戳(time.monotonic() 返回值);
|
||
None 时使用 _STEP_MIN_TIMEOUT_SEC
|
||
|
||
Raises:
|
||
asyncio.TimeoutError: 步骤超时或 deadline 已过
|
||
"""
|
||
if deadline is None:
|
||
# 无整体流程 deadline 时,使用 L2 下限作为固定超时
|
||
timeout: float = _STEP_MIN_TIMEOUT_SEC
|
||
else:
|
||
# 必须用 time.monotonic() 与源文件 deadline 语义对齐
|
||
# (asyncio.get_event_loop().time() 与 time.monotonic() epoch 不同,混用会误判)
|
||
remaining = deadline - time.monotonic()
|
||
if remaining <= 0:
|
||
# deadline 已过:关闭未启动的协程,避免 "never awaited" 警告
|
||
# (对齐 xdotool_driver.py L1290-1294 / L1408-1412 的 coro.close() 兜底)
|
||
if asyncio.iscoroutine(coro):
|
||
coro.close()
|
||
logger.warning("[ui-driver] step 已超时(deadline 已过): %s", desc)
|
||
raise asyncio.TimeoutError()
|
||
timeout = max(_STEP_MIN_TIMEOUT_SEC, remaining)
|
||
|
||
logger.info("[ui-driver] step start: %s", desc)
|
||
try:
|
||
result = await asyncio.wait_for(coro, timeout=timeout)
|
||
except asyncio.TimeoutError:
|
||
logger.error("[ui-driver] step timeout: %s (timeout=%.2fs)", desc, timeout)
|
||
raise
|
||
logger.info("[ui-driver] step done: %s", desc)
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_window_geometry(output: str) -> Optional[WindowGeom]:
|
||
"""解析 `xdotool getwindowgeometry --shell` 输出(替换 7+ 处重复解析)。
|
||
|
||
输入示例:
|
||
WINDOW=12345
|
||
X=10
|
||
Y=20
|
||
WIDTH=800
|
||
HEIGHT=600
|
||
|
||
对齐 xdotool_driver.py 中的内联解析模式:
|
||
geom = {}
|
||
for line in stdout.decode(errors="ignore").splitlines():
|
||
if "=" in line:
|
||
k, v = line.split("=", 1)
|
||
geom[k.strip()] = v.strip()
|
||
|
||
Args:
|
||
output: getwindowgeometry --shell 的 stdout 文本(已 decode)
|
||
|
||
Returns:
|
||
WindowGeom 或 None(解析失败时返回 None,由调用方决定是否抛错)
|
||
"""
|
||
geom: dict[str, str] = {}
|
||
for line in output.splitlines():
|
||
if "=" in line:
|
||
k, v = line.split("=", 1)
|
||
geom[k.strip()] = v.strip()
|
||
try:
|
||
return WindowGeom(
|
||
window_id=int(geom.get("WINDOW", 0)),
|
||
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, TypeError):
|
||
return None
|
||
|
||
async def _click_at(self, x: int, y: int) -> None:
|
||
"""链式命令点击(替换原 _click_at)。
|
||
|
||
优先委托 backend.click(x, y)(若已构造,复用其 _ui_lock 串行保护),
|
||
否则走 legacy xdotool 链式命令。
|
||
|
||
链式命令(mousemove --sync X Y click 1)单条子进程完成移动+点击,
|
||
相比分两条命令可少一次子进程创建与 L1 超时窗口,降低中途超时
|
||
导致“鼠标移动到位但未点击”的半成品状态概率。
|
||
|
||
Args:
|
||
x: 目标 X 坐标(绝对坐标)
|
||
y: 目标 Y 坐标(绝对坐标)
|
||
|
||
Raises:
|
||
RuntimeError: legacy 模式下 xdotool 命令失败
|
||
"""
|
||
# 优先委托 backend(推荐路径,复用其 _ui_lock 与错误处理)
|
||
if self._backend is not None and hasattr(self._backend, "click"):
|
||
await self._backend.click(x, y)
|
||
return
|
||
|
||
# legacy 模式:单条 xdotool 链式命令完成移动+点击
|
||
logger.info("[ui-driver] click at (%d, %d)", x, y)
|
||
rc, _, stderr = await self._run_managed(
|
||
["xdotool", "mousemove", "--sync", str(x), str(y), "click", "1"]
|
||
)
|
||
if rc != 0:
|
||
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
|
||
# 基类不依赖 BridgeError,抛 RuntimeError;子类可 catch 后包装
|
||
raise RuntimeError(f"点击 ({x},{y}) 失败: {err}")
|
||
logger.info("[ui-driver] click done rc=%s", rc)
|