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

416 lines
16 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
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] <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_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:
窗口 IDint找不到返回 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