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

286 lines
11 KiB
Python
Raw Normal View History

"""窗口管理子 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] <key>。"""
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:
窗口 IDint找不到返回 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