mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-12 03:28:20 +08:00
[feat] add Codex sidebar video and MCP Apps preview fallback
This commit is contained in:
1 parent
b15a63cc9d
commit
d8739c324d
10 files changed
+452
-144
No files matched your search
+174
-85
@@ -4,6 +4,8 @@
|
||||
|
||||
此目录实现 MCP 包 `@chunkdash/crossdesk-mcp` 0.1.0,绑定原生 CLI 包 0.2.0。**源码实现和本地打包不代表这些版本已经发布到 npm。** 发布后的安装示例需要以实际可用版本为准。
|
||||
|
||||
当前提供 31 个模型工具和 11 个 MCP Apps 面板专用接口,覆盖设备与会话管理、截图与条件等待、键鼠输入、窗口与可访问性元素、多显示器、实时视频与音频、文本剪贴板、文件传输、系统锁定与解锁、断线恢复及人工接管。具体能力取决于原生 CLI、被控端版本和系统权限;本机 MCP 接入使用 stdio,不提供 MCP Streamable HTTP 或通用远程 shell 工具。
|
||||
|
||||
## 从源码运行
|
||||
|
||||
仓库根目录构建原生程序(已有工作区可保留原有桌面构建选项):
|
||||
@@ -26,7 +28,7 @@ Node.js 要求 22 或以上。在 Agent 宿主中配置本地 stdio 进程,例
|
||||
"command": "node",
|
||||
"args": [
|
||||
"/absolute/path/crossdesk/apps/mcp/dist/main.js",
|
||||
"serve", "--stdio", "--panel=auto",
|
||||
"serve", "--stdio",
|
||||
"--native-binary", "/absolute/path/crossdesk/build/macosx/arm64/release/crossdesk-cli"
|
||||
]
|
||||
}
|
||||
@@ -43,7 +45,7 @@ Windows 和 Linux 使用各自构建产物路径;`--native-binary` 必须指
|
||||
"mcpServers": {
|
||||
"crossdesk": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@chunkdash/crossdesk-mcp@<已发布版本>", "serve", "--stdio", "--panel=auto"]
|
||||
"args": ["-y", "@chunkdash/crossdesk-mcp@<已发布版本>", "serve", "--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +61,7 @@ Windows 和 Linux 使用各自构建产物路径;`--native-binary` 必须指
|
||||
4. 有待执行的远程任务时,Agent 在打开面板后立即调用 `crossdesk_get_session({view_id, wait_ms: 25000})` 等待连接。连接就绪后,工具直接返回会话 ID 和权限状态,Agent 继续任务;用户无需回到对话发送连接确认,也无需点击继续按钮。浏览器和 Apps 面板使用同一流程。
|
||||
5. Agent 获取独立截图,用截图的 `observation_id` 和图像坐标提交动作。面板预览不消耗模型截图序列。
|
||||
|
||||
`crossdesk_open` 立即返回,让 Apps 宿主有机会渲染面板;等待连接在后续 `crossdesk_get_session` 调用中完成。按 `view_id` 查询默认最多等待 25 秒,`wait_ms: 0` 则立即查询;未就绪时返回 `wait_timed_out: true`,Agent 在原任务仍待执行时继续等待。按 `session_id` 查询仍立即返回。取消等待只取消本次查询,不断开用户正在建立或使用的连接。用户主动断开后返回 `closed`,Agent 停止等待,不自动重连。
|
||||
`crossdesk_open` 立即返回,让 Apps 宿主有机会渲染面板;等待连接在后续 `crossdesk_get_session` 调用中完成。按 `view_id` 查询默认最多等待 25 秒,`wait_ms: 0` 则立即查询;未就绪时返回 `wait_timed_out: true`,Agent 在原任务仍待执行时继续等待。按 `session_id` 查询默认立即返回,传入正数 `wait_ms` 可等待正在进行的连接恢复;带 `unlock_request_id` 时等待对应解锁请求。取消等待只取消本次查询,不断开用户正在建立或使用的连接。用户主动断开后返回 `closed`,Agent 停止等待,不自动重连。
|
||||
|
||||
面板发起的连接失败后,按 `view_id` 等待也会返回 `failed` 和错误码,避免 Agent 一直停留在等待用户状态。断开正在建立的连接会取消整个连接尝试。多个面板和 Agent 同时查询状态时共用一次原生查询,单次查询取消或暂时繁忙不会断开会话。`crossdesk_connect` 在连接成功但面板初始化失败时仍返回有效的 `session_id`,并附加 `panel_error`。
|
||||
|
||||
@@ -67,25 +69,25 @@ Windows 和 Linux 使用各自构建产物路径;`--native-binary` 必须指
|
||||
|
||||
自动继续依赖宿主保持当前 Agent 任务及工具调用流程。它不通过网页伪造用户消息,也不承诺从已经结束的对话中主动唤醒 Agent;此类唤醒由宿主负责。
|
||||
|
||||
人工暂停由原生层锁定,模型不能通过 `set_control(true)` 恢复;只有当前授权面板的恢复按钮可以恢复。仅查看连接不能升级为控制连接,需重新连接并授权。关闭/隐藏面板仅停止预览,远程会话保留;断开按钮或 `crossdesk_disconnect` 才结束会话。
|
||||
人工暂停由原生层锁定,模型不能通过 `crossdesk_set_control({session_id, enabled: true})` 恢复;只有当前授权面板的恢复按钮可以恢复。浏览器和 Apps 面板还支持人工接管键鼠,并由用户交还 Agent。仅查看连接不能升级为控制连接,需重新连接并授权。关闭/隐藏面板会停止预览并释放面板持有的按键,远程会话保留;断开按钮或 `crossdesk_disconnect` 才结束会话。
|
||||
|
||||
同一 MCP 进程支持最多八个并行设备连接,面板按设备绑定会话。相同配置目录中的进程用设备锁避免同时连接同一目标;它不协调另一个系统用户、另一份配置目录或独立桌面客户端。
|
||||
|
||||
## 面板策略
|
||||
|
||||
`--panel` 优先于设备配置中的 `panel`,默认 `auto`。
|
||||
`--panel` 优先于设备配置中的 `panel`,默认 `auto`;`--browser-launch` 同样默认 `auto`。MCP 根据客户端标识内置 Codex 的默认策略,不依赖个人 Codex 配置或 `AGENTS.md`。已有配置中显式指定的面板模式和启动方式仍然有效。
|
||||
|
||||
| 模式 | 行为 |
|
||||
| --- | --- |
|
||||
| `auto` | 有本机 GUI 时打开浏览器;无 GUI 且检测到终端环境时提供 CLI 画面入口;两者均无则使用 `off` |
|
||||
| `auto` | Codex 默认选择宿主侧栏浏览器;其他客户端有本机 GUI 时使用系统浏览器,无 GUI 且有终端环境时提供 CLI 画面入口,两者均无则使用 `off` |
|
||||
| `apps` | 只使用 MCP Apps;能力不匹配返回 `PANEL_UNSUPPORTED` |
|
||||
| `browser` | 只使用本地浏览器,不附加 Apps 工具资源元数据 |
|
||||
| `browser` | 使用本地浏览器页面;启动方式为 `auto` 时 Codex 使用宿主侧栏,其他客户端使用系统浏览器;可显式选择 `--browser-launch=host` 或 `--browser-launch=system`,不附加 Apps 工具资源元数据 |
|
||||
| `terminal` | 提供独立终端 `view` 命令,共用当前 MCP 会话,不启动网页服务 |
|
||||
| `off` | 不启动网页服务,保留截图和操作工具,凭据通过独立 `setup` 配置 |
|
||||
|
||||
`auto` 不再优先选择 MCP Apps;需要宿主内嵌面板时显式使用 `--panel=apps`。Apps 使用 `extensions["io.modelcontextprotocol/ui"].mimeTypes` 中的 `text/html;profile=mcp-app` 判断能力。旧协议读取 initialize 能力;2026-07-28 协议读取当前请求的能力,不按宿主产品名猜测支持情况。Apps 的卡片、侧栏或窗口形式由宿主决定。
|
||||
|
||||
Linux 根据 `DISPLAY` / `WAYLAND_DISPLAY` 判断 GUI;macOS 检查当前用户的桌面登录会话,SSH 会话不自动打开远端 Mac 的浏览器;Windows 排除 SSH 和 Services 会话。终端判断包含 TTY、`SSH_TTY` 和非 `dumb` 的 `TERM`,因为 MCP 的 stdin/stdout 通常是管道。环境变量被宿主清除或识别不适用时,可显式设置 `--panel=terminal` 或 `--panel=browser`。
|
||||
非 Codex 客户端使用 `auto` 时,Linux 根据 `DISPLAY` / `WAYLAND_DISPLAY` 判断 GUI;macOS 检查当前用户的桌面登录会话,SSH 会话不自动打开远端 Mac 的浏览器;Windows 排除 SSH 和 Services 会话。终端判断包含 TTY、`SSH_TTY` 和非 `dumb` 的 `TERM`,因为 MCP 的 stdin/stdout 通常是管道。需要终端或无面板运行时,显式设置 `--panel=terminal` 或 `--panel=off`。
|
||||
|
||||
终端模式的 `crossdesk_open` / `crossdesk_connect` 返回 `terminal_command`。用户在同一主机的另一个交互终端执行(源码运行时将 `crossdesk-mcp` 替换为 `node /absolute/path/apps/mcp/dist/main.js`):
|
||||
|
||||
@@ -103,7 +105,7 @@ Apps 资源为 `ui://crossdesk/viewer.html`,工具只在允许使用 Apps 且
|
||||
|
||||
浏览器面板只监听随机端口的 `127.0.0.1`,与 MCP 服务同一进程。它不是 MCP Streamable HTTP,也不向局域网提供 MCP 接入。MCP、CLI、用户浏览器应在同一控制端电脑上运行;SSH 容器/远程宿主的浏览器转发不属于此本地接入模式。
|
||||
|
||||
页面标题为 CrossDesk MCP,仅保留正式应用标志,使用文字按钮和简洁状态提示。布局填满浏览器或宿主面板的可用宽高,远程画面保持原比例自动缩放;连接设置在高度不足时独立滚动。连接后侧栏显示设备与授权信息,暂停、恢复和断开按钮位于画面工具栏。「放大画面」收起侧栏,按 Esc 还原;窄屏默认显示连接表单,连接后显示画面,可用「连接信息」展开设备详情。界面资源均可离线使用,保存设备与持久授权仍由用户分别选择。
|
||||
页面标题为 CrossDesk MCP,仅保留正式应用标志,使用文字按钮和简洁状态提示。布局填满浏览器或宿主面板的可用宽高,远程画面保持原比例自动缩放;连接设置在高度不足时独立滚动。连接后侧栏显示设备与授权信息,声音播放与音量、人工接管/交还 Agent、暂停/恢复和断开按钮位于画面工具栏。「放大画面」收起侧栏,按 Esc 还原;窄屏默认显示连接表单,连接后显示画面,可用「连接信息」展开设备详情。界面资源均可离线使用,保存设备与持久授权仍由用户分别选择。
|
||||
|
||||
首次打开使用 URL fragment 中的短期一次性 bootstrap,页面立即清除 fragment,并换取 HttpOnly/SameSite cookie 和 CSRF 令牌。模型只收到无特权的网页地址。复制无凭据地址到另一浏览器不能获得会话控制权。
|
||||
|
||||
@@ -115,6 +117,47 @@ crossdesk-mcp setup --open-panel --view <view_id> --config-dir <相同的配置
|
||||
|
||||
通过源码运行时用 `node /absolute/path/apps/mcp/dist/main.js` 替换 `crossdesk-mcp`。配对通过本用户配置目录中的受限运行描述符和本地 IPC 完成,不允许匿名 HTTP 请求签发控制凭证。`setup` 使用用户终端的桌面环境打开浏览器;仍无法打开时,只在该交互终端显示 60 秒有效的一次性地址,不将它返回 MCP 或发送到聊天。面板最多保留 32 个视图;再次打开时会清理超过 30 分钟未使用的视图。浏览器会话最长 12 小时,失效后重新打开。
|
||||
|
||||
### Codex 默认侧栏浏览器
|
||||
|
||||
任何用户在 Codex 中直接启动本 MCP,默认都会返回侧栏浏览器入口;无需额外配置面板参数:
|
||||
|
||||
```sh
|
||||
crossdesk-mcp serve --stdio
|
||||
```
|
||||
|
||||
MCP 识别 Codex 的客户端名称 `codex-mcp-client`:旧协议读取 initialize 中的 `clientInfo`,2026-07-28 协议读取当前请求的 `io.modelcontextprotocol/clientInfo`。该标识仅选择显示方式,不授予任何设备权限。`--panel=auto` 在 Codex 中选择 `browser`,`--browser-launch=auto` 在 Codex 中选择 `host`;其他客户端仍沿用环境检测和系统浏览器。
|
||||
|
||||
`host` 不启动系统浏览器,也不生成 MCP App 卡片;它要求宿主浏览器与 MCP 运行在同一台电脑,并能打开本机 `file://` 页面。显式使用 `--panel=browser --browser-launch=host` 可以强制宿主浏览器,使用 `--panel=browser --browser-launch=system` 则强制系统浏览器。显式将 `--browser-launch=host` 与 `apps`、`terminal` 或 `off` 组合会报错,这些模式单独使用不受浏览器默认值影响。
|
||||
|
||||
Codex 源码构建配置示例(替换实际路径,已有配置保留其他参数):
|
||||
|
||||
```toml
|
||||
[mcp_servers.crossdesk]
|
||||
command = "node"
|
||||
args = [
|
||||
"/absolute/path/crossdesk/apps/mcp/dist/main.js",
|
||||
"serve", "--stdio",
|
||||
"--native-binary", "/absolute/path/crossdesk/build/macosx/arm64/release/crossdesk-cli"
|
||||
]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
服务指令、工具说明和返回值均内置 Codex 的打开流程:Agent 拿到 `browser_launch_url` 后立即打开宿主浏览器,即使连接已就绪也不省略此步骤。无需用户再次要求打开侧栏,也无需添加个人 `AGENTS.md`。实际打开仍由宿主浏览器工具执行,MCP 协议本身不能强制所有客户端展示侧栏;缺少该工具时(例如没有侧栏浏览器的 Codex CLI)应明确说明限制并展示本地配对命令。
|
||||
|
||||
升级后重启 MCP 服务即可使用新默认值;原有 `--panel=apps`、`--panel=terminal`、`--panel=off` 等参数或设备配置中的显式策略会继续覆盖默认值,需要侧栏时删除旧覆盖或显式使用 `--panel=browser --browser-launch=host`。这项行为随 MCP 包分发,尚未升级到包含此改动的版本不会自动变化。
|
||||
|
||||
`crossdesk_open`、`crossdesk_connect` 和 `crossdesk_open_browser` 在此模式返回 `browser_launch_url`、`browser_url` 和剩余有效时间。Agent 应将 `browser_launch_url` 直接交给宿主的浏览器打开工具;在 Codex 中调用 `open_in_codex`,设置 `target.type` 为 `browser`、`target.url` 为该地址、`placement` 为 `right`。不要读取、粘贴或上传启动页内容。
|
||||
|
||||
启动页位于系统临时目录的独立目录中,POSIX 权限为目录 `0700`、文件 `0600`,内含 60 秒有效的一次性配对地址。浏览器在本机跳转到播放器并换取自己的 HttpOnly/SameSite cookie 和 CSRF 令牌;工具结果不包含原始 bootstrap 票据。票据绑定视图及代际,消费、到期或正常关闭服务时清理启动页。启动地址过期时,对已有 `view_id` 调用 `crossdesk_open_browser` 重新签发,不要重新连接远端。
|
||||
|
||||
`browser_url` 是不带配对凭据的本机 HTTP 地址,用于已配对浏览器重新打开;直接复制到另一浏览器不能获得当前会话权限。没有内置浏览器的宿主可由用户执行原有 `pairing_command`,在系统浏览器完成配对。
|
||||
|
||||
侧栏中的网页沿用本机 WebSocket → WebCodecs/VideoFrame 播放路径,媒体数据不经过 MCP 工具结果,也不需要新增公网中转。它与 MCP Apps iframe 是不同的运行环境;是否能播放及实际帧率需要在目标宿主验证。
|
||||
|
||||
2026-11-11 在本机 Codex 侧栏浏览器完成实测:通过上述启动页连接 macOS 远端,页面显示“实时”、1920 × 1080、约 26 FPS,画布帧计数持续递增。原生端确认 `h264-annexb/1` 经 `loopback-websocket/1` 传输,1 个活动观看者、发送队列无积压;验证期间仅查看画面,未发送远端输入。此结果验证当前环境下的侧栏实时播放,实际帧率仍取决于远端、网络和解码性能。
|
||||
|
||||
2026-11-12 使用工程外的全新临时配置完成 16 项 stdio 协议检查:Codex 新旧协议均在不设置面板参数时返回宿主浏览器启动页,其他客户端保留原有行为,显式面板及启动方式覆盖有效,重新签发及退出清理正常;另以模拟会话验证已就绪连接和解锁面板的 Codex 路由。此检查不读取个人 Codex 配置,不连接真实远端。
|
||||
|
||||
## 无面板配置与凭据
|
||||
|
||||
在用户自己的交互终端执行(不能在宿主 stdio 通道中交互输入密码):
|
||||
@@ -151,33 +194,87 @@ Apps 依赖宿主遵守 app-only 工具可见性和 `_meta` 的模型不可见
|
||||
|
||||
## 模型工具
|
||||
|
||||
以下 31 个工具面向 Agent,定义见 [src/server.ts](src/server.ts)。远端能力可通过会话状态中的 `capabilities` 查询,窗口与元素操作的具体平台能力通过 `crossdesk_desktop({session_id, operation: "capabilities"})` 查询。
|
||||
|
||||
| 工具 | 作用 |
|
||||
| --- | --- |
|
||||
| `crossdesk_open` | 打开设备选择/连接面板,返回 `view_id` |
|
||||
| `crossdesk_open_browser` | 用户显式请求的浏览器入口/回退 |
|
||||
| `crossdesk_list_devices` | 本机设备簿和授权摘要,不提供云发现 |
|
||||
| `crossdesk_connect` | 通过别名连接已经授权且有凭据的设备 |
|
||||
| `crossdesk_get_session` | `session_id`、`view_id` 严格二选一;按 `view_id` 可等待连接,`wait_ms` 范围 0–25000 |
|
||||
| `crossdesk_get_session` | `session_id`、`view_id` 严格二选一;查询状态或等待连接、恢复、指定解锁请求,`wait_ms` 范围 0–25000 |
|
||||
| `crossdesk_list_sessions` | 列出活动会话及设备、显示器和控制状态,最多八个并行连接 |
|
||||
| `crossdesk_set_recovery` | 开关有界断线恢复,尝试次数可设为 1–5,默认 3;需要持久授权和可用凭据 |
|
||||
| `crossdesk_disconnect` | 断开并释放输入状态、子进程和设备锁 |
|
||||
| `crossdesk_screenshot` | 返回 PNG image content 和观测元数据 |
|
||||
| `crossdesk_wait` | 根据已有观测等待画面区域变化或稳定,满足条件后返回新截图 |
|
||||
| `crossdesk_action` | 结构化输入,可附带动作之后的截图 |
|
||||
| `crossdesk_unlock` | 使用独立的系统锁屏凭据解锁,或在本地面板请求输入密码 |
|
||||
| `crossdesk_release_inputs` | 释放当前会话持有的所有按键和鼠标按钮,并取消当前输入动作 |
|
||||
| `crossdesk_set_control` | 在已授权范围内启停 Agent 输入 |
|
||||
| `crossdesk_select_display` | 切换显示器,使旧观测失效 |
|
||||
| `crossdesk_disconnect` | 断开并释放输入状态、子进程和设备锁 |
|
||||
| `crossdesk_capture_displays` | 顺序截取最多八个显示器,返回多屏图像及 `capture_id`,随后恢复原显示器 |
|
||||
| `crossdesk_drag_across` | 使用多屏截图坐标跨显示器拖拽 |
|
||||
| `crossdesk_video_settings` | 运行时调整预览帧率、分辨率、质量和码率 |
|
||||
| `crossdesk_desktop` | 查询平台能力、窗口清单或可访问性树,支持按进程和元素读取子树 |
|
||||
| `crossdesk_desktop_action` | 激活窗口、按下/聚焦元素、设置值、启动应用或打开 HTTP(S) 地址 |
|
||||
| `crossdesk_wait_ui` | 按文本和角色等待窗口或可访问性元素出现、消失 |
|
||||
| `crossdesk_audio` | 开关远端桌面音频接收,默认关闭 |
|
||||
| `crossdesk_audio_capture` | 采集 100–10000 毫秒远端音频,返回单声道 48 kHz WAV 音频内容块 |
|
||||
| `crossdesk_clipboard_get` | 读取远端文本剪贴板,需要已授权控制会话 |
|
||||
| `crossdesk_clipboard_set` | 写入或清空远端文本剪贴板 |
|
||||
| `crossdesk_files_list` | 分页浏览远端目录或文件系统根目录 |
|
||||
| `crossdesk_files_transfer` | 上传或下载文件、文件夹,返回可查询的传输任务 |
|
||||
| `crossdesk_files_status` | 查询传输进度、逐文件结果及完成/失败/取消状态 |
|
||||
| `crossdesk_files_cancel` | 取消传输并清理未完成的暂存文件,保留已完成文件 |
|
||||
| `crossdesk_system_status` | 查询远端系统锁定状态及安全桌面服务信息 |
|
||||
| `crossdesk_system_command` | 锁定远端系统,或通过 Windows 服务发送安全注意序列 `send_sas` |
|
||||
| `crossdesk_unlock` | 使用独立的系统锁屏凭据解锁,或在本地面板请求输入密码 |
|
||||
|
||||
动作包括 `move`、`click`、`double_click`、`scroll`、`drag`、`key_press`、`hotkey`、`type_text`。不提供 shell、任意键按下保持、剪贴板或文件操作。按键使用 Windows VK 对应的标准名称:`A`–`Z`、`0`–`9`、`F1`–`F12`、`CTRL`、`ALT`、`SHIFT`、`META`、`ENTER`、`ESC`、方向键等;应用快捷键仍需根据远端系统选择。
|
||||
## MCP Apps 面板专用接口
|
||||
|
||||
鼠标动作和 `key_press` 支持可选的 `modifiers: ["CTRL", "ALT", "SHIFT", "META"]`(选择需要的键,不能重复),用于组合点击、选择、拖拽和快捷键。修饰键仅在本次动作期间保持,结束、取消或人工暂停时释放。`drag` 可指定 `button: "left" | "right" | "middle"`,默认左键。标点键支持 `SEMICOLON`、`EQUAL`、`COMMA`、`MINUS`、`PERIOD`、`SLASH`、`BACKQUOTE`、`BRACKET_LEFT`、`BACKSLASH`、`BRACKET_RIGHT`、`QUOTE`;这些是 VK 键位名称,实际字符取决于远端键盘布局,输入文字应使用 `type_text`。例如:
|
||||
以下 11 个接口仅在 `--panel=apps` 且当前请求支持 MCP Apps 时暴露为 app-only 工具,由经过视图令牌验证的面板调用。浏览器和终端面板通过各自经过认证的本地通道使用会话功能。
|
||||
|
||||
| 接口 | 作用 |
|
||||
| --- | --- |
|
||||
| `crossdesk_ui_ready` | 回报面板已初始化 |
|
||||
| `crossdesk_ui_list_devices` | 获取设备簿及默认服务器配置 |
|
||||
| `crossdesk_ui_get_session` | 查询当前面板绑定的会话 |
|
||||
| `crossdesk_ui_connect` | 提交用户选择的设备、连接凭据及授权选项 |
|
||||
| `crossdesk_video_open` | 获取当前视图的实时视频连接票据 |
|
||||
| `crossdesk_ui_preview` | 实时视频受限时获取当前视图的有界 PNG 预览,仅通过面板元数据返回 |
|
||||
| `crossdesk_viewer_control` | 人工暂停、恢复、接管或断开 |
|
||||
| `crossdesk_ui_input` | 人工接管期间发送面板键鼠输入 |
|
||||
| `crossdesk_ui_unlock` | 提交或取消锁屏密码输入,可选择保存凭据 |
|
||||
| `crossdesk_ui_recovery` | 设置断线恢复开关和尝试次数 |
|
||||
| `crossdesk_ui_audio` | 开关面板会话的音频接收 |
|
||||
|
||||
## 远程操作
|
||||
|
||||
### 键鼠输入
|
||||
|
||||
`crossdesk_action` 支持 `move`、`click`、`double_click`、`scroll`、`drag`、`key_press`、`hotkey`、`type_text`,以及可跨动作保持的 `key_down`、`key_up`、`mouse_down`、`mouse_up`。按键使用 Windows VK 对应的标准名称,覆盖 `A`–`Z`、`0`–`9`、`F1`–`F24`、数字键盘、`CTRL`、`ALT`、`SHIFT`、`META`、`ENTER`、`ESC`、方向键、菜单、锁定键和媒体键等;应用快捷键及实际效果仍需根据远端系统选择。
|
||||
|
||||
`move`、`click`、`double_click`、`scroll`、`drag` 和 `key_press` 支持可选的 `modifiers: ["CTRL", "ALT", "SHIFT", "META"]`(选择需要的键,不能重复),用于组合点击、选择、拖拽和快捷键。修饰键仅在本次动作期间保持,结束、取消或人工暂停时释放。`drag` 可指定 `button: "left" | "right" | "middle"`,默认左键。标点键支持 `SEMICOLON`、`EQUAL`、`COMMA`、`MINUS`、`PERIOD`、`SLASH`、`BACKQUOTE`、`BRACKET_LEFT`、`BACKSLASH`、`BRACKET_RIGHT`、`QUOTE`;这些是 VK 键位名称,实际字符取决于远端键盘布局,输入文字应使用 `type_text`。例如:
|
||||
|
||||
```json
|
||||
{"session_id":"…","operation_id":"select-item-1","observation_id":"…","action":{"type":"click","x":120,"y":80,"modifiers":["SHIFT"]}}
|
||||
```
|
||||
|
||||
`key_down`、`key_up`、`mouse_down` 和 `mouse_up` 的按下状态可跨动作保持,例如按住 Ctrl 后移动或拖动鼠标。最后一次动作完成后,按住状态的有效期为十五秒,超时自动释放。`crossdesk_release_inputs` 可立即释放本会话的全部按键和按钮,并取消当前输入动作。人工暂停、断开及输入错误同样释放按住状态。
|
||||
|
||||
`key_press`、`hotkey` 支持 `duration_ms`,最长三十秒;`drag` 时长上限同样为三十秒。状态返回 `held_key_codes` 和 `held_mouse_flags`。文本输入前应释放持续按住的键鼠状态。
|
||||
|
||||
### 截图与条件等待
|
||||
|
||||
截图默认最长边 1280,最大 2048;允许指定源帧像素区域。返回 `source_size`、`source_rect`、`image_size`、`generation`、`geometry_epoch`、`frame_id`、`frame_age_ms` 和 `observation_id`。动作坐标使用返回图像坐标,不使用浏览器 CSS 像素。观测保留最多 32 个,30 秒过期;切换显示器、连接代际或图像几何变化立即失效。截图使用不超过 2 秒的帧,PNG 不超过 2 MiB,必要时继续缩小输出并返回真实尺寸。
|
||||
|
||||
`timeout_ms` 限制等待可用帧的时间,PNG 编码独立执行;`0` 表示只使用当前已就绪的新鲜帧,无帧返回 `FRAME_TIMEOUT`,编码器繁忙返回 `BUSY`。截图按到达顺序处理,并优先于预览。视频超过 2 秒未更新时拒绝新的键鼠输入,恢复新鲜画面后可继续;已提交操作的去重查询仍可读取原结果。
|
||||
|
||||
`operation_id` 在当前原生会话中缓存最近 128 个操作,至少在未被容量淘汰的 5 分钟窗口内支持同参数查询;不同参数复用 ID 返回冲突。`submitted` 仅说明本地发送完成,远端没有执行 ACK。超时、断网、取消或发送失败可能返回 `outcome_unknown`/`side_effect_possible`,禁止自动重放。`observe_after` 默认开启,后续图像只证明取得更新帧,不证明操作执行成功。
|
||||
键鼠动作的 `operation_id` 在当前原生会话中缓存最近 128 个操作,至少在未被容量淘汰的 5 分钟窗口内支持同参数查询;不同参数复用 ID 返回冲突。`submitted` 表示输入已发送,远端原生输入处理状态见 `remote_execution`,应用界面的实际结果仍需后续观察确认。超时、断网、取消或发送失败可能返回 `outcome_unknown`/`side_effect_possible`,禁止自动重放。`observe_after` 默认开启,后续图像只证明取得更新帧,不证明应用已完成预期操作。
|
||||
|
||||
更新后的被控端通过 `capabilities.input_ack` 声明输入处理回执。动作结果的 `remote_execution` 表示远端原生输入处理状态,应用界面的实际结果由后续观察确认。旧被控端返回 `unsupported`。回执等待有明确期限,超时动作保留结果不确定状态。
|
||||
|
||||
`crossdesk_wait` 使用已有截图的 `observation_id`,等待对应区域变化(`changed`)或持续稳定(`stable`)。可设置 `stable_ms` 和最长 25000 毫秒的 `timeout_ms`。满足条件后返回新截图,切换显示器、断开和取消会结束等待。
|
||||
|
||||
### 锁屏解锁与密码保存
|
||||
|
||||
@@ -187,70 +284,12 @@ Agent 识别到远端系统锁屏后,先聚焦画面中的系统密码框并
|
||||
|
||||
等待输入时,Agent 使用 `crossdesk_get_session`,提供 `session_id` 或 `view_id`、`unlock_request_id` 和 `wait_ms: 25000`。`wait_timed_out` 后可继续等待,等待取消仅结束当前查询。解锁请求有效期为五分钟,等待期间保留实时画面,常规 Agent 输入和显示器切换会返回 `UNLOCK_PENDING`。用户取消、人工暂停、连接结束或画面代际变化会终止请求。
|
||||
|
||||
提交前重新获取同一显示器代际的截图,原生端执行选择密码框内容、文本输入和回车。成功发送返回 `unlock.state: "submitted"`,Agent 随后用新截图确认桌面是否已解锁,再继续原任务。保存选项在输入提交后写入凭据库,密码正确性仍需检查远端画面。密码被拒绝时,使用新请求并设置 `use_saved: false` 让用户更正,结果不确定时先观察画面。相同 `operation_id` 与参数查询原请求结果。
|
||||
提交前重新获取同一显示器代际的截图,原生端执行选择密码框内容、文本输入和回车。对支持 `capabilities.system` 的被控端,服务在发送密码前先查询锁定状态,已经解锁时返回 `unlock.state: "verified"` 和 `unlock.result.status: "already_unlocked"`,不再输入密码。提交后会进入 `verifying`,在八秒验证窗口内查询系统状态;确认解锁时返回 `verified`,无法确认时保留 `submitted` 和验证信息。旧被控端成功发送后返回 `submitted`。Agent 随后获取新截图,确认桌面状态后继续原任务。
|
||||
|
||||
用户在解锁弹窗选择保存密码时,支持系统状态的被控端仅在验证为已解锁后写入凭据库;未确认时返回 `persistence_error: "UNLOCK_NOT_CONFIRMED"`。旧被控端仍在成功发送后保存,密码正确性需通过画面确认。密码被拒绝时,使用新请求并设置 `use_saved: false` 让用户更正,结果不确定时先观察画面。相同 `operation_id` 与参数查询原请求结果。
|
||||
|
||||
锁屏密码通过经过认证的本地面板或终端 IPC 进入原生输入通道。模型侧工具参数和结果只包含请求状态,原生操作缓存仅保存解锁请求标识及结果。解锁遵守现有控制授权和人工暂停状态,依赖远端文本输入能力。`off` 模式可使用预先配置的锁屏凭据,缺少凭据时返回 `UNLOCK_CREDENTIAL_REQUIRED`。原生 CLI 通过 `unlock_protocol: "password/1"` 声明支持,更新源码后需重建 CLI 和 MCP 并重新启动服务。
|
||||
|
||||
## 实时视频与按需截图
|
||||
|
||||
实时视频提供两条路径,默认 `browser`,两条路径均由原生 CLI 的本机 WebSocket 直接发送给浏览器。Node 只处理会话、授权票据和 MCP 工具,不读取或转发实时视频字节;不再创建视频管道,不经过 MCP stdio 或模型上下文,不逐帧转 PNG、不重新编码视频。
|
||||
|
||||
| `video.decode_path` | 画面路径 |
|
||||
| --- | --- |
|
||||
| `browser` | MiniRTC 输出原始 H.264 Annex B → 浏览器 WebCodecs 解码 → canvas |
|
||||
| `minirtc` | MiniRTC 解码 → 紧凑 NV12 原始帧 → 浏览器 VideoFrame / canvas;浏览器不再解码 H.264 |
|
||||
|
||||
`video.decoder` 默认 `auto`:优先使用可用硬件解码器,不支持时使用软件。`software` 禁用本程序的硬件选择与硬件回退。浏览器路径使用 WebCodecs 的 `prefer-hardware` / `prefer-software`;这些值是浏览器 API 的偏好,浏览器可以忽略,不能据此宣称已确认物理硬件或软件后端。原生路径由 MiniRTC 的解码器工厂选择,本机 macOS 使用 VideoToolbox 或 OpenH264;其他平台使用 MiniRTC 已支持的后端。状态中的 `native_video_decoder` 返回 MiniRTC 报告的后端名称(统计可用前为空),不代表浏览器后端。
|
||||
|
||||
在 `~/.config/crossdesk/mcp/devices.json` 的根对象中加入:
|
||||
|
||||
```json
|
||||
"video": {
|
||||
"decode_path": "minirtc",
|
||||
"decoder": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
也可在 Agent 宿主的 MCP 启动参数中配置,命令行优先于文件:
|
||||
|
||||
```sh
|
||||
crossdesk-mcp serve --stdio --video-path browser --video-decoder auto
|
||||
crossdesk-mcp serve --stdio --video-path minirtc --video-decoder software
|
||||
```
|
||||
|
||||
修改后重启 MCP,再建立连接。选项适用于整个 MCP 进程,两条路径均将解码选项传给原生端;浏览器路径中的原生解码用于截图缓存。截图工具或动作后的截图请求才触发 PNG 编码。这里配置的是控制端解码器,不更改远端编码器。原有配置文件无需新增字段即可继续使用。
|
||||
|
||||
Agent 模式默认请求高帧率档位,分辨率上限 1920 × 1080;实际分辨率和帧率由远端能力、网络和本机解码性能决定。面板显示实际尺寸与呈现 FPS。浏览器路径需要 WebCodecs H.264 解码能力;MiniRTC 路径仅需浏览器支持从 NV12 创建 VideoFrame 并绘制。能力不足时明确报错,不自动退回 PNG 轮询。NV12 不压缩,1080p / 30 FPS 约需 93 MB/s 本机传输,4K / 30 FPS 约需 373 MB/s;它不增加远端到控制端的 H.264 网络码率。移除 Node 转发没有消除 NV12 自身的本机传输与浏览器像素处理开销;这不是 GPU 零拷贝。
|
||||
|
||||
视频 WebSocket 在原生 CLI 的独立 I/O 线程中运行,只监听随机 `127.0.0.1` 端口,与 Node 面板 HTTP 端口不同。浏览器面板通过 cookie/CSRF 授权获取视频票据,Apps 通过 app-only `crossdesk_video_open` 和视图令牌获取。票据 15 秒内一次性有效,绑定视图、会话和代际,通过 WebSocket 第一条消息提交,不写入 URL、日志或模型可见工具结果。WebSocket 只提供画面、收帧 ACK 和关键帧恢复请求,不能执行远端输入操作。最多 8 个活动观看者。H.264 丢帧后等待关键帧恢复;NV12 每帧独立,每个观看者最多一帧未确认,原生发送队列只保留最新画面;H.264 最多 8 帧未确认并受字节阈值限制。10 秒未确认最早一帧则关闭该视频通道,不阻塞其他观看者或 MCP 控制。视图撤销、显示器/连接代际变化、断开和进程退出会关闭对应视频连接;暂停 Agent 控制仍继续播放。页面隐藏或卸载时释放视频订阅,恢复可见时重新订阅。
|
||||
|
||||
浏览器页面和 MCP Apps 资源的 CSP 允许原生服务使用的 `ws://127.0.0.1:*`(仅动态端口),HTTP 控制仍限制到 Node 面板的确切地址。原生服务另外校验确切 Host、Origin 和一次性票据。宿主仍可能限制 loopback、WebSocket、WebCodecs 或 iframe 权限;视频失败时提供重试和浏览器入口,`--panel=apps` 的严格策略仍禁止浏览器回退。浏览器和 Apps 共用同一播放器,具体宿主需要独立验收。
|
||||
|
||||
## 打包
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run pack:npm -- --out-dir /absolute/path/artifacts
|
||||
```
|
||||
|
||||
脚本生成可发布 tarball,包含面板、运行代码、源代码、许可和固定版本的五个平台可选依赖。CI 的 `MCP package` 工作流仅构建并上传 `mcp-package`,不发布 npm。原生包继续由 CLI 工作流构建,`doctor` 必须同时声明自动化协议和 `automation_video_protocols: ["h264-annexb/1", "nv12/1"]` 和 `automation_video_decoders: ["auto", "software"]`,以及 `automation_video_transport: "loopback-websocket/1"`、`automation_terminal_protocol: "ansi/1"`;先发布对应 0.2.0 平台包,再发布 MCP tarball。尚未进行真实跨平台发布/远端验收时,不应把构建成功等同于各宿主和设备上的联调完成。
|
||||
|
||||
从发布 tarball 重建 TypeScript/面板时,将包中的 `build-package.json` 复制为 `package.json`,运行 `npm ci && npm run build`。原生源码和依赖来源随平台包提供,详见 [源码来源](../cli/docs/source-provenance.md)。
|
||||
|
||||
## 本地审阅验证(2026-11-03)
|
||||
|
||||
动作参数校验和输入步骤生成集中在 `apps/cli/automation_protocol.*`,主循环负责调度、取消和权限检查。截图按色度网格对齐后仅转换所需区域,PNG 在容量受限的缓冲区中单次压缩;原生帧仅在没有其他读取者时复用内存。Node 按行累计响应字节,在完整响应到达时解码,避免反复扫描和复制大截图。
|
||||
|
||||
本机 macOS arm64 完成 TypeScript/面板构建、原生 CLI 构建、现有 wire/keyboard-state 测试,以及工程外的 MCP、会话和原生动作回归检查。ASan/UBSan 检查覆盖动作调度和输入清理;140 组随机图像覆盖完整帧、奇偶裁剪边界、缩放及容量回退,优化前后 PNG 字节一致。
|
||||
|
||||
使用 1920 × 1080 合成 NV12、最长边 1280、每项 6 次的 `-O2` 本地基准:完整画面编码约 22.5 → 11.5 ms,321 × 241 裁剪约 2.1 → 0.9 ms。数字仅衡量图像转换与编码,不包含网络、解码、远端执行或宿主渲染;未连接真实远端或完成 Windows/Linux 验收。额外测试代码、脚本、数据和产物均存放于工程外系统临时目录。
|
||||
|
||||
### 远端输入回执与条件等待
|
||||
|
||||
更新后的被控端通过 `capabilities.input_ack` 声明输入处理回执。动作结果的 `remote_execution` 表示远端原生输入处理状态,应用界面的实际结果由后续观察确认。旧被控端返回 `unsupported`。回执等待有明确期限,超时动作保留结果不确定状态。
|
||||
|
||||
`crossdesk_wait` 使用已有截图的 `observation_id`,等待对应区域变化(`changed`)或持续稳定(`stable`)。可设置 `stable_ms` 和最长 25000 毫秒的 `timeout_ms`。满足条件后返回新截图,切换显示器、断开和取消会结束等待。
|
||||
|
||||
### 远端剪贴板
|
||||
|
||||
`crossdesk_clipboard_get` 读取远端文本剪贴板,`crossdesk_clipboard_set` 设置文本,空字符串清空。读取上限为 128 KiB,单次工具写入上限为 16 KiB UTF-8。写入需要 `operation_id`,相同请求查询原结果。工具使用已授权控制会话,并遵守人工暂停及解锁等待状态。更新后的被控端通过 `capabilities.clipboard` 声明支持,写入成功由系统剪贴板更新回执确认。
|
||||
@@ -261,20 +300,12 @@ npm run pack:npm -- --out-dir /absolute/path/artifacts
|
||||
|
||||
`crossdesk_files_transfer` 支持上传和下载文件、文件夹,源路径为绝对路径,目标目录需要已存在。传输使用 `operation_id` 去重,通过 `crossdesk_files_status` 查询每个文件的字节进度和终态,通过 `crossdesk_files_cancel` 取消并清理未完成文件。每个连接同时处理一个传输任务,多文件按现有传输引擎排队。已完成任务保留四份历史记录,状态中的文件清单分页返回。目标同名文件按现有冲突命名规则另存。
|
||||
|
||||
文件操作需要更新后的被控端声明 `capabilities.files`。下载请求绑定目标目录及传输标识,接收器接受已为该请求预留的文件。人工暂停后仍可查询或取消已有任务。
|
||||
目录浏览及新建传输需要已授权且未暂停的控制会话,并要求被控端声明 `capabilities.files`。下载请求绑定目标目录及传输标识,接收器接受已为该请求预留的文件。人工暂停后仍可查询或取消已有任务。
|
||||
|
||||
### 系统状态与安全桌面
|
||||
|
||||
`crossdesk_system_status` 查询系统锁定状态。Windows 同时报告服务可用性和交互桌面阶段。`crossdesk_system_command` 支持 `lock`,Windows 可通过已运行的服务执行 `send_sas`。命令需要控制授权和独立的 `operation_id`,相同请求查询原结果。系统接受命令后,通过状态及画面观察确认实际界面。
|
||||
|
||||
支持系统状态的被控端会在密码提交后接受最长八秒的解锁验证。确认解锁后返回 `unlock.state: verified`,并保存用户选择记住的密码。系统状态无法确认时保留 `submitted` 和验证信息。已经解锁的桌面返回 `already_unlocked`。旧被控端沿用截图确认流程。
|
||||
|
||||
### 持续键鼠输入
|
||||
|
||||
`crossdesk_action` 增加 `key_down`、`key_up`、`mouse_down` 和 `mouse_up`。按下状态可跨动作保持,例如按住 Ctrl 后移动或拖动鼠标。最后一次动作完成后,按住状态的有效期为十五秒,超时自动释放。`crossdesk_release_inputs` 可立即释放本会话的全部按键和按钮,并取消当前输入动作。人工暂停、断开及输入错误同样释放按住状态。
|
||||
|
||||
`key_press`、`hotkey` 支持 `duration_ms`,拖拽时长上限提高到三十秒。扩展键名覆盖 F13–F24、数字键盘、菜单、锁定键和媒体键,实际效果取决于远端系统支持。状态返回 `held_key_codes` 和 `held_mouse_flags`。文本输入前应释放持续按住的键鼠状态。
|
||||
|
||||
### 多会话与显示管理
|
||||
|
||||
同一 MCP 实例支持最多八个并行设备连接。`crossdesk_list_sessions` 返回各连接的设备、显示器及控制状态。面板按设备绑定会话,断开一个连接会释放该连接的资源。每个连接的诊断日志位于 `logs/<session_id>/transport.log`。
|
||||
@@ -310,3 +341,61 @@ Agent 在人工接管期间仍可观察画面,键鼠、显示设置和其他
|
||||
`crossdesk_wait_ui` 根据文本及角色等待元素出现或消失。读取范围不完整时无法确认消失,会在超时结果中返回 `search_incomplete`。
|
||||
|
||||
macOS 使用 AX,需要系统辅助功能权限。Windows 使用 UI Automation,窗口激活遵守系统前台窗口及权限约束。Linux 构建时若检测到 `atspi-2` 开发包,会启用 AT-SPI 元素访问,否则提供 X11 窗口查询和激活。Wayland 的元素访问依赖桌面和应用提供 AT-SPI。应用启动参数接受 macOS bundle ID 或绝对 `.app` 路径、Windows 绝对 `.exe` 路径、Linux 绝对可执行文件路径。Linux 接口的所有权和超时处理依据官方文档。[AT-SPI 接口文档](https://docs.gtk.org/atspi2/)
|
||||
|
||||
## 实时视频与按需截图
|
||||
|
||||
实时视频提供两条路径,默认 `browser`,两条路径均由原生 CLI 的本机 WebSocket 直接发送给浏览器。Node 只处理会话、授权票据和 MCP 工具,不读取或转发实时视频字节;不再创建视频管道,不经过 MCP stdio 或模型上下文,不逐帧转 PNG、不重新编码视频。
|
||||
|
||||
| `video.decode_path` | 画面路径 |
|
||||
| --- | --- |
|
||||
| `browser` | MiniRTC 输出原始 H.264 Annex B → 浏览器 WebCodecs 解码 → canvas |
|
||||
| `minirtc` | MiniRTC 解码 → 紧凑 NV12 原始帧 → 浏览器 VideoFrame / canvas;浏览器不再解码 H.264 |
|
||||
|
||||
`video.decoder` 默认 `auto`:优先使用可用硬件解码器,不支持时使用软件。`software` 禁用本程序的硬件选择与硬件回退。浏览器路径使用 WebCodecs 的 `prefer-hardware` / `prefer-software`;这些值是浏览器 API 的偏好,浏览器可以忽略,不能据此宣称已确认物理硬件或软件后端。原生路径由 MiniRTC 的解码器工厂选择,本机 macOS 使用 VideoToolbox 或 OpenH264;其他平台使用 MiniRTC 已支持的后端。状态中的 `native_video_decoder` 返回 MiniRTC 报告的后端名称(统计可用前为空),不代表浏览器后端。
|
||||
|
||||
在 `~/.config/crossdesk/mcp/devices.json` 的根对象中加入:
|
||||
|
||||
```json
|
||||
"video": {
|
||||
"decode_path": "minirtc",
|
||||
"decoder": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
也可在 Agent 宿主的 MCP 启动参数中配置,命令行优先于文件:
|
||||
|
||||
```sh
|
||||
crossdesk-mcp serve --stdio --video-path browser --video-decoder auto
|
||||
crossdesk-mcp serve --stdio --video-path minirtc --video-decoder software
|
||||
```
|
||||
|
||||
修改后重启 MCP,再建立连接。选项适用于整个 MCP 进程,两条路径均将解码选项传给原生端;浏览器路径中的原生解码用于截图缓存。PNG 编码按截图请求触发,包括单屏截图、多屏采集及动作或等待后的截图,WebSocket 实时视频不触发 PNG 编码;Apps 的兼容预览按需触发 PNG 编码。这里配置的是控制端解码器,不更改远端编码器。原有配置文件无需新增字段即可继续使用。
|
||||
|
||||
Agent 模式初始请求高帧率档位,初始分辨率上限为 1920 × 1080。连接后可通过 `crossdesk_video_settings` 调整帧率、分辨率、质量和码率;工具接受的分辨率最大为 3840 × 2160,实际分辨率和帧率由远端能力、网络和本机解码性能决定。面板显示实际尺寸与呈现 FPS。浏览器路径需要 WebCodecs H.264 解码能力;MiniRTC 路径仅需浏览器支持从 NV12 创建 VideoFrame 并绘制。浏览器面板能力不足时明确报错;Apps 面板无法连接实时通道或不支持解码时切换到下述有界 PNG 预览。NV12 不压缩,1080p / 30 FPS 约需 93 MB/s 本机传输,4K / 30 FPS 约需 373 MB/s;它不增加远端到控制端的 H.264 网络码率。移除 Node 转发没有消除 NV12 自身的本机传输与浏览器像素处理开销;这不是 GPU 零拷贝。
|
||||
|
||||
视频 WebSocket 在原生 CLI 的独立 I/O 线程中运行,只监听随机 `127.0.0.1` 端口,与 Node 面板 HTTP 端口不同。浏览器面板通过 cookie/CSRF 授权获取视频票据,Apps 通过 app-only `crossdesk_video_open` 和视图令牌获取。票据 15 秒内一次性有效,绑定视图、会话和代际,通过 WebSocket 第一条消息提交,不写入 URL、日志或模型可见工具结果。WebSocket 提供画面、已启用的 PCM 音频、收帧 ACK 和关键帧恢复请求,不能执行远端输入操作。最多 8 个活动观看者。H.264 丢帧后等待关键帧恢复;NV12 每帧独立,每个观看者最多一帧未确认,原生发送队列只保留最新画面;H.264 最多 8 帧未确认并受字节阈值限制。10 秒未确认最早一帧则关闭该视频通道,不阻塞其他观看者或 MCP 控制。视图撤销、显示器/连接代际变化、断开和进程退出会关闭对应视频连接;暂停 Agent 控制仍继续播放。页面隐藏或卸载时释放视频订阅,恢复可见时重新订阅。
|
||||
|
||||
浏览器页面和 MCP Apps 资源的 CSP 允许原生服务使用的 `ws://127.0.0.1:*`(仅动态端口),HTTP 控制仍限制到 Node 面板的确切地址。原生服务另外校验确切 Host、Origin 和一次性票据。宿主仍可能限制 loopback、WebSocket、WebCodecs 或 iframe 权限。已观察到 Codex 内嵌宿主过滤 HTTP/WS 白名单,最终以 `connect-src 'self'` 拦截原生视频连接;添加同义 CSP 字段无法解决协议限制。播放器区分 CSP 拦截,并在 Apps 实时视频失败时通过已授权的 MCP 通道提供兼容预览。
|
||||
|
||||
兼容预览显示“预览”和实际 FPS,最高每秒 1 帧、最长边 960 像素、PNG 每帧不超过 256 KiB,不含声音。`crossdesk_ui_preview` 仅供面板使用,验证视图令牌、代际及绑定会话,每个视图最多一个进行中的请求,并在响应前重新检查会话是否改变。图像只放在工具结果 `_meta` 中,不进入模型可见内容,也不占用 Agent 的操作截图历史。前端直接解码 PNG 字节,无需新增网络白名单;隐藏或卸载面板时停止刷新,迟到响应不再绘制。需要流畅视频及声音时使用 `--panel=browser`;`--panel=apps` 仍禁止自动打开外部浏览器。
|
||||
|
||||
2026-11-11 在本机 Codex MCP Apps 中以仅查看模式连接 macOS 远端,确认 WebSocket 被 CSP 拦截后自动显示兼容预览,画布帧计数持续递增,实测约 0.5–1 FPS。该画面在 256 KiB 上限内自适应缩小为 540 × 303;此结果验证了内嵌预览,不代表宿主已支持本机 WebSocket 实时视频。
|
||||
|
||||
## 打包
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run pack:npm -- --out-dir /absolute/path/artifacts
|
||||
```
|
||||
|
||||
脚本生成可发布 tarball,包含面板、运行代码、源代码、许可和固定版本的五个平台可选依赖。CI 的 `MCP package` 工作流仅构建并上传 `mcp-package`,不发布 npm。原生包继续由 CLI 工作流构建,`doctor` 必须同时声明自动化协议和 `automation_video_protocols: ["h264-annexb/1", "nv12/1"]` 和 `automation_video_decoders: ["auto", "software"]`,以及 `automation_video_transport: "loopback-websocket/1"`、`automation_terminal_protocol: "ansi/1"`;先发布对应 0.2.0 平台包,再发布 MCP tarball。尚未进行真实跨平台发布/远端验收时,不应把构建成功等同于各宿主和设备上的联调完成。
|
||||
|
||||
从发布 tarball 重建 TypeScript/面板时,将包中的 `build-package.json` 复制为 `package.json`,运行 `npm ci && npm run build`。原生源码和依赖来源随平台包提供,详见 [源码来源](../cli/docs/source-provenance.md)。
|
||||
|
||||
## 本地审阅验证(2026-11-03)
|
||||
|
||||
动作参数校验和输入步骤生成集中在 `apps/cli/automation_protocol.*`,主循环负责调度、取消和权限检查。截图按色度网格对齐后仅转换所需区域,PNG 在容量受限的缓冲区中单次压缩;原生帧仅在没有其他读取者时复用内存。Node 按行累计响应字节,在完整响应到达时解码,避免反复扫描和复制大截图。
|
||||
|
||||
本机 macOS arm64 完成 TypeScript/面板构建、原生 CLI 构建、现有 wire/keyboard-state 测试,以及工程外的 MCP、会话和原生动作回归检查。ASan/UBSan 检查覆盖动作调度和输入清理;140 组随机图像覆盖完整帧、奇偶裁剪边界、缩放及容量回退,优化前后 PNG 字节一致。
|
||||
|
||||
使用 1920 × 1080 合成 NV12、最长边 1280、每项 6 次的 `-O2` 本地基准:完整画面编码约 22.5 → 11.5 ms,321 × 241 裁剪约 2.1 → 0.9 ms。数字仅衡量图像转换与编码,不包含网络、解码、远端执行或宿主渲染;未连接真实远端或完成 Windows/Linux 验收。额外测试代码、脚本、数据和产物均存放于工程外系统临时目录。
|
||||
+63
-18
@@ -10,11 +10,11 @@ import {
|
||||
type Server as IPCServer,
|
||||
} from "node:net";
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile, writeFile, unlink, chmod, readdir } from "node:fs/promises";
|
||||
import { readFile, writeFile, unlink, chmod, readdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Failure, token, equal, failure, type Data } from "./common.js";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { Failure, token, equal, failure, type BrowserLaunch, type Data } from "./common.js";
|
||||
import { Sessions, type View } from "./sessions.js";
|
||||
import { uiAction } from "./ui-actions.js";
|
||||
import { displayEnvironment } from "./environment.js";
|
||||
@@ -25,6 +25,13 @@ interface BrowserSession {
|
||||
views: Set<string>;
|
||||
expires: number;
|
||||
}
|
||||
interface BrowserGrant {
|
||||
view: string;
|
||||
generation: number;
|
||||
expires: number;
|
||||
directory?: string;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
export async function openURL(url: string): Promise<boolean> {
|
||||
if (!displayEnvironment().gui) return false;
|
||||
const command =
|
||||
@@ -64,10 +71,10 @@ export class BrowserPanel {
|
||||
private descriptor?: string;
|
||||
private origin = "";
|
||||
private cookie = "crossdesk_" + token();
|
||||
private grants = new Map<string, { view: string; expires: number }>();
|
||||
private grants = new Map<string, BrowserGrant>();
|
||||
private clients = new Map<string, BrowserSession>();
|
||||
private html = "";
|
||||
constructor(private sessions: Sessions) {}
|
||||
constructor(private sessions: Sessions, private launch: BrowserLaunch = "auto") {}
|
||||
async connectDomains() {
|
||||
await this.start();
|
||||
// Native processes own ephemeral video ports, including after reconnect.
|
||||
@@ -79,15 +86,40 @@ export class BrowserPanel {
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
return this.sessions.openVideo(view, this.origin);
|
||||
}
|
||||
async open(view: View): Promise<Data> {
|
||||
async open(view: View, preferHost = false): Promise<Data> {
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
await this.start();
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
const opened = await openURL(this.bootstrapURL(view));
|
||||
const { key, grant, url } = this.bootstrap(view);
|
||||
const launch = this.launch === "auto" ? (preferHost ? "host" : "system") : this.launch;
|
||||
let launcher: string | undefined;
|
||||
let opened = false;
|
||||
try {
|
||||
if (launch === "host") {
|
||||
// The host opens a local document, never a model-visible bearer URL.
|
||||
// Its browser gets its own cookie through the normal bootstrap flow.
|
||||
grant.directory = await mkdtemp(join(tmpdir(), "crossdesk-mcp-launch-"));
|
||||
const path = join(grant.directory, "open.html");
|
||||
await writeFile(path,
|
||||
`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'"><meta http-equiv="refresh" content="0;url=${url}"><title>CrossDesk MCP</title></head><body><p>正在打开 CrossDesk…</p><a href="${url}" rel="noreferrer">打开画面</a></body></html>`,
|
||||
{ mode: 0o600, flag: "wx" });
|
||||
if (this.closed || this.grants.get(key) !== grant) throw new Failure("PANEL_CLOSED");
|
||||
launcher = pathToFileURL(path).href;
|
||||
} else opened = await openURL(url);
|
||||
} catch (error) {
|
||||
await this.removeGrant(key, grant);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
...this.sessions.describe(view),
|
||||
browser_opened: opened,
|
||||
browser_url: this.origin,
|
||||
browser_launch: launch,
|
||||
...(launcher ? {
|
||||
browser_launch_url: launcher,
|
||||
browser_launch_expires_in_ms: Math.max(0, grant.expires - Date.now()),
|
||||
next_step: "Open browser_launch_url in the host's built-in browser/sidebar using its browser-open tool (Codex: open_in_codex, target.type=browser, target.url=browser_launch_url, placement=right). Do not read or quote the launcher file: it contains a one-time pairing credential. If no host browser is available, show the pairing_command for the user to run locally. Then call crossdesk_get_session with view_id and wait_ms=25000. If the launcher expired, call crossdesk_open_browser with this view_id to issue another; do not reconnect the remote device.",
|
||||
} : {}),
|
||||
pairing_command: {
|
||||
command: "crossdesk-mcp",
|
||||
args: [
|
||||
@@ -101,14 +133,22 @@ export class BrowserPanel {
|
||||
},
|
||||
};
|
||||
}
|
||||
private bootstrapURL(view: View): string {
|
||||
private async removeGrant(key: string, grant: BrowserGrant) {
|
||||
if (this.grants.get(key) === grant) this.grants.delete(key);
|
||||
clearTimeout(grant.timer);
|
||||
if (grant.directory) await rm(grant.directory, { recursive: true, force: true });
|
||||
}
|
||||
private bootstrap(view: View) {
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
for (const [key, value] of this.grants)
|
||||
if (value.expires < Date.now()) this.grants.delete(key);
|
||||
if (view.panel !== "browser" || !["auto", "browser"].includes(this.sessions.policy))
|
||||
throw new Failure("PANEL_POLICY_DENIED");
|
||||
if (this.grants.size >= 32) throw new Failure("VIEW_LIMIT");
|
||||
const grant = token();
|
||||
this.grants.set(grant, { view: view.id, expires: Date.now() + 60000 });
|
||||
return `${this.origin}/#bootstrap=${grant}`;
|
||||
const key = token();
|
||||
const grant: BrowserGrant = { view: view.id, generation: view.generation, expires: Date.now() + 60000 };
|
||||
this.grants.set(key, grant);
|
||||
grant.timer = setTimeout(() => void this.removeGrant(key, grant).catch(() => {}), 60000);
|
||||
grant.timer.unref();
|
||||
return { key, grant, url: `${this.origin}/#bootstrap=${key}` };
|
||||
}
|
||||
private async start() {
|
||||
this.starting ??= (async () => {
|
||||
@@ -217,9 +257,14 @@ export class BrowserPanel {
|
||||
typeof body.token === "string"
|
||||
? this.grants.get(body.token)
|
||||
: undefined;
|
||||
if (!grant || grant.expires < Date.now())
|
||||
const view = grant ? this.sessions.views.get(grant.view) : undefined;
|
||||
if (!grant || grant.expires < Date.now() || !view ||
|
||||
view.panel !== "browser" || view.generation !== grant.generation)
|
||||
throw new Failure("PAIRING_REQUIRED");
|
||||
await this.removeGrant(body.token, grant);
|
||||
if (this.closed || this.sessions.views.get(grant.view) !== view ||
|
||||
view.panel !== "browser" || view.generation !== grant.generation)
|
||||
throw new Failure("PAIRING_REQUIRED");
|
||||
this.grants.delete(body.token);
|
||||
for (const [id, client] of this.clients)
|
||||
if (client.expires < Date.now()) this.clients.delete(id);
|
||||
const existing = this.client(req);
|
||||
@@ -242,7 +287,7 @@ export class BrowserPanel {
|
||||
);
|
||||
return this.respond(res, 200, {
|
||||
csrf: client.csrf,
|
||||
...this.sessions.describe(this.sessions.view(grant.view)),
|
||||
...this.sessions.describe(view),
|
||||
});
|
||||
}
|
||||
const client = this.client(req);
|
||||
@@ -313,7 +358,7 @@ export class BrowserPanel {
|
||||
// Only the authenticated local setup process receives this URL.
|
||||
// It uses its own desktop environment to open the browser.
|
||||
socket.end(
|
||||
JSON.stringify({ ok: true, url: this.bootstrapURL(v) }) + "\n",
|
||||
JSON.stringify({ ok: true, url: this.bootstrap(v).url }) + "\n",
|
||||
);
|
||||
} catch {
|
||||
socket.end('{"ok":false}\n');
|
||||
@@ -339,7 +384,7 @@ export class BrowserPanel {
|
||||
async close(final = true) {
|
||||
if (final) this.closed = true;
|
||||
await this.sessions.revokeVideo();
|
||||
this.grants.clear();
|
||||
await Promise.all([...this.grants].map(([key, grant]) => this.removeGrant(key, grant)));
|
||||
this.clients.clear();
|
||||
this.server?.closeAllConnections();
|
||||
if (this.server?.listening) this.server.close();
|
||||
|
||||
@@ -50,6 +50,8 @@ export const unlockPassword = z.string().min(1).max(256)
|
||||
.refine((value) => Buffer.byteLength(value) <= 256 && !/[\0\r\n\t]/.test(value));
|
||||
export const panel = z.enum(["auto", "apps", "browser", "terminal", "off"]);
|
||||
export type Panel = z.infer<typeof panel>;
|
||||
export const browserLaunch = z.enum(["auto", "system", "host"]);
|
||||
export type BrowserLaunch = z.infer<typeof browserLaunch>;
|
||||
export const videoOptions = z.strictObject({
|
||||
decode_path: z.enum(["browser", "minirtc"]).default("browser"),
|
||||
decoder: z.enum(["auto", "software"]).default("auto"),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TerminalPanel } from "./terminal.js";
|
||||
import { terminalView } from "./terminal-view.js";
|
||||
import {
|
||||
panel,
|
||||
browserLaunch,
|
||||
parse,
|
||||
Failure,
|
||||
failure,
|
||||
@@ -135,6 +136,7 @@ async function main() {
|
||||
options: {
|
||||
stdio: { type: "boolean" },
|
||||
panel: { type: "string" },
|
||||
"browser-launch": { type: "string" },
|
||||
"video-path": { type: "string" },
|
||||
"video-decoder": { type: "string" },
|
||||
"config-dir": { type: "string" },
|
||||
@@ -152,7 +154,7 @@ async function main() {
|
||||
}
|
||||
if (values.help || !positionals.length) {
|
||||
process.stdout.write(
|
||||
"crossdesk-mcp serve --stdio [--panel auto|apps|browser|terminal|off] [--video-path browser|minirtc] [--video-decoder auto|software] [--config-dir directory]\ncrossdesk-mcp view --view VIEW_ID [--config-dir directory] [--cell-aspect 0.5]\ncrossdesk-mcp setup [--config-dir directory]\ncrossdesk-mcp setup --open-panel --view VIEW_ID [--config-dir directory]\nDevelopment: --native-binary /absolute/path/to/crossdesk-cli\n",
|
||||
"crossdesk-mcp serve --stdio [--panel auto|apps|browser|terminal|off] [--browser-launch auto|host|system] [--video-path browser|minirtc] [--video-decoder auto|software] [--config-dir directory]\nDefaults: --panel=auto --browser-launch=auto (Codex: host sidebar browser; other clients: local environment/system browser)\nExplicit host sidebar: --panel=browser --browser-launch=host\nSystem browser: --panel=browser --browser-launch=system\ncrossdesk-mcp view --view VIEW_ID [--config-dir directory] [--cell-aspect 0.5]\ncrossdesk-mcp setup [--config-dir directory]\ncrossdesk-mcp setup --open-panel --view VIEW_ID [--config-dir directory]\nDevelopment: --native-binary /absolute/path/to/crossdesk-cli\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -179,6 +181,11 @@ async function main() {
|
||||
panel,
|
||||
values.panel ?? config.panel,
|
||||
);
|
||||
const launch = parse(browserLaunch, values["browser-launch"] ?? "auto");
|
||||
// Other explicit panel policies do not use the browser launch default.
|
||||
// auto resolves per client when a browser panel is opened.
|
||||
if (values["browser-launch"] === "host" && !["auto", "browser"].includes(policy))
|
||||
throw new Failure("BROWSER_LAUNCH_REQUIRES_BROWSER");
|
||||
const sessions = new Sessions(
|
||||
store,
|
||||
policy,
|
||||
@@ -188,7 +195,7 @@ async function main() {
|
||||
decoder: values["video-decoder"] ?? config.video.decoder,
|
||||
}),
|
||||
);
|
||||
const browser = new BrowserPanel(sessions);
|
||||
const browser = new BrowserPanel(sessions, launch);
|
||||
const terminal = new TerminalPanel(sessions);
|
||||
const handle = serveStdio(() => createServer(sessions, browser, terminal), {
|
||||
transport: new StdioServerTransport(),
|
||||
|
||||
+28
-14
@@ -37,6 +37,7 @@ const uiNames: Record<UIAction, string> = {
|
||||
status: "crossdesk_ui_get_session",
|
||||
connect: "crossdesk_ui_connect",
|
||||
video_open: "crossdesk_video_open",
|
||||
preview: "crossdesk_ui_preview",
|
||||
control: "crossdesk_viewer_control",
|
||||
unlock: "crossdesk_ui_unlock",
|
||||
recovery: "crossdesk_ui_recovery",
|
||||
@@ -81,7 +82,7 @@ export function createServer(
|
||||
{
|
||||
capabilities: { tools: {}, resources: {} },
|
||||
instructions:
|
||||
"Remote computer control. Open a panel for the user to select and authorize a device. Never request passwords in chat. When a remote task is pending, immediately follow crossdesk_open with crossdesk_get_session(view_id, wait_ms: 25000) in the same turn. Repeat bounded waits on wait_timed_out until ready or the user cancels; do not ask the user to type a connection confirmation or click Continue. On ready, continue the requested task within current authorization. When state is reconnecting, continue bounded waits for the explicitly enabled recovery policy. Use the returned session_id after recovery and inspect a fresh screenshot. On closed/failed, stop waiting and do not automatically reconnect. Obtain a screenshot before actions and pass its observation_id; coordinates refer to that returned image. If the remote system is locked, focus its visible password field, take a fresh screenshot and call crossdesk_unlock. A saved system password is used automatically; otherwise the local panel asks the user. Wait with crossdesk_get_session(unlock_request_id, wait_ms: 25000), then verify a fresh screenshot before continuing the original task. Submitted actions are not remote execution acknowledgements. Never replay actions after uncertain outcomes. Human pause can only be resumed in the user panel.",
|
||||
"Remote computer control. Open a panel for the user to select and authorize a device. Never request passwords in chat. In Codex, the default panel is the host built-in browser/sidebar for every user; no personal agent instructions are required. Other clients keep their local environment and system browser defaults. Explicit panel and launch settings override these defaults. Whenever browser_launch_url is returned, immediately open that local launcher using the host browser-open tool before waiting for authorization or continuing the remote task, even if the connection is already ready. Do not wait for the user to ask to open the sidebar. Do not read or quote its contents; it contains a one-time pairing credential. In Codex, use open_in_codex with target.type=browser, target.url=browser_launch_url, placement=right. If the host has no browser-open tool, explain the limitation and show pairing_command for the user to run locally; do not claim the sidebar is open. Reissue expired launchers with crossdesk_open_browser using the existing view_id, without reconnecting. When a remote task is pending, immediately follow crossdesk_open with crossdesk_get_session(view_id, wait_ms: 25000) in the same turn. Repeat bounded waits on wait_timed_out until ready or the user cancels; do not ask the user to type a connection confirmation or click Continue. On ready, continue the requested task within current authorization. When state is reconnecting, continue bounded waits for the explicitly enabled recovery policy. Use the returned session_id after recovery and inspect a fresh screenshot. On closed/failed, stop waiting and do not automatically reconnect. Obtain a screenshot before actions and pass its observation_id; coordinates refer to that returned image. If the remote system is locked, focus its visible password field, take a fresh screenshot and call crossdesk_unlock. A saved system password is used automatically; otherwise the local panel asks the user. Wait with crossdesk_get_session(unlock_request_id, wait_ms: 25000), then verify a fresh screenshot before continuing the original task. Submitted actions are not remote execution acknowledgements. Never replay actions after uncertain outcomes. Human pause can only be resumed in the user panel.",
|
||||
},
|
||||
);
|
||||
type Definition = {
|
||||
@@ -94,6 +95,14 @@ export function createServer(
|
||||
run: (p: Data, ctx: ServerContext) => Promise<CallToolResult>;
|
||||
};
|
||||
const definitions = new Map<string, Definition>();
|
||||
const codex = (ctx: ServerContext) => {
|
||||
const envelope = ctx.mcpReq.envelope as Data | undefined;
|
||||
const info = envelope
|
||||
? envelope["io.modelcontextprotocol/clientInfo"]
|
||||
: server.server.getClientVersion();
|
||||
// Client identity selects a presentation default, never authorization.
|
||||
return info?.name === "codex-mcp-client";
|
||||
};
|
||||
const supported = (ctx: ServerContext) => {
|
||||
const envelope = ctx.mcpReq.envelope as Data | undefined;
|
||||
const caps = envelope
|
||||
@@ -107,7 +116,7 @@ export function createServer(
|
||||
};
|
||||
const apps = (ctx: ServerContext) =>
|
||||
sessions.policy === "apps" && supported(ctx);
|
||||
const openPanel = async (v: View) => v.panel === "browser" ? browser.open(v)
|
||||
const openPanel = async (v: View, ctx: ServerContext) => v.panel === "browser" ? browser.open(v, codex(ctx))
|
||||
: v.panel === "terminal" ? terminal.open(v) : sessions.describe(v);
|
||||
const viewResult = (v: View, result: Data) =>
|
||||
output(
|
||||
@@ -165,16 +174,16 @@ export function createServer(
|
||||
add(
|
||||
"crossdesk_open",
|
||||
z.strictObject({ device_alias: alias.optional() }),
|
||||
"Open CrossDesk so the user can choose a device, enter its password, and authorize this session. GUI environments use a browser; headless terminal environments return terminal_command, which you must show to the user to run in a separate terminal. Never run the terminal viewer in a captured tool-output channel. Returns immediately. To continue a pending remote task, call crossdesk_get_session with the returned view_id and wait_ms: 25000; no chat confirmation is needed.",
|
||||
"Open CrossDesk so the user can choose a device, enter its password, and authorize this session. In Codex, defaults to browser_launch_url: immediately open it in the host sidebar without reading the launcher file or asking the user to request the sidebar. Other clients default to the local environment and system browser; explicit settings take precedence. Headless terminal environments return terminal_command, which you must show to the user to run in a separate terminal. Never run the terminal viewer in a captured tool-output channel. Returns immediately. To continue a pending remote task, call crossdesk_get_session with the returned view_id and wait_ms: 25000; no chat confirmation is needed.",
|
||||
async (p, ctx) => {
|
||||
if (p.device_alias) await sessions.store.device(p.device_alias);
|
||||
const v = sessions.create(
|
||||
sessions.choose(supported(ctx)),
|
||||
sessions.choose(supported(ctx), codex(ctx)),
|
||||
p.device_alias,
|
||||
);
|
||||
return viewResult(
|
||||
v,
|
||||
await openPanel(v),
|
||||
await openPanel(v, ctx),
|
||||
);
|
||||
},
|
||||
{ ui: true },
|
||||
@@ -182,8 +191,8 @@ export function createServer(
|
||||
add(
|
||||
"crossdesk_open_browser",
|
||||
z.strictObject({ view_id: id.optional() }),
|
||||
"Explicitly open the local browser panel after the user requests browser fallback. Does not create another remote connection.",
|
||||
async (p) => {
|
||||
"Open or reissue the local browser panel using the configured launch mode. In host mode (the Codex default), immediately open browser_launch_url in the host sidebar without reading the launcher file. Use the existing view_id to renew an expired launcher without creating another remote connection. Strict apps/off policies deny browser fallback.",
|
||||
async (p, ctx) => {
|
||||
if (!["auto", "browser"].includes(sessions.policy))
|
||||
throw new Failure("PANEL_POLICY_DENIED");
|
||||
const v = p.view_id
|
||||
@@ -197,7 +206,7 @@ export function createServer(
|
||||
v.ready = false;
|
||||
v.created = Date.now();
|
||||
}
|
||||
return output(await browser.open(v));
|
||||
return output(await browser.open(v, codex(ctx)));
|
||||
},
|
||||
);
|
||||
add(
|
||||
@@ -214,9 +223,9 @@ export function createServer(
|
||||
mode: mode.default("observe"),
|
||||
display_index: z.int().min(0).max(63).default(0),
|
||||
}),
|
||||
"Connect only a saved, previously authorized device with available credentials. AUTH_REQUIRED means the user must authorize through the panel or setup.",
|
||||
"Connect only a saved, previously authorized device with available credentials. In Codex, defaults to browser_launch_url: immediately open it in the host sidebar even when the remote connection is already ready. Do not read the launcher file. AUTH_REQUIRED means the user must authorize through the panel or setup.",
|
||||
async (p, ctx) => {
|
||||
const selected = sessions.choose(supported(ctx));
|
||||
const selected = sessions.choose(supported(ctx), codex(ctx));
|
||||
// Reserve panel capacity before starting a connection. Once connected,
|
||||
// panel setup errors must not hide the live session from the caller.
|
||||
const v = sessions.create(selected, p.device_alias);
|
||||
@@ -234,7 +243,7 @@ export function createServer(
|
||||
}
|
||||
v.session = result.session_id;
|
||||
let panelResult: Data;
|
||||
try { panelResult = await openPanel(v); }
|
||||
try { panelResult = await openPanel(v, ctx); }
|
||||
catch (error) { panelResult = { ...sessions.describe(v), panel_error: failure(error) }; }
|
||||
return viewResult(v, {
|
||||
...result,
|
||||
@@ -361,13 +370,13 @@ export function createServer(
|
||||
async (p, ctx) => {
|
||||
let v = [...sessions.views.values()].find((view) => view.session === p.session_id);
|
||||
const created = !v;
|
||||
if (!v) v = sessions.create(sessions.choose(supported(ctx)));
|
||||
if (!v) v = sessions.create(sessions.choose(supported(ctx), codex(ctx)));
|
||||
let result: Data;
|
||||
try { result = await sessions.requestUnlock(p, ctx.mcpReq.signal); }
|
||||
catch (error) { if (created) sessions.views.delete(v.id); throw error; }
|
||||
let panel = sessions.describe(v);
|
||||
try {
|
||||
if (result.unlock.state === "awaiting_password" && !v.ready) panel = await openPanel(v);
|
||||
if (result.unlock.state === "awaiting_password" && !v.ready) panel = await openPanel(v, ctx);
|
||||
} catch (error) { panel.panel_error = failure(error); }
|
||||
return viewResult(v, { ...panel, ...result, next_step: result.unlock.next_step });
|
||||
},
|
||||
@@ -464,6 +473,11 @@ export function createServer(
|
||||
args,
|
||||
ctx.mcpReq.signal,
|
||||
);
|
||||
if (name === "preview") return {
|
||||
content: [{ type: "text", text: "Panel preview frame" }],
|
||||
structuredContent: { transport: "mcp-preview/1" },
|
||||
_meta: { "crossdesk/preview": result },
|
||||
};
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -474,7 +488,7 @@ export function createServer(
|
||||
structuredContent: result,
|
||||
};
|
||||
},
|
||||
{ app: true },
|
||||
{ app: true, readOnly: name === "preview" },
|
||||
);
|
||||
}
|
||||
registerAppResource(
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface View {
|
||||
session?: string;
|
||||
disconnected?: boolean;
|
||||
connectionError?: Data;
|
||||
previewPending?: boolean;
|
||||
previewAt?: number;
|
||||
}
|
||||
interface Session {
|
||||
id: string;
|
||||
@@ -107,9 +109,10 @@ export class Sessions {
|
||||
private nativePath?: string,
|
||||
private videoOptions?: VideoOptions,
|
||||
) {}
|
||||
choose(apps: boolean): View["panel"] {
|
||||
choose(apps: boolean, preferHostBrowser = false): View["panel"] {
|
||||
if (this.policy === "apps" && !apps) throw new Failure("PANEL_UNSUPPORTED");
|
||||
if (this.policy !== "auto") return this.policy;
|
||||
if (preferHostBrowser) return "browser";
|
||||
const environment = displayEnvironment();
|
||||
return environment.gui ? "browser" : environment.terminal ? "terminal" : "off";
|
||||
}
|
||||
@@ -1123,6 +1126,29 @@ export class Sessions {
|
||||
throw new Failure("NATIVE_PROTOCOL_MISMATCH");
|
||||
return { ...result, ticket };
|
||||
}
|
||||
async preview(v: View, args: Data, signal?: AbortSignal): Promise<Data> {
|
||||
if (v.panel !== "apps" || !v.session || this.views.get(v.id) !== v)
|
||||
throw new Failure("NOT_READY");
|
||||
const s = this.session(v.session);
|
||||
if (this.closing || s.connecting || s.displayBatch || s.status.state !== "ready")
|
||||
throw new Failure("NOT_READY");
|
||||
if (args.after_frame_id !== undefined && args.generation === undefined)
|
||||
throw new Failure("GENERATION_REQUIRED");
|
||||
if (v.previewPending || Date.now() - (v.previewAt ?? 0) < 1000)
|
||||
throw new Failure("BUSY", false, true);
|
||||
const generation = v.generation, session = v.session;
|
||||
v.previewPending = true;
|
||||
v.previewAt = Date.now();
|
||||
try {
|
||||
// Native preview is capped at 960px / 256 KiB and does not consume the
|
||||
// agent's observation history. Never send these frames through tools visible to the model.
|
||||
const result = await s.native.request("preview", { ...args, max_edge: 960, timeout_ms: 1000 }, 4000, signal);
|
||||
if (this.active.get(s.id) !== s || this.views.get(v.id) !== v ||
|
||||
v.generation !== generation || v.session !== session || s.closing)
|
||||
throw new Failure("SESSION_CHANGED");
|
||||
return result;
|
||||
} finally { v.previewPending = false; }
|
||||
}
|
||||
async listSessions(): Promise<Data> {
|
||||
const sessions = await Promise.all([...this.active.keys()].map((id) => this.status(id)));
|
||||
return { sessions };
|
||||
|
||||
@@ -8,6 +8,10 @@ export const uiSchemas = {
|
||||
status: z.strictObject({}),
|
||||
connect: uiConnect,
|
||||
video_open: z.strictObject({}),
|
||||
preview: z.strictObject({
|
||||
generation: z.int().nonnegative().optional(),
|
||||
after_frame_id: z.int().nonnegative().optional(),
|
||||
}),
|
||||
input: viewerInput,
|
||||
audio: z.strictObject({ enabled: z.boolean() }),
|
||||
recovery: z.strictObject({ enabled: z.boolean(), max_attempts: z.int().min(1).max(5).default(3) }),
|
||||
@@ -45,6 +49,8 @@ export async function uiAction(
|
||||
}
|
||||
case "status":
|
||||
return sessions.status(undefined, view.id, signal);
|
||||
case "preview":
|
||||
return sessions.preview(view, p, signal);
|
||||
case "connect":
|
||||
return sessions.connectUI(view, p, signal);
|
||||
case "audio":
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FrameInfo } from "./video-player.js";
|
||||
|
||||
type Capture = (args: { generation: number; after_frame_id?: number }) => Promise<Record<string, any>>;
|
||||
|
||||
// A bounded, app-only fallback for hosts that cannot reach the native video
|
||||
// socket. Decode bytes directly; no fetch, data URL, or extra network permission.
|
||||
export class PreviewPlayer {
|
||||
private stopped = false;
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
private frameId?: number;
|
||||
private count = 0;
|
||||
private presentedAt = 0;
|
||||
constructor(private canvas: HTMLCanvasElement, private generation: number,
|
||||
private capture: Capture, private onFrame: (info: FrameInfo) => void,
|
||||
private onError: (error: Error) => void) {}
|
||||
|
||||
start() { void this.poll(); }
|
||||
private async poll() {
|
||||
if (this.stopped) return;
|
||||
const started = performance.now();
|
||||
try {
|
||||
const result = await this.capture({ generation: this.generation,
|
||||
...(this.frameId === undefined ? {} : { after_frame_id: this.frameId }) });
|
||||
if (this.stopped) return;
|
||||
if (result.unchanged) return;
|
||||
const meta = result.metadata;
|
||||
if (result.mime_type !== "image/png" || typeof result.data !== "string" ||
|
||||
result.data.length > 349528 || !meta || meta.generation !== this.generation ||
|
||||
!Number.isSafeInteger(meta.frame_id) || meta.frame_id < 0 ||
|
||||
!Number.isFinite(meta.frame_age_ms) || meta.frame_age_ms < 0 ||
|
||||
![meta.source_size?.width, meta.source_size?.height].every(
|
||||
(n) => Number.isInteger(n) && n > 0 && n <= 8192))
|
||||
throw new Error("VIDEO_INVALID");
|
||||
if (meta.frame_age_ms > 2000) throw new Error("FRAME_TIMEOUT");
|
||||
if (this.frameId !== undefined && meta.frame_id <= this.frameId) return;
|
||||
const bytes = Uint8Array.from(atob(result.data), (c) => c.charCodeAt(0));
|
||||
const frame = await createImageBitmap(new Blob([bytes], { type: "image/png" }));
|
||||
try {
|
||||
if (this.stopped) return;
|
||||
if (!frame.width || !frame.height || frame.width > 960 || frame.height > 960)
|
||||
throw new Error("VIDEO_INVALID");
|
||||
this.canvas.width = frame.width;
|
||||
this.canvas.height = frame.height;
|
||||
this.canvas.getContext("2d", { alpha: false })!.drawImage(frame, 0, 0);
|
||||
this.frameId = meta.frame_id;
|
||||
this.canvas.dataset.frames = String(++this.count);
|
||||
const now = performance.now();
|
||||
const fps = this.presentedAt ? 1000 / (now - this.presentedAt) : 0;
|
||||
this.presentedAt = now;
|
||||
this.onFrame({ width: frame.width, height: frame.height, generation: meta.generation,
|
||||
sourceSize: meta.source_size, fps, frames: this.count });
|
||||
} finally { frame.close(); }
|
||||
} catch (error) {
|
||||
if (this.stopped) return;
|
||||
if (!(error instanceof Error) || !["BUSY", "FRAME_TIMEOUT"].includes(error.message)) {
|
||||
this.stop();
|
||||
this.onError(error instanceof Error ? error : new Error("VIDEO_UNAVAILABLE"));
|
||||
}
|
||||
} finally {
|
||||
if (!this.stopped)
|
||||
this.timer = setTimeout(() => void this.poll(), Math.max(0, 1000 - (performance.now() - started)));
|
||||
}
|
||||
}
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
type FrameInfo = { width: number; height: number; generation: number; fps: number; frames: number };
|
||||
export type FrameInfo = { width: number; height: number; generation: number; fps: number; frames: number;
|
||||
sourceSize?: { width: number; height: number } };
|
||||
type Cursor = { type: "cursor"; visible: boolean; x: number; y: number;
|
||||
generation: number; geometry_epoch: number; width: number; height: number };
|
||||
function parameters(data: Uint8Array) {
|
||||
@@ -48,6 +49,8 @@ export class VideoPlayer {
|
||||
private cursor?: Cursor;
|
||||
private cursorDrawn = "";
|
||||
private resize?: ResizeObserver;
|
||||
private policyListener?: (event: SecurityPolicyViolationEvent) => void;
|
||||
private socketError?: ReturnType<typeof setTimeout>;
|
||||
constructor(private canvas: HTMLCanvasElement,
|
||||
private onFrame: (info: FrameInfo) => void,
|
||||
private onError: (error: Error) => void,
|
||||
@@ -68,11 +71,26 @@ export class VideoPlayer {
|
||||
if (typeof VideoFrame === "undefined" || (!this.raw && typeof VideoDecoder === "undefined"))
|
||||
throw new Error("VIDEO_UNSUPPORTED");
|
||||
if (!/^ws:\/\/127\.0\.0\.1:\d+\/api\/video$/.test(url)) throw new Error("VIDEO_INVALID");
|
||||
const socket = this.socket = new WebSocket(url);
|
||||
this.policyListener = (event) => {
|
||||
if (event.disposition === "enforce" &&
|
||||
["connect-src", "default-src"].includes(event.effectiveDirective) &&
|
||||
[url, new URL(url).origin].includes(event.blockedURI))
|
||||
this.fail(new Error("VIDEO_POLICY_BLOCKED"));
|
||||
};
|
||||
document.addEventListener("securitypolicyviolation", this.policyListener);
|
||||
let socket: WebSocket;
|
||||
try { socket = this.socket = new WebSocket(url); }
|
||||
catch (error) {
|
||||
this.fail(new Error(error instanceof DOMException && error.name === "SecurityError"
|
||||
? "VIDEO_POLICY_BLOCKED" : "VIDEO_UNAVAILABLE"));
|
||||
return;
|
||||
}
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.onopen = () => socket.send(JSON.stringify({ ticket }));
|
||||
socket.onerror = () => this.fail(new Error("VIDEO_UNAVAILABLE"));
|
||||
socket.onclose = () => { if (!this.stopped) this.fail(new Error("VIDEO_UNAVAILABLE")); };
|
||||
// Give a queued CSP event a chance to identify the failure precisely.
|
||||
socket.onerror = socket.onclose = () => {
|
||||
if (!this.stopped) this.socketError ??= setTimeout(() => this.fail(new Error("VIDEO_UNAVAILABLE")), 0);
|
||||
};
|
||||
this.firstFrame = setTimeout(() => this.fail(new Error("VIDEO_UNAVAILABLE")), 10000);
|
||||
socket.onmessage = (event) => {
|
||||
if (this.stopped) return;
|
||||
@@ -293,6 +311,8 @@ export class VideoPlayer {
|
||||
this.resize?.disconnect();
|
||||
this.drawCursor();
|
||||
clearTimeout(this.firstFrame);
|
||||
clearTimeout(this.socketError);
|
||||
if (this.policyListener) document.removeEventListener("securitypolicyviolation", this.policyListener);
|
||||
this.socket?.close();
|
||||
if (this.decoder && this.decoder.state !== "closed") this.decoder.close();
|
||||
this.decoder = undefined;
|
||||
|
||||
+51
-20
@@ -2,7 +2,8 @@ import type { App } from "@modelcontextprotocol/ext-apps";
|
||||
import "./viewer.css";
|
||||
import { AudioPlayer } from "./audio-player.js";
|
||||
import { HumanInput } from "./human-input.js";
|
||||
import { VideoPlayer } from "./video-player.js";
|
||||
import { VideoPlayer, type FrameInfo } from "./video-player.js";
|
||||
import { PreviewPlayer } from "./preview-player.js";
|
||||
|
||||
declare const CROSSDESK_APPS: boolean;
|
||||
declare const CROSSDESK_LOGO: string;
|
||||
@@ -100,7 +101,8 @@ let unlockError = "";
|
||||
let unlockToastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let disconnectedByUser = false;
|
||||
let frameSize = "", frameRate = "";
|
||||
let player: VideoPlayer | undefined;
|
||||
let player: VideoPlayer | PreviewPlayer | undefined;
|
||||
let usePreview = false;
|
||||
let videoStarting = false, videoFailed = false, videoEpoch = 0;
|
||||
let videoSession: string | undefined;
|
||||
let videoGeneration: number | undefined;
|
||||
@@ -122,6 +124,7 @@ const messages: Record<string, string> = {
|
||||
NATIVE_INPUT_UNSUPPORTED: "请更新原生 CLI 以启用人工接管。",
|
||||
VIDEO_UNSUPPORTED: "当前浏览器不支持此视频格式,请使用支持 WebCodecs 的浏览器。",
|
||||
VIDEO_UNAVAILABLE: "视频通道暂不可用。可重新加载画面;内嵌面板受限时请在浏览器中观看。",
|
||||
VIDEO_POLICY_BLOCKED: "宿主的安全策略阻止了实时视频连接。",
|
||||
VIDEO_INVALID: "视频格式发生变化,请重新加载画面。",
|
||||
VIDEO_LIMIT: "观看面板数量已达上限,请关闭其他面板。",
|
||||
AUTH_FAILED: "访问密码错误,请重新输入。",
|
||||
@@ -390,7 +393,7 @@ function renderStatus(s: Data) {
|
||||
if (lost && !disconnectedByUser && ["ready", "waiting_frame"].includes(status.state)) showError(new Error("CONNECTION_LOST"));
|
||||
const changedSession = s.session_id !== status.session_id;
|
||||
status = s;
|
||||
element<HTMLButtonElement>("audio-play").disabled = !connected;
|
||||
element<HTMLButtonElement>("audio-play").disabled = !connected || usePreview;
|
||||
text("audio-play", audioPlayer.enabled ? "静音" : "播放声音");
|
||||
if (!connected || changedSession) audioPlayer.flush();
|
||||
humanInput.update(connected && ownsInput() ? s.human_input_epoch : undefined);
|
||||
@@ -577,6 +580,7 @@ async function closePreview() {
|
||||
videoStarting = videoFailed = false;
|
||||
element("video-retry").hidden = true;
|
||||
element("video-browser").hidden = true;
|
||||
element("panel-status").hidden = true;
|
||||
}
|
||||
async function startPreview() {
|
||||
if (player || videoStarting || videoFailed || document.hidden) return;
|
||||
@@ -586,6 +590,15 @@ async function startPreview() {
|
||||
videoStarting = true;
|
||||
const failed = (error: Error) => {
|
||||
if (epoch !== videoEpoch) return;
|
||||
if (CROSSDESK_APPS && !usePreview &&
|
||||
["VIDEO_POLICY_BLOCKED", "VIDEO_UNAVAILABLE", "VIDEO_UNSUPPORTED"].includes(error.message)) {
|
||||
player?.stop();
|
||||
player = undefined;
|
||||
usePreview = true;
|
||||
videoStarting = false;
|
||||
void startPreview();
|
||||
return;
|
||||
}
|
||||
videoFailed = true;
|
||||
showError(error);
|
||||
element("video-retry").hidden = false;
|
||||
@@ -598,25 +611,40 @@ async function startPreview() {
|
||||
text("empty-description", "会话仍然保留。请尝试重新加载画面。");
|
||||
}
|
||||
};
|
||||
const presented = (info: FrameInfo) => {
|
||||
if (epoch !== videoEpoch) return;
|
||||
humanInput.setFrame({ ...info, ...(info.sourceSize ?? {}) });
|
||||
imageAt = Date.now();
|
||||
element("frame").hidden = false;
|
||||
element("empty").hidden = true;
|
||||
element("stale").hidden = true;
|
||||
const size = `${info.width} × ${info.height}`;
|
||||
const rate = info.fps > 0 ? `${info.fps.toFixed(1)} FPS` : "测量中";
|
||||
if (size !== frameSize) { frameSize = size; text("resolution", size); }
|
||||
if (rate !== frameRate) { frameRate = rate; text("fps", rate); }
|
||||
element<HTMLButtonElement>("focus-view").disabled = false;
|
||||
text("video-state", usePreview ? "预览" : "实时");
|
||||
element("video-state").dataset.state = "live";
|
||||
};
|
||||
try {
|
||||
if (usePreview) {
|
||||
audioPlayer.flush();
|
||||
element<HTMLButtonElement>("audio-play").disabled = true;
|
||||
element("error").hidden = true;
|
||||
text("panel-status", "实时视频不可用,已切换为内嵌预览:最高每秒 1 帧,不含声音。需要流畅画面时可使用浏览器面板。");
|
||||
element("panel-status").hidden = false;
|
||||
const preview = new PreviewPlayer(element<HTMLCanvasElement>("frame"), status.generation,
|
||||
(args) => invoke("preview", args), presented, failed);
|
||||
player = preview;
|
||||
preview.start();
|
||||
return;
|
||||
}
|
||||
const grant = await invoke("video_open");
|
||||
if (epoch !== videoEpoch || document.hidden) return;
|
||||
player = new VideoPlayer(element<HTMLCanvasElement>("frame"), (info) => {
|
||||
if (epoch !== videoEpoch) return;
|
||||
humanInput.setFrame(info);
|
||||
imageAt = Date.now();
|
||||
element("frame").hidden = false;
|
||||
element("empty").hidden = true;
|
||||
element("stale").hidden = true;
|
||||
const size = `${info.width} × ${info.height}`;
|
||||
const rate = info.fps > 0 ? `${info.fps.toFixed(1)} FPS` : "测量中";
|
||||
if (size !== frameSize) { frameSize = size; text("resolution", size); }
|
||||
if (rate !== frameRate) { frameRate = rate; text("fps", rate); }
|
||||
element<HTMLButtonElement>("focus-view").disabled = false;
|
||||
text("video-state", "实时");
|
||||
element("video-state").dataset.state = "live";
|
||||
}, failed, element<HTMLCanvasElement>("agent-cursor"), (bytes) => audioPlayer.accept(bytes));
|
||||
player.start(grant.url, grant.ticket, grant.protocol, grant.decoder);
|
||||
const video = new VideoPlayer(element<HTMLCanvasElement>("frame"), presented,
|
||||
failed, element<HTMLCanvasElement>("agent-cursor"), (bytes) => audioPlayer.accept(bytes));
|
||||
player = video;
|
||||
video.start(grant.url, grant.ticket, grant.protocol, grant.decoder);
|
||||
} catch (error) {
|
||||
failed(error instanceof Error ? error : new Error("VIDEO_UNAVAILABLE"));
|
||||
} finally {
|
||||
@@ -625,6 +653,7 @@ async function startPreview() {
|
||||
}
|
||||
element("video-retry").onclick = async () => {
|
||||
await closePreview();
|
||||
usePreview = false;
|
||||
element("error").hidden = true;
|
||||
await startPreview();
|
||||
};
|
||||
@@ -708,6 +737,7 @@ async function start() {
|
||||
status: "crossdesk_ui_get_session",
|
||||
connect: "crossdesk_ui_connect",
|
||||
video_open: "crossdesk_video_open",
|
||||
preview: "crossdesk_ui_preview",
|
||||
control: "crossdesk_viewer_control",
|
||||
unlock: "crossdesk_ui_unlock",
|
||||
recovery: "crossdesk_ui_recovery",
|
||||
@@ -729,7 +759,8 @@ async function start() {
|
||||
const data =
|
||||
result.structuredContent ??
|
||||
JSON.parse((result.content?.[0] as Data)?.text ?? "{}");
|
||||
return check(data);
|
||||
check(data);
|
||||
return name === "preview" ? (result._meta?.["crossdesk/preview"] as Data) ?? data : data;
|
||||
};
|
||||
app.ontoolresult = (result) => {
|
||||
const meta = result._meta?.["crossdesk/ui"] as Data | undefined;
|
||||
|
||||
Reference in new issue
Block a user