286 lines
11 KiB
Python
286 lines
11 KiB
Python
|
|
"""窗口管理子 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:
|
|||
|
|
窗口 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
|