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