"""窗口管理子 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 import time 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) # 缓存最近一次找到的主窗口信息,用于抑制连续重复的 info 日志 self._last_window_key: Optional[tuple[int, int, int, int, int, int]] = None # ------------------------------------------------------------------ # 命令执行包装(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_pids(self) -> list[int]: """查找微信主进程 PID 列表。 微信可执行文件常见路径: - /config/wechat/opt/wechat/wechat(安装后的实际路径) - /opt/wechat/wechat 通过 pgrep 按命令行匹配,避免依赖固定路径。 Returns: PID 列表(可能为空) """ pids: list[int] = [] for pattern in ["wechat", "/config/wechat/opt/wechat"]: rc, stdout, _ = await self._run(["pgrep", "-f", pattern]) if rc != 0: continue for line in stdout.decode(errors="ignore").splitlines(): line = line.strip() if not line: continue try: pids.append(int(line)) except ValueError: continue # 去重保持顺序 seen = set() unique: list[int] = [] for pid in pids: if pid not in seen: seen.add(pid) unique.append(pid) return unique async def find_wechat_window(self) -> Optional[int]: """查找微信主窗口 ID。 策略: 1. 优先通过 wechat 进程 PID 搜索窗口(最可靠,微信主窗口 name/class 可能为空); 2. 再用 name/class 搜索兜底; 3. 过滤掉面积极小的隐藏/装饰/加载窗口; 4. 多个候选时取面积最大者。 Returns: 窗口 ID(int),找不到返回 None """ candidates: list[tuple[int, int, WindowGeom]] = [] seen_wids: set[int] = set() async def add_candidate(wid: int) -> None: """获取窗口几何并加入候选(去重)。""" if wid in seen_wids: return seen_wids.add(wid) rc, gstdout, _ = await self._run( ["xdotool", "getwindowgeometry", "--shell", str(wid)] ) if rc != 0: return geom = self._parse_window_geometry(gstdout.decode(errors="ignore")) if geom is None: return area = geom.width * geom.height # 过滤极小/无效的窗口 if area < 10000 or geom.width < 100 or geom.height < 100: return candidates.append((area, wid, geom)) # 1. 通过 wechat 进程 PID 搜索窗口(优先) for pid in await self._find_wechat_pids(): rc, stdout, _ = await self._run( ["xdotool", "search", "--pid", str(pid)] ) if rc != 0: continue for line in stdout.decode(errors="ignore").splitlines(): wid_str = line.strip() if not wid_str: continue try: await add_candidate(int(wid_str)) except ValueError: continue # 2. name/class 兜底 searches = [ ["xdotool", "search", "--onlyvisible", "--name", "微信"], ["xdotool", "search", "--onlyvisible", "--class", "wechat"], ["xdotool", "search", "--name", "微信"], ["xdotool", "search", "--class", "wechat"], ] for args in searches: returncode, stdout, _ = await self._run(args) if returncode != 0: continue for line in stdout.decode(errors="ignore").splitlines(): wid_str = line.strip() if not wid_str: continue try: await add_candidate(int(wid_str)) except ValueError: continue if not candidates: if self._last_window_key is not None: logger.warning("[window] 未找到有效微信主窗口") self._last_window_key = None else: logger.debug("[window] 仍未找到有效微信主窗口") return None # 取面积最大者(微信主窗口通常远大于通知/托盘窗口) candidates.sort(key=lambda x: x[0], reverse=True) area, wid, geom = candidates[0] key = (wid, geom.x, geom.y, geom.width, geom.height, area) if key == self._last_window_key: logger.debug( "[window] 主窗口未变化 id=%d area=%d geom=(%d,%d %dx%d)", wid, area, geom.x, geom.y, geom.width, geom.height, ) else: logger.info( "[window] 选中微信主窗口 id=%d area=%d geom=(%d,%d %dx%d)", wid, area, geom.x, geom.y, geom.width, geom.height, ) self._last_window_key = key return wid async def activate_window(self, window_id: Optional[int] = None) -> None: """激活微信窗口。 使用非阻塞 windowactivate 避免 --sync 在 VNC 无人操作时死等, 之后轮询 getactivewindow 确认焦点是否真正落到目标窗口。 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", str(window_id)]) deadline = time.monotonic() + 2.0 while time.monotonic() < deadline: rc, stdout, _ = await self._run(["xdotool", "getactivewindow"]) if rc == 0: active_text = stdout.decode(errors="ignore").strip() try: if int(active_text) == window_id: return except ValueError: pass await asyncio.sleep(0.1) logger.warning( "[window] activate_window: focus verify timeout id=%d, continue", 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