diff --git a/client/ScreenManager.cpp b/client/ScreenManager.cpp index 4247b54..82b7dea 100644 --- a/client/ScreenManager.cpp +++ b/client/ScreenManager.cpp @@ -1726,11 +1726,15 @@ bool IsExtendedKey(WPARAM vKey) VOID CScreenManager::ProcessCommand(LPBYTE szBuffer, ULONG ulLength) { + // 记录大小判定:现代控制端(本服务端)统一发 48 字节 MSG64;28 字节 MSG32 仅为兼容 + // 老 32 位控制端。二者长度的最小公倍数是 336(=7×48=12×28),批量注入(如 MCP 远程 + // 控制的 type/拖拽)时若先判 %28,会把 7 的整数倍条 MSG64 误判成 MSG32,字段错位导致 + // 输入错乱甚至吞掉按键;故先判 %48,仅当不整除 48 才回落到 28。 int msgSize = sizeof(MSG64); - if (ulLength % 28 == 0) // 32位控制端发过来的消息 - msgSize = 28; - else if (ulLength % 48 == 0) // 64位控制端发过来的消息 + if (ulLength % 48 == 0) // 64位控制端(现代服务端)发过来的消息 msgSize = 48; + else if (ulLength % 28 == 0) // 32位控制端发过来的消息(兼容) + msgSize = 28; else return; // 数据包不合法 // 命令个数 diff --git a/docs/Mcp_RemoteControl_Design.md b/docs/Mcp_RemoteControl_Design.md new file mode 100644 index 0000000..39dd1c0 --- /dev/null +++ b/docs/Mcp_RemoteControl_Design.md @@ -0,0 +1,484 @@ +# MCP 远程控制(Remote Control)设计文档 + +> 版本:v1(设计基线) 状态:待开发 关联:`docs/Mcp_Design.md`、`docs/Mcp_Terminal_Design.md` +> 定位:本文件是「AI 远程控制一台 Windows 客户端(像人一样点鼠标、敲键盘、看画面完成任务)」功能的**唯一参照设计**,后续开发一律以本文为准,改需求先改本文。 + +--- + +## 1. 背景与目标 + +### 1.1 问题 + +现有 MCP 工具只能做「文本/命令行」层面的操作(`exec_command` 一次性命令、`terminal_*` 持久终端、`list_files`/`get_screenshot` 等只读)。很多真实任务只能在 **GUI 上完成**:点按钮、填表单、拖拽窗口、操作只有图形界面的软件。 + +AI 无法「看懂」视频流。把 25fps 的远程画面直接丢给视觉模型是行不通的——每秒 25 张、每张数十万像素,海量且绝大部分帧对决策无用。 + +### 1.2 目标 + +提供一组 MCP 工具,让 AI 以**「截图 → 观察 → 决策 → 注入单个输入动作 → 等待 → 重复」**的闭环驱动远程桌面: + +- **Observe(看)**:按需取一帧解码后的位图(JPEG),按视觉模型能力缩放到合适尺寸。 +- **Act(动)**:注入鼠标/键盘事件,复用现有远程桌面的输入注入链路。 +- **Decide(想)**:由 AI/宿主(Claude Code / 任何 MCP 客户端)在工具返回结果之上编排循环——**YAMA 只提供原语,不提供黑盒 `perform_task`**(与终端设计同一哲学)。 + +### 1.3 关键设计决策(已确认) + +| # | 决策 | 理由 | +|---|---|---| +| D1 | **截图驱动,非视频流** | 每次决策一张图,3–8s/步;视觉模型天然面向静态图 | +| D2 | **原语 + AI 编排,非黑盒任务** | 可控、可审、可中断;AI 在循环外可插判断/确认 | +| D3 | **归一化坐标(0..1)** | 彻底规避「截图缩放 vs 注入物理像素」的比例错位(最佳实践第一大坑) | +| D4 | **复用现有注入链路** | `COMMAND_SCREEN_CONTROL` + `MSG64` + 客户端 `SendInput` 已成熟 | +| D5 | **Observe 复用 `get_screenshot`** | 主连接按需抓 JPEG,无需常驻子连接,最省 | +| D6 | **独立开关 + 全程审计** | 与终端同级:`McpRemoteControl=1 && McpReadonly=0`,默认关 | + +--- + +## 2. 行业最佳实践借鉴 + +本节是「科学设计」的依据,逐条落到下文对应章节。主要参考 Anthropic 官方 Computer Use / Browser Use 最佳实践与参考实现(见文末「参考」)。 + +| 最佳实践 | 本设计落地 | +|---|---| +| 视觉模型是「规划者+眼睛」,宿主是「手」;工具调用=动作,返回=截图 | §3 循环归属:YAMA=手,AI=规划者 | +| **客户端先自行缩图**,绝不让模型 API 静默缩放(否则坐标系统性偏移) | §5:客户端按 `max_width` 缩图后 JPEG 回传,模型见到的就是声明尺寸 | +| 声明 `display_width/height` 必须 == 实际发送的图尺寸 | §7:归一化坐标,服务器侧映射物理像素,不依赖声明尺寸 | +| 动作原语集合(move/click/drag/scroll/type/key/…) | §4:`remote_mouse`/`remote_keyboard` 的动作枚举对齐 | +| 文本指令放在截图**之前**(内容顺序) | §8/宿主侧建议:由 MCP 客户端保证,本文仅记录 | +| 缩图上限:长边 ~1568px、总像素 ~1.15MP(Claude 4.6 系);推荐 1280×720 | §5.3:`max_width` 默认建议 1280,可配 | +| 输入注入 vs 高危动作:不可逆操作应人工确认(human-in-the-loop) | §9.4:审计 + 宿主确认,YAMA 不做每动作确认(防延迟) | +| 提示注入是首要风险(模型处理不可信 UI 文本) | §9.5:全审计 + 只读关才可用 + 建议宿主隔离 | +| 长会话上下文管理(滚动缓冲/压缩) | §3:宿主职责,本文提示 | + +--- + +## 3. 总体架构 + +``` +┌──────────────────── 宿主(Claude Code / MCP 客户端)────────────────────┐ +│ 系统提示(含远程控制约定) + 任务文本 │ +│ ↓ │ +│ ┌──────────────────────── 智能体循环 ─────────────────────────┐ │ +│ │ get_screenshot(max_width) ──→ 视觉模型看图 ──→ 决策 ──→ │ │ +│ │ remote_mouse/remote_keyboard(归一化坐标/文本) ──→ 等待0.5–2s │ │ +│ │ 循环,直到任务完成或模型判定需用户介入 │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└───────────────────────────────┬───────────────────────────────────────┘ + │ JSON-RPC over HTTP POST /mcp (Bearer) +┌───────────────────────────────▼───────────────────────────────────────┐ +│ YAMA 服务器(McpServer.cpp) │ +│ · get_screenshot:COMMAND_SCREEN_PREVIEW_REQ → 客户端缩图 JPEG │ +│ · remote_open:COMMAND_SCREEN_SPY → 建立屏幕子连接(控制会话) │ +│ · remote_mouse/remote_keyboard:归一化→物理像素 → MSG64 → │ +│ COMMAND_SCREEN_CONTROL(经屏幕子连接) │ +└───────────────────────────────┬───────────────────────────────────────┘ + │ 主连接(控制)+ 屏幕子连接(注入) +┌───────────────────────────────▼───────────────────────────────────────┐ +│ 客户端(ClientDll / ScreenManager / ScreenPreview) │ +│ · CaptureAndEncodePreview:抓主屏 + StretchBlt 缩图 + JPEG │ +│ · ScreenManager.ProcessCommand:解码 MSG64 → SendInput │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +**核心事实(已核实,见 §13 源码索引)**: + +- **Observe** 走**主连接**、无子连接、无需控制模式:`COMMAND_SCREEN_PREVIEW_REQ`(247) → 客户端抓主屏缩图 JPEG → `TOKEN_SCREEN_PREVIEW_RSP`(248)。现有 `get_screenshot` 已完整实现。 +- **Act(注入)必须走屏幕子连接**:`COMMAND_SCREEN_CONTROL`(20) 只能经 `COMMAND_SCREEN_SPY`(16) 建立的子连接发送(`hub.go` 注释:input events MUST go through the sub-connection)。故需要一个 `remote_open` 建立/持有该子连接。 +- 服务器端同时维护一张**原始 BGRA 32bpp DIB**(`m_BitmapData_Full`),但 MCP 的 `get_screenshot` **不使用**它——它拿的是客户端已编码的 JPEG。两者坐标空间不同,见 §7。 + +--- + +## 4. 工具集设计 + +### 4.1 工具清单 + +| 工具 | 作用 | 入参(required 标注) | 出参 | +|---|---|---|---| +| `get_screenshot`(**已有,复用**) | Observe | `id`;可选 `max_width`(64..1920) | JPEG 图片(见 §5) | +| `remote_open` | 建立控制会话(屏幕子连接) | `id`;可选 `timeout_ms` | `session_id`(32hex)、`screen_w`/`screen_h`(物理虚拟桌面像素) | +| `remote_close` | 关闭会话 | `id`、`session_id` | `closed`(bool) | +| `remote_mouse` | 注入鼠标 | `id`、`session_id`、`action`、`x`、`y`(+ 按 action 需 `x2`/`y2`/`button`/`delta`/`clicks`) | `{}`(成功即空对象) | +| `remote_keyboard` | 注入键盘 | `id`、`session_id`、`action`(+ 按 action 需 `text`/`key`/`modifiers`) | `{}` | + +> 命名对齐现有 `terminal_*`(open/exec/close),Observe 沿用 `get_screenshot` 避免重复造轮子。鼠标/键盘各一个工具、用 `action` 枚举区分具体动作——既对齐 Anthropic 的 `computer` 工具动作集,又贴合现有 `WebService::HandleMouse/HandleKey` 的「鼠标/键盘分家」实现。 + +### 4.2 `remote_mouse` 动作枚举 + +| action | 参数 | 语义 | +|---|---|---| +| `move` | `x`,`y` | 移动光标(不按键) | +| `down` | `x`,`y`,`button`(left/middle/right) | 按下 | +| `up` | `x`,`y`,`button` | 抬起 | +| `click` | `x`,`y`,`button`(默认 left)、`clicks`(1/2/3,默认 1) | 单击/双击/三击 | +| `right_click` | `x`,`y` | 右键(`click` 的便捷别名) | +| `middle_click` | `x`,`y` | 中键 | +| `drag` | `x`,`y`,`x2`,`y2`,`button`(默认 left) | 从 (x,y) 拖到 (x2,y2) | +| `scroll` | `x`,`y`,`delta`(+/- 滚动量)、`button`(可选,垂直默认) | 滚轮 | + +### 4.3 `remote_keyboard` 动作枚举 + +| action | 参数 | 语义 | +|---|---|---| +| `type` | `text` | 输入一串文本(字符级映射,非粘贴) | +| `key_down` | `key`、`modifiers`(可选) | 按下组合键(如 `Ctrl`+`C`) | +| `key_up` | `key`、`modifiers`(可选) | 抬起 | +| `key_press` | `key`、`modifiers`(可选) | 按下并抬起(最常用) | + +`key` 取值对齐 Windows 虚拟键名(`VK_*` 去掉前缀,如 `ENTER`/`TAB`/`F5`/`LEFT`)与常见修饰键 `CTRL`/`ALT`/`SHIFT`/`WIN`。`type` 文本中的字符经客户端 `VkKeyScan`/Unicode 注入映射为键盘事件(非剪贴板粘贴,保证焦点内输入)。 + +### 4.4 可选/扩展工具(`remote_clipboard`、`remote_focus_window` 为 §17 代聊实验所需,建议随阶段一落地) + +- `remote_clipboard`:写远程剪贴板(UTF-8→Unicode),非 ASCII 文本输入的关键路径;复用 `COMMAND_C2C_TEXT`(90) 的 UTF-8 逻辑或新增 UTF-8 设剪贴板命令(§6.4)。 +- `remote_focus_window`:`COMMAND_SCREEN_WINDOW`(153) / 客户端 `SetForegroundWindow`,把焦点给到指定窗口(配合 `list_windows` 的 `hwnd`);代聊第一步。 +- `remote_cursor_position`:读当前光标(`TOKEN_NEXTSCREEN` 帧头已带 `POINT cursor`)。 +- `remote_screenshot`(会话内/窗口抓屏):从子连接 DIB 服务端缩图,或按窗口抓取(`COMMAND_SCREEN_WINDOW`),供高分辨率读聊天文字与多显示器场景(§12 阶段二、§17)。 + +--- + +## 5. 屏幕捕获与编码(Observe) + +### 5.1 现有链路(复用) + +`get_screenshot`(`McpServer.cpp` `BuildGetScreenshot`,~1597)已实现: + +1. `ParseHostIdArg` → `FindMainContext` → 能力门 `ctx->SupportsScreenPreview()`(`CLIENT_CAP_SCREEN_PREVIEW`,不支持 → `-32005`)。 +2. `BeginPending(devId,"get_screenshot")` 设备级互斥(忙 → `-32003`)。 +3. `reqId = NextPreviewReqId()`;`SetPendingReqId(devId, reqId)`(stale-drop 机制)。 +4. `parent->ChooseScreenPreviewParams(...)` 按 RTT/FRP 自适应缩图档位 + `max_width` 覆盖(clamp 64..1920)。 +5. `SendScreenPreviewRequest` → `COMMAND_SCREEN_PREVIEW_REQ`。 +6. `WaitPending` 收 `[ScreenPreviewRspHeader][JPEG]`,校验 `status==OK && format==JPEG`。 +7. base64 JPEG 返回:`structuredContent.image = {mimeType:"image/jpeg", width, height, bytes}`,`content=[{type:"image", data:, mimeType:"image/jpeg"}]`。 + +客户端 `CaptureAndEncodePreview`(`client/ScreenPreview.cpp:107`)抓**主屏** + `StretchBlt`(HALFTONE) 缩图 + GDI+ `Bitmap::Save` JPEG——**位图永不离开客户端,只回传 JPEG**。 + +### 5.2 缩放策略(对齐最佳实践 D5) + +- 缩图在**客户端**完成(`max_width` 长边约束),服务器/MCP 宿主**不再二次缩放**,模型看到的就是声明尺寸——避免「模型 API 静默缩放导致坐标漂移」这个头号坑。 +- `max_width` 由宿主按视觉模型能力显式传入;YAMA 提供**默认建议 1280**(长边),上限维持 1920。 + +### 5.3 推荐参数 + +| 项 | 建议值 | 说明 | +|---|---|---| +| `max_width`(长边) | 1280(默认)/ 1568(上限) | 1280×720 ≈ 80% 像素预算、训练常见分辨率 | +| 编码 | JPEG,quality ~70–85 | 客户端 `jpegQuality` 已有,按 `ChooseScreenPreviewParams` 自适应 | +| 返回 | base64 `image/jpeg` | 已实现 | + +--- + +## 6. 输入注入(Act) + +### 6.1 协议(复用,已核实) + +- **包格式**:`[cmd:1][MSG64:48]`,可批量重复(`commands.h` `COMMAND_SCREEN_CONTROL=20`,`MSG64` 1581–1623)。 +- **MSG64 布局**:`{uint64 hwnd, message, wParam, lParam, time; POINT pt;}` = 48 字节(`_WIN64` 下 `MYMSG` 即 `MSG`;客户端亦接受 28 字节 `MSG32`,由 `ulLength % 28/48` 判定)。 +- **客户端解码**:`ScreenManager.cpp:1175` → `ProcessCommand(1727)` → `SendInput`: + - 鼠标:坐标取 `LOWORD/HIWORD(lParam)`,`WM_MOUSEMOVE`→`MOUSEEVENTF_MOVE`,`WM_LBUTTONDOWN/UP`→`LEFTDOWN/LEFTUP`,`WM_LBUTTONDBLCLK`→额外 `LEFTDOWN`,`WM_MBUTTONDOWN/UP`,`WM_MOUSEWHEEL`→`MOUSEEVENTF_WHEEL`(`mouseData=GET_WHEEL_DELTA_WPARAM`)。 + - 键盘:`wVk=(WORD)wParam`,`wScan=(lParam>>16)&0xFF`,`KEYEVENTF_EXTENDEDKEY` 由 `(lParam>>24)&1`。 + +### 6.2 服务端可复用实现 + +`WebService::HandleMouse`(`WebService.cpp:773-865`)与 `HandleKey`(`867-952`)已从 JSON(`type/x/y/button/delta`;`keyCode/down/altKey`)构造 `MSG64` 并 `[COMMAND_SCREEN_CONTROL][MSG64]` 发送。其中 `HandleKey` 已正确处理 `lParam`:repeat=1、`MapVirtualKey` 扫瞄码、扩展键表、Alt 上下文位 29、keyup 位 30–31。 + +**结论**:`remote_mouse`/`remote_keyboard` 是这两个函数的 MCP 薄封装——先归一化→物理像素,再套用其 `MSG64` 构造逻辑。**不要新写注入协议**。 + +### 6.3 子连接前置条件(关键约束) + +注入**必须**经屏幕子连接: + +1. 主连接发 `COMMAND_SCREEN_SPY`(16)(载荷 `{cmd, USING_DXGI|2, ALGORITHM_*, MultiScreen}`,见 `2015RemoteDlg.cpp:8443-8470`)→ 客户端开子连接(`ClientDll.cpp:89-94`)。 +2. 客户端回 `TOKEN_BITMAPINFO`(含物理 `BITMAPINFOHEADER`:`biWidth/biHeight`)——**这是归一化坐标映射所需的物理分辨率来源**。 +3. 服务器发 `COMMAND_NEXT`(30) 开始流/允许控制。 +4. `GetScreenContext(device_id)`(`WebService.cpp:2084`)从 `m_ScreenContexts` 取该子连接上下文。 + +> 实现提示:上述 1–4 的建立/持有逻辑**耦合在 `CScreenSpyDlg`(隐藏对话框)里**——子连接、帧 DIB、`TOKEN_BITMAPINFO` 处理都由它完成。MCP 的 `remote_open` 应复用 `WebService::StartRemoteDesktop`(1725) 的「隐藏对话框」路径,而非另造无对话框持有者(§13.2)。 + +`remote_open` 即上述 1–4 的封装;`screen_w/screen_h` 取自 `TOKEN_BITMAPINFO`。 + +### 6.4 文本输入:ASCII 直注 vs 非 ASCII 走剪贴板 + +`remote_keyboard` 的 `type` 走物理键事件(`wVk`/`wScan` → `SendInput`),只能可靠注入 ASCII/ANSI 字符——**中文等非 ASCII 文本无法靠模拟键盘键入**(需 IME 组合,键事件层面做不到)。 + +非 ASCII 文本的可靠路径是**剪贴板 + 粘贴**: + +- 服务器设远程剪贴板命令 `COMMAND_SCREEN_SET_CLIPBOARD`(25),包 `[cmd:1][text:N]`,经屏幕子连接发送;客户端 `UpdateClientClipboard`(`ScreenManager.cpp:1223/1599`)落到剪贴板。 +- 随后 `remote_keyboard` 注入 `Ctrl+V` 粘贴、`Enter` 发送。 +- **编码注意(已核实)**:`UpdateClientClipboard` 用 `SetClipboardData(CF_TEXT,...)`——ANSI/GBK 格式(`CF_TEXT`,非 `CF_UNICODETEXT`)。MVP 下中文可经 `ToAnsi(utf8, 936)` 转 GBK 后直发(与终端命令同模式);但 emoji/非 GBK 字符会丢、部分新式聊天软件粘贴偏好 Unicode。更稳做法是复用 `COMMAND_C2C_TEXT`(90) 的 UTF-8→Unicode→`CF_UNICODETEXT` 逻辑(`KernelManager.cpp:1573-1587`,且已内置「设完剪贴板后自动模拟 Ctrl+V」),或新增一个 UTF-8 设剪贴板变体。见 §17 实验案例。 + +--- + +## 7. 坐标约定与映射 + +### 7.1 归一化坐标(D3) + +- AI 在**所有鼠标动作**里输出 **0..1 浮点**:`x = 目标横向比例,y = 目标纵向比例`,相对**它看到的那张截图**。 +- 服务器侧映射:`phys_x = round(norm_x * screen_w)`、`phys_y = round(norm_y * screen_h)`,其中 `screen_w/h` 是 `remote_open` 时从 `TOKEN_BITMAPINFO` 得到的**物理捕获分辨率**。 +- 好处:无论 `max_width` 怎么缩、显示器多大,AI 永远只说「在图的 40%/60% 处点一下」,比例错位风险为零。 + +### 7.2 坐标空间一致性(最重要正确性点) + +Observe(`get_screenshot` 抓**主屏**)与 Act(`SendInput` 绝对坐标在**虚拟桌面**空间)默认不同源。**阶段一只支持单显示器**:主屏 == 虚拟桌面,两者重合,映射严格正确。 + +多显示器(`MultiScreen`)在**阶段二**处理:届时 Observe 必须改抓**整个虚拟桌面**(而非主屏),`screen_w/h` 用虚拟桌面尺寸,归一化坐标才继续成立。**在阶段二落地前,`remote_open` 对多显示器配置应返回显式错误或降级为主屏**(见 §12)。 + +### 7.3 边界与钳制 + +- 归一化值越界(`<0` 或 `>1`)→ 钳制到 `[0,1]` 或返回 `-32602`(建议钳制,模型偶发 `1.0001`)。 +- `phys_x/y` 不得越过 `screen_w/h`,注入前 `clamp(0, screen_w-1)`。 + +--- + +## 8. 控制会话状态机 + +### 8.1 生命周期 + +``` +remote_open ──► 建立子连接 ──► [remote_mouse/remote_keyboard × N + get_screenshot × M] + │ │ + └──(busy 互斥 / idle 回收 / 断线清理)──────────┴──► remote_close(幂等) +``` + +### 8.2 状态与不变量(镜像 `docs/Mcp_Terminal_Design.md` §并发) + +- **单设备单控制会话**:`remote_open` 对已存在会话 → `-32003`(忙)。 +- **互斥**:同一设备上,MCP 远程控制会话与**人类远程桌面观看**(`m_ScreenContexts` 已被占用)互斥——`remote_open` 发现已有屏幕子连接 → `-32003`,避免 AI 向人类正在观看/控制的画面注入。`remote_mouse/keyboard` 要求 `session_id` 匹配且会话未关闭。 +- **busy 标志**:一条注入在飞时 `busy=true`,保证等待线程是唯一擦除者(沿用终端的 `m_TermMutex`+`busy` 模式;远程控制建议独立 `m_ScreenCtrlMutex`/`m_ScreenCtrlCv`)。 +- **idle 回收**:会话闲置超时(默认 300s)自动关闭并 `CancelIO`(镜像 `SweepIdleTerminals`)。 +- **断线清理**:`OfflineProc`/`OnTerminalClosed` 同位置加 `McpServer().OnScreenControlClosed(subCtx)`,防悬空 `subCtx` 被连接池复用(复刻终端设计里发现的第 2 个修复点)。 +- **锁外 `CancelIO`**:所有取消动作在锁外执行(终端的 P2 教训)。 + +### 8.3 并发(P5 教训) + +`httplib` 多线程并发处理请求;`remote_mouse` 会高频调用。会话状态一律 `m_ScreenCtrlMutex` 串行化,**不要假定请求串行**。高频注入可加**节流**(沿用 `SendScaledMouseMessage` 的鼠标移动节流思路,`ScreenSpyDlg.cpp:2728-2743`)。 + +--- + +## 9. 安全模型 + +延续既有「可控、可关、可审、可隔离」原则(与终端同级): + +### 9.1 开关与门控 + +- 新增 `McpRemoteControl`(默认 **0=关**)。 +- `remote_*` 生效条件:`McpRemoteControl=1 && McpReadonly=0`,否则 `-32006`。 +- `get_screenshot` 是只读工具,不受此门控,仍仅受现有能力门(供纯观察场景)。 + +### 9.2 全程审计 + +每条 `remote_open/close/mouse/keyboard` 调用 → `PostMessageA(WM_SHOWERRORMSG, ...)` → `m_MessageLog` → `get_audit_log` 可查(复用 `McpServer.cpp:2050` 的审计模式,标题 `MCP远程控制`,标记「不可关闭」)。审计内容含:设备 id、动作、归一化坐标与换算后的物理坐标、session_id、时间戳。 + +### 9.3 会话级鉴权 + +- `session_id` 由 `GenerateRandomToken()`(32hex),每次 `remote_open` 独立随机,防串用(镜像终端 P6)。 +- 注入工具校验 `session_id` 归属与设备匹配,不匹配 → `-32002`。 + +### 9.4 不可逆操作与人工确认 + +- YAMA **不做逐动作确认**(会引入每步 0.5s+ 延迟,破坏闭环节奏)。 +- 高危动作(提交表单、删除、付款、改系统设置)的确认由**宿主/AI 编排层**负责(Claude Code 的权限系统 / 系统提示约定「不可逆操作前暂停询问用户」)。本文记录此分工;YAMA 提供审计留痕兜底。 + +### 9.5 提示注入(Prompt Injection) + +视觉模型会读到客户端屏幕上的**不可信 UI 文本**,存在注入风险。缓解:远程控制本身即高危能力(默认关 + 只读关才开 + 全审计);建议宿主将远程控制会话隔离在独立权限域,并对「屏幕文本里的指令」保持怀疑。YAMA 侧不做内容分类(无意义且加延迟),记录即可。 + +--- + +## 10. 错误码 + +沿用现有约定(`BuildError(id, code, msg)`): + +| code | 含义 | 触发 | +|---|---|---| +| -32602 | 参数非法 | action 未知、必填参数缺失、`max_width` 越界、归一化值格式错 | +| -32000 | 通用失败 | 未分类错误 | +| -32001 | 超时 | `remote_open` 等子连接/`get_screenshot` 等帧超时 | +| -32002 | 设备/会话不存在 | `id` 无在线主机、`session_id` 不存在或不匹配 | +| -32003 | 设备/会话忙 | 已有控制会话、人类远程桌面占用、`get_screenshot` 在飞 | +| -32004 | 发送失败 | 注入命令发送失败 | +| -32005 | 能力不支持 | 客户端无 `CLIENT_CAP_SCREEN_PREVIEW`(Observe)/ 不支持屏幕控制(Act) | +| -32006 | 被开关禁用 | `McpRemoteControl=0` 或 `McpReadonly=1` | +| -32008 | 非法/被拒参数 | 多显示器未支持时的 `remote_open` 降级拒绝(阶段一) | + +--- + +## 11. 配置项 + +新增 1 项(`THIS_CFG` `settings` 节,键名沿用蛇形): + +| 键 | 默认 | 说明 | +|---|---|---| +| `McpRemoteControl` | `0` | 远程控制开关;要求 `McpReadonly=0` 才生效 | + +其余沿用:`McpEnabled`/`McpPort`/`McpBind`/`McpToken`/`McpReadonly`/`McpTerminal`/`McpCmdWhitelist`。改动需重启生效(与终端一致)。 + +--- + +## 12. 已知限制与后续扩展 + +### 12.1 阶段一(本期) + +- **单显示器**:Observe 抓主屏,坐标空间与虚拟桌面重合;多显示器 `remote_open` 显式报错(`-32008`)。 +- 注入基于**绝对坐标** `SendInput`,要求客户端处于可注入的桌面会话(已登录、非锁屏/非 UAC 安全桌面);锁屏/UAC 提示场景注入无效(客户端 `SendInput` 限制),设计文档记录为已知限制。 +- **代聊实验使能项**:窗口抓屏(`COMMAND_SCREEN_WINDOW`)、UTF-8 剪贴板、窗口聚焦随阶段一落地(§17)。 + +### 12.2 阶段二(后续) + +1. **多显示器虚拟桌面捕获**:`get_screenshot` 增加虚拟桌面抓屏,或新增 `remote_screenshot` 从子连接 DIB 服务端缩图(复用 `BmpToJpeg`/`CacheThumbnail` GDI+ 路径),使 Observe/Act 同源。 +2. **窗口定向**:`remote_focus_window`(复用 `COMMAND_SCREEN_WINDOW` + 客户端 `SetForegroundWindow`)。 +3. **光标读取 / 剪贴板**。 +4. **服务端缩图**:从 `m_BitmapData_Full`(BGRA DIB)直接按 API 上限缩图编码,进一步省客户端往返。 + +--- + +## 13. 改动清单(供实现参照) + +> 行号以当前树为准(实现前请 grep 复核)。新增/改动尽量「复用」而非「重写」。 + +### 13.1 `server/2015Remote/McpServer.h` + +- 新增 `struct ScreenCtrlSession`(仿 `TermSession`):`sessionId`、`deviceId`、`busy`、`started`、`closed`、`lastActiveAt`、`screenW/screenH`(来自 `TOKEN_BITMAPINFO`)、`subCtx` 引用。 +- 新增方法:`SetRemoteControlEnabled/IsRemoteControlEnabled`、`BeginScreenCtrlOpen/…/CloseScreenCtrlSession/SweepIdleScreenCtrl`、`OnScreenControlClosed(context*)`、`ResolveScreenCtrlSessionHost`。 +- 新增成员:`bool m_remoteControlEnabled=false;` 及 `std::mutex m_ScreenCtrlMutex; std::condition_variable m_ScreenCtrlCv; std::map m_ScreenCtrlContextToDevice; std::map m_ScreenCtrlSessions;`(或复用 WebService 的 `m_ScreenContexts` 生命周期,见下)。 + +### 13.2 `server/2015Remote/McpServer.cpp` + +- 新增 4 个 schema 构建器 + 3 个 handler:`BuildRemoteOpen/Close/Mouse/Keyboard`(镜像 `BuildTerminalOpen` 等,`BuildToolsListResult` ~1278 后加条目,`BuildToolsCall` ~2124 加分派)。 +- `remote_open`:`SweepIdleScreenCtrl` → `ResolveScreenCtrlSessionHost` → 已有会话 `-32003` → `GenerateRandomToken` → **复用 Web 远程桌面的会话建立路径 `WebService::StartRemoteDesktop`(1725)**(发 `COMMAND_SCREEN_SPY` → 以 `SW_HIDE` 打开隐藏 `CScreenSpyDlg` 持有子连接 → 客户端子连接到达 → `RegisterScreenContext` 记入 `m_ScreenContexts`)→ 等 `TOKEN_BITMAPINFO` 取 `screenW/H` → `COMMAND_NEXT` → 审计 → 返回 `{session_id, screen_w, screen_h}`。**注意:屏幕子连接/帧 DIB/`TOKEN_BITMAPINFO` 处理都与 `CScreenSpyDlg` 耦合,不要另造无对话框持有者——沿 Web 的隐藏对话框路径是低风险正解。** +- `remote_mouse/keyboard`:`ResolveScreenCtrlSessionHost` → 校验 `session_id`/`busy` → 归一化→物理像素(`round(norm * screenW/H)` + clamp)→ **复用 `WebService::HandleMouse/HandleKey` 的 `MSG64` 构造**(或抽取成共享 helper `BuildMouseMsg64/BuildKeyMsg64`,避免 MCP 与 Web 两处重复)→ `subCtx->Send2Client([COMMAND_SCREEN_CONTROL][MSG64])` → 审计。 +- `remote_close`:幂等 `CloseScreenCtrlSession`(不存在 → `{closed:true}`;sid 不匹配 → `-32002`)→ 锁外 `CancelIO` → 审计。 +- `OnScreenControlClosed`:镜像终端修复点——空闲会话直接擦路由+会话,busy 会话唤醒等待线程由其清理。 +- `SweepIdleScreenCtrl`:镜像 `SweepIdleTerminals`(`difftime` 防时钟回拨)。 + +### 13.3 `server/2015Remote/McpSettingsDlg.h/.cpp` + +- 新增复选框 `IDC_MCP_REMOTECONTROL = 1008`,文案 `_TR("启用远程控制(AI 操控桌面)")`;回填/落盘 `McpRemoteControl`;对话框高度 +30。 + +### 13.4 `server/2015Remote/2015RemoteDlg.cpp` + +- 启动读配置处加 `McpServer().SetRemoteControlEnabled(THIS_CFG.GetInt("settings","McpRemoteControl",0)!=0);`。 +- `OfflineProc` 加 `if (McpServer().IsRunning() && McpServer().IsScreenCtrlContext(ContextObject)) McpServer().OnScreenControlClosed(ContextObject);`(复刻终端修复点)。 + +### 13.5 语言文件 + +- 沿用终端约定:MCP 相关文案本就在 `lang/*.ini` 无条目(`_TR` 对未命中键原样返回中文)。若日后翻译,用 **GBK 工具**改 `lang/{en_US,zh_TW}.ini`(ANSI/GBK,**不能用 Write/Edit 直接写**)。 + +--- + +## 14. 关键点(实现时务必遵守) + +- **P1(坐标一致性,最重要)**:归一化坐标映射所用 `screenW/H` 必须与**当前截图来源的分辨率**一致。阶段一单屏下 `TOKEN_BITMAPINFO` 的主屏尺寸 == 截图主屏物理尺寸,才成立;任何「用虚拟桌面尺寸映射主屏截图」都会造成系统性偏移(最佳实践第一大坑)。 +- **P2(注入走子连接)**:`COMMAND_SCREEN_CONTROL` **必须**经 `GetScreenContext` 取得的子连接发送,主连接无效。 +- **P3(锁外 CancelIO)**:所有 `CancelIO` 在 `m_ScreenCtrlMutex` 之外(终端 P2 教训)。 +- **P4(复用勿重写)**:`MSG64` 构造逻辑复用 `WebService::HandleMouse/HandleKey`;理想做法抽共享 helper,否则未来两处漂移。 +- **P5(并发串行化)**:`remote_mouse` 高频并发,状态全部走锁;勿假定请求串行(终端 P5 教训)。 +- **P6(session_id 独立随机 + 互斥)**:会话 token 用 `GenerateRandomToken`;单设备单会话;与人类远程桌面观看互斥(`-32003`)。 +- **P7(非黑盒)**:不实现 `perform_task`;循环归宿主/AI,YAMA 只出原语(D2)。 + +--- + +## 15. 验证(端到端,127.0.0.1:6544,Bearer `a2176213eddcf0acfdb285940ec4eb2a`) + +前置:设置开 `McpEnabled=1`、`McpReadonly=0`、`McpRemoteControl=1`;目标 Windows 客户端在线、已登录桌面。`id` 取 `list_online_hosts` 的十进制主机 id。 + +```bash +B='Authorization: Bearer a2176213eddcf0acfdb285940ec4eb2a' +U=http://127.0.0.1:6544/mcp + +# 1) tools/list 应出现 remote_open/close/mouse/keyboard 四个工具 +curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' + +# 2) 打开会话 → session_id + screen_w/h +curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"remote_open","arguments":{"id":""}}}' + +# 3) Observe:截图(复用) +curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_screenshot","arguments":{"id":"","max_width":1280}}}' + +# 4) Act:移动 + 单击(归一化坐标,0.5,0.5 = 屏幕中心) +curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"remote_mouse","arguments":{"id":"","session_id":"","action":"move","x":0.5,"y":0.5}}}' +curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"remote_mouse","arguments":{"id":"","session_id":"","action":"click","x":0.5,"y":0.5,"button":"left"}}}' + +# 5) 键盘:按下并抬起 Ctrl+Esc 或输入文本 +curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"remote_keyboard","arguments":{"id":"","session_id":"","action":"key_press","key":"WIN"}}}' + +# 6) 负例:未开会话/错误 sid → -32002;已开会话再 open → -32003;只读/开关关 → -32006 +# 7) 关闭 + 幂等关闭 +curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"remote_close","arguments":{"id":"","session_id":""}}}' + +# 8) 审计链 +curl -s $U -H "$B" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"get_audit_log","arguments":{}}}' +``` + +人工核对:注入后目标机光标/焦点确实变化;审计列表出现标题「MCP远程控制」的 open/close/mouse/keyboard 条目;闲置 >300s 后旧 `session_id` 调用 → `-32002`。编译由你以 VS2019(MSBuild v143)完成;我侧仅代码审查(工具集 v142,无法在此构建)。 + +--- + +## 16. 涉及文件 + +- `server/2015Remote/McpServer.h` / `.cpp`(会话状态机 + schema + handler + 分派) +- `server/2015Remote/McpSettingsDlg.h` / `.cpp`(新开关) +- `server/2015Remote/2015RemoteDlg.cpp`(启动读配置 + `OfflineProc` 清理钩子) +- `common/commands.h`(**只读参照**:`COMMAND_SCREEN_SPY`/`COMMAND_SCREEN_CONTROL`/`MSG64`/`COMMAND_SCREEN_PREVIEW_REQ` 等,无需改) +- `server/2015Remote/WebService.cpp`(**复用参照**:`HandleMouse`/`HandleKey`/`GetScreenContext`) +- `lang/{en_US,zh_TW}.ini`(可选,GBK 工具改) + +--- + +## 17. 实验案例:AI 代聊(chat-on-behalf) + +> 目标:以「AI 通过远程桌面,在聊天软件里代表你与朋友聊天」为第一个实验用例,验证整条「看 → 想 → 动 → 确认」链路。低风险、目标清晰、信号明确(有新消息 / 已发出)。 + +### 17.1 闭环 + +1. `list_windows` 找到聊天窗口(`hwnd`/`title`)。 +2. `remote_focus_window`(或 `remote_mouse` 点击输入框)聚焦聊天窗。 +3. 抓**聊天窗口**高分辨率截图 → 视觉模型读出朋友最新消息。 +4. 模型生成回复。 +5. `remote_clipboard` 把回复写入远程剪贴板(UTF-8)。 +6. `remote_keyboard` 注入 `Ctrl+V`(粘贴)+ `Enter`(发送)。 +7. 再截图确认已发出,等待下一条。 + +### 17.2 两个已验证的关键依赖 + +- **中文输入(走剪贴板,已解决)**:见 §6.4。`COMMAND_SCREEN_SET_CLIPBOARD`(25) 为 ANSI/GBK(`CF_TEXT`),中文可经 `ToAnsi(utf8, 936)` 直发跑通 MVP;要支持 emoji/完整 Unicode,复用 `COMMAND_C2C_TEXT`(90) 的 UTF-8→`CF_UNICODETEXT` 逻辑或新增 UTF-8 变体。客户端 `KernelManager.cpp:1590` 已内置「设剪贴板后自动模拟 Ctrl+V」。 +- **读消息(需窗口抓屏)**:`get_screenshot` 抓整屏,缩到 1280 后聊天文字过小、OCR 易错。用 `COMMAND_SCREEN_WINDOW`(153) 按窗口抓取聊天窗(更高有效分辨率),或对该区域用更高 `max_width`。 + +### 17.3 使能清单(相对现有代码的增量) + +| 能力 | 状态 | 复用点 | +|---|---|---| +| `get_screenshot` / `list_windows` | ✅ 已有 | — | +| 剪贴板写入 | ⚠️ 客户端已有(GBK 直发可跑通),缺 MCP 封装 | `COMMAND_SCREEN_SET_CLIPBOARD`(25) / `COMMAND_C2C_TEXT`(90) | +| `remote_open` / `remote_close`(屏幕子连接) | ❌ 待建 | `COMMAND_SCREEN_SPY`(16) 建立逻辑 `2015RemoteDlg.cpp:8443` | +| `remote_mouse` / `remote_keyboard` | ❌ 待建 | `WebService::HandleMouse/HandleKey` | +| `remote_focus_window` | ❌ 待建 | `COMMAND_SCREEN_WINDOW`(153) | +| `remote_clipboard` | ❌ 待建 | 见 §6.4 | + +### 17.4 最小 MVP 实施顺序(按依赖) + +1. `remote_open` / `remote_close`(屏幕子连接 + `TOKEN_BITMAPINFO` 取分辨率)。 +2. `remote_keyboard`(先只做 `key_press`/`type` + 修饰键,够发 `Ctrl+V`/`Enter`)。 +3. `remote_mouse`(`click`/`move`,用于点输入框;聚焦可用点击替代)。 +4. `remote_clipboard`(先 GBK 直发跑通,再上 UTF-8 变体)。 +5. `remote_focus_window`(或用步骤 3 的点击替代,二选一即可跑通)。 + +> 1→2→3 已足以发出一条消息(点输入框 + 粘贴 + 回车);4 是中文关键路径;5 是体验优化,可后置。 + +### 17.5 实验注意事项 + +- 目标机必须**已登录且未锁屏**(锁屏/UAC 安全桌面下抓屏与注入均失效,§12.1)。 +- 「代聊」会让朋友误以为是真人在聊——实验前建议告知对方;避免让 AI 在承诺/隐私等话术上自由发挥(§9.4 的人工确认原则同样适用)。 +- 新消息检测先用「轮询截图」即可(MVP 接受),后续用未读角标/窗口通知优化(§17.6)。 + +### 17.6 后续优化(非 MVP) + +- 新消息触发:从「定时截图」改为「未读角标/窗口通知驱动」,大幅省 token。 +- 窗口定向抓屏(`COMMAND_SCREEN_WINDOW`)并入 `remote_screenshot`。 +- 会话历史:AI 保留最近几轮聊天上下文,避免回复脱节(宿主侧滚动缓冲,§3)。 + +--- + +## 参考(行业最佳实践) + +- Anthropic《Best practices for computer and browser use with Claude》— https://claude.com/fr/blog/best-practices-for-computer-and-browser-use-with-claude +- Anthropic 参考实现 `computer.py`(动作枚举、缩放、坐标换算)— https://github.com/anthropics/claude-quickstarts/blob/main/computer-use-demo/computer_use_demo/tools/computer.py +- 《How Claude Computer Use Works: Architecture Internals》— https://callsphere.ai/blog/how-claude-computer-use-works-architecture-internals diff --git a/docs/Mcp_Terminal_Design.md b/docs/Mcp_Terminal_Design.md index e73c744..c6be08e 100644 --- a/docs/Mcp_Terminal_Design.md +++ b/docs/Mcp_Terminal_Design.md @@ -451,7 +451,7 @@ McpServer().SetTerminalEnabled(THIS_CFG.GetInt("settings", "McpTerminal", 0) != ### 10.6 中文/乱码 - 输出乱码:检查 `isPty` 与 `cp` 是否匹配(PTY=UTF-8,老管道=GBK)。 -- **`get_audit_log` 中文乱码**:已知问题(GBK 写入 UTF-8 JSON),影响**所有**审计条目,非终端特有,暂未修复。 +- **`get_audit_log` 中文正确**:`BuildGetAuditLog` 用 `ToUtf8(type/time/msg, 936)` 把 GBK 转 UTF-8,jsoncpp 输出 `\uXXXX` 转义,客户端解析后中文正常(已实测 `MCP持久终端`/`MCP命令执行`/`操作成功`/`主机上线` 等均无乱码)。 ### 10.7 会话泄漏 / 子链接不关 diff --git a/server/2015Remote/2015RemoteDlg.cpp b/server/2015Remote/2015RemoteDlg.cpp index 2ce018c..c85cc56 100644 --- a/server/2015Remote/2015RemoteDlg.cpp +++ b/server/2015Remote/2015RemoteDlg.cpp @@ -2195,6 +2195,8 @@ BOOL CMy2015RemoteDlg::OnInitDialog() McpServer().SetCmdWhitelist(THIS_CFG.GetStr("settings", "McpCmdWhitelist", "")); // 持久终端开关:默认关;要求只读关(McpReadonly=0)才生效(工具列表/分派双重门控)。 McpServer().SetTerminalEnabled(THIS_CFG.GetInt("settings", "McpTerminal", 0) != 0); + // 远程控制开关:默认关;要求只读关(McpReadonly=0)才生效(工具列表/分派双重门控)。 + McpServer().SetRemoteControlEnabled(THIS_CFG.GetInt("settings", "McpRemoteControl", 0) != 0); if (!McpServer().Start(mcpBind, mcpPort)) { Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort); } else { @@ -5166,6 +5168,11 @@ BOOL CALLBACK CMy2015RemoteDlg::OfflineProc(CONTEXT_OBJECT* ContextObject) McpServer().OnTerminalClosed(ContextObject); } + // MCP 远程控制的屏幕子连接断开:同步清理会话,避免悬空 subCtx 被连接池复用误路由。 + if (McpServer().IsRunning() && McpServer().IsScreenCtrlContext(ContextObject)) { + McpServer().OnScreenControlClosed(ContextObject); + } + SOCKET nSocket = ContextObject->sClientSocket; CDialogBase* p = (CDialogBase*)ContextObject->hDlg; diff --git a/server/2015Remote/McpServer.cpp b/server/2015Remote/McpServer.cpp index 601eef7..3000277 100644 --- a/server/2015Remote/McpServer.cpp +++ b/server/2015Remote/McpServer.cpp @@ -4,6 +4,8 @@ #include "HostJson.h" // BuildHostJson(单台主机序列化公共函数) #include "context.h" // context 接口 #include "2015RemoteDlg.h" // CMy2015RemoteDlg 成员(m_HostList/m_cs/m_ClientMap)+ VERSION_STR +#include "WebService.h" // 远程控制复用屏幕子连接(StartRemoteDesktop / GetScreenContext / GetScreenSize) +#include "Server.h" // CONTEXT_OBJECT 定义(GetScreenContext 返回 CONTEXT_OBJECT* → context* 上转型) #include "LangManager.h" // _TR(审计日志标题语言映射) #include @@ -1125,6 +1127,23 @@ std::string BuildTerminalOpen(const Json::Value& id, const Json::Value& args, CM std::string BuildTerminalExec(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); std::string BuildTerminalClose(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); +// ===== P5 前置声明(定义见下方「tools/call 分派」前)===== +Json::Value BuildRemoteOpenInputSchema(); +Json::Value BuildRemoteOpenOutputSchema(); +Json::Value BuildRemoteCloseInputSchema(); +Json::Value BuildRemoteCloseOutputSchema(); +Json::Value BuildRemoteKeyboardInputSchema(); +Json::Value BuildRemoteKeyboardOutputSchema(); +Json::Value BuildRemoteMouseInputSchema(); +Json::Value BuildRemoteMouseOutputSchema(); +Json::Value BuildRemoteClipboardInputSchema(); +Json::Value BuildRemoteClipboardOutputSchema(); +std::string BuildRemoteOpen(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); +std::string BuildRemoteClose(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); +std::string BuildRemoteKeyboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); +std::string BuildRemoteMouse(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); +std::string BuildRemoteClipboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); + // tools/list std::string BuildToolsListResult(const Json::Value& id) { Json::Value result(Json::objectValue); @@ -1328,6 +1347,51 @@ std::string BuildToolsListResult(const Json::Value& id) { } } + // 15) remote_open / remote_close(P5:MCP 远程控制,仅 Windows, + // 安全门:McpRemoteControl=1 且 McpReadonly=0;全程审计 + idle 回收) + if (CMcpServer::Instance().IsRemoteControlEnabled() && !CMcpServer::Instance().IsReadonly()) { + { + Json::Value tool(Json::objectValue); + tool["name"] = "remote_open"; + tool["description"] = u8"在指定在线 Windows 主机上建立远程控制会话(隐藏屏幕子连接)并返回 session_id 与物理屏幕分辨率(screen_w/screen_h)。后续 remote_mouse/remote_keyboard 用归一化坐标(0..1)注入输入,用毕须 remote_close。"; + tool["inputSchema"] = BuildRemoteOpenInputSchema(); + tool["outputSchema"] = BuildRemoteOpenOutputSchema(); + tools.append(tool); + } + { + Json::Value tool(Json::objectValue); + tool["name"] = "remote_close"; + tool["description"] = u8"关闭并释放指定远程控制会话(幂等:会话已不存在也返回成功)。"; + tool["inputSchema"] = BuildRemoteCloseInputSchema(); + tool["outputSchema"] = BuildRemoteCloseOutputSchema(); + tools.append(tool); + } + { + Json::Value tool(Json::objectValue); + tool["name"] = "remote_keyboard"; + tool["description"] = u8"向已建立的远程控制会话注入键盘事件。action 可选 key_down / key_up / key_press / type:前三种需 key(Windows 虚拟键名,如 ENTER/TAB/F5/LEFT/CTRL/ALT/SHIFT/WIN),type 需 text(仅 ASCII 文本;非 ASCII 请用 remote_clipboard + Ctrl+V)。modifiers 可选(CTRL/ALT/SHIFT/WIN)。"; + tool["inputSchema"] = BuildRemoteKeyboardInputSchema(); + tool["outputSchema"] = BuildRemoteKeyboardOutputSchema(); + tools.append(tool); + } + { + Json::Value tool(Json::objectValue); + tool["name"] = "remote_mouse"; + tool["description"] = u8"向已建立的远程控制会话注入鼠标事件。action 可选 move / down / up / click / right_click / middle_click / drag / scroll;坐标 x/y(及 drag 的 x2/y2)为归一化 0..1 浮点。button 可选 left/middle/right(默认 left);click 的 clicks 可选 1/2/3(默认 1);scroll 的 delta 正=向下滚、负=向上滚。"; + tool["inputSchema"] = BuildRemoteMouseInputSchema(); + tool["outputSchema"] = BuildRemoteMouseOutputSchema(); + tools.append(tool); + } + { + Json::Value tool(Json::objectValue); + tool["name"] = "remote_clipboard"; + tool["description"] = u8"把文本写入远程主机的剪贴板(UTF-8 → GBK/ANSI,非 GBK 字符如 emoji 会丢失)。仅设置剪贴板,粘贴需随后用 remote_keyboard 注入 Ctrl+V。"; + tool["inputSchema"] = BuildRemoteClipboardInputSchema(); + tool["outputSchema"] = BuildRemoteClipboardOutputSchema(); + tools.append(tool); + } + } + result["tools"] = tools; return BuildResult(id, result); } @@ -2533,6 +2597,932 @@ std::string BuildTerminalClose(const Json::Value& id, const Json::Value& args, C return BuildResult(id, result); } +// ===== P5:MCP 远程控制(remote_open / remote_close)===== + +static const int kScreenCtrlIdleTimeoutSec = 300; // 远程控制 idle 回收超时(秒) + +// 共享前置:校验 McpRemoteControl && !readonly → 解析 id → FindMainContext → 拒绝 LNX/MAC。 +// 成功返回 true 并输出 devId/ctx;失败时 errJson 已写入对应 JSON 错误串。 +static bool ResolveScreenCtrlSessionHost(const Json::Value& id, const Json::Value& args, + CMy2015RemoteDlg* parent, + uint64_t& devId, context*& ctx, std::string& errJson) { + CMcpServer& mcp = CMcpServer::Instance(); + if (!mcp.IsRemoteControlEnabled() || mcp.IsReadonly()) { + errJson = BuildError(id, -32006, + "Remote control is disabled: requires McpRemoteControl=1 and McpReadonly=0"); + return false; + } + + std::string err; + if (!ParseHostIdArg(args, devId, err)) { + errJson = BuildError(id, -32602, err); + return false; + } + + ctx = FindMainContext(parent, devId); + if (!ctx) { + errJson = BuildError(id, -32002, "Host not found or offline: " + std::to_string(devId)); + return false; + } + + CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE); + if (clientType == "LNX" || clientType == "MAC") { + errJson = BuildError(id, -32005, "Remote control is only supported on Windows hosts"); + return false; + } + return true; +} + +// remote_open 的 inputSchema(id 必填、timeout_ms 可选) +Json::Value BuildRemoteOpenInputSchema() { + Json::Value props(Json::objectValue); + Json::Value idProp(Json::objectValue); + idProp["type"] = "string"; + idProp["description"] = u8"主机 id,取 list_online_hosts / search_hosts 返回的 id 字段"; + props["id"] = idProp; + Json::Value timeoutProp(Json::objectValue); + timeoutProp["type"] = "integer"; + timeoutProp["description"] = u8"等待屏幕子连接建立 + 分辨率到达的超时毫秒数(可选,默认 20000,上限 600000)"; + props["timeout_ms"] = timeoutProp; + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + Json::Value required(Json::arrayValue); + required.append("id"); + schema["required"] = required; + return schema; +} + +Json::Value BuildRemoteOpenOutputSchema() { + Json::Value props(Json::objectValue); + Json::Value sid(Json::objectValue); + sid["type"] = "string"; + sid["description"] = u8"控制会话 token,后续 remote_mouse / remote_keyboard / remote_close 用"; + props["session_id"] = sid; + Json::Value sw(Json::objectValue); + sw["type"] = "integer"; + sw["description"] = u8"物理屏幕宽度(虚拟桌面像素),用于把归一化坐标 0..1 映射为像素"; + props["screen_w"] = sw; + Json::Value sh(Json::objectValue); + sh["type"] = "integer"; + sh["description"] = u8"物理屏幕高度(虚拟桌面像素)"; + props["screen_h"] = sh; + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + Json::Value required(Json::arrayValue); + required.append("session_id"); + required.append("screen_w"); + required.append("screen_h"); + schema["required"] = required; + return schema; +} + +Json::Value BuildRemoteCloseInputSchema() { + Json::Value props(Json::objectValue); + Json::Value idProp(Json::objectValue); + idProp["type"] = "string"; + idProp["description"] = u8"主机 id,取 list_online_hosts / search_hosts 返回的 id 字段"; + props["id"] = idProp; + Json::Value sid(Json::objectValue); + sid["type"] = "string"; + sid["description"] = u8"remote_open 返回的 session_id"; + props["session_id"] = sid; + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + Json::Value required(Json::arrayValue); + required.append("id"); + required.append("session_id"); + schema["required"] = required; + return schema; +} + +Json::Value BuildRemoteCloseOutputSchema() { + Json::Value props(Json::objectValue); + Json::Value closed(Json::objectValue); + closed["type"] = "boolean"; + closed["description"] = u8"恒为 true(幂等)"; + props["closed"] = closed; + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + Json::Value required(Json::arrayValue); + required.append("closed"); + schema["required"] = required; + return schema; +} + +// tools/call:remote_open(建立隐藏屏幕子连接 + 取物理分辨率) +std::string BuildRemoteOpen(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) { + CMcpServer& mcp = CMcpServer::Instance(); + mcp.SweepIdleScreenCtrl(kScreenCtrlIdleTimeoutSec); + + uint64_t devId = 0; + context* ctx = nullptr; + std::string errJson; + if (!ResolveScreenCtrlSessionHost(id, args, parent, devId, ctx, errJson)) + return errJson; + + // 多显示器门(§7.2 / §12.1):Observe 抓主屏,Act 注入在虚拟桌面空间,两者仅单显示器时 + // 重合。客户端心跳 RES_RESOLUTION = "N:W*H"(N=显示器数);N>1 时归一化坐标会系统性 + // 偏移,阶段一直接拒绝(-32008)。无冒号(老客户端格式)则无法判定,放行不拦。 + { + CString res = ctx->GetAdditionalData(RES_RESOLUTION); + int colon = res.Find(':'); + if (colon > 0) { + CString countStr = res.Left(colon); + int monitorCount = atoi(countStr); + if (monitorCount > 1) + return BuildError(id, -32008, + "Multi-monitor hosts are not supported yet (phase 1): monitor count " + + std::to_string(monitorCount)); + } + } + + // 远程控制复用 Web 的隐藏屏幕子连接(COMMAND_SCREEN_SPY → CScreenSpyDlg → RegisterScreenContext)。 + // 该路径依赖 Web 远程服务已启动(WebSvrPort>0,默认开启);未启动时子连接无法建立/注册。 + if (!WebService().IsRunning()) + return BuildError(id, -32005, + "Remote control requires the Web remote service (WebSvrPort) to be enabled"); + + // 与人类远程桌面观看互斥:已有屏幕子连接(web 观看)→ 拒绝,避免向人类正在看的画面注入。 + if (WebService().HasActiveSession(devId)) + return BuildError(id, -32003, "Device busy: a remote desktop session is already active for this host"); + + int timeoutMs = kMcpToolTimeoutMs; + std::string t = GetStringArg(args, "timeout_ms"); + if (!t.empty() && IsDigits(t)) { + int v = atoi(t.c_str()); + if (v > 0 && v <= 600000) timeoutMs = v; + } + + std::string sessionId = GenerateRandomToken(); + if (!mcp.BeginScreenCtrlOpen(devId, sessionId)) + return BuildError(id, -32003, "Device busy: a remote control session is already active for this host"); + + // 建立隐藏屏幕子连接(发 COMMAND_SCREEN_SPY、标记 web-triggered)。 + if (!WebService().StartRemoteDesktop(devId)) { + mcp.CloseScreenCtrlSession(devId, sessionId); + // StartRemoteDesktop 先置 web-triggered 再 Send2Client,发送失败需一并清理, + // 否则该设备残留 web-triggered 标记、后续人类 web 观看会误走隐藏会话路径。 + WebService().ClearWebTriggered(devId); + return BuildError(id, -32004, "Failed to start remote desktop session"); + } + + // 轮询等待子连接就绪(HasActiveSession)+ 分辨率到达(TOKEN_BITMAPINFO → NotifyResolutionChange)。 + // 屏幕子连接的注册/分辨率由 CScreenSpyDlg 完成,无 McpServer 回调,故用轮询(open 低频)。 + int screenW = 0, screenH = 0; + context* subCtx = nullptr; + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + bool ready = false; + while (std::chrono::steady_clock::now() < deadline) { + if (WebService().HasActiveSession(devId) && WebService().GetScreenSize(devId, screenW, screenH)) { + subCtx = WebService().GetScreenContext(devId); + ready = (subCtx != nullptr); + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + if (!ready) { + mcp.CloseScreenCtrlSession(devId, sessionId); + if (parent) parent->CloseWebRemoteDesktopByClientID(devId); + WebService().ClearWebTriggered(devId); + return BuildError(id, -32001, "Timeout waiting for screen sub-connection / resolution"); + } + + if (!mcp.MarkScreenCtrlReady(devId, sessionId, subCtx, screenW, screenH)) { + // 并发 remote_close 已关掉会话:放弃本次 open(子连接已由 close 关闭)。 + return BuildError(id, -32002, "Remote control session was closed concurrently: " + sessionId); + } + + // 审计:打开会话(不可关闭)。 + if (parent) { + std::string text = "host " + std::to_string(devId) + " remote-open: session " + sessionId + + " (" + std::to_string(screenW) + "x" + std::to_string(screenH) + ")"; + parent->PostMessageA(WM_SHOWERRORMSG, + (WPARAM)new CString(ToAnsi(text, 936).c_str()), + (LPARAM)new CString(_TR("MCP远程控制"))); + } + + Json::Value result(Json::objectValue); + Json::Value structuredContent(Json::objectValue); + structuredContent["session_id"] = sessionId; + structuredContent["screen_w"] = screenW; + structuredContent["screen_h"] = screenH; + result["structuredContent"] = structuredContent; + + Json::Value content(Json::arrayValue); + Json::Value item(Json::objectValue); + item["type"] = "text"; + item["text"] = sessionId; + content.append(item); + result["content"] = content; + result["isError"] = false; + + return BuildResult(id, result); +} + +// tools/call:remote_close(关闭控制会话;幂等,session_id 不匹配报错) +std::string BuildRemoteClose(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) { + CMcpServer& mcp = CMcpServer::Instance(); + mcp.SweepIdleScreenCtrl(kScreenCtrlIdleTimeoutSec); + + if (!mcp.IsRemoteControlEnabled() || mcp.IsReadonly()) + return BuildError(id, -32006, "Remote control is disabled: requires McpRemoteControl=1 and McpReadonly=0"); + + uint64_t devId = 0; + std::string err; + if (!ParseHostIdArg(args, devId, err)) + return BuildError(id, -32602, err); + + std::string sessionId = GetStringArg(args, "session_id"); + if (sessionId.empty()) + return BuildError(id, -32602, "Missing required parameter: session_id"); + + int r = mcp.CloseScreenCtrlSession(devId, sessionId); + if (r == 2) + return BuildError(id, -32002, "Remote control session_id mismatch: " + sessionId); + + // 仅当确实关闭了 MCP 会话(r==0)时才关闭隐藏屏幕子连接;r==1(幂等,会话已不存在, + // 如已被 idle 回收)时不得误关人类正在观看的 web 会话(CloseWebRemoteDesktopByClientID + // 会关掉该设备任一 IsWebSession 的对话框)。 + if (r == 0) { + // 关闭隐藏屏幕子连接(幂等):WM_CLOSE → CScreenSpyDlg 析构 → UnregisterScreenContext。 + if (parent) parent->CloseWebRemoteDesktopByClientID(devId); + WebService().ClearWebTriggered(devId); + } + + // 审计:关闭会话(不可关闭)。 + if (parent) { + std::string text = "host " + std::to_string(devId) + " remote-close: session " + sessionId; + parent->PostMessageA(WM_SHOWERRORMSG, + (WPARAM)new CString(ToAnsi(text, 936).c_str()), + (LPARAM)new CString(_TR("MCP远程控制"))); + } + + Json::Value result(Json::objectValue); + Json::Value structuredContent(Json::objectValue); + structuredContent["closed"] = true; + result["structuredContent"] = structuredContent; + + Json::Value content(Json::arrayValue); + Json::Value item(Json::objectValue); + item["type"] = "text"; + item["text"] = std::string(u8"closed"); + content.append(item); + result["content"] = content; + result["isError"] = false; + + return BuildResult(id, result); +} + +// ===== P5:remote_keyboard(键盘注入,key_down / key_up / key_press / type)===== + +// 键名 → 虚拟键码(对齐 Windows VK_* 去掉前缀,见设计 §4.3)。 +// 支持:单字符 A-Z/0-9、F1-F24、命名键、修饰键。未知返回 0(VK 0 非真实键)。 +static int MapKeyNameToVk(const std::string& nameIn) { + std::string n; + for (char c : nameIn) + n += (c >= 'a' && c <= 'z') ? (char)(c - 'a' + 'A') : c; + + if (n.size() == 1) { + unsigned char c = (unsigned char)n[0]; + if (c >= 'A' && c <= 'Z') return c; // 'A'..'Z' == VK_A..VK_Z + if (c >= '0' && c <= '9') return c; // '0'..'9' == VK_0..VK_9 + return 0; // 标点等请走 type + } + + // F1..F24 + if (n.size() >= 2 && n.size() <= 3 && n[0] == 'F') { + int fnum = atoi(n.c_str() + 1); + if (fnum >= 1 && fnum <= 24) return VK_F1 + (fnum - 1); + return 0; + } + + struct Named { const char* name; int vk; }; + static const Named kNames[] = { + {"ENTER", VK_RETURN}, {"RETURN", VK_RETURN}, {"TAB", VK_TAB}, {"SPACE", VK_SPACE}, + {"ESC", VK_ESCAPE}, {"ESCAPE", VK_ESCAPE}, {"BACKSPACE", VK_BACK}, + {"DELETE", VK_DELETE}, {"DEL", VK_DELETE}, {"INSERT", VK_INSERT}, {"INS", VK_INSERT}, + {"HOME", VK_HOME}, {"END", VK_END}, {"PAGEUP", VK_PRIOR}, {"PRIOR", VK_PRIOR}, + {"PAGEDOWN", VK_NEXT}, {"NEXT", VK_NEXT}, + {"LEFT", VK_LEFT}, {"RIGHT", VK_RIGHT}, {"UP", VK_UP}, {"DOWN", VK_DOWN}, + {"CAPSLOCK", VK_CAPITAL}, {"CAPITAL", VK_CAPITAL}, {"NUMLOCK", VK_NUMLOCK}, + {"SCROLLLOCK", VK_SCROLL}, {"PRINTSCREEN", VK_SNAPSHOT}, {"SNAPSHOT", VK_SNAPSHOT}, + {"PAUSE", VK_PAUSE}, {"APPS", VK_APPS}, {"CONTEXTMENU", VK_APPS}, + {"CTRL", VK_CONTROL}, {"CONTROL", VK_CONTROL}, {"ALT", VK_MENU}, {"MENU", VK_MENU}, + {"SHIFT", VK_SHIFT}, {"WIN", VK_LWIN}, {"WINDOWS", VK_LWIN}, {"CMD", VK_LWIN}, {"SUPER", VK_LWIN}, + }; + for (const Named& e : kNames) + if (n == e.name) return e.vk; + return 0; +} + +// 是否修饰键(modifiers 白名单:CTRL/ALT/SHIFT/WIN) +static bool IsModifierVk(int vk) { + return vk == VK_CONTROL || vk == VK_MENU || vk == VK_SHIFT || vk == VK_LWIN || vk == VK_RWIN; +} + +// 解析可选 modifiers 数组(JSON 字符串数组)→ 修饰键 VK 序列。非法返回 false 并填 err。 +static bool ParseModifiers(const Json::Value& args, std::vector& out, std::string& err) { + out.clear(); + if (!args.isMember("modifiers")) return true; + const Json::Value& mods = args["modifiers"]; + if (mods.isNull()) return true; + if (!mods.isArray()) { err = "modifiers must be an array of strings"; return false; } + for (const auto& m : mods) { + if (out.size() >= 8) { err = "too many modifiers (max 8)"; return false; } + int vk = MapKeyNameToVk(m.asString()); + if (vk == 0 || !IsModifierVk(vk)) { + err = "invalid modifier: " + (m.isString() ? m.asString() : std::string("(non-string)")); + return false; + } + out.push_back(vk); + } + return true; +} + +// 是否纯 ASCII(type 走物理键事件,只能可靠注入 ASCII/ANSI;见设计 §6.4) +static bool IsAscii(const std::string& s) { + for (unsigned char c : s) if (c >= 0x80) return false; + return true; +} + +// type:逐字符 VkKeyScanA 映射为「(Shift↓)字符↓ 字符↑(Shift↑)」,回车/制表符映射为按键。 +// 注:VkKeyScanA 在服务端按服务端线程键盘布局解析字符→虚拟键;仅限 ASCII(0..127)时 +// 各布局的字母/数字/常用标点虚拟键一致,跨布局差异可忽略(非 ASCII 一律走剪贴板)。 +static void BuildTypeBatch(const std::string& text, std::vector& batch) { + for (unsigned char ch : text) { + if (ch == '\r' || ch == '\n') { + batch.push_back(BuildKeyMsg64(VK_RETURN, true, false)); + batch.push_back(BuildKeyMsg64(VK_RETURN, false, false)); + continue; + } + if (ch == '\t') { + batch.push_back(BuildKeyMsg64(VK_TAB, true, false)); + batch.push_back(BuildKeyMsg64(VK_TAB, false, false)); + continue; + } + SHORT scan = VkKeyScanA((char)ch); + if (scan == -1) continue; // 无法映射(控制字符等)→ 跳过 + int vk = scan & 0xFF; + bool needShift = (scan >> 8) & 1; + if (needShift) batch.push_back(BuildKeyMsg64(VK_SHIFT, true, false)); + batch.push_back(BuildKeyMsg64(vk, true, false)); + batch.push_back(BuildKeyMsg64(vk, false, false)); + if (needShift) batch.push_back(BuildKeyMsg64(VK_SHIFT, false, false)); + } +} + +Json::Value BuildRemoteKeyboardInputSchema() { + Json::Value props(Json::objectValue); + Json::Value idProp(Json::objectValue); + idProp["type"] = "string"; + idProp["description"] = u8"主机 id,取 list_online_hosts / search_hosts 返回的 id 字段"; + props["id"] = idProp; + + Json::Value sid(Json::objectValue); + sid["type"] = "string"; + sid["description"] = u8"remote_open 返回的 session_id"; + props["session_id"] = sid; + + Json::Value act(Json::objectValue); + act["type"] = "string"; + act["description"] = u8"动作:key_down / key_up / key_press / type"; + props["action"] = act; + + Json::Value key(Json::objectValue); + key["type"] = "string"; + key["description"] = u8"按键(key_down/key_up/key_press 用):Windows 虚拟键名(如 ENTER/TAB/F5/LEFT/CTRL/ALT/SHIFT/WIN)或单个字母/数字"; + props["key"] = key; + + Json::Value text(Json::objectValue); + text["type"] = "string"; + text["description"] = u8"文本(type 用,仅 ASCII;非 ASCII 请走 remote_clipboard + Ctrl+V)"; + props["text"] = text; + + Json::Value mods(Json::objectValue); + mods["type"] = "array"; + Json::Value modItem(Json::objectValue); + modItem["type"] = "string"; + mods["items"] = modItem; + mods["description"] = u8"修饰键(可选):CTRL/ALT/SHIFT/WIN"; + props["modifiers"] = mods; + + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + Json::Value required(Json::arrayValue); + required.append("id"); + required.append("session_id"); + required.append("action"); + schema["required"] = required; + return schema; +} + +Json::Value BuildRemoteKeyboardOutputSchema() { + Json::Value props(Json::objectValue); + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + return schema; +} + +// tools/call:remote_keyboard(键盘注入;成功返回空对象) +std::string BuildRemoteKeyboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) { + CMcpServer& mcp = CMcpServer::Instance(); + mcp.SweepIdleScreenCtrl(kScreenCtrlIdleTimeoutSec); + + uint64_t devId = 0; + context* ctx = nullptr; + std::string errJson; + if (!ResolveScreenCtrlSessionHost(id, args, parent, devId, ctx, errJson)) + return errJson; + + std::string sessionId = GetStringArg(args, "session_id"); + if (sessionId.empty()) + return BuildError(id, -32602, "Missing required parameter: session_id"); + + std::string action = GetStringArg(args, "action"); + if (action.empty()) + return BuildError(id, -32602, "Missing required parameter: action"); + + std::string keyName = GetStringArg(args, "key"); + std::string text = GetStringArg(args, "text"); + std::vector mods; + { + std::string mErr; + if (!ParseModifiers(args, mods, mErr)) + return BuildError(id, -32602, mErr); + } + + // 组装注入批次(按发送顺序) + std::vector batch; + if (action == "type") { + if (text.empty()) + return BuildError(id, -32602, "Missing required parameter: text"); + if (!IsAscii(text)) + return BuildError(id, -32602, "text must be ASCII; non-ASCII text requires remote_clipboard + Ctrl+V"); + if (text.size() > 1024) + return BuildError(id, -32602, "text too long (max 1024 bytes); use remote_clipboard + Ctrl+V for long text"); + BuildTypeBatch(text, batch); + } else if (action == "key_down" || action == "key_up" || action == "key_press") { + if (keyName.empty()) + return BuildError(id, -32602, "Missing required parameter: key"); + int keyVk = MapKeyNameToVk(keyName); + if (keyVk == 0) + return BuildError(id, -32602, "Unknown key: " + keyName); + bool doDown = (action != "key_up"); + bool doUp = (action != "key_down"); + if (doDown) { + for (int m : mods) batch.push_back(BuildKeyMsg64(m, true, false)); + batch.push_back(BuildKeyMsg64(keyVk, true, false)); + } + if (doUp) { + batch.push_back(BuildKeyMsg64(keyVk, false, false)); + for (auto it = mods.rbegin(); it != mods.rend(); ++it) + batch.push_back(BuildKeyMsg64(*it, false, false)); + } + } else { + return BuildError(id, -32602, "Unknown action: " + action); + } + + if (batch.empty()) + return BuildError(id, -32602, "No key events generated"); + + // 校验 session_id/busy 并置 busy(注入期间 close/sweep/断线不擦会话,镜像终端 busy 模式)。 + context* subCtx = nullptr; + int screenW = 0, screenH = 0; + int r = mcp.BeginScreenCtrlAction(devId, sessionId, subCtx, screenW, screenH); + if (r == 2) return BuildError(id, -32003, "Device busy: another injection is in flight for this session"); + if (r == 1) return BuildError(id, -32002, "Remote control session not found, not ready, or closed: " + sessionId); + + // 组包 [COMMAND_SCREEN_CONTROL][MSG64*N],经屏幕子连接发送(主连接无效,设计 P2)。 + const int len = (int)(1 + batch.size() * sizeof(MSG64)); + std::vector packet((size_t)len); + packet[0] = COMMAND_SCREEN_CONTROL; + memcpy(packet.data() + 1, batch.data(), batch.size() * sizeof(MSG64)); + bool ok = subCtx->Send2Client(packet.data(), (ULONG)len) != FALSE; + + mcp.EndScreenCtrlAction(devId, sessionId); + + if (!ok) + return BuildError(id, -32004, "Failed to send keyboard injection"); + + // 审计(不可关闭) + if (parent) { + std::string audit = "host " + std::to_string(devId) + " remote-keyboard: session " + sessionId + + " action=" + action; + if (action == "type") audit += " text=" + text; + else audit += " key=" + keyName; + parent->PostMessageA(WM_SHOWERRORMSG, + (WPARAM)new CString(ToAnsi(audit, 936).c_str()), + (LPARAM)new CString(_TR("MCP远程控制"))); + } + + Json::Value result(Json::objectValue); + result["structuredContent"] = Json::Value(Json::objectValue); + Json::Value content(Json::arrayValue); + Json::Value item(Json::objectValue); + item["type"] = "text"; + item["text"] = std::string(u8"ok"); + content.append(item); + result["content"] = content; + result["isError"] = false; + + return BuildResult(id, result); +} + +// ===== P5:remote_mouse(鼠标注入,move/down/up/click/right_click/middle_click/drag/scroll)===== + +// 读取归一化坐标(0..1 浮点,接受 JSON number 或数字字符串),越界钳到 [0,1]。 +static bool GetNormCoord(const Json::Value& args, const char* key, double& out, std::string& err) { + if (!args.isMember(key)) { err = std::string("Missing required parameter: ") + key; return false; } + const Json::Value& v = args[key]; + if (v.isNumeric()) { + out = v.asDouble(); + } else if (v.isString()) { + const std::string s = v.asString(); + if (s.empty()) { err = std::string("Invalid ") + key + ": expected a number in 0..1"; return false; } + char* end = nullptr; + double d = strtod(s.c_str(), &end); + if (end == s.c_str() || *end != '\0') { err = std::string("Invalid ") + key + ": expected a number in 0..1"; return false; } + out = d; + } else { + err = std::string("Invalid ") + key + ": expected a number in 0..1"; + return false; + } + if (out != out) { // NaN(如字符串 "nan")会绕过钳制并令 (int)(n*screen) 未定义行为 + err = std::string("Invalid ") + key + ": not a finite number"; + return false; + } + if (out < 0.0) out = 0.0; + if (out > 1.0) out = 1.0; + return true; +} + +// 读取可选整数参数(JSON number 或数字字符串);缺失/非法返回 false(不写 out)。 +static bool GetIntArg(const Json::Value& args, const char* key, int& out) { + if (!args.isMember(key)) return false; + const Json::Value& v = args[key]; + if (v.isNumeric()) { out = v.asInt(); return true; } + if (v.isString()) { + const std::string s = v.asString(); + if (IsDigits(s) || (!s.empty() && s[0] == '-' && IsDigits(s.substr(1)))) { + out = atoi(s.c_str()); + return true; + } + } + return false; +} + +// button 字符串 → 0=左/1=中/2=右;空或 left 默认左。 +static bool ParseButton(const std::string& s, int& out) { + if (s.empty() || s == "left") { out = 0; return true; } + if (s == "middle") { out = 1; return true; } + if (s == "right") { out = 2; return true; } + return false; +} + +// button → 按下/抬起消息 + 按下 wParam(对齐 WebService::HandleMouse)。 +static void ButtonMessages(int button, UINT& downMsg, UINT& upMsg, uint64_t& downWParam) { + if (button == 1) { downMsg = WM_MBUTTONDOWN; upMsg = WM_MBUTTONUP; downWParam = MK_MBUTTON; } + else if (button == 2) { downMsg = WM_RBUTTONDOWN; upMsg = WM_RBUTTONUP; downWParam = MK_RBUTTON; } + else { downMsg = WM_LBUTTONDOWN; upMsg = WM_LBUTTONUP; downWParam = MK_LBUTTON; } +} + +Json::Value BuildRemoteMouseInputSchema() { + Json::Value props(Json::objectValue); + Json::Value idProp(Json::objectValue); + idProp["type"] = "string"; + idProp["description"] = u8"主机 id,取 list_online_hosts / search_hosts 返回的 id 字段"; + props["id"] = idProp; + + Json::Value sid(Json::objectValue); + sid["type"] = "string"; + sid["description"] = u8"remote_open 返回的 session_id"; + props["session_id"] = sid; + + Json::Value act(Json::objectValue); + act["type"] = "string"; + act["description"] = u8"动作:move / down / up / click / right_click / middle_click / drag / scroll"; + Json::Value actEnum(Json::arrayValue); + actEnum.append("move"); actEnum.append("down"); actEnum.append("up"); actEnum.append("click"); + actEnum.append("right_click"); actEnum.append("middle_click"); actEnum.append("drag"); actEnum.append("scroll"); + act["enum"] = actEnum; + props["action"] = act; + + Json::Value xProp(Json::objectValue); + xProp["type"] = "number"; + xProp["description"] = u8"归一化 X 坐标(0=屏幕最左,1=最右)"; + props["x"] = xProp; + + Json::Value yProp(Json::objectValue); + yProp["type"] = "number"; + yProp["description"] = u8"归一化 Y 坐标(0=屏幕最上,1=最下)"; + props["y"] = yProp; + + Json::Value x2Prop(Json::objectValue); + x2Prop["type"] = "number"; + x2Prop["description"] = u8"拖拽终点 X(仅 drag)"; + props["x2"] = x2Prop; + + Json::Value y2Prop(Json::objectValue); + y2Prop["type"] = "number"; + y2Prop["description"] = u8"拖拽终点 Y(仅 drag)"; + props["y2"] = y2Prop; + + Json::Value btn(Json::objectValue); + btn["type"] = "string"; + btn["description"] = u8"按键:left / middle / right(默认 left)"; + Json::Value btnEnum(Json::arrayValue); + btnEnum.append("left"); btnEnum.append("middle"); btnEnum.append("right"); + btn["enum"] = btnEnum; + props["button"] = btn; + + Json::Value clk(Json::objectValue); + clk["type"] = "integer"; + clk["description"] = u8"点击次数 1/2/3(仅 click,默认 1)"; + props["clicks"] = clk; + + Json::Value del(Json::objectValue); + del["type"] = "integer"; + del["description"] = u8"滚动量(仅 scroll;正=向下滚,负=向上滚,与 Web 控制台一致;仅垂直滚轮,忽略 button)"; + props["delta"] = del; + + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + Json::Value required(Json::arrayValue); + required.append("id"); + required.append("session_id"); + required.append("action"); + required.append("x"); + required.append("y"); + schema["required"] = required; + return schema; +} + +Json::Value BuildRemoteMouseOutputSchema() { + Json::Value props(Json::objectValue); + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + return schema; +} + +// tools/call:remote_mouse(鼠标注入;归一化坐标 → 物理像素) +std::string BuildRemoteMouse(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) { + CMcpServer& mcp = CMcpServer::Instance(); + mcp.SweepIdleScreenCtrl(kScreenCtrlIdleTimeoutSec); + + uint64_t devId = 0; + context* ctx = nullptr; + std::string errJson; + if (!ResolveScreenCtrlSessionHost(id, args, parent, devId, ctx, errJson)) + return errJson; + + std::string sessionId = GetStringArg(args, "session_id"); + if (sessionId.empty()) + return BuildError(id, -32602, "Missing required parameter: session_id"); + + std::string action = GetStringArg(args, "action"); + if (action.empty()) + return BuildError(id, -32602, "Missing required parameter: action"); + + // ---- 纯参数解析(不依赖会话分辨率;BeginScreenCtrlAction 之后再无提前返回)---- + bool knownAction = + action == "move" || action == "down" || action == "up" || action == "click" || + action == "right_click" || action == "middle_click" || action == "drag" || action == "scroll"; + if (!knownAction) + return BuildError(id, -32602, "Unknown action: " + action); + + int button = 0; + std::string buttonStr = GetStringArg(args, "button"); + if (!ParseButton(buttonStr, button)) + return BuildError(id, -32602, "Invalid button: " + buttonStr + " (expected left/middle/right)"); + + int clicks = 1; + if (args.isMember("clicks")) { + if (!GetIntArg(args, "clicks", clicks)) + return BuildError(id, -32602, "clicks must be an integer 1..3"); + if (clicks < 1 || clicks > 3) + return BuildError(id, -32602, "clicks must be 1, 2 or 3"); + } + + int delta = 0; + if (args.isMember("delta") && !GetIntArg(args, "delta", delta)) + return BuildError(id, -32602, "delta must be an integer"); + + int notches = 0; // scroll 实际注入的滚动档数(= clamp(delta, -10, 10)),审计用 + + double nx = 0, ny = 0, nx2 = 0, ny2 = 0; + std::string coordErr; + if (!GetNormCoord(args, "x", nx, coordErr)) return BuildError(id, -32602, coordErr); + if (!GetNormCoord(args, "y", ny, coordErr)) return BuildError(id, -32602, coordErr); + if (action == "drag") { + if (!GetNormCoord(args, "x2", nx2, coordErr)) return BuildError(id, -32602, coordErr); + if (!GetNormCoord(args, "y2", ny2, coordErr)) return BuildError(id, -32602, coordErr); + } + + // 校验 session_id/busy 并置 busy,同时取物理分辨率(screenW/H>0 由会话就绪保证)。 + context* subCtx = nullptr; + int screenW = 0, screenH = 0; + int r = mcp.BeginScreenCtrlAction(devId, sessionId, subCtx, screenW, screenH); + if (r == 2) return BuildError(id, -32003, "Device busy: another injection is in flight for this session"); + if (r == 1) return BuildError(id, -32002, "Remote control session not found, not ready, or closed: " + sessionId); + + // 归一化坐标 → 物理像素(round + clamp 到 [0, screen-1]) + auto toPxX = [&](double n) -> int { int p = (int)(n * screenW + 0.5); return p < 0 ? 0 : (p > screenW - 1 ? screenW - 1 : p); }; + auto toPxY = [&](double n) -> int { int p = (int)(n * screenH + 0.5); return p < 0 ? 0 : (p > screenH - 1 ? screenH - 1 : p); }; + int px = toPxX(nx), py = toPxY(ny); + int px2 = 0, py2 = 0; + + UINT downMsg, upMsg; uint64_t downWParam; + ButtonMessages(button, downMsg, upMsg, downWParam); + + // 组装注入批次(按发送顺序;每个 MSG64 由客户端 ProcessCommand 分发到 SendInput) + std::vector batch; + if (action == "move") { + batch.push_back(BuildMouseMsg64(px, py, WM_MOUSEMOVE, 0)); + } else if (action == "down") { + batch.push_back(BuildMouseMsg64(px, py, downMsg, downWParam)); + } else if (action == "up") { + batch.push_back(BuildMouseMsg64(px, py, upMsg, 0)); + } else if (action == "click" || action == "right_click" || action == "middle_click") { + int b = (action == "right_click") ? 2 : (action == "middle_click" ? 1 : button); + UINT dMsg, uMsg; uint64_t dW; + ButtonMessages(b, dMsg, uMsg, dW); + for (int i = 0; i < clicks; ++i) { + batch.push_back(BuildMouseMsg64(px, py, dMsg, dW)); + batch.push_back(BuildMouseMsg64(px, py, uMsg, 0)); + } + } else if (action == "drag") { + px2 = toPxX(nx2); py2 = toPxY(ny2); + batch.push_back(BuildMouseMsg64(px, py, downMsg, downWParam)); + batch.push_back(BuildMouseMsg64(px2, py2, WM_MOUSEMOVE, 0)); + batch.push_back(BuildMouseMsg64(px2, py2, upMsg, 0)); + } else if (action == "scroll") { + // delta 正=向下滚(与 Web 控制台一致);WM_MOUSEWHEEL 负值=向下滚,故取反。 + // 客户端对 WM_MOUSEWHEEL 会先 SetCursorPos(x,y) 再 MOUSEEVENTF_WHEEL,单条消息即可定位+滚动。 + notches = delta; + if (notches > 10) notches = 10; + if (notches < -10) notches = -10; + short wheelDelta = (short)(-notches * 120); + batch.push_back(BuildMouseMsg64(px, py, WM_MOUSEWHEEL, MAKEWPARAM(0, wheelDelta))); + } + + // 组包 [COMMAND_SCREEN_CONTROL][MSG64*N],经屏幕子连接发送(主连接无效,设计 P2)。 + const int len = (int)(1 + batch.size() * sizeof(MSG64)); + std::vector packet((size_t)len); + packet[0] = COMMAND_SCREEN_CONTROL; + memcpy(packet.data() + 1, batch.data(), batch.size() * sizeof(MSG64)); + bool ok = subCtx->Send2Client(packet.data(), (ULONG)len) != FALSE; + + mcp.EndScreenCtrlAction(devId, sessionId); + + if (!ok) + return BuildError(id, -32004, "Failed to send mouse injection"); + + // 审计(不可关闭;含归一化坐标与换算后的物理像素) + if (parent) { + std::string audit = "host " + std::to_string(devId) + " remote-mouse: session " + sessionId + + " action=" + action; + char cbuf[96]; + sprintf(cbuf, " norm=(%.3f,%.3f) px=(%d,%d)", nx, ny, px, py); + audit += cbuf; + if (action == "drag") { + sprintf(cbuf, " -> norm2=(%.3f,%.3f) px2=(%d,%d)", nx2, ny2, px2, py2); + audit += cbuf; + } + if (action == "scroll") + audit += " notches=" + std::to_string(notches); + if (action == "click" || action == "right_click" || action == "middle_click") + audit += " clicks=" + std::to_string(clicks); + parent->PostMessageA(WM_SHOWERRORMSG, + (WPARAM)new CString(ToAnsi(audit, 936).c_str()), + (LPARAM)new CString(_TR("MCP远程控制"))); + } + + Json::Value result(Json::objectValue); + result["structuredContent"] = Json::Value(Json::objectValue); + Json::Value content(Json::arrayValue); + Json::Value item(Json::objectValue); + item["type"] = "text"; + item["text"] = std::string(u8"ok"); + content.append(item); + result["content"] = content; + result["isError"] = false; + + return BuildResult(id, result); +} + +// ===== P5:remote_clipboard(写远程剪贴板,UTF-8 → GBK 直发,见设计 §6.4)===== + +Json::Value BuildRemoteClipboardInputSchema() { + Json::Value props(Json::objectValue); + Json::Value idProp(Json::objectValue); + idProp["type"] = "string"; + idProp["description"] = u8"主机 id,取 list_online_hosts / search_hosts 返回的 id 字段"; + props["id"] = idProp; + + Json::Value sid(Json::objectValue); + sid["type"] = "string"; + sid["description"] = u8"remote_open 返回的 session_id"; + props["session_id"] = sid; + + Json::Value text(Json::objectValue); + text["type"] = "string"; + text["description"] = u8"要写入剪贴板的文本(UTF-8;非 GBK 字符如 emoji 会丢失)"; + props["text"] = text; + + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + Json::Value required(Json::arrayValue); + required.append("id"); + required.append("session_id"); + required.append("text"); + schema["required"] = required; + return schema; +} + +Json::Value BuildRemoteClipboardOutputSchema() { + Json::Value props(Json::objectValue); + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + return schema; +} + +// tools/call:remote_clipboard(写远程剪贴板;成功返回空对象) +std::string BuildRemoteClipboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) { + CMcpServer& mcp = CMcpServer::Instance(); + mcp.SweepIdleScreenCtrl(kScreenCtrlIdleTimeoutSec); + + uint64_t devId = 0; + context* ctx = nullptr; + std::string errJson; + if (!ResolveScreenCtrlSessionHost(id, args, parent, devId, ctx, errJson)) + return errJson; + + std::string sessionId = GetStringArg(args, "session_id"); + if (sessionId.empty()) + return BuildError(id, -32602, "Missing required parameter: session_id"); + + std::string text = GetStringArg(args, "text"); + if (text.empty()) + return BuildError(id, -32602, "Missing required parameter: text"); + if (text.size() > 262144) // 256 KB 上限(CF_TEXT 单包,防内存/传输失控) + return BuildError(id, -32602, "text too long (max 262144 bytes)"); + + // UTF-8 → GBK:客户端 UpdateClientClipboard 以 CF_TEXT(ANSI/GBK)落剪贴板(设计 §6.4)。 + // 非 GBK 字符(emoji 等)在此被替换为 '?',MVP 已知限制。 + std::string ansi = ToAnsi(text, 936); + + // 校验 session_id/busy 并置 busy,取屏幕子连接(注入与剪贴板共用同一子连接串行化)。 + context* subCtx = nullptr; + int screenW = 0, screenH = 0; + int r = mcp.BeginScreenCtrlAction(devId, sessionId, subCtx, screenW, screenH); + if (r == 2) return BuildError(id, -32003, "Device busy: another injection is in flight for this session"); + if (r == 1) return BuildError(id, -32002, "Remote control session not found, not ready, or closed: " + sessionId); + + // 组包 [COMMAND_SCREEN_SET_CLIPBOARD][GBK text],经屏幕子连接发送(客户端自行补 '\0')。 + const int len = (int)(1 + ansi.size()); + std::vector packet((size_t)len); + packet[0] = COMMAND_SCREEN_SET_CLIPBOARD; + memcpy(packet.data() + 1, ansi.data(), ansi.size()); + bool ok = subCtx->Send2Client(packet.data(), (ULONG)len) != FALSE; + + mcp.EndScreenCtrlAction(devId, sessionId); + + if (!ok) + return BuildError(id, -32004, "Failed to send clipboard text"); + + // 审计(不可关闭;记字节数,不落全文以免审计日志膨胀) + if (parent) { + std::string audit = "host " + std::to_string(devId) + " remote-clipboard: session " + sessionId + + " input_bytes=" + std::to_string(text.size()); + parent->PostMessageA(WM_SHOWERRORMSG, + (WPARAM)new CString(ToAnsi(audit, 936).c_str()), + (LPARAM)new CString(_TR("MCP远程控制"))); + } + + Json::Value result(Json::objectValue); + result["structuredContent"] = Json::Value(Json::objectValue); + Json::Value content(Json::arrayValue); + Json::Value item(Json::objectValue); + item["type"] = "text"; + item["text"] = std::string(u8"ok"); + content.append(item); + result["content"] = content; + result["isError"] = false; + + return BuildResult(id, result); +} + // tools/call 分派 std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) { const Json::Value& id = root["id"]; @@ -2561,6 +3551,11 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) { if (toolName == "terminal_open") return BuildTerminalOpen(id, args, parent); if (toolName == "terminal_exec") return BuildTerminalExec(id, args, parent); if (toolName == "terminal_close") return BuildTerminalClose(id, args, parent); + if (toolName == "remote_open") return BuildRemoteOpen(id, args, parent); + if (toolName == "remote_close") return BuildRemoteClose(id, args, parent); + if (toolName == "remote_keyboard") return BuildRemoteKeyboard(id, args, parent); + if (toolName == "remote_mouse") return BuildRemoteMouse(id, args, parent); + if (toolName == "remote_clipboard") return BuildRemoteClipboard(id, args, parent); return BuildError(id, -32602, "Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName)); @@ -2842,6 +3837,136 @@ int CMcpServer::SweepIdleTerminals(time_t idleTimeoutSec) { return (int)toCancel.size(); } +// ===== P5:MCP 远程控制(状态机方法)===== + +bool CMcpServer::IsScreenCtrlContext(context* subCtx) { + std::lock_guard lk(m_ScreenCtrlMutex); + return m_ScreenCtrlContextToDevice.find(subCtx) != m_ScreenCtrlContextToDevice.end(); +} + +bool CMcpServer::BeginScreenCtrlOpen(uint64_t device_id, const std::string& sessionId) { + std::lock_guard lk(m_ScreenCtrlMutex); + if (m_ScreenCtrlSessions.find(device_id) != m_ScreenCtrlSessions.end()) return false; // 单设备单会话 + ScreenCtrlSession s; + s.sessionId = sessionId; + s.lastActiveAt = time(nullptr); + m_ScreenCtrlSessions[device_id] = std::move(s); + // 双向互斥(方向二):标记该设备屏幕子连接归 MCP 会话独占,人类 Web 观看期间被拒绝 + // (HandleConnect 查 IsMcpTriggered)。会话擦除点(CloseScreenCtrlSession / + // SweepIdleScreenCtrl / OnScreenControlClosed / EndScreenCtrlAction)同步 ClearMcpTriggered。 + WebService().SetMcpTriggered(device_id); + return true; +} + +bool CMcpServer::MarkScreenCtrlReady(uint64_t device_id, const std::string& sessionId, + context* subCtx, int screenW, int screenH) { + std::lock_guard lk(m_ScreenCtrlMutex); + auto it = m_ScreenCtrlSessions.find(device_id); + if (it == m_ScreenCtrlSessions.end() || it->second.sessionId != sessionId) return false; + ScreenCtrlSession& s = it->second; + s.subCtx = subCtx; + s.started = true; + s.screenW = screenW; + s.screenH = screenH; + s.lastActiveAt = time(nullptr); + m_ScreenCtrlContextToDevice[subCtx] = device_id; + return true; +} + +int CMcpServer::CloseScreenCtrlSession(uint64_t device_id, const std::string& sessionId) { + std::lock_guard lk(m_ScreenCtrlMutex); + auto it = m_ScreenCtrlSessions.find(device_id); + if (it == m_ScreenCtrlSessions.end()) return 1; // 不存在(幂等) + if (it->second.sessionId != sessionId) return 2; // token 不匹配 + if (it->second.busy) { + // 注入在飞:置 closed 交注入线程收尾,避免与注入并发擦会话(镜像终端 busy 模式)。 + it->second.closed = true; + return 0; + } + if (it->second.subCtx) m_ScreenCtrlContextToDevice.erase(it->second.subCtx); + m_ScreenCtrlSessions.erase(it); + WebService().ClearMcpTriggered(device_id); + return 0; +} + +int CMcpServer::SweepIdleScreenCtrl(time_t idleTimeoutSec) { + time_t now = time(nullptr); + std::vector toClose; + { + std::lock_guard lk(m_ScreenCtrlMutex); + for (auto it = m_ScreenCtrlSessions.begin(); it != m_ScreenCtrlSessions.end(); ) { + ScreenCtrlSession& s = it->second; + if (!s.busy && difftime(now, s.lastActiveAt) > (double)idleTimeoutSec) { + if (s.subCtx) m_ScreenCtrlContextToDevice.erase(s.subCtx); + uint64_t devId = it->first; + toClose.push_back(devId); + it = m_ScreenCtrlSessions.erase(it); + WebService().ClearMcpTriggered(devId); // 锁内清除,避免与并发 remote_open 竞态 + } else { + ++it; + } + } + } + // 锁外关闭隐藏对话框(WM_CLOSE → CScreenSpyDlg 析构 → UnregisterScreenContext)。 + for (uint64_t devId : toClose) { + if (m_parent) m_parent->CloseWebRemoteDesktopByClientID(devId); + WebService().ClearWebTriggered(devId); + } + return (int)toClose.size(); +} + +void CMcpServer::OnScreenControlClosed(context* subCtx) { + std::lock_guard lk(m_ScreenCtrlMutex); + auto it = m_ScreenCtrlContextToDevice.find(subCtx); + if (it == m_ScreenCtrlContextToDevice.end()) return; + auto sit = m_ScreenCtrlSessions.find(it->second); + if (sit == m_ScreenCtrlSessions.end()) { // 路由在但会话已擦(防御) + m_ScreenCtrlContextToDevice.erase(it); + return; + } + if (sit->second.busy) { + // 注入在飞:不擦会话(注入线程仍持 subCtx),仅置 closed,由 EndScreenCtrlAction 收尾。 + sit->second.closed = true; + } else { + uint64_t devId = it->second; + m_ScreenCtrlSessions.erase(sit); + m_ScreenCtrlContextToDevice.erase(it); + WebService().ClearMcpTriggered(devId); + } +} + +int CMcpServer::BeginScreenCtrlAction(uint64_t device_id, const std::string& sessionId, + context*& subCtx, int& screenW, int& screenH) { + std::lock_guard lk(m_ScreenCtrlMutex); + auto it = m_ScreenCtrlSessions.find(device_id); + if (it == m_ScreenCtrlSessions.end()) return 1; // 会话不存在 + ScreenCtrlSession& s = it->second; + if (s.sessionId != sessionId) return 1; // token 不匹配 + if (!s.started) return 1; // 未就绪 + if (s.busy) return 2; // 已有注入在飞 + if (s.closed) return 1; // 子连接已断 + s.busy = true; + s.lastActiveAt = time(nullptr); + subCtx = s.subCtx; + screenW = s.screenW; + screenH = s.screenH; + return 0; +} + +void CMcpServer::EndScreenCtrlAction(uint64_t device_id, const std::string& sessionId) { + std::lock_guard lk(m_ScreenCtrlMutex); + auto it = m_ScreenCtrlSessions.find(device_id); + if (it == m_ScreenCtrlSessions.end()) return; + if (it->second.sessionId != sessionId) return; + it->second.busy = false; + it->second.lastActiveAt = time(nullptr); + if (it->second.closed) { // 注入期间子连接已断:擦会话+路由 + if (it->second.subCtx) m_ScreenCtrlContextToDevice.erase(it->second.subCtx); + m_ScreenCtrlSessions.erase(it); + WebService().ClearMcpTriggered(device_id); + } +} + ////////////////////////////////////////////////////////////////////////// // CMcpServer Implementation ////////////////////////////////////////////////////////////////////////// diff --git a/server/2015Remote/McpServer.h b/server/2015Remote/McpServer.h index cb15131..57c27c7 100644 --- a/server/2015Remote/McpServer.h +++ b/server/2015Remote/McpServer.h @@ -157,6 +157,43 @@ public: // idle 回收:关闭 lastActiveAt 超时且非 busy 的持久会话。返回回收数量。 int SweepIdleTerminals(time_t idleTimeoutSec); + // ===== P5:MCP 远程控制(remote_open / remote_close / remote_mouse / remote_keyboard)===== + // 屏幕子连接由 WebService::StartRemoteDesktop 建立的隐藏 CScreenSpyDlg 持有(复用 Web + // 路径,见 docs/Mcp_RemoteControl_Design.md §6.3);会话仅记录逻辑状态 + 子连接指针。 + // 注入走该子连接(COMMAND_SCREEN_CONTROL),与终端一样单设备单会话。 + void SetRemoteControlEnabled(bool enabled) { m_remoteControlEnabled = enabled; } + bool IsRemoteControlEnabled() const { return m_remoteControlEnabled; } + + // 该 host 的屏幕子连接是否被 MCP 远程控制会话持有(OfflineProc 反查用)。 + bool IsScreenCtrlContext(context* subCtx); + + // 登记控制会话(单设备单会话;sessionId 由调用方生成)。false = 该设备已有会话。 + bool BeginScreenCtrlOpen(uint64_t device_id, const std::string& sessionId); + + // 子连接就绪(HasActiveSession && 分辨率到达)后调用:填 subCtx/分辨率并置 started。 + // false = 会话已被并发关闭(调用方应放弃本次 open)。 + bool MarkScreenCtrlReady(uint64_t device_id, const std::string& sessionId, + context* subCtx, int screenW, int screenH); + + // 注入前登记(校验 sessionId/started/!busy/!closed)。输出 subCtx/screenW/H。 + // 注入期间 busy=true,保证 close/sweep/断线不与注入并发擦会话(镜像终端 busy 模式, + // 评审发现 #6)。返回:0=ok;1=不存在/不匹配/未就绪/已断(-32002);2=忙(-32003)。 + int BeginScreenCtrlAction(uint64_t device_id, const std::string& sessionId, + context*& subCtx, int& screenW, int& screenH); + + // 注入后复位 busy;若注入期间屏幕子连接已断(closed),擦会话+路由(防泄漏)。 + void EndScreenCtrlAction(uint64_t device_id, const std::string& sessionId); + + // 关闭控制会话(校验 sessionId;擦路由 + 会话)。屏幕子连接的关闭由调用方锁外执行 + // (CloseWebRemoteDesktopByClientID)。返回 0=已关;1=不存在(幂等);2=sessionId 不匹配。 + int CloseScreenCtrlSession(uint64_t device_id, const std::string& sessionId); + + // idle 回收:关闭 lastActiveAt 超时的控制会话,并锁外关闭其隐藏对话框。返回回收数量。 + int SweepIdleScreenCtrl(time_t idleTimeoutSec); + + // 屏幕子连接断开(OfflineProc 调用):擦除该子连接对应的会话 + 路由(幂等)。 + void OnScreenControlClosed(context* subCtx); + // 安全配置(启动时由 CMy2015RemoteDlg 读 THIS_CFG 后设置)。 void SetReadonly(bool readonly) { m_readonly = readonly; } void SetCmdWhitelist(const std::string& whitelist) { m_cmdWhitelist = whitelist; } @@ -220,9 +257,25 @@ private: std::map m_TermSessions; // device_id → 会话 std::map m_TermContextToDevice; // subCtx → device_id(顶部路由) + // ===== P5:远程控制会话(受 m_ScreenCtrlMutex 保护;单设备单会话)===== + struct ScreenCtrlSession { + std::string sessionId; // 会话 token(每次 open 独立随机) + context* subCtx = nullptr; // 屏幕子连接上下文(就绪后填;用于注入) + bool started = false; // false=子连接建立中;true=已就绪 + bool busy = false; // 一条注入在飞(BeginScreenCtrlAction→End 之间) + bool closed = false; // 注入期间屏幕子连接已断(由注入线程收尾) + time_t lastActiveAt = 0; // idle 回收用(秒) + int screenW = 0; // 物理捕获分辨率(来自 TOKEN_BITMAPINFO) + int screenH = 0; + }; + std::mutex m_ScreenCtrlMutex; + std::map m_ScreenCtrlSessions; // device_id → 会话 + std::map m_ScreenCtrlContextToDevice; // subCtx → device_id(OfflineProc 反查) + bool m_readonly = true; std::string m_cmdWhitelist; - bool m_terminalEnabled = false; // 持久终端开关(默认关;要求 m_readonly=false) + bool m_terminalEnabled = false; // 持久终端开关(默认关;要求 m_readonly=false) + bool m_remoteControlEnabled = false; // 远程控制开关(默认关;要求 m_readonly=false) }; // 全局访问器(仿 WebService(),见 WebService.h 末尾) diff --git a/server/2015Remote/McpSettingsDlg.cpp b/server/2015Remote/McpSettingsDlg.cpp index ffe2427..34bd92b 100644 --- a/server/2015Remote/McpSettingsDlg.cpp +++ b/server/2015Remote/McpSettingsDlg.cpp @@ -106,7 +106,7 @@ INT_PTR CMcpSettingsDlg::DoModal() { USES_CONVERSION; CString title = _TR("MCP设置"); - BuildDialogTemplate(m_Template, T2CW(title), 320, 360); + BuildDialogTemplate(m_Template, T2CW(title), 320, 390); InitModalIndirect((LPCDLGTEMPLATE)m_Template.data()); return CDialog::DoModal(); } @@ -139,6 +139,9 @@ BOOL CMcpSettingsDlg::OnInitDialog() m_btnTerminal.Create(_TR("启用持久终端(全命令,无白名单)"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX, r0, this, IDC_MCP_TERMINAL); + m_btnRemoteControl.Create(_TR("启用远程控制(AI 操控桌面)"), + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX, + r0, this, IDC_MCP_REMOTECONTROL); m_lblWhitelist.Create(_TR("命令白名单"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1); m_editWhitelist.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_MULTILINE | ES_AUTOVSCROLL | ES_WANTRETURN | WS_VSCROLL, @@ -160,6 +163,7 @@ BOOL CMcpSettingsDlg::OnInitDialog() m_editToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnReadonly.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnTerminal.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + m_btnRemoteControl.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_lblWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_editWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); @@ -174,6 +178,7 @@ BOOL CMcpSettingsDlg::OnInitDialog() if (tok.empty()) tok = GenerateRandomToken(); int readonly = THIS_CFG.GetInt("settings", "McpReadonly", 1); int terminal = THIS_CFG.GetInt("settings", "McpTerminal", 0); + int remoteControl = THIS_CFG.GetInt("settings", "McpRemoteControl", 0); std::string whitelist = THIS_CFG.GetStr("settings", "McpCmdWhitelist", ""); m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED); @@ -182,6 +187,7 @@ BOOL CMcpSettingsDlg::OnInitDialog() m_editToken.SetWindowText(CString(tok.c_str())); m_btnReadonly.SetCheck(readonly ? BST_CHECKED : BST_UNCHECKED); m_btnTerminal.SetCheck(terminal ? BST_CHECKED : BST_UNCHECKED); + m_btnRemoteControl.SetCheck(remoteControl ? BST_CHECKED : BST_UNCHECKED); // 白名单存储为逗号分隔,展示为每行一条。 m_editWhitelist.SetWindowText(CString(WhitelistForDisplay(whitelist).c_str())); @@ -199,6 +205,7 @@ void CMcpSettingsDlg::OnOK() bool enabled = (m_btnEnable.GetCheck() == BST_CHECKED); bool readonly = (m_btnReadonly.GetCheck() == BST_CHECKED); bool terminal = (m_btnTerminal.GetCheck() == BST_CHECKED); + bool remoteControl = (m_btnRemoteControl.GetCheck() == BST_CHECKED); // 端口校验:1-65535 int port = atoi(CT2A(sPort)); @@ -223,14 +230,15 @@ void CMcpSettingsDlg::OnOK() THIS_CFG.SetStr("settings", "McpToken", token); THIS_CFG.SetInt("settings", "McpReadonly", readonly ? 1 : 0); THIS_CFG.SetInt("settings", "McpTerminal", terminal ? 1 : 0); + THIS_CFG.SetInt("settings", "McpRemoteControl", remoteControl ? 1 : 0); std::string whitelist = CT2A(sWhitelist); whitelist = NormalizeWhitelist(whitelist); THIS_CFG.SetStr("settings", "McpCmdWhitelist", whitelist); // 拆成两段可翻译的单行键,中间用 \r\n 连接(多行键无法在 INI 中表示) MessageBox(_TR("MCP 设置已保存。") + _T("\r\n") + - _TR("启用/端口/绑定地址/Token/只读/白名单/持久终端的改动需重启程序生效。") + _T("\r\n") + - _TR("持久终端仅在只读模式关闭时生效。"), + _TR("启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制的改动需重启程序生效。") + _T("\r\n") + + _TR("持久终端与远程控制仅在只读模式关闭时生效。"), _TR("提示"), MB_ICONINFORMATION); CDialog::OnOK(); @@ -267,6 +275,9 @@ void CMcpSettingsDlg::LayoutControls(int cx, int cy) m_btnTerminal.MoveWindow(margin, y, cx - margin * 2, 22); y += 30; + m_btnRemoteControl.MoveWindow(margin, y, cx - margin * 2, 22); + y += 30; + const int whitelistH = 90; m_lblWhitelist.MoveWindow(margin, y, labelW, rowH); m_editWhitelist.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, whitelistH); diff --git a/server/2015Remote/McpSettingsDlg.h b/server/2015Remote/McpSettingsDlg.h index c11e8aa..54b5623 100644 --- a/server/2015Remote/McpSettingsDlg.h +++ b/server/2015Remote/McpSettingsDlg.h @@ -29,6 +29,7 @@ private: IDC_MCP_READONLY = 1005, // 「只读模式」复选框(默认勾选,禁 exec_command) IDC_MCP_WHITELIST = 1006, // 命令白名单编辑框(多行,逗号/换行分隔,空 = 内置只读前缀) IDC_MCP_TERMINAL = 1007, // 「启用持久终端」复选框(全命令,无白名单,要求只读关) + IDC_MCP_REMOTECONTROL = 1008, // 「启用远程控制」复选框(AI 操控桌面,要求只读关) }; CButton m_btnEnable; @@ -36,6 +37,7 @@ private: CEdit m_editPort, m_editBind, m_editToken; CButton m_btnReadonly; CButton m_btnTerminal; + CButton m_btnRemoteControl; CStatic m_lblWhitelist; CEdit m_editWhitelist; CButton m_btnOK, m_btnCancel; diff --git a/server/2015Remote/WebService.cpp b/server/2015Remote/WebService.cpp index 8b34f09..bf2777c 100644 --- a/server/2015Remote/WebService.cpp +++ b/server/2015Remote/WebService.cpp @@ -643,6 +643,15 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t } } + // Bidirectional mutex (direction 2, §8.2): reject human Web viewing while an MCP + // remote control session owns this host's screen sub-connection (SetMcpTriggered on + // remote_open, cleared on session teardown), so AI input cannot be injected into a + // picture a human is watching/controlling. + if (IsMcpTriggered(device_id)) { + SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Device busy: remote control session active")); + return; + } + // Check max clients per device int current_count = GetWebClientCount(device_id); if (current_count >= m_nMaxClientsPerDevice) { @@ -799,34 +808,18 @@ void CWebService::HandleMouse(void* ws_ptr, const std::string& msg) { return; } - // Build MSG64 structure - MSG64 msg64; - memset(&msg64, 0, sizeof(MSG64)); - msg64.pt.x = x; - msg64.pt.y = y; - msg64.lParam = MAKELPARAM(x, y); - msg64.time = GetTickCount(); + // Build MSG64 structure(共享 helper,见 WebService.h;与 MCP remote_mouse 复用) + MSG64 msg64 = BuildMouseMsg64(x, y, 0, 0); // 默认 WM_NULL;未知 button 时仍发送(保持原行为) // Map type and button to Windows message if (type == "down") { - if (button == 0) { - msg64.message = WM_LBUTTONDOWN; - msg64.wParam = MK_LBUTTON; - } else if (button == 1) { - msg64.message = WM_MBUTTONDOWN; - msg64.wParam = MK_MBUTTON; - } else if (button == 2) { - msg64.message = WM_RBUTTONDOWN; - msg64.wParam = MK_RBUTTON; - } + if (button == 0) { msg64.message = WM_LBUTTONDOWN; msg64.wParam = MK_LBUTTON; } + else if (button == 1) { msg64.message = WM_MBUTTONDOWN; msg64.wParam = MK_MBUTTON; } + else if (button == 2) { msg64.message = WM_RBUTTONDOWN; msg64.wParam = MK_RBUTTON; } } else if (type == "up") { - if (button == 0) { - msg64.message = WM_LBUTTONUP; - } else if (button == 1) { - msg64.message = WM_MBUTTONUP; - } else if (button == 2) { - msg64.message = WM_RBUTTONUP; - } + if (button == 0) { msg64.message = WM_LBUTTONUP; } + else if (button == 1) { msg64.message = WM_MBUTTONUP; } + else if (button == 2) { msg64.message = WM_RBUTTONUP; } } else if (type == "move") { msg64.message = WM_MOUSEMOVE; } else if (type == "wheel") { @@ -845,13 +838,8 @@ void CWebService::HandleMouse(void* ws_ptr, const std::string& msg) { if (clientType != GetClientType(CLIENT_TYPE_MACOS) && clientType != "macOS") { return; // Skip dblclick for non-macOS clients } - if (button == 0) { - msg64.message = WM_LBUTTONDBLCLK; - msg64.wParam = MK_LBUTTON; - } else if (button == 2) { - msg64.message = WM_RBUTTONDBLCLK; - msg64.wParam = MK_RBUTTON; - } + if (button == 0) { msg64.message = WM_LBUTTONDBLCLK; msg64.wParam = MK_LBUTTON; } + else if (button == 2) { msg64.message = WM_RBUTTONDBLCLK; msg64.wParam = MK_RBUTTON; } } else { return; // Unknown type } @@ -907,41 +895,8 @@ void CWebService::HandleKey(void* ws_ptr, const std::string& msg) { return; } - // Build MSG64 structure - MSG64 msg64; - memset(&msg64, 0, sizeof(MSG64)); - - // Use WM_SYSKEYDOWN/UP for Alt combinations (same as MFC version) - if (altKey) { - msg64.message = isDown ? WM_SYSKEYDOWN : WM_SYSKEYUP; - } else { - msg64.message = isDown ? WM_KEYDOWN : WM_KEYUP; - } - msg64.wParam = keyCode; - msg64.time = GetTickCount(); - - // Build lParam for keyboard message: - // bits 0-15: repeat count (1) - // bits 16-23: scan code - // bit 24: extended key flag - // bit 29: context code (1 if Alt is pressed) - // bit 30: previous key state (1 for keyup) - // bit 31: transition state (1 for keyup) - UINT scanCode = MapVirtualKey(keyCode, MAPVK_VK_TO_VSC); - - // Extended keys: arrows, insert, delete, home, end, page up/down, numpad enter, etc. - bool isExtended = (keyCode >= VK_PRIOR && keyCode <= VK_DOWN) || // Page Up/Down, End, Home, Arrows - keyCode == VK_INSERT || keyCode == VK_DELETE || - keyCode == VK_NUMLOCK || keyCode == VK_RCONTROL || keyCode == VK_RMENU || - keyCode == VK_APPS; - - LPARAM lParam = 1; // repeat count = 1 - lParam |= (scanCode & 0xFF) << 16; - if (isExtended) lParam |= (1 << 24); - if (altKey) lParam |= (1 << 29); // context code for Alt - if (!isDown) lParam |= (3UL << 30); // bit 30 and 31 set for key up - - msg64.lParam = lParam; + // Build MSG64 structure(共享 helper,见 WebService.h;与 MCP remote_keyboard 复用) + MSG64 msg64 = BuildKeyMsg64(keyCode, isDown, altKey); // Send command to device const int length = sizeof(MSG64) + 1; @@ -1670,6 +1625,17 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig } } +bool CWebService::GetScreenSize(uint64_t device_id, int& width, int& height) { + width = 0; + height = 0; + std::lock_guard lock(m_DeviceCacheMutex); + auto it = m_DeviceCache.find(device_id); + if (it == m_DeviceCache.end()) return false; + width = it->second->screen_width; + height = it->second->screen_height; + return width > 0 && height > 0; +} + void CWebService::NotifyAudioState(uint64_t device_id, bool enabled) { if (m_bStopping) return; @@ -2193,6 +2159,21 @@ void CWebService::ClearMfcTriggered(uint64_t device_id) { m_MfcTriggeredDevices.erase(device_id); } +void CWebService::SetMcpTriggered(uint64_t device_id) { + std::lock_guard lock(m_McpTriggeredMutex); + m_McpTriggeredDevices.insert(device_id); +} + +bool CWebService::IsMcpTriggered(uint64_t device_id) { + std::lock_guard lock(m_McpTriggeredMutex); + return m_McpTriggeredDevices.find(device_id) != m_McpTriggeredDevices.end(); +} + +void CWebService::ClearMcpTriggered(uint64_t device_id) { + std::lock_guard lock(m_McpTriggeredMutex); + m_McpTriggeredDevices.erase(device_id); +} + bool CWebService::HasActiveSession(uint64_t device_id) { std::lock_guard lock(m_ScreenContextsMutex); return m_ScreenContexts.find(device_id) != m_ScreenContexts.end(); diff --git a/server/2015Remote/WebService.h b/server/2015Remote/WebService.h index 41b80be..cb4c207 100644 --- a/server/2015Remote/WebService.h +++ b/server/2015Remote/WebService.h @@ -13,11 +13,56 @@ #include #include +#include "common/commands.h" // MSG64 / COMMAND_SCREEN_CONTROL:共享键盘注入 helper(BuildKeyMsg64) + // Forward declarations class context; class CMy2015RemoteDlg; class CONTEXT_OBJECT; +// 构造单个键盘注入 MSG64(复用 WebService::HandleKey 的 lParam 逻辑,抽取为共享 helper, +// 供 Web 与 MCP 远程控制两处使用,避免漂移,见 docs/Mcp_RemoteControl_Design.md §6.2)。 +// keyCode:虚拟键码;isDown:按下/抬起;isSysKey:true 用 WM_SYSKEYDOWN/UP 并置 Alt 上下文位。 +inline MSG64 BuildKeyMsg64(int keyCode, bool isDown, bool isSysKey) { + MSG64 msg64; + memset(&msg64, 0, sizeof(MSG64)); + + if (isSysKey) msg64.message = isDown ? WM_SYSKEYDOWN : WM_SYSKEYUP; + else msg64.message = isDown ? WM_KEYDOWN : WM_KEYUP; + msg64.wParam = (uint64_t)keyCode; + msg64.time = GetTickCount(); + + UINT scanCode = MapVirtualKey(keyCode, MAPVK_VK_TO_VSC); + bool isExtended = (keyCode >= VK_PRIOR && keyCode <= VK_DOWN) || + keyCode == VK_INSERT || keyCode == VK_DELETE || + keyCode == VK_NUMLOCK || keyCode == VK_RCONTROL || keyCode == VK_RMENU || + keyCode == VK_APPS; + + LPARAM lParam = 1; // repeat count = 1 + lParam |= (scanCode & 0xFF) << 16; + if (isExtended) lParam |= (1 << 24); + if (isSysKey) lParam |= (1 << 29); // context code for Alt + if (!isDown) lParam |= (3UL << 30); // bit 30/31 for key up + + msg64.lParam = (uint64_t)lParam; + return msg64; +} + +// 构造单个鼠标注入 MSG64(复用 WebService::HandleMouse 的基座字段逻辑,抽取为共享 helper, +// 供 Web 与 MCP 远程控制两处使用,见 docs/Mcp_RemoteControl_Design.md §6.2)。 +// x/y 为物理像素坐标(已由调用方从归一化坐标映射);message/wParam 由调用方按动作设定。 +inline MSG64 BuildMouseMsg64(int x, int y, UINT message, uint64_t wParam) { + MSG64 msg64; + memset(&msg64, 0, sizeof(MSG64)); + msg64.pt.x = x; + msg64.pt.y = y; + msg64.lParam = MAKELPARAM(x, y); + msg64.time = GetTickCount(); + msg64.message = message; + msg64.wParam = wParam; + return msg64; +} + // Web client state struct WebClient { std::string token; @@ -104,6 +149,10 @@ public: // Resolution change notification void NotifyResolutionChange(uint64_t device_id, int width, int height, bool top_down = false, const std::string& client_type = ""); + // 读回 NotifyResolutionChange 缓存的物理捕获分辨率(来自 TOKEN_BITMAPINFO)。 + // 尚未收到分辨率时返回 false 并置 w/h 为 0。MCP 远程控制(remote_open)用它映射归一化坐标。 + bool GetScreenSize(uint64_t device_id, int& width, int& height); + // Audio enable/disable notification — pushes current state to all web // clients watching this device and caches it for newcomers. void NotifyAudioState(uint64_t device_id, bool enabled); @@ -250,6 +299,12 @@ public: bool IsMfcTriggered(uint64_t device_id); void ClearMfcTriggered(uint64_t device_id); + // MCP trigger management - an MCP remote control session owns the screen + // sub-connection and blocks human Web viewing (bidirectional mutex, §8.2). + void SetMcpTriggered(uint64_t device_id); + bool IsMcpTriggered(uint64_t device_id); + void ClearMcpTriggered(uint64_t device_id); + // Check if a remote desktop session already exists for device bool HasActiveSession(uint64_t device_id); @@ -295,6 +350,10 @@ private: std::set m_MfcTriggeredDevices; std::mutex m_MfcTriggeredMutex; + // MCP triggered devices: screen sub-connection owned by an MCP remote control session + std::set m_McpTriggeredDevices; + std::mutex m_McpTriggeredMutex; + // Web 终端会话状态 struct WebTermSession { void* ws_ptr; // browser WebSocket