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

474 lines
18 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.

"""L1 xdotool/scrot 后端实现XdotoolBackend。
通过 xdotool 调用 X11 自动化操作(点击 / 输入 / 按键 / 窗口管理),
scrot 截图输出 stdout 不落盘。所有子进程调用经 _run_managed 上下文管理器,
确保 TimeoutError / CancelledError / 其他异常路径下子进程均被 kill+wait 收尸,
无僵尸进程残留。
分层超时常量:
- _CMD_TIMEOUT_SEC=5.0:单条 xdotool/scrot 命令超时
- _STEP_MIN_TIMEOUT_SEC=7.0:单步 UI 操作最小超时
- _FLOW_TIMEOUT_SEC=30.0:完整发送流程超时
- _HTTP_TIMEOUT_SEC=60.0HTTP 端到端超时
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from contextlib import asynccontextmanager
from typing import AsyncIterator, Optional
from woc_bridge.ui.backends.base import BackendProtocol, WindowGeometry
logger = logging.getLogger("woc-bridge")
# 分层超时常量P1 阶段已落地,供 _run_managed / _step / flow / http 使用)
_CMD_TIMEOUT_SEC = 5.0
_STEP_MIN_TIMEOUT_SEC = 7.0
_FLOW_TIMEOUT_SEC = 30.0
_HTTP_TIMEOUT_SEC = 60.0
class XdotoolBackend(BackendProtocol):
"""xdotool + scrot 后端实现。
所有 UI 操作受 self._ui_lock 串行保护,避免并发点击导致焦点错乱。
asyncio.Lock 必须在事件循环中创建,故在 __init__ 中创建而非模块顶层。
"""
def __init__(self, display: str = ":1") -> None:
"""初始化后端。
Args:
display: X server display 地址,如 ":1"
"""
self.display = display
# asyncio.Lock 必须在事件循环中创建,不在模块顶层
self._ui_lock = asyncio.Lock()
self._debug_shots_enabled = (
os.environ.get("WOC_UI_DEBUG_SHOTS", "false").lower() == "true"
)
self._debug_queue: asyncio.Queue = asyncio.Queue()
self._debug_task: Optional[asyncio.Task] = None
# ------------------------------------------------------------------
# 环境与子进程管理
# ------------------------------------------------------------------
def _env(self) -> dict:
"""返回带 DISPLAY 的环境副本。"""
env = dict(os.environ)
env["DISPLAY"] = self.display
return env
@asynccontextmanager
async def _run_managed(
self, args: list[str]
) -> AsyncIterator[tuple[int, bytes, bytes]]:
"""执行子进程的上下文管理器,确保取消/超时/异常都 kill+wait 收尸。
用法:
async with self._run_managed(args) as (rc, stdout, stderr):
...
任何异常路径均保证 proc.kill() + await proc.wait(),无僵尸残留。
"""
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=self._env(),
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=_CMD_TIMEOUT_SEC
)
except (asyncio.TimeoutError, asyncio.CancelledError):
# 显式 kill + wait再超时仅记日志
proc.kill()
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
logger.error(
"[ui-backend] proc.wait() timeout after kill: %s", args[0]
)
raise
except Exception:
# 兜底:未退出的子进程一律 kill+wait带超时避免无限阻塞 _ui_lock
if proc.returncode is None:
proc.kill()
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
logger.error(
"[ui-backend] proc.wait() timeout after kill: %s",
args[0] if args else "?",
)
raise
yield (proc.returncode, stdout, stderr)
async def _run(self, args: list[str]) -> tuple[int, bytes, bytes]:
"""执行一条命令并返回 (returncode, stdout, stderr)。"""
t0 = time.perf_counter()
async with self._run_managed(args) as result:
rc, stdout, stderr = result
# 隐藏 stdout/stderr 内容避免污染日志,仅记录长度和 rc
logger.debug(
"[ui-backend] cmd rc=%d %s (%.0fms) out=%dB err=%dB",
rc, " ".join(args[:6]),
(time.perf_counter() - t0) * 1000,
len(stdout) if stdout else 0,
len(stderr) if stderr else 0,
)
if rc != 0:
err_str = stderr.decode(errors="ignore").strip()[:200] if stderr else ""
logger.warning(
"[ui-backend] cmd FAILED rc=%d cmd=%s err=%s",
rc, " ".join(args), err_str,
)
return result
# ------------------------------------------------------------------
# BackendProtocol 实现
# ------------------------------------------------------------------
async def click(self, x: int, y: int) -> None:
"""合并 mousemove --sync + click 1 为单条 xdotool 命令。
关键:单条命令,避免分两次 fork 之间窗口焦点变化导致点击错位。
"""
logger.info("[ui-backend] click start (%d,%d)", x, y)
t0 = time.perf_counter()
async with self._ui_lock:
await self._run(
[
"xdotool",
"mousemove",
"--sync",
str(x),
str(y),
"click",
"1",
]
)
logger.info(
"[ui-backend] click done (%d,%d) (%.0fms)",
x, y, (time.perf_counter() - t0) * 1000,
)
async def type_text(self, text: str) -> None:
"""xdotool type -- <text>:原样输入文本,不解析修饰键组合。"""
text_len = len(text)
logger.info(
"[ui-backend] type_text start len=%d preview=%r",
text_len, text[:30],
)
t0 = time.perf_counter()
async with self._ui_lock:
await self._run(["xdotool", "type", "--", text])
logger.info(
"[ui-backend] type_text done len=%d (%.0fms)",
text_len, (time.perf_counter() - t0) * 1000,
)
async def key_press(self, key: str, repeat: int = 1) -> None:
"""xdotool key [--repeat N] <key>。"""
logger.info("[ui-backend] key_press start key=%s repeat=%d", key, repeat)
t0 = time.perf_counter()
async with self._ui_lock:
args = ["xdotool", "key"]
if repeat > 1:
args.extend(["--repeat", str(repeat)])
args.append(key)
await self._run(args)
logger.info(
"[ui-backend] key_press done key=%s repeat=%d (%.0fms)",
key, repeat, (time.perf_counter() - t0) * 1000,
)
async def screenshot(self) -> bytes:
"""scrot 截图输出到临时文件,再读取为 bytes。
注意s6 服务内 /dev/stdout 不是有效设备scrot -o - 会失败,
故改用临时文件路径。超时 5sTimeoutError/CancelledError 时 kill+wait。
"""
import tempfile
async with self._ui_lock:
fd, path = tempfile.mkstemp(suffix=".png", prefix="woc_screenshot_")
os.close(fd)
try:
proc = await asyncio.create_subprocess_exec(
"scrot",
"-o",
path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=self._env(),
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=_CMD_TIMEOUT_SEC
)
except (asyncio.TimeoutError, asyncio.CancelledError):
proc.kill()
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
logger.error("[ui-backend] 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-backend] scrot proc.wait() timeout after kill"
)
raise
if proc.returncode != 0:
err = stderr.decode(errors="ignore").strip()[:200]
logger.error("[ui-backend] screenshot failed: rc=%s err=%s", proc.returncode, err)
return b""
with open(path, "rb") as f:
data = f.read()
logger.info(
"[ui-backend] screenshot: scrot rc=0 file=%s bytes=%d",
path, len(data),
)
return data
finally:
try:
os.unlink(path)
except OSError:
pass
async def _find_main_window_id(
self, window_title: str, min_area: int = 10000
) -> int:
"""查找窗口标题匹配者中面积最大的主窗口,过滤隐藏/加载小窗。
微信启动过程中会出现多个窗口(隐藏加载窗、托盘提示窗等),
仅按标题搜索并取第一个常常会拿到 1×1 的无效窗口。本方法:
1. 搜索所有标题匹配窗口;
2. 逐个获取几何并计算面积;
3. 过滤掉面积 < min_area 的候选;
4. 返回面积最大者。
Args:
window_title: 窗口标题,如 "微信"
min_area: 最小有效窗口面积(默认 10000 像素)
Returns:
主窗口 ID
Raises:
RuntimeError: 未找到有效窗口
"""
rc, stdout, _ = await self._run(
["xdotool", "search", "--name", window_title]
)
if rc != 0:
raise RuntimeError(
f"WINDOW_NOT_FOUND: search failed for title={window_title!r}"
)
text = stdout.decode(errors="ignore").strip()
if not text:
raise RuntimeError(
f"WINDOW_NOT_FOUND: no window matches title={window_title!r}"
)
candidates: list[tuple[int, int]] = []
for line in text.splitlines():
wid_str = line.strip()
if not wid_str:
continue
try:
wid = int(wid_str)
except ValueError:
continue
rc, gstdout, _ = await self._run(
["xdotool", "getwindowgeometry", "--shell", str(wid)]
)
if rc != 0:
continue
geom: dict[str, str] = {}
for gline in gstdout.decode(errors="ignore").splitlines():
if "=" in gline:
k, v = gline.split("=", 1)
geom[k.strip()] = v.strip()
try:
w = int(geom.get("WIDTH", 0))
h = int(geom.get("HEIGHT", 0))
area = w * h
if area >= min_area and w >= 200 and h >= 200:
candidates.append((area, wid))
except ValueError:
continue
if not candidates:
raise RuntimeError(
f"WINDOW_NOT_FOUND: no valid window for title={window_title!r} "
f"(candidates filtered by min_area={min_area})"
)
candidates.sort(key=lambda x: x[0], reverse=True)
return candidates[0][1]
async def get_window_geometry(self, window_title: str) -> WindowGeometry:
"""按标题查窗口并返回几何。容忍多个匹配窗口,取面积最大主窗口。
Raises:
RuntimeError: 窗口未找到TODO: P3 时换 FlowError(code=WINDOW_NOT_FOUND)
"""
t0 = time.perf_counter()
logger.info("[ui-backend] get_window_geometry start title=%r", window_title)
try:
window_id = await self._find_main_window_id(window_title)
except RuntimeError as exc:
logger.warning(
"[ui-backend] get_window_geometry: %s (%.0fms)",
exc, (time.perf_counter() - t0) * 1000,
)
raise
rc, stdout, _ = await self._run(
["xdotool", "getwindowgeometry", "--shell", str(window_id)]
)
if rc != 0:
logger.warning(
"[ui-backend] get_window_geometry: getwindowgeometry failed id=%d",
window_id,
)
raise RuntimeError(
f"WINDOW_NOT_FOUND: getwindowgeometry failed for id={window_id}"
)
geom: dict[str, str] = {}
for line in stdout.decode(errors="ignore").splitlines():
if "=" in line:
k, v = line.split("=", 1)
geom[k.strip()] = v.strip()
try:
result = WindowGeometry(
x=int(geom.get("X", 0)),
y=int(geom.get("Y", 0)),
width=int(geom.get("WIDTH", 0)),
height=int(geom.get("HEIGHT", 0)),
)
logger.info(
"[ui-backend] get_window_geometry OK id=%d geom=%dx%d@(%d,%d) (%.0fms)",
window_id, result.width, result.height, result.x, result.y,
(time.perf_counter() - t0) * 1000,
)
return result
except ValueError:
logger.warning(
"[ui-backend] get_window_geometry: invalid geom dict=%s id=%d",
geom, window_id,
)
raise RuntimeError(
f"WINDOW_NOT_FOUND: invalid geometry for id={window_id}"
)
async def activate_window(self, window_title: str) -> None:
"""激活指定标题的窗口。
Raises:
RuntimeError: 窗口未找到TODO: P3 时换 FlowError(code=WINDOW_NOT_FOUND)
"""
t0 = time.perf_counter()
logger.info("[ui-backend] activate_window start title=%r", window_title)
try:
window_id = await self._find_main_window_id(window_title)
except RuntimeError as exc:
logger.warning(
"[ui-backend] activate_window: %s (%.0fms)",
exc, (time.perf_counter() - t0) * 1000,
)
raise
# 使用非阻塞 windowactivate避免 --sync 在 VNC 无人操作时死等。
# 之后轮询 getactivewindow 确认焦点是否真正落到目标窗口。
rc, _, stderr = await self._run(
["xdotool", "windowactivate", str(window_id)]
)
if rc != 0:
err_str = stderr.decode(errors="ignore").strip()[:200] if stderr else ""
logger.warning(
"[ui-backend] activate_window: windowactivate failed id=%d rc=%d err=%s",
window_id, rc, err_str,
)
raise RuntimeError(
f"WINDOW_ACTIVATE_FAILED: windowactivate failed for id={window_id} rc={rc} err={err_str}"
)
deadline = time.monotonic() + 2.0
activated = False
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:
activated = True
break
except ValueError:
pass
await asyncio.sleep(0.1)
if activated:
logger.info(
"[ui-backend] activate_window OK id=%d title=%r (%.0fms)",
window_id, window_title, (time.perf_counter() - t0) * 1000,
)
else:
logger.warning(
"[ui-backend] activate_window: focus verify timeout id=%d title=%r (%.0fms), continue",
window_id, window_title, (time.perf_counter() - t0) * 1000,
)
# ------------------------------------------------------------------
# 调试截图异步队列(生产环境关闭,调试模式后台写盘)
# ------------------------------------------------------------------
async def _debug_screenshot_async(self, step: str, trace_id: str = "") -> None:
"""入队调试截图请求,由后台 worker 串行写盘,不阻塞关键路径。
生产环境WOC_UI_DEBUG_SHOTS=false直接 return。
"""
if not self._debug_shots_enabled:
return
try:
self._debug_queue.put_nowait((step, trace_id, time.time()))
except asyncio.QueueFull:
logger.warning("[ui-backend] debug queue full, drop shot step=%s", step)
return
if self._debug_task is None or self._debug_task.done():
self._debug_task = asyncio.create_task(self._debug_writer())
async def _debug_writer(self) -> None:
"""后台 worker从队列取 (step, trace_id, ts) 写盘到 /tmp/woc_debug/。
文件名格式:{trace_id}_{step}_{ts_ms}.png
异常时记 warning 不抛,避免拖垮整个后端。
"""
while True:
try:
step, trace_id, ts = await self._debug_queue.get()
except asyncio.CancelledError:
return
try:
os.makedirs("/tmp/woc_debug", exist_ok=True)
path = f"/tmp/woc_debug/{trace_id}_{step}_{int(ts * 1000)}.png"
await self._run(["scrot", path])
except Exception as exc:
logger.warning(
"[ui-backend] debug writer failed step=%s: %s", step, exc
)
@property
def capabilities(self) -> set[str]:
"""返回后端支持的能力集合。"""
return {"click", "type", "key", "screenshot", "window"}