WechatOnCloud/bridge/woc_bridge/ui/drivers/window.py
Kris c45282f094 feat: 新增好友自动通过、UI自动化能力与多分辨率适配
- 新增登录状态守卫后台任务
- 新增好友申请自动通过规则引擎
- 新增多分辨率UI配置与模板资源
- 新增消息拉取复合游标支持
- 优化发送队列与UI自动化逻辑
- 新增批量发送日志与错误处理
- 优化Docker镜像构建与ptrace初始化
- 新增联系人名称缓存预热
2026-07-17 18:10:31 +08:00

416 lines
16 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
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