"""窗口管理子 driver:窗口查找 / 激活 / 几何 / 截图 / 启动清场。 从 xdotool_driver.py 单类中拆出的窗口域方法,继承 BaseDriver 复用 _step / _parse_window_geometry / _click_at 公共能力。 方法清单(与原 XdotoolDriver 签名一致): - find_wechat_window() -> Optional[int] - activate_window(window_id=None) -> None - get_window_geometry(window_id=None) -> WindowGeom (新增公开方法) - screenshot() -> bytes (新增,对齐 backends 模式) - _full_cleanup_on_startup() -> bool """ from __future__ import annotations import asyncio import logging from typing import Optional from woc_bridge.models import BridgeError from woc_bridge.ui.drivers.base import BaseDriver, WindowGeom logger = logging.getLogger("woc-bridge") class WindowDriver(BaseDriver): """窗口管理子 driver。""" def __init__(self, display: str, backend=None) -> None: super().__init__(display, backend) # ------------------------------------------------------------------ # 命令执行包装(BaseDriver._run_managed 返回 tuple 且抛 TimeoutError, # 这里包装为 BridgeError 以保持与原 XdotoolDriver._run 语义一致) # ------------------------------------------------------------------ async def _run( self, args: list[str], *, input_bytes: Optional[bytes] = None, ) -> tuple[int, bytes, bytes]: """执行一条命令并返回 (returncode, stdout, stderr)。 委托 BaseDriver._run_managed(持 _ui_lock 串行 + L1 超时兜底), TimeoutError 转 BridgeError(code=SEND_FAILED),保持原 _run 语义。 input_bytes 参数保留以兼容原签名,当前实现未使用(无方法依赖)。 """ try: return await self._run_managed(args) except asyncio.TimeoutError as exc: logger.error("[ui] command timeout: %s", args[0] if args else "?") raise BridgeError( code="SEND_FAILED", message=f"命令超时: {args[0] if args else '?'}", ) from exc async def _key(self, key: str, repeat: int = 1) -> None: """执行 xdotool key [--repeat N] 。""" logger.info("[ui] key: %s repeat=%d", key, repeat) args = ["xdotool", "key"] if repeat > 1: args.extend(["--repeat", str(repeat)]) args.append(key) rc, _, _ = await self._run(args) logger.info("[ui] key %s -> rc=%s", key, rc) # ------------------------------------------------------------------ # 窗口与进程检测 # ------------------------------------------------------------------ async def find_wechat_window(self) -> Optional[int]: """用 xdotool search --name "微信" 查找微信窗口 ID。 Returns: 窗口 ID(int),找不到返回 None """ returncode, stdout, stderr = await self._run( ["xdotool", "search", "--name", "微信"] ) if returncode != 0: return None text = stdout.decode(errors="ignore").strip() if not text: return None # 取第一个匹配的窗口 ID first_line = text.splitlines()[0].strip() try: return int(first_line) except ValueError: return None async def activate_window(self, window_id: Optional[int] = None) -> None: """激活微信窗口。 Args: window_id: 指定窗口 ID;为 None 时自动查找 """ if window_id is None: window_id = await self.find_wechat_window() if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口,无法激活", ) await self._run(["xdotool", "windowactivate", "--sync", str(window_id)]) async def get_window_geometry( self, window_id: Optional[int] = None ) -> WindowGeom: """获取微信窗口几何信息,返回 WindowGeom。 新增公开方法:原 XdotoolDriver 仅有私有 _get_window_geometry(返回 tuple),本方法对外暴露 WindowGeom 并复用 BaseDriver._parse_window_geometry 消除内联解析重复。 Args: window_id: 指定窗口 ID;为 None 时自动查找 Raises: BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口 BridgeError(SEND_FAILED): 获取/解析几何信息失败 """ if window_id is None: window_id = await self.find_wechat_window() if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口", ) returncode, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if returncode != 0: raise BridgeError( code="SEND_FAILED", message="无法获取微信窗口几何信息", ) geom = self._parse_window_geometry(stdout.decode(errors="ignore")) if geom is None: raise BridgeError( code="SEND_FAILED", message="解析窗口几何信息失败", ) return geom async def screenshot(self) -> bytes: """截取整个 X 屏幕为 PNG bytes。 新增方法:原 XdotoolDriver 无 screenshot,仅 _debug_screenshot 写盘。 本方法对齐 woc_bridge.ui.backends.xdotool.XdotoolBackend.screenshot 的 scrot -o - 模式,输出 PNG bytes 到内存,不落盘。 """ async with self._ui_lock: proc = await asyncio.create_subprocess_exec( "scrot", "-o", "-", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=self._env(), ) try: stdout, stderr = await asyncio.wait_for( proc.communicate(), timeout=5.0 ) except (asyncio.TimeoutError, asyncio.CancelledError): proc.kill() try: await asyncio.wait_for(proc.wait(), timeout=5.0) except asyncio.TimeoutError: logger.error("[ui-driver] 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-driver] scrot proc.wait() timeout after kill") raise if proc.returncode != 0: err = stderr.decode(errors="ignore").strip() if stderr else "unknown" raise BridgeError( code="SEND_FAILED", message=f"截图失败: {err}", ) return stdout # ------------------------------------------------------------------ # 内部几何工具 # ------------------------------------------------------------------ async def _get_window_geometry(self) -> tuple[int, int, int, int]: """获取微信窗口几何信息,返回 (win_x, win_y, win_w, win_h)。 统一封装 getwindowgeometry --shell 解析逻辑,复用 BaseDriver._parse_window_geometry 消除重复代码。 """ window_id = await self.find_wechat_window() if window_id is None: raise BridgeError( code="WINDOW_NOT_FOUND", message="未找到微信窗口", ) returncode, stdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(window_id)] ) if returncode != 0: raise BridgeError( code="SEND_FAILED", message="无法获取微信窗口几何信息", ) geom = self._parse_window_geometry(stdout.decode(errors="ignore")) if geom is None: raise BridgeError( code="SEND_FAILED", message="解析窗口几何信息失败", ) return (geom.x, geom.y, geom.width, geom.height) # ------------------------------------------------------------------ # 启动清场(P1 高可用修复) # ------------------------------------------------------------------ async def _full_cleanup_on_startup(self) -> bool: """启动时全量清场(3 次重试)。 上次崩溃可能残留脏状态(搜索框未关、弹窗未处理、焦点错位等), 直接进入发送流程会放大故障。连续重置 3 次,仍失败则告警但不阻塞 启动(返回 False,由调用方决定是否告警)。 Returns: True 表示清场成功(或窗口未找到直接跳过);False 表示 3 次重置均失败, 可能需要人工 VNC 接入排查。 """ # 1. 获取窗口几何;窗口未找到时跳过清场(不阻塞启动) try: win_geom = await self._get_window_geometry() except BridgeError as e: logger.warning("启动清场: 微信窗口未找到,跳过 (%s)", e.message) return True # 2. 连续重置 3 次 for attempt in range(3): if await self._reset_to_idle(win_geom): logger.info("启动清场成功 (attempt=%d)", attempt + 1) return True logger.warning("启动清场失败 (attempt=%d)", attempt + 1) # 3. 3 次均失败,告警但不抛异常(不阻塞启动) logger.error("启动清场 3 次失败,可能需要人工 VNC 接入") return False async def _reset_to_idle(self, win_geom: tuple[int, int, int, int]) -> bool: """安全重置到 IDLE 状态(幂等,失败不抛异常)。 多策略清场,逐层剥离可能残留的脏状态: 1. Esc×3:关闭弹窗/搜索覆盖层 2. 点击窗口左上角空白区域:退 webview 焦点,避免焦点停在输入框 3. BackSpace×30:清空可能残留的输入框内容 4. Esc×2:兜底关闭剩余弹窗 Args: win_geom: _get_window_geometry 返回的 (win_x, win_y, win_w, win_h) Returns: True 表示重置流程执行完成;False 表示中途异常(需重试或人工介入) """ win_x, win_y, _win_w, _win_h = win_geom try: # 1. Esc×3 关闭弹窗/搜索覆盖层 for _ in range(3): await self._key("Escape") await asyncio.sleep(0.3) # 2. 点击窗口左上角空白区域,退出 webview 焦点(链式命令点击) await self._click_at(win_x + 10, win_y + 10) await asyncio.sleep(0.3) # 3. BackSpace×30 清空可能残留的输入框内容 await self._key("BackSpace", repeat=30) await asyncio.sleep(0.3) # 4. Esc×2 兜底关闭剩余弹窗 for _ in range(2): await self._key("Escape") await asyncio.sleep(0.3) return True except Exception as e: logger.error("_reset_to_idle 异常: %r", e) return False