上一篇把 SealDesk 开源之后,我在想它下一步该补什么。远控之上再加 AI 操作,听起来顺理成章,但这个组合已经有不少现成方案了,所以我没有急着改 SealDesk,先用现成的东西跑一遍,看够不够用。
这次让 AI 助手(Claude)通过一个远程 shell 工具,在我的 Windows 机器上用 Windows-MCP 操作一个 Electron 桌面客户端:新建对话、输入、发送、等回复、断言结果,最后把测试产生的会话删掉。实验和调试都是 AI 助手在我的指挥下完成的,这些问题不是我手动排查的;下面的数字来自它实际跑出来的日志。
结论放前面:这条路走得通,修掉几处问题之后,连续 45 轮测试步骤没有失败;整条链路里没有用到 SealDesk。
链路 #
AI 助手手里只有一个远程 shell 工具,看不到 Windows-MCP 的工具列表,所以我们写了一个小的命令行桥 wmcp.py,提供 ping、tools、call 三条命令,把 MCP 工具调用变成 shell 命令。
flowchart LR
A["AI 助手"] --> B["远程 shell 工具"]
B --> C["wmcp.py"]
C --> D["Windows-MCP
127.0.0.1:8000 + bearer key"]
D --> E["UIA 语义树 / 输入注入"]
E --> F["Electron 桌面应用"]
H["在用这台机器的人"] -.->|"USER_CONTROL:让出控制权"| D
Windows-MCP 只绑 127.0.0.1,带 bearer key。安装用 uv tool install,不用 uvx:uvx 的环境放在 uv 缓存里,缓存一清,常驻服务就起不来了。
截图返回的图片会超过 MCP Python SDK 对单个 SSE 事件的默认 1 MiB 上限,桥里改用底层的 streamable_http_client 并关掉这个限制。有人在用机器时,Windows-MCP 会返回 USER_CONTROL,桥按它给的等待时间重试,超时就退出,不去抢控制权。
装之前先处理遥测 #
以下基于 0.8.8 版本的源码。上游 README 里写了会收集使用数据,细节是这样的:
- 只有环境变量
ANONYMIZED_TELEMETRY等于false才会关闭,默认开启。 - 客户端是 PostHog,初始化时没有关 geoip,并且开了异常自动捕获。
- 每次工具调用上报工具名、耗时、是否成功、客户端名称和版本,再加一个持久化在
~/.windows-mcp/下的匿名 ID。调用参数不在这个事件里。 - 出错时会上报错误信息原文和 traceback,里面带什么取决于报错内容。
windows-mcp install 生成的启动脚本没有设置这个变量,要自己加一行:
@echo off
setlocal
set ANONYMIZED_TELEMETRY=false
"<uv tool dir>\windows-mcp\Scripts\python.exe" -m windows_mcp serve --transport streamable-http --host 127.0.0.1 --port 8000另外,Windows-MCP 自带 PowerShell、Registry 这类工具。调用方已经有 shell 的话,它们等于第二条不受外层工具管控的路径,可以用 --exclude-tools "PowerShell,Registry" 在服务端去掉。这次我没有去掉,只是约定不用。
Electron 的无障碍树,冷启动时几乎是空的 #
应用刚启动时,第一次探测只拿到 14 个节点:1 个窗口、11 个 Pane、2 个 Document,没有任何可交互控件。6 秒之后再查,是 764 个节点,其中 299 个可交互、296 个有名字,按钮、链接、开关状态都能读到。
Chromium 只在检测到辅助技术时才构建无障碍树,所以第一次查询本身就是唤醒动作。原本我打算用 --force-renderer-accessibility 启动应用,实测不需要:回放用例的第一步(preheat)对主窗口做一次完整遍历,然后轮询节点数,到 200 以上才继续。把应用彻底关掉再启动,连续 3 次,第一次 preheat 都在约 1.1 秒内拿到 402 个节点。
回放用例 #
用例走一遍最常见的路径,每一步都用 UIA 属性定位和断言,截图只在结束时保存,留作人工复核:
| 步骤 | 定位与断言 | 中位耗时 |
|---|---|---|
| preheat | 完整遍历一次,节点数 ≥ 200 | 203 ms |
| new_chat | 点「新对话」,输入框(Edit)出现 | 642 ms |
| compose | 输入带唯一 token 的探针文字 | 1420 ms |
| enable_send | 「发送」按钮从不可用变为可用 | 83 ms |
| send | 点「发送」,记录此前的回复数 | 658 ms |
| echo | 消息列表里出现含 token 的文字 | 21 ms |
| reply | 出现新回复:去掉句末标点后以「收到」开头,总长不超过 8 | 5283 ms |
| cleanup | 只删含本轮 token 的会话,最多两次尝试 | 5676 ms |
「发送」按钮的可用状态是少数能直接断言的 UI 状态。除去模型等待,整轮约 9.1 秒,其中清理占 5.7 秒。表里的耗时来自最后一批 5 轮。
几个只有跑起来才会遇到的问题 #
窗口标题会随会话改变 #
第一版按窗口名查找主窗口。会话有了标题之后,Electron 把窗口标题改成会话标题,查找就失败了,前面几轮都是「新对话」,所以没暴露。改成用 Win32 枚举窗口,按进程的可执行文件路径匹配,再对这些窗口调 ElementFromHandle:
def app_hwnds() -> list[int]:
user32 = ctypes.windll.user32
hits: list[int] = []
@ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)
def visit(hwnd, _):
if user32.IsWindowVisible(hwnd):
pid = ctypes.wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
try:
if APP_EXE_MARK in psutil.Process(pid.value).exe():
hits.append(hwnd)
except (psutil.Error, OSError):
pass
return True
user32.EnumWindows(visit, 0)
return hits这是节选,改了名字。
别遍历桌面上所有窗口 #
最早的版本从 UIA 根节点往下找顶层窗口,有一轮卡了 63 秒,还有两轮根节点查询返回了 0 个窗口。我怀疑是碰到了某个无响应的窗口,没有验证。改成只对目标应用自己的窗口操作之后,这个问题没有再出现。
同样的思路也解决了清理慢的问题。菜单和确认框是目标应用自己的弹出窗口,但我一开始是在整个桌面上找它们:扫 9 个顶层窗口要 5.3 秒,限定到应用自己的窗口之后是 0.02 秒,清理的中位耗时从 29.6 秒降到 5.7 秒。多半是别的应用(浏览器之类)的大树拖慢了,我没有逐个窗口量。
菜单没关,下一轮的树只剩 20 个节点 #
有一轮清理失败,「会话操作」的下拉菜单留在了屏幕上。下一轮 preheat 等了 30 秒,无障碍树只有 20 个节点,后面一轮也一样。现在每轮开始前、清理失败后都先按一次 Esc。
菜单项刚出现就点,点击没生效 #
清理失败有两种现象:点了「删除」但确认框没出现,或者确认框出现了却找不到确认按钮。我的判断是菜单还在展开动画,点得太早被吞掉了。菜单项出现后等 0.6 秒再重新定位、点击,清理的首次失败从 20 轮里的 4 次降到 2 次,没有消失,所以又加了一次重试,两次都失败的情况在后面的批次里没有再出现。根因我没有完全查清。
UIA 的瞬时 COM 错误 #
第一批 20 轮里有 3 轮在等回复时抛出 COMError -2147220991(EVENT_E_ALL_SUBSCRIBERS_FAILED,中文系统里显示为「事件无法调用任何订户」)。对 UIA 查询做最多 3 次重试之后,后面 45 轮没有再出现。
坐标要用物理像素 #
这台机器的缩放是 200%。进程不声明 DPI 感知时,UIA 返回的是逻辑坐标,和输入注入用的物理坐标差了一倍,点击会落到别处。脚本开头调用 SetProcessDpiAwarenessContext(-4)(per-monitor v2)之后两边对齐。
测试数据 #
| 批次 | 轮数 | 测试步骤通过 | 清理最终成功 | 改动 |
|---|---|---|---|---|
| A | 20 | 17 | 16 | 初版,3 轮失败都是上面那个 COM 错误 |
| B | 20 | 20 | 16 | UIA 查询加重试 |
| C | 20 | 20 | 20 | 菜单项等待 0.6 秒,清理最多两次 |
| D | 5 | 5 | 5 | 弹出窗口只在应用自己的窗口里找 |
| 冷启动 | 3 | 3 | 3 | 每轮先彻底关掉应用再启动 |
B 到 D 一共 45 轮,测试步骤没有失败。调试过程中还有几轮零散的运行,没有计入。
对前端的要求 #
这套回放能跑,靠的是 UIA 里按名字和控件类型找得到东西。看下来有几处值得前端补:
- 764 个元素里只有 11 个带 AutomationId。从
root、DndLiveRegion-0这些值看,HTML 的id会被映射成 AutomationId;但现有的基本是框架生成的(比如 React 生成的radix-_r_9_),依赖渲染顺序,不适合当选择器。关键控件可以加稳定的id。 - 会话列表里每一行都有同名的「重命名」「删除」按钮,同名元素各有 60 个,行内按钮的
aria-label带上会话名才能区分。 - 同一个输入框,空会话里是 Edit,进入会话后换了控件类型,定位时只能按几种类型依次找。
对 SealDesk 意味着什么 #
回头看这一圈,SealDesk 一行代码都没有用上。AI 助手到这台机器靠的是已有的远程 shell 通道,操作靠 UIA 语义树而不是像素,有人在用机器时让出控制权也是 Windows-MCP 自带的。
SealDesk 能补的是语义树够不到的场合:自绘界面、游戏和 CAD、远端虚拟机里的桌面,还有需要人亲眼看着画面、低延迟接手的时候;另外就是机器上没有现成通道时的那一段连接。这些场合有多常见,值不值得为它往 SealDesk 里加 agent 能力,我现在还没有答案。
限制 #
- 每一轮都会在账号里新建一个会话并调用一次模型,测试结束后删除。
- 只测了一个应用、一条流程。
- 没有和基于截图的方案做过对比,只能说语义树够用,不能说它更快或更稳。
- 界面文案是写死的中文字面量,界面改版要同步改。
- 回复断言只检查以「收到」开头且不超过 8 个字符,模型换种说法会被判失败。
- 桌面会话断开(比如 RDP 断开)时,Windows-MCP 返回
CONTROL_UNAVAILABLE,脚本会直接失败,不会误报通过。