WechatOnCloud/bridge/woc_bridge/ui/drivers/window.py
Kris 2348a04163 feat: 完成微信4.0模板UI重构与批量群发、导出功能落地
本次提交包含多项核心更新:
1. 新增微信4.0浅色主题UI模板资源与说明文档,补充了联系人图标、新的朋友入口等四个UI元素的图像匹配模板
2. 重构UI驱动层,将原2375行的单文件拆分为5个职责清晰的子驱动类,提升代码可维护性
3. 新增批量群发消息与聊天记录导出的完整数据模型、路由与后台协程,支持幂等校验与风控配置
4. 为所有业务接口新增post_verify校验逻辑,补充了verified字段返回操作结果
5. 优化媒体文件解密逻辑,修复了导出功能中的数据库错误处理与分辨率硬编码问题
6. 更新版本配置,将媒体发送功能标记为已落地,调整了能力列表与配置项
2026-07-16 16:53:42 +08:00

286 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""窗口管理子 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