WechatOnCloud/bridge/woc_bridge/ui/drivers/base.py

275 lines
11 KiB
Python
Raw Normal View History

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