15 Commits
Author SHA1 Message Date
yuanyuanxiang b41b27bfe4 Release v1.3.9 2026-09-02 19:13:40 +02:00
yuanyuanxianganddeepseek-v4-pro 6bcd593b6d Feature: Add upload_file MCP tool (V2 protocol, main-connection upload)
Implement the P2 upload_file tool to push a local file or directory from the
master to an online Windows host over the existing V2 file-transfer protocol.
The server drives FileBatchTransferWorkerV2 synchronously on the main
connection through a headless callback, reuses the list_files chain for the
overwrite pre-check, and is gated by McpFileTransfer=1 plus McpReadonly=0.

The client main connection never initialized the file-transfer module, so
g_status stayed 0 and RecvFileChunkV2 silently dropped every chunk, truncating
uploads to zero bytes. Add a once-per-process lazy InitFileUpload in the
COMMAND_SEND_FILE_V2 handler that mirrors the FileManager init; the destructor
deliberately does not Uninit so g_status remains 1 across reconnects.

Update the design doc to record the client-side change and correct the
upload_file description to state that sha256 is not returned (V2 has no
receiver-to-sender ACK; integrity is checked client-side and logged only).

Co-Authored-By: deepseek-v4-pro
2026-08-31 22:43:16 +02:00
yuanyuanxianganddeepseek-v4-pro 5c77f5ce14 Feature: Add download_file MCP tool (V2 protocol, SHA-256 verified)
download_file pulls a remote file or directory back to the controller over
the existing V2 file-transfer protocol. It reuses COMMAND_LIST_DRIVE to open
the file-manager sub-link, then sends CMD_DOWN_FILES_V2 so the Windows client
streams COMMAND_SEND_FILE_V2 chunks and a per-file COMMAND_FILE_COMPLETE_V2
SHA-256 checksum over its own authenticated sub-connection. The server routes
those two packet types into a new per-device FileTransferSession whenever a
download is pending, so the headless path never opens the GUI progress dialog;
the C2C and existing GUI branches are left untouched.

Files are written under a normalized local_dir, and every chunk filename is
resolved and verified to stay inside local_dir (directories included) to block
.. traversal; overwrite=false skips existing files and counts them as skipped.
One transfer per host (mutually exclusive with the one-shot pending registry),
a dedicated McpFileTransfer=0-by-default gate surfaced in the settings dialog,
and audit logging complete the change. upload_file remains future work.

Co-Authored-By: deepseek-v4-pro
2026-08-30 13:19:51 +02:00
yuanyuanxianganddeepseek-v4-pro ac2855198b Fix: terminal_exec sentinel timeout on numeric timeout_ms and silent commands
Two independent bugs made MCP terminal tools time out despite the command
finishing.

First, timeout_ms is declared integer in the tool schemas but was parsed
through GetStringArg, which only reads JSON strings. A numeric value fell
through to the 20s default, so callers asking for a longer wait were cut off
at 20s. exec_command, terminal_open, terminal_exec and remote_open now parse
timeout_ms through GetIntArg, which accepts both JSON numbers and digit
strings, keeping the 1..600000 range guard.

Second, FindSentinel treated the __MCP_DONE_<nonce>__ marker as a line start
only when preceded by \n. Commands that produce no output (ping > nul,
Start-Sleep, tar -czf) echo their command line ending in \r, so the marker
never matched and the wait ran to timeout even though the command had
completed. The check now also accepts \r.

Co-Authored-By: deepseek-v4-pro
2026-08-30 13:19:22 +02:00
yuanyuanxianganddeepseek-v4-pro 52b4afb8cf Improve: three-state online host list sorting
Clicking the same column header now cycles ascending -> descending ->
restore default order instead of only toggling direction. The default order
is the host connection order, reconstructed by sorting on online time
(descending alive time) so it no longer depends on the in-memory append
order surviving an earlier sort. The reset preference is persisted as an
empty value so it survives a restart.

Co-Authored-By: deepseek-v4-pro
2026-08-29 16:29:49 +02:00
yuanyuanxianganddeepseek-v4-pro 35dc2d89a0 Fix: hold IoRefCount across deferred heartbeat update to prevent use-after-free
Frequent standby-park churn (duplicate logins sharing one clientID) was
crashing the server with 0xC0000409 (STACK_BUFFER_OVERRUN). The heartbeat
handler posts the raw CONTEXT_OBJECT pointer via PostMessageA
(WM_UPDATE_ACTIVEWND) without holding a reference, so RemoveStaleContext
could recycle the context back to the pool and reuse it before the UI
thread consumed the queued message. UpdateActiveWindow then read a
stale/recycled object, and the unguarded Authorization memcpy overflowed
the 200-byte HeartbeatACK field and clobbered the stack cookie.

Hold the object's IoRefCount across the deferred message: fetch_add
before PostMessageA (with rollback on failure) and a paired fetch_sub in
UpdateUserEvent after UpdateActiveWindow returns. RemoveStaleContext
already waits for IoRefCount == 0 before MoveContextToFreePoolList, so
the object is now guaranteed to stay alive for the whole deferred call.

Also bound the three memcpy sites (two Signature[64], one
Authorization[200]) with explicit truncation so an unexpectedly long
signature or license can no longer overflow its fixed buffer in Release
builds, where ASSERT is a no-op.

Co-Authored-By: deepseek-v4-pro
2026-08-29 06:44:27 +02:00
yuanyuanxianganddeepseek-v4-pro d99122b25f Fix: Window manager view check reads stale state after restore
The window manager's "view" action checks the window state against the
ItemData.Data[2] field populated when the list was built. The right-click
show/hide/maximize/minimize handlers updated only the list control's display
text via SetItemText, leaving Data[2] stale. After restoring a minimized
window, viewing still reported "该窗口已最小化" until the dialog was reopened.

Sync Data[2] alongside SetItemText in all four handlers so the view check and
the status-column sort both see the current state.

Co-Authored-By: deepseek-v4-pro
2026-08-26 21:24:07 +02:00
yuanyuanxianganddeepseek-v4-pro d55d40e7a2 Feature: Exclude human Web viewing from MCP remote control sessions
Complete the bidirectional mutual exclusion between MCP remote control
and human Web viewing (§8.2). Direction 1 (remote_open rejected while a
human session holds the screen sub-connection) already existed; this adds
direction 2: a human Web viewer is now rejected while an MCP session owns
the device.

Add a m_McpTriggeredDevices marker (mirroring m_MfcTriggeredDevices) that
is set when an MCP session is created (BeginScreenCtrlOpen) and cleared at
every session-erasure site (CloseScreenCtrlSession, SweepIdleScreenCtrl,
OnScreenControlClosed, EndScreenCtrlAction), so the marker cannot go stale
and permanently block humans. HandleConnect checks IsMcpTriggered before
mutating client state or starting the remote desktop.

Co-Authored-By: deepseek-v4-pro
2026-08-25 14:14:26 +02:00
yuanyuanxianganddeepseek-v4-pro 25a6e2d07a Feature: Add remote_clipboard MCP tool
Add the remote_clipboard tool to the MCP remote control surface, writing
text to the remote host's clipboard via COMMAND_SCREEN_SET_CLIPBOARD
through the established screen sub-connection. Input UTF-8 is converted
to GBK (ToAnsi, code page 936) to match the client's CF_TEXT/ANSI
clipboard path, mirroring the existing CScreenSpyDlg::SendServerClipboard
packet format. Pasting remains a separate step (remote_keyboard Ctrl+V).

Known MVP limitation, documented in the tool description: non-GBK
characters (e.g. emoji) are replaced by '?' on the CF_TEXT path.

Co-Authored-By: deepseek-v4-pro
2026-08-25 13:45:51 +02:00
yuanyuanxianganddeepseek-v4-pro 18259b8526 Feature: Add remote_mouse MCP tool
Add the remote_mouse tool to the MCP remote control surface, injecting
mouse events (move / down / up / click / right_click / middle_click /
drag / scroll) into an established remote_open session. Normalized 0..1
coordinates are mapped to physical pixels via the session's captured
resolution and clamped to screen bounds. Scroll follows the existing web
console's wheel sign convention (positive delta scrolls down) and is
vertical-only.

Extract BuildMouseMsg64 into WebService.h as a shared helper, reused by
both the web console's HandleMouse and the new MCP handler so the two
injection paths cannot drift.

Co-Authored-By: deepseek-v4-pro
2026-08-25 13:38:09 +02:00
yuanyuanxianganddeepseek-v4-pro f04d892ac8 Feature: Add remote_keyboard MCP tool (key_down / key_up / key_press / type)
Implement milestone M2b of the MCP remote-control design: inject keyboard
events through an existing screen sub-connection.

- Extract the MSG64 keyboard construction from WebService::HandleKey into a
  shared inline BuildKeyMsg64 helper in WebService.h, and have HandleKey use
  it (behaviour-preserving) so the Web and MCP paths cannot drift.
- Add BuildRemoteKeyboard with four actions: key_down / key_up / key_press
  (key name -> VK via MapKeyNameToVk, plus CTRL/ALT/SHIFT/WIN modifiers) and
  type (per-character VkKeyScanA mapping, ASCII only, newline/tab -> Enter/Tab).
  The batch is sent as one [COMMAND_SCREEN_CONTROL][MSG64*N] packet over the
  screen sub-connection under the existing Begin/EndScreenCtrlAction busy
  discipline, then audited via WM_SHOWERRORMSG.

Non-ASCII text is rejected (-32602) and must go through remote_clipboard +
Ctrl+V (milestone M4), matching the design's clipboard path for CJK input.

Co-Authored-By: deepseek-v4-pro
2026-08-25 13:22:00 +02:00
yuanyuanxianganddeepseek-v4-pro c844ba7614 Fix: Client misparses screen-control batches when record count is a multiple of 7
ProcessCommand chose the MSG record size by testing "ulLength % 28 == 0"
before "% 48 == 0". The two sizes' least common multiple is 336 (7*48 =
12*28), so a 48-byte batch whose record count is a multiple of 7 was
misclassified as 28-byte MSG32 records, shifting every field and garbling
(or silently dropping) injected input.

Check "% 48 == 0" first. The modern controller always emits 48-byte MSG64;
the 28-byte MSG32 path is legacy 32-bit-controller compatibility and is only
reached when 48 does not divide evenly. This is the first feature (MCP
remote_keyboard "type", and the upcoming remote_mouse drag) to emit
multi-record batches, which is what made the latent bug reachable.

Co-Authored-By: deepseek-v4-pro
2026-08-25 13:21:54 +02:00
yuanyuanxianganddeepseek-v4-pro 09018e2b4d Improve: Harden MCP screen-ctrl session state machine for injection
Add the busy/closed flags to ScreenCtrlSession plus BeginScreenCtrlAction
/ EndScreenCtrlAction, mirroring the terminal's busy discipline so an
in-flight injection cannot race with session teardown (review finding #6).

While an injection is in flight (busy=true), OnScreenControlClosed marks the
session closed instead of erasing it (the injection thread, which still holds
subCtx, cleans up in EndScreenCtrlAction), SweepIdleScreenCtrl skips it, and
CloseScreenCtrlSession defers the erase. The subCtx is looked up under
m_ScreenCtrlMutex and the injection is sent outside the lock, matching the
established terminal pattern; the screen sub-connection's CONTEXT_OBJECT::Send2Client
already serializes internally via SendLock.

Co-Authored-By: deepseek-v4-pro
2026-08-25 13:04:05 +02:00
yuanyuanxianganddeepseek-v4-pro 6045baadb8 Feature: Add MCP remote_open/remote_close remote control sessions
Add M1 of MCP remote control (docs/Mcp_RemoteControl_Design.md): the
remote_open / remote_close tools plus the ScreenCtrlSession state machine.

remote_open establishes a hidden screen sub-connection by reusing
WebService::StartRemoteDesktop (COMMAND_SCREEN_SPY -> CScreenSpyDlg ->
RegisterScreenContext), polls for the sub-connection plus its physical
resolution (TOKEN_BITMAPINFO -> NotifyResolutionChange -> GetScreenSize),
then records the session (single device, single session, reverse-mapped
subCtx for OfflineProc cleanup) and returns {session_id, screen_w,
screen_h}. remote_close validates session_id and tears down the
sub-connection idempotently. A McpRemoteControl settings checkbox (default
off, requires McpReadonly=0) gates the tools; every open/close is audited
via WM_SHOWERRORMSG.

Gating: multi-monitor hosts are rejected with -32008 (phase 1 supports only
single monitor, where Observe=main screen and Act=virtual desktop coincide);
the monitor count comes from the client heartbeat RES_RESOLUTION ("N:W*H").

Known limitations (deferred to the injection milestones): mutual exclusion
with human remote-desktop viewing is one-directional in M1 (a human who
joins during an MCP session can tear it down on disconnect), and subCtx is
not yet dereferenced so no liveness re-check is needed until
remote_mouse/remote_keyboard.

Co-Authored-By: deepseek-v4-pro
2026-08-25 12:55:52 +02:00
yuanyuanxianganddeepseek-v4-pro 84e3565de1 doc: Add remote control MCP design doc
Add docs/Mcp_RemoteControl_Design.md, the reference design for an
screenshot-driven AI remote-control feature: the existing get_screenshot for
observation plus remote_open/remote_close/remote_mouse/remote_keyboard for
input injection, reusing COMMAND_SCREEN_PREVIEW_REQ and COMMAND_SCREEN_CONTROL
+ MSG64. Coordinates are normalized (0..1) and mapped server-side to physical
pixels; injection runs over the screen sub-connection opened by
WebService::StartRemoteDesktop's hidden CScreenSpyDlg. Includes a
chat-on-behalf experiment case with clipboard-encoding and window-capture
notes.

Also fold in the get_audit_log encoding correction for
docs/Mcp_Terminal_Design.md, so the two doc changes ship as one commit.

Co-Authored-By: deepseek-v4-pro
2026-08-24 21:51:12 +02:00
25 changed files with 3385 additions and 114 deletions

No files matched your search

+28
View File
@@ -378,6 +378,34 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
## 更新日志
### v1.3.9 (2026.9.2)
**MCP 服务端集成(AI 远程管理)& 远程控制与文件传输闭环 & 主控增强**
**新功能:**
- **MCP 服务端集成**:本地 `127.0.0.1:6544` 暴露 JSON-RPC 2.0 接口,Bearer Token 鉴权,「扩展 > MCP 设置」对话框配置启用 / 端口 / 绑定 / Token;23 个工具覆盖「观测 → 执行 → 控制 → 传输」全链路,默认关闭、分级开关(只读 / 白名单 / 终端 / 远程控制 / 文件传输)全面审计
- **MCP 观测类工具(只读)**:search_hosts、get_host_detail、list_processes、list_windows、get_activity_history、list_files、get_screenshot(可选 max_width)、list_services、get_client_log、list_registry、get_audit_log;单飞请求注册表 20s 超时 + 编码修正(clientType / clientID 定位)
- **MCP 远程终端工具**:exec_command 一次性执行 + terminal_open / terminal_exec / terminal_close 持久终端(cwd / 环境保留,空闲 300s 清扫),哨兵判定与 timeout_ms 解析修复
- **MCP 远程控制工具**:remote_open / remote_close 会话 + remote_keyboard(key_down / up / press / type)、remote_mouse(move / click / drag / scroll)、remote_clipboard 注入;与人工 Web 查看双向互斥,会话状态机 busy / closed 加固
- **MCP 文件传输工具**:download_file(V2 协议 + SHA-256 校验)、upload_file(主连接上传),客户端侧 `COMMAND_SEND_FILE_V2` 懒初始化 `InitFileUpload` 修复上传截断
- **客户端活动历史**:记录活跃窗口历史(上限 500 条、跳过 <5s),主机「客户端管理」子菜单新增「活动历史」对话框,Unicode 编辑控件正确渲染 UTF-8 标题
- **日志右键菜单三连**:消息日志右键新增「在线通知」「拉黑 IP」「解封 IP」,IPv4 提取即时生效
- **列表排序记忆**:主机列表、进程 / 窗口列表排序偏好持久化(`list\*Sort` 注册表键),下次启动默认沿用
**改进:**
- 重复连接 standby 处理:重复登录暂存 standby 待旧连接超时 / 断线后晋升,避免循环重连与误报离线
- 远程桌面 / 虚拟屏幕对话框标题显示主机备注而非 IP(无备注回退 IP)
- 主机列表三态排序(升序 → 降序 → 恢复默认,默认序按在线时间重建)
- 在线主机右键三个菜单项新增图标(位图数组 63→66)
**Bug 修复:**
- 客户端屏幕控制批解析错误:记录数是 7 的倍数时误判 MSG32 致注入错乱(`%48==0` 优先)
- 心跳延迟更新 use-after-free:跨 PostMessage 持有 IoRefCount + memcpy 显式截断
- ONLINELIST_PRIVILEGE 枚举错位损坏历史数据库(权限列移至 16)
- 远程桌面光标跨对话框污染(改 `OnSetCursor` 逐窗口路由)
- 客户端日志对话框 EDIT 上限停止刷新(`SetLimitText(0)`)
- 窗口管理器视图检查读取陈旧状态、进程列表架构列排序无效、FRP / NAT 下窗口标题乱码
### v1.3.8 (2026.8.3)
**SYSTEM 权限客户端 & 主控可用性增强 & Bug 修复**
+28
View File
@@ -378,6 +378,34 @@ Valid : 2026-02-01 to 2028-02-01
## Changelog
### v1.3.9 (2026.9.2)
**MCP Server Integration (AI Remote Management) & Remote Control / File Transfer Loop & Master Enhancements**
**New features:**
- **MCP server integration**: local JSON-RPC 2.0 endpoint on `127.0.0.1:6544` with Bearer token auth, configured via "Extensions > MCP Settings"; 23 tools spanning observe → execute → control → transfer, disabled by default with per-tier gates (read-only / whitelist / terminal / remote control / file transfer) and full audit logging
- **MCP read-only observation tools**: search_hosts, get_host_detail, list_processes, list_windows, get_activity_history, list_files, get_screenshot (optional max_width), list_services, get_client_log, list_registry, get_audit_log; single-flight pending registry with 20s timeout, plus encoding fixes (clientType / clientID resolution)
- **MCP remote terminal tools**: one-shot exec_command plus persistent terminal_open / terminal_exec / terminal_close (cwd and environment preserved, 300s idle sweep); sentinel detection and timeout_ms parsing fixes
- **MCP remote control tools**: remote_open / remote_close sessions plus remote_keyboard (key_down / up / press / type), remote_mouse (move / click / drag / scroll), and remote_clipboard injection; two-way mutual exclusion with human Web viewing and a hardened busy/closed session state machine
- **MCP file transfer tools**: download_file (V2 protocol + SHA-256 verification) and upload_file (main-connection upload); client-side lazy `InitFileUpload` in `COMMAND_SEND_FILE_V2` fixes truncated uploads
- **Client activity history**: records active-window history (capped at 500 entries, skips <5s); new "Activity History" item under the host's Client Management menu with a Unicode edit control for correct UTF-8 title rendering
- **Log context-menu trio**: right-click "Online Notify", "Ban IP", and "Unban IP" on the message log, with IPv4 extraction applied immediately
- **Sort preference memory**: host list and process/window list sort preferences persisted (`list\*Sort` registry keys), restored on next launch
**Improvements:**
- Duplicate connections parked as standby and promoted after the old connection times out / disconnects, avoiding reconnect loops and false offline notifications
- Remote desktop and virtual screen dialog titles show the host remark instead of the IP (falling back to IP)
- Three-state host list sorting (ascending → descending → restore default, default rebuilt by online time)
- Icons added to three online-host context menu items (bitmap array 63→66)
**Bug fixes:**
- Client screen-control batch misparse: record count that is a multiple of 7 was misclassified as MSG32, garbling injected input (`%48==0` checked first)
- Heartbeat deferred-update use-after-free: IoRefCount held across PostMessage plus explicit memcpy truncation
- ONLINELIST_PRIVILEGE enum shift corrupted the history host database (column moved to 16)
- Remote desktop cursor cross-contamination across dialogs (routed via `OnSetCursor` per window)
- Client log dialog stopped refreshing past the EDIT control limit (`SetLimitText(0)`)
- Window manager view check reading stale state, process-list architecture column sort no-op, and FRP/NAT window-title mojibake
### v1.3.8 (2026.8.3)
**SYSTEM-level Client & Master Usability Enhancements & Bug Fixes**
+28
View File
@@ -378,6 +378,34 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
## 更新日誌
### v1.3.9 (2026.9.2)
**MCP 伺服端整合(AI 遠端管理)& 遠端控制與檔案傳輸閉環 & 主控增強**
**新功能:**
- **MCP 伺服端整合**:本地 `127.0.0.1:6544` 暴露 JSON-RPC 2.0 介面,Bearer Token 鑑權,「擴充 > MCP 設定」對話框設定啟用 / 連接埠 / 綁定 / Token;23 個工具覆蓋「觀測 → 執行 → 控制 → 傳輸」全鏈路,預設關閉、分級開關(唯讀 / 白名單 / 終端 / 遠端控制 / 檔案傳輸)全面稽核
- **MCP 觀測類工具(唯讀)**:search_hosts、get_host_detail、list_processes、list_windows、get_activity_history、list_files、get_screenshot(可選 max_width)、list_services、get_client_log、list_registry、get_audit_log;單飛請求登錄表 20s 逾時 + 編碼修正(clientType / clientID 定位)
- **MCP 遠端終端工具**:exec_command 一次性執行 + terminal_open / terminal_exec / terminal_close 持久終端(cwd / 環境保留,閒置 300s 清掃),哨兵判定與 timeout_ms 解析修復
- **MCP 遠端控制工具**:remote_open / remote_close 工作階段 + remote_keyboard(key_down / up / press / type)、remote_mouse(move / click / drag / scroll)、remote_clipboard 注入;與人工 Web 查看雙向互斥,工作階段狀態機 busy / closed 加固
- **MCP 檔案傳輸工具**:download_file(V2 協定 + SHA-256 校驗)、upload_file(主連線上傳),用戶端側 `COMMAND_SEND_FILE_V2` 懶初始化 `InitFileUpload` 修復上傳截斷
- **用戶端活動歷史**:記錄活躍視窗歷史(上限 500 條、跳過 <5s),主機「用戶端管理」子選單新增「活動歷史」對話框,Unicode 編輯控件正確渲染 UTF-8 標題
- **訊息右鍵選單三連**:訊息記錄右鍵新增「線上通知」「封鎖 IP」「解封 IP」,IPv4 提取即時生效
- **列表排序記憶**:主機列表、處理程序 / 視窗列表排序偏好持久化(`list\*Sort` 登錄檔鍵),下次啟動預設沿用
**改進:**
- 重複連線 standby 處理:重複登入暫存 standby 待舊連線逾時 / 斷線後晉升,避免循環重連與誤報離線
- 遠端桌面 / 虛擬螢幕對話框標題顯示主機備註而非 IP(無備註回退 IP)
- 主機列表三態排序(升冪 → 降冪 → 恢復預設,預設序按上線時間重建)
- 線上主機右鍵三個選單項目新增圖示(位圖陣列 63→66)
**Bug 修復:**
- 用戶端螢幕控制批次解析錯誤:記錄數是 7 的倍數時誤判 MSG32 致注入錯亂(`%48==0` 優先)
- 心跳延遲更新 use-after-free:跨 PostMessage 持有 IoRefCount + memcpy 顯式截斷
- ONLINELIST_PRIVILEGE 列舉錯位損壞歷史資料庫(權限列移至 16)
- 遠端桌面游標跨對話框污染(改 `OnSetCursor` 逐視窗路由)
- 用戶端記錄對話框 EDIT 上限停止刷新(`SetLimitText(0)`)
- 視窗管理員檢視檢查讀取陳舊狀態、處理程序列表架構欄排序無效、FRP / NAT 下視窗標題亂碼
### v1.3.8 (2026.8.3)
**SYSTEM 權限用戶端 & 主控可用性增強 & Bug 修復**
+7
View File
@@ -1379,6 +1379,13 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
case COMMAND_SEND_FILE_V2: {
// 主连接接收 V2 上传:确保文件传输模块已初始化(RecvFileChunkV2 依赖 g_status==1)。
// static 标志保证进程内只初始化一次,不随主连接重连反复 Init/Uninit。
static bool s_v2RecvInited = false;
if (!s_v2RecvInited) {
InitFileUpload({}, m_LoginMsg, m_LoginSignature, 64, 50, Logf);
s_v2RecvInited = true;
}
// C2C/V2 文件接收(RecvFileChunkV2 内部会打印进度)
int n = RecvFileChunkV2((char*)szBuffer, ulLength, m_conn,
nullptr, m_hash, m_hmac, m_MyClientID);
+7 -3
View File
@@ -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; // 数据包不合法
// 命令个数
+2 -2
View File
@@ -88,7 +88,7 @@ IDR_WAVE WAVE "Res\\msg.wav"
//
VS_VERSION_INFO VERSIONINFO
FILEVERSION 1,0,3,8
FILEVERSION 1,0,3,9
PRODUCTVERSION 1,0,0,1
FILEFLAGSMASK 0x3fL
#ifdef _DEBUG
@@ -106,7 +106,7 @@ BEGIN
BEGIN
VALUE "CompanyName", "FUCK THE UNIVERSE"
VALUE "FileDescription", "A GHOST"
VALUE "FileVersion", "1.0.3.8"
VALUE "FileVersion", "1.0.3.9"
VALUE "InternalName", "ServerDll.dll"
VALUE "LegalCopyright", "Copyright (C) 2019-2026"
VALUE "OriginalFilename", "ServerDll.dll"
BIN
View File
Binary file not shown.
+396
View File
@@ -0,0 +1,396 @@
# YAMA MCP 文件传输功能设计(download_file / upload_file)
> **状态**:设计定稿(经专家审查修订,结论见 §13)。`download_file`(P1)已实施并验收;`upload_file`(P2)已实施(详见 §7,评审修订见 §13.1)。`download_file` 采用 **V2 文件传输协议**(`CMD_DOWN_FILES_V2` + 流式子连接 + SHA-256 校验),`upload_file` 复用服务端既有 `FileBatchTransferWorkerV2`(主连接同步驱动)。两者共享同一套「文件会话」注册表。
> **读者**:MCP 后续功能研发/评审人员。
> **关联文档**:[Mcp_Phase2_Design.md](./Mcp_Phase2_Design.md)(双模式无头驱动机制)、[FILE_TRANSFER_V2.md](./FILE_TRANSFER_V2.md)(V2 协议清单)、[Mcp_Design.md](./Mcp_Design.md)(Phase 1 协议/架构/配置)。
---
## 1. 背景与目标
MCP 目前已暴露 `list_files`(只读列目录,`McpServer.cpp:1741`),但**没有文件传输工具**:把远程主机的文件/目录拉回主控本机(下载),或把本机文件推到远程(上传),只能通过 GUI 文件管理器手工操作。
本技术书解决:
1. **`download_file`**(本期 P1)——远程文件/目录 → 主控本机指定目录,复用 V2 推送协议,带 SHA-256 完整性校验、支持大文件。
2. **`upload_file`**(后续 P2)——主控本机文件/目录 → 远程指定目录,复用服务端 V2 发送器。
3. **一套共享的「流式文件会话」基础设施**——一次投入,下载/上传两处受益,镜像现有终端(`TermSession`)与远程控制(`ScreenCtrlSession`)会话模式。
---
## 2. 现状回顾
| 能力 | 现有协议/命令 | 服务端现有实现 | 是否暴露给 MCP |
|---|---|---|---|
| 远程列目录 | `COMMAND_LIST_DRIVE`→`TOKEN_DRIVE_LIST`;`COMMAND_LIST_FILES`→`TOKEN_FILE_LIST` | `McpServer.cpp:1741`(`list_files`) | ✅ |
| 远程→本地下载(V1) | `COMMAND_DOWN_FILES`(`commands.h:166`) + `TOKEN_FILE_SIZE/DATA/FINISH` | `FileManagerDlg.cpp:2164` / `file/CFileManagerDlg.cpp:1136` | ❌ 仅 GUI |
| 远程→本地下载(V2) | `CMD_DOWN_FILES_V2`(94, `commands.h:260`) + `COMMAND_SEND_FILE_V2`(85) + `COMMAND_FILE_COMPLETE_V2`(91) | `FileManagerDlg.cpp:3337-3384`(下发)、`2756`(`RecvFileChunkV2` 落盘) | ❌ 仅 GUI |
| 本地→远程上传(V2) | `FileBatchTransferWorkerV2`(服务端作 sender) | `2015RemoteDlg.cpp:7572` | ❌ 仅 GUI |
### 2.1 关键事实
1. **V2 下载的客户端会另开一条新子连接**:`client/FileManager.cpp:1101-1190` `UploadToRemoteV2()` 解析完文件列表后,`new IOCPClient(...)` → `EnableSubConnAuth()` → `ConnectServer(...)`(`FileManager.cpp:1170-1172`),在**独立鉴权子连接**上推 `COMMAND_SEND_FILE_V2` 分块 + `COMMAND_FILE_COMPLETE_V2` 校验包。即下载 ≠ 复用文件管理器那条子链接,而是一条**持续流子链接**——正属 `Mcp_Phase2_Design.md §4.3` 当初判定「本期不做」的流式能力。
2. **接收端 `RecvFileChunkV2` 无 UI 可调**:`CDlgFileSend.cpp:128` 已 `RecvFileChunkV2(buf, len, nullptr, nullptr, hash, hmac, 0)` 无头落盘;落盘状态在 `SimplePlugins/file_upload.cpp` 内部按 `transferID` 自维护,天然适合 MCP 无头复用。
3. **V2 落盘路径由客户端回填**:`CMD_DOWN_FILES_V2` 的 `targetDir` 是**主控本机**保存目录(`FileManagerDlg.cpp:3370` `m_Local_Path`);客户端把它拼进每个 chunk 的 `filename` 发回,服务端 `RecvFileChunkV2` 直接按该完整路径写盘。因此「下载保存到哪」由服务端在命令里指定、客户端原样回填。
---
## 3. 协议选型(已定:V2)
| 维度 | **V2 推送(选定)** | V1 拉取(回退) |
|---|---|---|
| 命令 | `CMD_DOWN_FILES_V2` + `COMMAND_SEND_FILE_V2` + `COMMAND_FILE_COMPLETE_V2` | `COMMAND_DOWN_FILES` + `TOKEN_FILE_SIZE/DATA/FINISH` |
| 连接 | 客户端新开一条鉴权流式子连接 | 复用文件管理器子链接 |
| 完整性 | ✅ SHA-256(`COMMAND_FILE_COMPLETE_V2`) | ❌ 无 |
| 大文件 | ✅ 64 位偏移,>4GB | 64 位偏移但无校验 |
| 客户端代码 | ✅ 已存在,是当前 GUI 正规路径 | ⚠️ 遗留路径 |
| MCP 侧复杂度 | 需路由一条**新流式子连接**(新增会话注册表) | 需在 `MessageHandle` 维护「发 CONTINUE/收 DATA」状态机,逐块 ACK 慢 |
**结论:采用 V2。** 代价是服务端要新增「流式文件会话」注册与路由;但这是 `upload_file` 同样要复用的基础设施,一次投入两处受益。V1 仅作「不想引入新子连接路由」时的降级备案,不在本期实现。
---
## 4. 设计原则
1. **复用现有协议,不新造命令**:`CMD_DOWN_FILES_V2`、`COMMAND_SEND_FILE_V2`、`COMMAND_FILE_COMPLETE_V2`、`COMMAND_LIST_DRIVE` 全部为 `common/commands.h` 既有;客户端零改动(upload 方向例外:`COMMAND_SEND_FILE_V2` 需懒初始化文件模块,见 §7.8)。
2. **无头接管复用会话模式**:镜像 `McpServer.h` 的 `TermSession` / `ScreenCtrlSession`,新增 `FileTransferSession`;`MessageHandle` 用 `IsFileTransferContext(context*)` 判定是否路由到 MCP 无头落盘,否则回落 GUI 进度框。
3. **单设备单传输会话**:与终端/远程控制一致,避免同 host 并发传输的归属歧义;并发返回 `-32003 Device busy`。
4. **超时与清理是硬约束**:大文件传输是长任务,超时需独立于 `kMcpToolTimeoutMs`(20s);断线/超时必须清会话 + 关子链接 + 删半成品文件。
5. **输出 schema 先行**:两个工具先在 `tools/list` 声明完整 `inputSchema`/`outputSchema`。
---
## 5. 核心机制:流式文件会话
### 5.1 会话注册表(`McpServer.h/.cpp`)
```cpp
struct FileTransferSession {
std::string tool; // "download_file" / "upload_file"
std::string localDir; // download: 本机落盘目录(规范化后绝对路径,用于穿越校验)
std::string remotePath; // 审计用原始远程路径(UTF-8)
uint64_t transferID = 0; // 绑定后从首包填充(download);upload 由本端 GenerateTransferID()
uint32_t totalFiles = 0; // 完成判定:首个 COMMAND_SEND_FILE_V2 chunk 的 totalFiles(§13 F4)
uint32_t filesDone = 0; // 完成判定:累计收到的 COMMAND_FILE_COMPLETE_V2 数
bool done = false;
int error = 0; // FEV2_* / 自定义
std::vector<FileEntry> files; // {path, size} 已落盘/已发送文件
std::vector<FileEntry> skipped; // overwrite=false 时跳过的同名文件
time_t startAt = 0;
};
std::mutex m_FileXferMutex;
std::map<uint64_t, FileTransferSession> m_FileXferSessions; // device_id → 会话
```
> **取消 `m_FileXferContextToDevice` 映射与 `IsFileTransferContext(context*)`**:流式子连接经 `TOKEN_CONN_AUTH` 已把 `clientID` 钉在 `ContextObject` 上(`2015RemoteDlg.cpp:6411 SetID`),分派时直接用 `ContextObject->GetClientID()` 定位 `device_id`,无需 context 路由表(§13 F1)。
配套接口(与 `BeginTermPending` / `OnTerminalData` / `WaitTerminalDone` 同构):
```cpp
bool BeginFileTransferPending(uint64_t device_id, const std::string& tool,
const std::string& localDir, const std::string& remotePath);
bool IsFileTransferPending(uint64_t device_id); // 分支顶部守卫
void OnFileChunkV2(uint64_t device_id, const BYTE* buf, ULONG len); // 路径校验 + 无头 RecvFileChunkV2
void OnFileCompleteV2(uint64_t device_id, const BYTE* buf, ULONG len);// SHA-256 + filesDone 计数
bool WaitFileTransferDone(uint64_t device_id, int timeoutMs, std::vector<FileEntry>& out);
void ClearFileTransfer(uint64_t device_id); // 失败/超时/断线收尾(删半成品)
```
### 5.2 流式子连接的路由(device_id 键,镜像 `TOKEN_DRIVE_LIST` 守卫)
V2 下载的数据包 `COMMAND_SEND_FILE_V2`(85) / `COMMAND_FILE_COMPLETE_V2`(91) 本就由 `MessageHandle` 按 `ContextObject->GetClientID()` 分派(`2015RemoteDlg.cpp:5842` / `6101`)。MCP 只需在这两个 case 的 **`dstClientID==0`(M2C)分支顶部**加守卫:
```cpp
case COMMAND_SEND_FILE_V2: {
FileChunkPacketV2* pkt = (FileChunkPacketV2*)szBuffer;
if (pkt->dstClientID == 0) {
uint64_t devId = ContextObject->GetClientID();
if (McpServer().IsFileTransferPending(devId)) { // ← 新增守卫:无头接管
McpServer().OnFileChunkV2(devId, szBuffer, len);
break; // 不建 CDlgFileSend、不设 hDlg
}
// ... 原 GUI 逻辑(建 CDlgFileSend + OnReceiveComplete)不变
}
// ... C2C 分支(dstClientID != 0)完全不动
}
```
- `TOKEN_CONN_AUTH`(`6378`) **完全不动**——它只负责把 `clientID` 钉在 ctx 上,下载复用既有行为。
- `TOKEN_DRIVE_LIST`(`6430`) 既有 `IsPending(devId)` 守卫**扩展复用**:`OnDriveList` 识别当前挂起工具为 `download_file` 时,改发 `CMD_DOWN_FILES_V2` 而非 `COMMAND_LIST_FILES`,发完即 `CancelIO` 文件管理器子链接(数据走新流式子连接,§13 F3)。
- 守卫为假时全部回落原 GUI/C2C 路径——对既有功能零影响(原则 #1,§13 F7)。
---
## 6. `download_file` 设计(P1)
### 6.1 工具 Schema
```
input: {
id: string (必填, 主机 id)
remote_path: string (必填, 远程文件或目录绝对路径, 如 C:\Users\shaun\Pictures)
local_dir: string (必填, 主控本机保存目录; 不存在会自动创建)
overwrite: boolean (可选, 默认 false; true 覆盖同名文件, false 则跳过)
timeout_ms: integer (可选, 默认 600000, 上限 3600000)
}
output: {
files: [{ path: string, size: integer, sha256: string }] // 实际落盘文件
total_bytes: integer
skipped: integer
}
```
### 6.2 时序
```
MCP工具线程 服务端 MessageHandle 客户端
│ 1. BeginFileTransferPending(id,"download_file",localDir,remotePath) │
│ 2. 主连接发 COMMAND_LIST_DRIVE ────────────────────────────────────────► 开文件管理器子链接
│ 3. ◄── TOKEN_DRIVE_LIST ─────────────────────┤
│ OnDriveList → 锁外下发 CMD_DOWN_FILES_V2[targetDir\0][remote_path\0]\0 │
│ 4. ────────────────────────────────────────► UploadToRemoteV2()
│ (客户端另开一条鉴权流式子连接 ConnectServer)
│ 5. 新子连接 TOKEN_CONN_AUTH 钉 clientID → 后续分块按 device_id 守卫接管 │
│ 6. ◄── COMMAND_SEND_FILE_V2 分块流 ──────────┤
│ → OnFileChunkV2 → RecvFileChunkV2() 无头落盘
│ 7. ◄── COMMAND_FILE_COMPLETE_V2(SHA-256) ────┤
│ 8. 校验通过 → done → 唤醒工具线程 → CancelIO 两条子连接 → 返回 │
```
### 6.3 服务端改动
1. **`McpServer.h`**:新增 `FileTransferSession` 结构 + 注册表 + 接口声明(§5.1)。
2. **`McpServer.cpp`**:
- `BuildDownloadFileInputSchema/OutputSchema` + `BuildDownloadFile(...)`(`tools/call` 分派);
- `OnFileChunkV2`:**先路径校验**(chunk `filename` 规范化后仍在 `local_dir` 内,防 `..` 穿越,§13 F5)→ 调 `RecvFileChunkV2(buf,len,nullptr,nullptr,hash,hmac,0)` 无头落盘,记 `totalFiles`/进度;
- `OnFileCompleteV2`:`HandleFileCompleteV2` 校验 SHA-256 → `filesDone++`;`filesDone==totalFiles` 时置 `done` + `notify`(§13 F4);
- `hash/hmac` 镜像 `FileManagerDlg.cpp:2755` 的 `GetPwdHash()/GetHMAC(100)`,兼容客户端 M2C `hmac` 为空(`client/FileManager.cpp:1167`,§13 F6);
- `ClearFileTransfer`(发送失败/超时/断线):`CancelIO` 子链接 + 删除半成品。
3. **`2015RemoteDlg.cpp`(`MessageHandle`)**:`COMMAND_SEND_FILE_V2`(85)、`COMMAND_FILE_COMPLETE_V2`(91) 的 `dstClientID==0` 分支顶部加 `IsFileTransferPending(devId)` 守卫;`TOKEN_DRIVE_LIST` 处扩展 `OnDriveList` 识别 `download_file` 改发 `CMD_DOWN_FILES_V2`。`TOKEN_CONN_AUTH` 与 C2C 分支**完全不动**。
4. **`McpSettingsDlg.cpp/.h`**:新增 `McpFileTransfer` 配置项(默认 0)。
### 6.4 落盘与路径安全
- 发 `CMD_DOWN_FILES_V2` 前把 `local_dir` 规范化为绝对路径并 `CreateDirectory`;`targetDir` 用 ANSI(MBCS 构建本机路径)。
- 每收到一个 chunk,校验其 `filename` 规范化后**前缀必须在 `local_dir` 内**(防 `..` 穿越,呼应 `FILE_TRANSFER_V2.md §8.3` 已识别的风险);逃逸则置 `error` 并 `CancelIO`。
- 编码:`remote_path` 走 UTF-8→ANSI(936)(Windows 客户端,复用 `McpServer.cpp` 既有 `ToAnsi`,与 `list_files` 一致)。
### 6.5 超时与并发
- **P1 范围:同步 + 单文件/已打包目录**。一次 `tools/call` 阻塞到底,不引入 job/task 异步模型(决策见 §12)。核心场景「先 `terminal_exec` 压缩成单 zip,再 `download_file` 拉回」天然是单文件,同步足够。
- 默认 `timeout_ms=600000`、上限 3600000,因大文件远超 `kMcpToolTimeoutMs`(20s)。
- **单飞互斥(§13 F2)**:`BeginFileTransferPending` 与既有一次性 `m_Pending` **双向互斥**——下载登记时占用 `m_Pending`,反之 `BeginPending` 也检查 `m_FileXferSessions`,保证同 host 任意时刻只一个 MCP 工具在飞;并发返回 `-32003 Device busy`。
- 断线(`OfflineProc` 擦会话)+ 超时(删半成品)双收尾。
---
## 7. `upload_file` 设计(P2)
### 7.1 工具 Schema
```
input: {
id: string (必填, 目标主机 id)
local_path: string (必填, 主控本机文件或目录绝对路径; 目录递归上传)
remote_dir: string (必填, 远程主机保存目录; 不存在会自动创建)
overwrite: boolean (可选, 默认 false; true 覆盖同名文件, false 跳过)
timeout_ms: integer (可选, 默认 600000, 上限 3600000)
}
output: {
files: [{ path: string, size: integer, sha256: string }] // 已发送文件(远程完整路径);P2 sha256 恒为空串(见 §7.5)
total_bytes: integer
skipped: integer
}
```
### 7.2 方向与连接(与 download 的关键差异)
| 维度 | download_file(P1) | upload_file(P2) |
|---|---|---|
| 数据方向 | 远程客户端 → 主控 | 主控 → 远程客户端 |
| 发送方 | 客户端(`UploadToRemoteV2` → `FileBatchTransferWorkerV2`) | 主控(`FileBatchTransferWorkerV2`) |
| 连接 | 客户端**新开一条鉴权流式子连接** | 复用**主连接**(`ctx->Send2Client`),无子连接 |
| 完成信号 | 客户端发 `COMMAND_FILE_COMPLETE_V2`,服务端 `OnFileCompleteV2` 校验计数 | 服务端**作为发送方**自己发 `COMMAND_FILE_COMPLETE_V2`(SHA-256),客户端 `HandleFileCompleteV2` 校验 |
| MessageHandle 改动 | 2 处守卫分支 + `OnDriveList` 扩展 | **零改动**(客户端 `KernelManager.cpp:1381` 接收,但需懒初始化文件模块,见 §7.8) |
> **修正旧稿(§13.1 评审)**:旧稿写「只需等客户端回 `COMMAND_FILE_COMPLETE_V2`」是错的。V2 协议里 COMPLETE 包恒由**发送方**发、接收方校验,**无接收方→发送方 ACK**。upload 的服务端是发送方,故它**发** COMPLETE 而非「等」。这也意味着 upload 比 download 更简单——无需路由外来流式子连接、无需 `OnFileCompleteV2` 计数。
### 7.3 时序
```
MCP工具线程 主连接 (ctx→Send2Client) 客户端 KernelManager
1. 校验 McpFileTransfer=1 && McpReadonly=0(§8)
2. CollectLocalFiles({local_path}) 收集本机文件+目录项(目录项在前、子项随后)
3. (overwrite=false) list_files 预检 remote_dir 一层 → 得已存在顶层名 → skip 列表
4. BeginFileUpload(id) 登记会话(单设备单传输互斥标记,§6.5)
5. 同步驱动(工具线程内):FileBatchTransferWorkerV2(files, remote_dir, cb, f, hash, hmac, opts)
6. ── COMMAND_SEND_FILE_V2 分块流 ───► RecvFileChunkV2 落盘
7. ── COMMAND_FILE_COMPLETE_V2(SHA256)► HandleFileCompleteV2 校验
8. worker 同步返回 → ClearFileTransfer → 返回 files/total_bytes/skipped
```
### 7.4 服务端改动
1. **`McpServer.h`**:新增 `BeginFileUpload(id)`,登记 `FileTransferSession{tool="upload_file", startAt}` 作为**单设备单传输互斥标记**(与 `m_Pending`/`m_FileXferSessions` 互斥,见 §6.5)。upload 走主连接、工具线程**同步**驱动 `FileBatchTransferWorkerV2`,会话**无** `fmSubCtx`/`streamSubCtx`/`fileEntries`/`done` 等字段(`ClearFileTransfer` 对空指针安全)。
2. **`McpServer.cpp`**:
- `BuildUploadFileInputSchema/OutputSchema` + `BuildUploadFile(...)`(`tools/call` 分派);
- 无头回调 `UploadSendChunkHeadless(user, chunk, data, size)`:经 `UploadCallbackData{parent,clientID,deadline}` 里 `parent->FindHost(clientID)` 定位 ctx → `ctx->Send2Client(data,size)`;客户端离线或整体超时返回 false 中止(镜像 GUI `SendFileChunkToClientV2`,去掉 `dlg` 进度,见 `2015RemoteDlg.cpp:7424-7459`);
- 收集:本地 `CollectLocalFiles` 递归(`common/file_upload.cpp:38` 的 `ExpandDirectories` 未在 `file_upload.h` 导出,故在 McpServer.cpp 本地同构实现,目录项在前、子项随后);
- overwrite 预检:复用 `list_files` 的 `COMMAND_LIST_DRIVE→TOKEN_DRIVE_LIST→COMMAND_LIST_FILES→TOKEN_FILE_LIST` 机制列 `remote_dir` **一层**(顶层名,不递归),过滤同名 → `skipped`;
- 驱动:`FileBatchTransferWorkerV2(files, remote_dir, &cb, headlessCallback, nullptr, GetPwdHash(), GetHMAC(100), opts)`;`opts.transferID=GenerateTransferID()`、`srcClientID=0`、`dstClientID=clientID`、`enableResume=false`;
- 完成判定:`result==0 && FindHost(clientID)!=nullptr`(镜像 GUI `SendFilesToClientV2Internal` 末尾)。
3. **`2015RemoteDlg.cpp`(MessageHandle)**:**零改动**。
4. **`McpSettingsDlg.cpp/.h`**:零改动(`McpFileTransfer` 已存在,§8 复用)。
### 7.5 完整性边界(诚实声明)
- V2 无接收方→发送方 ACK:客户端 `HandleFileCompleteV2` 校验失败只打日志(`RecvFileChunkV2` 返回 8,`KernelManager.cpp:1381` 不回传主控)。
- **P2 的 `output.files[].sha256` 恒为空串**:`FileBatchTransferWorkerV2` 内部会为每个文件自算 SHA-256 并写入 `COMMAND_FILE_COMPLETE_V2` 包发给客户端,但该值**不回传调用方**;服务端侧又无导出的 SHA-256 工具函数可自行复算。故 P2 输出不填 sha256(延后 P3:worker 回传哈希,或服务端引入 SHA-256 工具)。传输完整性仍依赖客户端本地 `HandleFileCompleteV2` 校验(与 GUI upload / C2C 同权,属 V2 协议既有边界,非 MCP 引入)。
- 若未来需要「接收方验真回执」,需新增反向 ACK 包(协议扩展,进 P3)。
### 7.6 overwrite 语义
- `overwrite=false`(默认):发送前用 `list_files` 预检 `remote_dir` **一层**(顶层名,不递归),本地顶层项名已存在者整体跳过(单文件精确、目录整体跳过),文件条目计入 `skipped`(目录项不计)。编码 UTF-8→ANSI(936),与 `list_files`/`download_file` 一致。
- `overwrite=true`:全量发送(客户端 `RecvFileChunkV2` 覆盖写)。
- 代价:一次预检往返;对称于 download 的「本地同名跳过」语义。
### 7.7 安全
- 门槛 `McpFileTransfer=1 && McpReadonly=0`(§8):upload 写远程盘,复用「允许写」主开关。
- `remote_dir` 路径规范化(防 `..` 穿越到预期目录之外)。`local_path` 是主控本机路径(信任本地文件系统),主要风险是 AI 误推敏感文件/覆盖远程关键文件——由 `McpReadonly=0` + 审计兜底(与 `terminal_*`/`remote_*` 同款「开关+审计」,不加目录黑名单,见 §12.1)。
### 7.8 客户端改动(实施期修正,评审见 §13.1 U7)
`RecvFileChunkV2` 依赖全局 `g_status==1`(`SimplePlugins/file_upload.cpp:2812` `if (!g_status) return -1;`),但客户端**主连接此前从不初始化文件传输模块**——`InitFileUpload` 只在 `FileManager`/`ScreenManager` 构造里成对调用(下载/屏幕方向),上传走主连接时 `g_status==0`,每个 chunk 都被 `RecvFileChunkV2` 直接丢弃(表现为落盘 0 字节 / 截断)。
修法:在 `client/KernelManager.cpp` 的 `COMMAND_SEND_FILE_V2` 分支加**懒初始化**(进程内仅一次):
```cpp
static bool s_v2RecvInited = false;
if (!s_v2RecvInited) {
InitFileUpload({}, m_LoginMsg, m_LoginSignature, 64, 50, Logf);
s_v2RecvInited = true;
}
int n = RecvFileChunkV2((char*)szBuffer, ulLength, m_conn, nullptr, m_hash, m_hmac, m_MyClientID);
```
要点(独立评审结论,均为安全):
- 与 `FileManager.cpp:40` 的 Init 参数**逐字节一致**(`{}`, `m_LoginMsg`, `m_LoginSignature`, 64, 50, `Logf`),不新增路径。
- `static` 保证进程内只初始化一次,主连接重连(`ClientDll.cpp:660/665` 反复 `SAFE_DELETE`+`new CKernelManager`)不反复 Init/Uninit;`~CKernelManager` 保持原样**不** `UninitFileUpload`,故 `g_threadCount` 从 1 起永不归 0,`g_status` 恒为 1。
- `InitFileUpload` 幂等(`g_fileStatesMtx` + `g_threadCount` 引用计数,二次调用 `g_threadCount>1` 早退)、非阻塞(仅置标志 + 派生 detach 线程)、license 校验在启动期 `licenseInit()` 后必过、`verifyMessage` 对空签名 `return false`(`license.cpp:244`)不会越界。
- 影响面:主连接 `OnReceive` 无 V1 `COMMAND_SEND_FILE` 分支,`g_status=1` 只作用于 `RecvFileChunkV2`;`FileManager`/`ScreenManager` 的成对 Init/Uninit 只是在 1↔2 间震荡,语义不变。
---
## 8. 安全门
| 工具 | 定性 | 门槛 |
|---|---|---|
| `download_file` | 不改远程状态,但**数据外带** | `McpFileTransfer=1`(仅此一开关;**不依赖** `McpReadonly`,与 `list_files`/`get_screenshot` 同权) |
| `upload_file` | **写远程盘** | `McpFileTransfer=1` **且** `McpReadonly=0`(复用既有「允许写」主开关) |
- **只新增一个开关 `McpFileTransfer`(默认 0)**:download 只看它;upload 额外要求 `McpReadonly=0`。决策依据见 §12。
- `tools/list` 据此隐藏两个工具(与 `exec_command`/`terminal_*`/`remote_*` 同款开关判断)。
- 审计:`host_id + tool + remote_path + local_dir + 结果`,写 `Mprintf` + 审计日志。
---
## 9. 改动文件清单
| 文件 | 改动 |
|---|---|
| `server/2015Remote/McpServer.h` | `FileTransferSession` + 注册表 + 8 个接口声明 |
| `server/2015Remote/McpServer.cpp` | `download_file` schema/实现 + 会话状态机 + 路由分支 |
| `server/2015Remote/2015RemoteDlg.cpp` | `MessageHandle`:`COMMAND_SEND_FILE_V2`(85) / `COMMAND_FILE_COMPLETE_V2`(91) 两处守卫分支 + `TOKEN_DRIVE_LIST` 处 `OnDriveList` 扩展;`TOKEN_CONN_AUTH` 不动 |
| `server/2015Remote/McpSettingsDlg.cpp/.h` | `McpFileTransfer` 配置项 |
| (P2)`server/2015Remote/McpServer.h` | `FileTransferSession.tool` 增 `"upload_file"`;新增 `BeginFileUpload(id)`(单设备单传输互斥标记) |
| (P2)`server/2015Remote/McpServer.cpp` | `upload_file` schema/实现:无头回调 `UploadSendChunkHeadless` + `CollectLocalFiles` 收集 + overwrite 预检(复用 `list_files` 链路)+ 驱动 `FileBatchTransferWorkerV2`(复用主连接,无子连接路由) |
| (P2)`client/KernelManager.cpp` | `COMMAND_SEND_FILE_V2` 分支加懒初始化 `InitFileUpload`(`g_status` 由 0 置 1,见 §7.8) |
**客户端**:`UploadToRemoteV2` / `RecvFileChunkV2` / `FileBatchTransferWorkerV2` 均已存在、未改动;仅 `COMMAND_SEND_FILE_V2` 分支新增懒初始化(见 §7.8)。
---
## 10. 分阶段实施与回滚
- **P1**:`download_file` + `McpFileTransfer` 开关 + 路由分支。可独立合入、独立验收(真实主机拖回一个目录,SHA-256 与 `certutil -hashfile` 比对一致)。
- **P2**:`upload_file`(§7,主连接复用 + 发送方驱动,`MessageHandle` 零改动)。可独立合入、独立验收(推一个目录到真实主机,SHA-256 与源文件 `certutil -hashfile` 比对一致)。
- **P3**(可选):断点续传(需先验证服务端续传状态落盘;文件管理器侧现 `enableResume=false`,`client/FileManager.cpp:1164`)、大文件进度流式上报。
- **回滚**:改动集中在 `McpServer.*` + `MessageHandle` 三个 `if` 分支,revert 当期 commit 即可,不影响既有 GUI 文件管理器。
---
## 11. 测试点
- 单文件 / 目录(含中文名、深层嵌套)下载;SHA-256 与 `certutil -hashfile` 比对一致。
- `overwrite=false` 同名跳过;`local_dir` 不存在自动创建。
- 路径穿越:`remote_path` 含 `..` 或 chunk 文件名逃逸 `local_dir` 时被拒。
- 大文件(>2GB)超时与断线;并发下载同主机返回 `-32003`。
- 编码:GBK 中文文件名往返无乱码(与 `list_files` 同规则)。
- 断线收尾:传输中客户端掉线 → 会话擦除 + 半成品删除,无句柄/内存泄漏。
- **上传(P2)**:单文件 / 目录(含中文名、深层嵌套)上传;`remote_dir` 不存在自动创建;SHA-256 与源文件 `certutil -hashfile` 比对一致。
- 上传 `overwrite=false` 同名跳过(`skipped` 计数);`overwrite=true` 覆盖。
- 上传 `remote_dir` 含 `..` 逃逸被拒;大文件(>2GB)超时与断线;并发上传/上传-下载同主机返回 `-32003`。
- 上传编码:GBK 中文文件名往返无乱码。
---
## 12. 决策记录(以「简单易用」为原则)
原则:最少开关、最少认知负担、复用既有机制、不为低频场景预留复杂度;只有威胁模型确实需要时才加复杂度。
### 12.1 download 数据外带 → **不加目录白名单,信任 `McpFileTransfer` + 审计**
- **威胁模型**:MCP 默认 `127.0.0.1` + token;「数据外带」的实际顾虑是 AI 误操作或提示注入。但一旦开启 `terminal_exec`(全 shell、无白名单),外带通道远大于 download,目录白名单对已开终端者是冗余防御。
- **一致性**:`terminal_*` / `remote_*` 均只有开关 + 审计、无路径白名单;给 download 单独加白名单是特例,增加解释负担。
- **简单**:工具契约保持 `id + remote_path + local_dir`,不引入「允许根目录」概念。
- **逃生舱**:若未来确有需要,复用 `McpCmdWhitelist` 那种**单字符串**配置作可选收紧项,进 P3,不进 P1。
### 12.2 大文件模型 → **P1 同步 + 大超时,收窄到单文件/已打包目录**
- MCP 是 request/response;为一次性传输引入「提交任务 + 轮询进度 + 任务清理」违背「简单 + 每阶段独立可交付」。
- 核心场景(先压缩成单 zip 再拉回)天然是单文件,同步足够;`get_screenshot` 已先例式地内联返回字节。
- 异步任务模型进 P3,且**只在确有超大流式需求时**才做,不预建。
### 12.3 upload 开关 → **单一 `McpFileTransfer`,upload 复用 `McpReadonly=0`**
- `download_file` = `McpFileTransfer=1`(不依赖 `McpReadonly`):它只读远程,与 `list_files`/`get_screenshot` 同权,readonly 语义本就只拦「改远程」。
- `upload_file` = `McpFileTransfer=1 && McpReadonly=0`:upload 改远程,复用既有「允许写」主开关,与 `terminal_*`/`remote_*` 的 `X && !Readonly` 模式一致。
- 只新增 **1 个**开关;代价是「不能只开 upload 不开 download」——这是可接受的简化(无人有此诉求)。
---
## 13. 专家审查记录(实施前)
原则优先级:① 对既有功能影响最小 → ② 简单易用 → ③ 优先 V2。逐条核对了 `2015RemoteDlg.cpp` 的 `MessageHandle` 分派、`client/FileManager.cpp` 与 `SimplePlugins/file_upload.cpp` 的实现,结论如下:
| # | 审查发现 | 结论 |
|---|---|---|
| F1 | 路由机制比草案更简单:`COMMAND_SEND_FILE_V2`(`5842`)/`COMMAND_FILE_COMPLETE_V2`(`6101`) 本就按 `ContextObject->GetClientID()` 分派,流式子连接经 `TOKEN_CONN_AUTH`(`6411 SetID`) 钉住 clientID | **无需 context 路由表**。改为 `device_id` 键 + 两个 case 顶部的 `IsFileTransferPending(devId)` 守卫,`TOKEN_CONN_AUTH` 完全不动 |
| F2 | 一次性 `m_Pending` 与新增文件会话可能并发冲突(同 host 双工具) | `BeginFileTransferPending` 与 `m_Pending` **双向互斥**,同 host 全 MCP 工具单飞 |
| F3 | 文件管理器子链接发完 `CMD_DOWN_FILES_V2` 后即无用(数据走新流式子连接) | `OnDriveList` 发完命令即 `CancelIO` 该子链接;客户端 `UploadToRemoteV2` 用独立 `IOCPClient`,不依赖它 |
| F4 | 完成信号是**每文件一个** `COMMAND_FILE_COMPLETE_V2`(`FileCompletePacketV2.fileIndex`) | 会话记 `totalFiles`(首 chunk)/`filesDone`(complete 计数),`filesDone==totalFiles` 才算 done |
| F5 | `RecvFileChunkV2` 内部直接按 chunk `filename` 落盘 | MCP 在调用**前**校验 `filename` 规范化仍在 `local_dir` 内,逃逸即取消传输 |
| F6 | `RecvFileChunkV2` 需正确 `hash/hmac` | 镜像 `FileManagerDlg.cpp:2755` 的 `GetPwdHash()/GetHMAC(100)`;客户端 M2C `hmac` 为空(`FileManager.cpp:1167`) |
| F7 | 三处改动均为「加 if 守卫 + 现有逻辑作 else」,C2C 分支不动 | 结构性满足原则 #1,对既有 GUI/C2C 零影响 |
**判定**:7 项问题均已在正文对应章节修正,无阻塞项,**定稿**。
### 13.1 upload_file 评审记录(P2,实施前)
核对了 `2015RemoteDlg.cpp` 的 `SendFilesToClientV2Internal`/`SendFileChunkToClientV2`(GUI upload 发送方)、`SimplePlugins/file_upload.cpp` 的 `FileBatchTransferWorkerV2`/`RecvFileChunkV2`、`client/KernelManager.cpp:1381` 的接收分支,结论:
| # | 审查发现 | 结论 |
|---|---|---|
| U1 | 旧稿「服务端等客户端回 `COMMAND_FILE_COMPLETE_V2`」方向写反 | COMPLETE 恒由**发送方**发、接收方 `HandleFileCompleteV2` 校验,无 ACK 回传。upload 服务端是发送方 → **发** COMPLETE,不是「等」 |
| U2 | upload 走**主连接**(`ctx->Send2Client`),不像 download 要客户端新开鉴权流式子连接 | `MessageHandle` 零改动;无需 `IsFileTransferPending` 路由守卫、无需 `OnFileCompleteV2` 收包计数 |
| U3 | 复用 `FileBatchTransferWorkerV2` 时回调需无头版 | 镜像 GUI `SendFileChunkToClientV2` 去掉 `dlg` 进度,`m_parent->FindHost(clientID)` 定位 ctx;离线返回 false 中止 |
| U4 | 完成判定无接收方回执 | `result==0 && FindHost(clientID)!=nullptr`(与 GUI `SendFilesToClientV2Internal` 末尾一致);`output.sha256` 恒为空串,非接收方验真(见 §7.5) |
| U5 | `overwrite=false` 需预知远程同名文件 | 复用 `list_files` 的 `COMMAND_LIST_DRIVE→TOKEN_DRIVE_LIST` 预检 `remote_dir`,过滤同名进 `skipped` |
| U6 | 本地文件收集 | `common/file_upload.cpp:38` `ExpandDirectories` 未在 `file_upload.h` 导出,改为 McpServer.cpp 本地 `CollectLocalFiles` 同构实现(目录项在前、子项随后) |
| U7 | 实施验证发现:客户端主连接 `g_status==0`,`RecvFileChunkV2` 逐 chunk `return -1`(上传落盘 0 字节/截断) | 客户端 `COMMAND_SEND_FILE_V2` 分支加懒初始化(§7.8):`static` 一次 + 永久引用计数、析构不 `Uninit`;与 `FileManager.cpp:40` 参数一致,独立评审确认无稳定性风险 |
**判定**:7 项均已写入 §7 对应小节,无阻塞项,**定稿**。
+484
View File
@@ -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:<base64>, 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<context*,uint64_t> m_ScreenCtrlContextToDevice; std::map<uint64_t,ScreenCtrlSession> 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":"<HOSTID>"}}}'
# 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":"<HOSTID>","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":"<HOSTID>","session_id":"<SID>","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":"<HOSTID>","session_id":"<SID>","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":"<HOSTID>","session_id":"<SID>","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":"<HOSTID>","session_id":"<SID>"}}}'
# 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
+1 -1
View File
@@ -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 会话泄漏 / 子链接不关
Binary file not shown.
+86 -14
View File
@@ -1689,8 +1689,12 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
std::string msg = startTime;
msg += "|" + std::to_string(ContextObject->GetClientID());
auto signature = signMessage("", (BYTE*)msg.c_str(), msg.length());
ASSERT(signature.size() <= sizeof(copy.Signature));
memcpy(copy.Signature, signature.data(), signature.size());
// 防御性边界保护:signMessage 正常返回 64 字节,但 ASSERT 在 Release 下是空操作,
// 若签名意外超长,无界 memcpy 会越界写 MasterSettings.Signature[64]。这里强制截断。
size_t sigLen = signature.size();
if (sigLen > sizeof(copy.Signature))
sigLen = sizeof(copy.Signature);
memcpy(copy.Signature, signature.data(), sigLen);
LeaveCriticalSection(&m_cs);
Mprintf("主机[%s]上线: %s[%s][%s]\n", v[RES_CLIENT_PUBIP].empty() ? strIP : v[RES_CLIENT_PUBIP].c_str(),
std::to_string(id).c_str(), loc, groupName.c_str());
@@ -2195,6 +2199,10 @@ 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);
// 文件传输开关:默认关;仅 download_file 门控(不要求只读关)。
McpServer().SetFileTransferEnabled(THIS_CFG.GetInt("settings", "McpFileTransfer", 0) != 0);
if (!McpServer().Start(mcpBind, mcpPort)) {
Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort);
} else {
@@ -4038,8 +4046,13 @@ void CMy2015RemoteDlg::SortByColumn(int nColumn)
if (ColumnToDataSlot(nColumn) < 0) return;
if (nColumn == m_nSortColumn) {
// 如果点击的是同一列,切换排序顺序
m_bSortAscending = !m_bSortAscending;
// 同一列三态循环:升序 -> 降序 -> 恢复默认顺序 -> 升序 ...
if (m_bSortAscending) {
m_bSortAscending = false; // 升序 -> 降序
} else {
m_nSortColumn = -1; // 降序 -> 恢复默认(按上线先后)
m_bSortAscending = true;
}
} else {
// 否则,切换到新列并设置为升序
m_nSortColumn = nColumn;
@@ -4059,12 +4072,18 @@ void CMy2015RemoteDlg::SortByColumn(int nColumn)
void CMy2015RemoteDlg::ApplySort()
{
// 无有效排序偏好或列不合法(缩略图/越界)时保持列表原始顺序
// 虚拟列表:对数据源进行排序,调用方需持有 m_cs
if (m_nSortColumn <= COL_THUMBNAIL || m_nSortColumn >= g_Column_Count_Online) {
return;
}
// 虚拟列表:对数据源进行排序(列号映射到底层数据槽),调用方需持有 m_cs
// 无有效排序偏好(缩略图/越界/已清零):恢复默认顺序。
// 默认顺序 = 上线先后 = 上线时间(OnlineTime)升序 = 在线时长(GetAliveTime)降序。
std::sort(m_HostList.begin(), m_HostList.end(),
[](context* a, context* b) {
uint64_t ta = a ? a->GetAliveTime() : 0;
uint64_t tb = b ? b->GetAliveTime() : 0;
return ta > tb;
});
} else {
// 按指定列排序(列号映射到底层数据槽)
int slot = ColumnToDataSlot(m_nSortColumn);
bool asc = m_bSortAscending;
std::sort(m_HostList.begin(), m_HostList.end(),
@@ -4074,6 +4093,7 @@ void CMy2015RemoteDlg::ApplySort()
int result = s1.Compare(s2);
return asc ? (result < 0) : (result > 0);
});
}
// 排序后 clientID -> 索引映射已改变,需重建
m_ClientIndex.clear();
@@ -4105,7 +4125,10 @@ void CMy2015RemoteDlg::LoadSortPreference()
void CMy2015RemoteDlg::SaveSortPreference()
{
std::string val = std::to_string(m_nSortColumn) + "," + (m_bSortAscending ? "1" : "0");
// 清零(恢复默认顺序)时写入空值,等价于"无偏好",下次启动走 LoadSortPreference 的空分支
std::string val = (m_nSortColumn >= 0)
? std::to_string(m_nSortColumn) + "," + (m_bSortAscending ? "1" : "0")
: "";
THIS_CFG.SetStr("list", "OnlineListSort", val);
}
@@ -5166,6 +5189,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;
@@ -5817,6 +5845,14 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
// V2 文件传输(支持 C2C)
FileChunkPacketV2* pkt = (FileChunkPacketV2*)szBuffer;
// P6:MCP download_file 挂起时接管流式子连接(dstClientID==0 → 主控端)。
// OnFileChunkV2 自身会做 len 校验,这里先判长度再读 dstClientID,避免越界。
if (len >= sizeof(FileChunkPacketV2) && pkt->dstClientID == 0 &&
McpServer().IsFileTransferPending(ContextObject->GetClientID())) {
McpServer().OnFileChunkV2(ContextObject->GetClientID(), ContextObject, szBuffer, len);
break;
}
if (pkt->dstClientID == 0) {
// 目标是主控端:本地接收
if (ContextObject->hDlg == NULL) {
@@ -6077,6 +6113,13 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
if (len < sizeof(FileCompletePacketV2)) break;
FileCompletePacketV2* pkt = (FileCompletePacketV2*)szBuffer;
// P6:MCP download_file 挂起时接管完成校验包(dstClientID==0 → 主控端)。
if (pkt->dstClientID == 0 &&
McpServer().IsFileTransferPending(ContextObject->GetClientID())) {
McpServer().OnFileCompleteV2(ContextObject->GetClientID(), szBuffer, len);
break;
}
if (pkt->dstClientID == 0) {
// 目标是主控端:本地校验
bool verifyOk = HandleFileCompleteV2((char*)szBuffer, len, 0);
@@ -6257,7 +6300,13 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
case TOKEN_HEARTBEAT:
case 137: // 心跳【L】
ContextObject->HeartbeatRecvMs.store(GetUnixMs(), std::memory_order_relaxed);
g_2015RemoteDlg->PostMessageA(WM_UPDATE_ACTIVEWND, 0, (LPARAM)ContextObject);
// 为延迟消息占一个引用计数:RemoveStaleContext 会等 IoRefCount==0 才回收对象,
// 否则这里经消息队列传出的裸指针在 UI 线程消费时可能已被替补抖动回收复用(use-after-free)。
// UpdateUserEvent 处理完后配对 fetch_sub 释放。
ContextObject->IoRefCount.fetch_add(1);
if (!g_2015RemoteDlg->PostMessageA(WM_UPDATE_ACTIVEWND, 0, (LPARAM)ContextObject)) {
ContextObject->IoRefCount.fetch_sub(1); // 投递失败,退回引用,避免泄漏
}
break;
case TOKEN_SCREEN_PREVIEW_RSP: {
// 屏幕预览响应:把整个包 + 来源 clientId 一并堆分配,转给主线程处理
@@ -6404,6 +6453,13 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
ContextObject->CancelIO(); // 只列盘 → 用完即关一次性子链接
break;
}
// P6:MCP download_file 挂起时接管文件管理器子链接,下发 CMD_DOWN_FILES_V2。
// true=已接管(保持子链接,ClearFileTransfer 收尾);false=会话已清理(迟到包)→ 关孤儿子链接。
if (McpServer().IsFileTransferPending(devId)) {
if (!McpServer().OnDownloadDriveList(devId, ContextObject))
ContextObject->CancelIO();
break;
}
ContextObject->EnableZstdContext(6);
g_2015RemoteDlg->SendMessage(WM_OPENFILEMANAGERDIALOG, 0, (LPARAM)ContextObject);
break;
@@ -6809,8 +6865,12 @@ bool CMy2015RemoteDlg::PromoteStandby(uint64_t clientID)
std::string msg = startTime.GetString();
msg += "|" + std::to_string(clientID);
auto signature = signMessage("", (BYTE*)msg.c_str(), msg.length());
ASSERT(signature.size() <= sizeof(copy.Signature));
memcpy(copy.Signature, signature.data(), signature.size());
// 防御性边界保护:signMessage 正常返回 64 字节,但 ASSERT 在 Release 下是空操作,
// 若签名意外超长,无界 memcpy 会越界写 MasterSettings.Signature[64]。这里强制截断。
size_t sigLen = signature.size();
if (sigLen > sizeof(copy.Signature))
sigLen = sizeof(copy.Signature);
memcpy(copy.Signature, signature.data(), sigLen);
SendMasterSettings(standby, copy);
return true;
}
@@ -6867,7 +6927,12 @@ LRESULT CMy2015RemoteDlg::OnUserOfflineMsg(WPARAM wParam, LPARAM lParam)
LRESULT CMy2015RemoteDlg::UpdateUserEvent(WPARAM wParam, LPARAM lParam)
{
CONTEXT_OBJECT* ctx = (CONTEXT_OBJECT*)lParam;
if (ctx) {
UpdateActiveWindow(ctx);
// 释放 TOKEN_HEARTBEAT 投递延迟消息时占用的引用计数(与那里的 fetch_add 配对)。
// 必须在 UpdateActiveWindow 完全返回后再释放,保证整个处理期间对象不被回收。
ctx->IoRefCount.fetch_sub(1);
}
return S_OK;
}
@@ -7204,7 +7269,14 @@ void CMy2015RemoteDlg::UpdateActiveWindow(CONTEXT_OBJECT* ctx)
HeartbeatACK ack = { hb.Time, (char)authStatus, (char)isTrail };
if (authorized) {
std::string authorization = isV2 ? LoadLicenseAuthorization(hb.SN) : BuildV1Authorization(hb.SN, true);
memcpy(ack.Authorization, authorization.c_str(), authorization.length());
// 防御性边界保护:正常 authorization 约 150~200 字节;一旦异常超长,无界 memcpy
// 会越界写 HeartbeatACK.Authorization[200] 并踩栈金丝雀(0xC0000409)。
// Release 下强制截断并保证 NUL 结尾。
size_t authLen = authorization.length();
if (authLen >= sizeof(ack.Authorization))
authLen = sizeof(ack.Authorization) - 1;
memcpy(ack.Authorization, authorization.c_str(), authLen);
ack.Authorization[authLen] = '\0';
}
// 在 send 前一刻填进处理耗时(毫秒)。GetUnixMs 底层是 chrono::system_clock,
// 在 VS2019+ MSVC 上精度亚微秒(截断到 ms),两次作差误差 ≤ 1ms,能准确捕获
+1 -1
View File
@@ -173,7 +173,7 @@ protected:
DECLARE_MESSAGE_MAP()
public:
void SortByColumn(int nColumn);
void ApplySort(); // 按偏好重排 m_HostList(调用方需持有 m_cs)
void ApplySort(); // 按偏好重排 m_HostList;无偏好时恢复默认顺序(上线先后)(调用方需持有 m_cs)
void LoadSortPreference();
void SaveSortPreference();
afx_msg VOID OnHdnItemclickList(NMHDR* pNMHDR, LRESULT* pResult);
@@ -518,6 +518,7 @@
<Image Include="res\Bitmap\AuthGen.bmp" />
<Image Include="res\Bitmap\authorize.bmp" />
<Image Include="res\Bitmap\Backup.bmp" />
<Image Include="res\Bitmap\BrowseWnd.bmp" />
<Image Include="res\Bitmap\CancelShare.bmp" />
<Image Include="res\Bitmap\ClientLog.bmp" />
<Image Include="res\Bitmap\Forbidden.bmp" />
@@ -534,6 +535,7 @@
<Image Include="res\Bitmap\GrayDesktop.bmp" />
<Image Include="res\Bitmap\Help.bmp" />
<Image Include="res\Bitmap\History.bmp" />
<Image Include="res\Bitmap\HistoryActivity.bmp" />
<Image Include="res\Bitmap\HostProxy.bmp" />
<Image Include="res\Bitmap\Import.bmp" />
<Image Include="res\Bitmap\ImportLicense.bmp" />
@@ -552,6 +554,7 @@
<Image Include="res\Bitmap\PEEdit.bmp" />
<Image Include="res\Bitmap\Plugin.bmp" />
<Image Include="res\Bitmap\PluginConfig.bmp" />
<Image Include="res\Bitmap\PortProxyAuto.bmp" />
<Image Include="res\Bitmap\PortProxyStd.bmp" />
<Image Include="res\Bitmap\PrivateScreen.bmp" />
<Image Include="res\Bitmap\proxy.bmp" />
@@ -88,6 +88,8 @@
<ClCompile Include="McpServer.cpp" />
<ClCompile Include="McpSettingsDlg.cpp" />
<ClCompile Include="CClientLog.cpp" />
<ClCompile Include="CActivityDialog.cpp" />
<ClCompile Include="LogSearchBar.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\..\client\Audio.h" />
@@ -196,6 +198,8 @@
<ClInclude Include="McpServer.h" />
<ClInclude Include="McpSettingsDlg.h" />
<ClInclude Include="CClientLog.h" />
<ClInclude Include="CActivityDialog.h" />
<ClInclude Include="LogSearchBar.h" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="2015Remote.rc" />
@@ -338,5 +342,9 @@
<Image Include="res\Bitmap\Debug.bmp" />
<Image Include="res\ClientLog.ico" />
<Image Include="res\Bitmap\ClientLog.bmp" />
<Image Include="res\Bitmap\Forbidden.bmp" />
<Image Include="res\Bitmap\HistoryActivity.bmp" />
<Image Include="res\Bitmap\BrowseWnd.bmp" />
<Image Include="res\Bitmap\PortProxyAuto.bmp" />
</ItemGroup>
</Project>
File diff suppressed because it is too large. Load diff
+125
View File
@@ -8,6 +8,7 @@
#include <mutex>
#include <condition_variable>
#include <map>
#include <set>
#include <vector>
#include <cstdint>
#include <ctime>
@@ -28,6 +29,14 @@
class CMy2015RemoteDlg;
class context;
// 文件传输结果条目(download_file 输出 files[] 的一项)。
// path 为本机落盘绝对路径(ANSI,输出 JSON 时再转 UTF-8);sha256 为小写 hex。
struct FileTransferEntry {
std::string path;
uint64_t size = 0;
std::string sha256;
};
// MCP (Model Context Protocol) 服务端:httplib 封装 + JSON-RPC 2.0 分发 + 静态 token 校验。
// 与 CWebService 平级、互不依赖;默认禁用,经「扩展 → MCP设置」开启后监听
// (默认 127.0.0.1:6544,仅本机回环)。见 docs/Mcp_Design.md。
@@ -157,6 +166,82 @@ 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);
// ===== P6:MCP 文件传输(download_file,V2 协议)=====
// 下载走 CMD_DOWN_FILES_V2 → 客户端新开流式子连接回传 COMMAND_SEND_FILE_V2 /
// COMMAND_FILE_COMPLETE_V2(dstClientID==0)。会话按 device_id 键控,单设备单传输;
// 复用 list_files 的 COMMAND_LIST_DRIVE → TOKEN_DRIVE_LIST 开文件管理器子链接下发命令。
void SetFileTransferEnabled(bool enabled) { m_fileTransferEnabled = enabled; }
bool IsFileTransferEnabled() const { return m_fileTransferEnabled; }
// 该 host 是否有进行中的文件传输会话(MessageHandle 分派守卫)。
bool IsFileTransferPending(uint64_t device_id);
// 登记文件传输会话(false = 该 host 已有文件会话或一次性挂起请求)。
bool BeginFileTransferPending(uint64_t device_id, const std::string& tool,
const std::string& localDir, const std::string& remotePath,
bool overwrite);
// 登记上传会话(false = 该 host 已有文件会话或一次性挂起请求)。upload 走主连接、
// 工具线程同步驱动 FileBatchTransferWorkerV2,会话仅为单设备单传输互斥标记
// (无 fmSubCtx/streamSubCtx,ClearFileTransfer 对空指针安全)。
bool BeginFileUpload(uint64_t device_id);
// TOKEN_DRIVE_LIST:识别 download_file,存文件管理器子链接并下发 CMD_DOWN_FILES_V2。
// 返回 true=已接管(保持子链接,收尾由 ClearFileTransfer);false=会话已清理(调用方 CancelIO)。
bool OnDownloadDriveList(uint64_t device_id, context* fmSubCtx);
// COMMAND_SEND_FILE_V2:解析 chunk,路径校验 + overwrite 判定 + 落盘(RecvFileChunkV2)。
void OnFileChunkV2(uint64_t device_id, context* streamSubCtx, const BYTE* buf, ULONG len);
// COMMAND_FILE_COMPLETE_V2:SHA-256 校验(HandleFileCompleteV2)+ 完成判定。
void OnFileCompleteV2(uint64_t device_id, const BYTE* buf, ULONG len);
// 工具线程:等待传输完成;true=成功(files 已填,error=0),false=失败/超时(error 填原因)。
// 无论成败,files 都会返回已见文件的落盘路径(成功=完整文件,失败=供调用方删半成品)。
// 调用方随后必须调 ClearFileTransfer 收尾(擦会话 + CancelIO 子链接)。
bool WaitFileTransferDone(uint64_t device_id, int timeoutMs,
std::vector<FileTransferEntry>& files, int& skipped, int& error);
// 收尾:擦会话 + 锁外 CancelIO 文件管理器/流式两条子链接(幂等)。
void ClearFileTransfer(uint64_t device_id);
// 安全配置(启动时由 CMy2015RemoteDlg 读 THIS_CFG 后设置)。
void SetReadonly(bool readonly) { m_readonly = readonly; }
void SetCmdWhitelist(const std::string& whitelist) { m_cmdWhitelist = whitelist; }
@@ -220,9 +305,49 @@ private:
std::map<uint64_t, TermSession> m_TermSessions; // device_id → 会话
std::map<context*, uint64_t> 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<uint64_t, ScreenCtrlSession> m_ScreenCtrlSessions; // device_id → 会话
std::map<context*, uint64_t> m_ScreenCtrlContextToDevice; // subCtx → device_id(OfflineProc 反查)
// ===== P6:文件传输会话(受 m_FileXferMutex 保护;单设备单传输)=====
struct FileTransferSession {
std::string tool; // "download_file" / "upload_file"
std::string localDir; // download:本机保存目录(ANSI,结尾 '\')
std::string remotePath; // 客户端远程路径(ANSI,发 CMD_DOWN_FILES_V2 用)
bool overwrite = false; // 是否覆盖已存在文件
context* fmSubCtx = nullptr; // 文件管理器子链接(下发 CMD_DOWN_FILES_V2)
context* streamSubCtx = nullptr; // 流式子链接(首个 chunk 填,收尾 CancelIO)
uint64_t transferID = 0; // 客户端回填的传输会话 ID
uint32_t totalFiles = 0; // 客户端声明的文件总数(含目录项,见 F4)
uint32_t directoryCount = 0; // 目录项计数(目录不发 COMPLETE 包)
uint32_t filesDone = 0; // 已完成的文件数(含跳过的)
bool done = false; // 全部完成或出错
int error = 0; // 0=ok;8=哈希不匹配;1001=路径逃逸;1002=超时
std::map<uint32_t, FileTransferEntry> fileEntries; // fileIndex → {path,size,sha256}
std::set<uint32_t> skipIndexes; // overwrite=false 跳过的 fileIndex
std::vector<FileTransferEntry> skipped; // 跳过的文件(供输出)
time_t startAt = 0;
};
std::mutex m_FileXferMutex;
std::condition_variable m_FileXferCv;
std::map<uint64_t, FileTransferSession> m_FileXferSessions; // device_id → 会话
bool m_readonly = true;
std::string m_cmdWhitelist;
bool m_terminalEnabled = false; // 持久终端开关(默认关;要求 m_readonly=false)
bool m_remoteControlEnabled = false; // 远程控制开关(默认关;要求 m_readonly=false)
bool m_fileTransferEnabled = false; // 文件传输开关(默认关;download_file)
};
// 全局访问器(仿 WebService(),见 WebService.h 末尾)
+38 -6
View File
@@ -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, 420);
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
return CDialog::DoModal();
}
@@ -139,6 +139,12 @@ 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_btnFileTransfer.Create(_TR("启用文件传输(download_file)"),
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
r0, this, IDC_MCP_FILETRANSFER);
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 +166,8 @@ 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_btnFileTransfer.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 +182,8 @@ 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);
int fileTransfer = THIS_CFG.GetInt("settings", "McpFileTransfer", 0);
std::string whitelist = THIS_CFG.GetStr("settings", "McpCmdWhitelist", "");
m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED);
@@ -182,6 +192,8 @@ 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_btnFileTransfer.SetCheck(fileTransfer ? BST_CHECKED : BST_UNCHECKED);
// 白名单存储为逗号分隔,展示为每行一条。
m_editWhitelist.SetWindowText(CString(WhitelistForDisplay(whitelist).c_str()));
@@ -199,6 +211,8 @@ 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);
bool fileTransfer = (m_btnFileTransfer.GetCheck() == BST_CHECKED);
// 端口校验:1-65535
int port = atoi(CT2A(sPort));
@@ -223,14 +237,16 @@ 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);
THIS_CFG.SetInt("settings", "McpFileTransfer", fileTransfer ? 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,12 +283,28 @@ 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;
m_btnFileTransfer.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);
y += whitelistH + gap;
int bottomY = cy - margin - btnH;
m_btnCancel.MoveWindow(cx - margin - btnW, bottomY, btnW, btnH);
m_btnOK.MoveWindow(cx - margin - btnW * 2 - gap, bottomY, btnW, btnH);
// 确定 / 取消按钮紧跟白名单下方,不再锚定到底部,避免白名单与按钮之间出现大段空白。
m_btnCancel.MoveWindow(cx - margin - btnW, y, btnW, btnH);
m_btnOK.MoveWindow(cx - margin - btnW * 2 - gap, y, btnW, btnH);
y += btnH + margin;
// 收缩对话框到内容实际高度并重新居中,消除底部空白。
if (y < cy) {
CRect wnd;
GetWindowRect(&wnd);
SetWindowPos(NULL, 0, 0, wnd.Width(), y + (wnd.Height() - cy),
SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
CenterWindow();
}
}
+4
View File
@@ -29,6 +29,8 @@ private:
IDC_MCP_READONLY = 1005, // 「只读模式」复选框(默认勾选,禁 exec_command)
IDC_MCP_WHITELIST = 1006, // 命令白名单编辑框(多行,逗号/换行分隔,空 = 内置只读前缀)
IDC_MCP_TERMINAL = 1007, // 「启用持久终端」复选框(全命令,无白名单,要求只读关)
IDC_MCP_REMOTECONTROL = 1008, // 「启用远程控制」复选框(AI 操控桌面,要求只读关)
IDC_MCP_FILETRANSFER = 1009, // 「启用文件传输」复选框(download_file,不要求只读关)
};
CButton m_btnEnable;
@@ -36,6 +38,8 @@ private:
CEdit m_editPort, m_editBind, m_editToken;
CButton m_btnReadonly;
CButton m_btnTerminal;
CButton m_btnRemoteControl;
CButton m_btnFileTransfer;
CStatic m_lblWhitelist;
CEdit m_editWhitelist;
CButton m_btnOK, m_btnCancel;
+4
View File
@@ -541,6 +541,7 @@ void CSystemDlg::OnWlistHide()
lpMsgBuf[0]=CMD_WINDOW_TEST; //窗口处理数据头
DWORD hwnd = data->ID; //得到窗口的句柄一同发送
pListCtrl->SetItemText(nItem,2,"hidden"); //注意这时将列表中的显示状态为"隐藏"
data->Data[2] = "hidden"; // 同步 ItemData,避免查看/排序读到旧状态
//这样在删除列表条目时就不删除该项了 如果删除该项窗口句柄会丢失 就永远也不能显示了
memcpy(lpMsgBuf+1,&hwnd,sizeof(DWORD)); //得到窗口的句柄一同发送
DWORD dHow=SW_HIDE; //窗口处理参数 0
@@ -564,6 +565,7 @@ void CSystemDlg::OnWlistRecover()
lpMsgBuf[0]= CMD_WINDOW_TEST;
DWORD hwnd = data->ID;
pListCtrl->SetItemText(nItem,2,"normal");
data->Data[2] = "normal"; // 同步 ItemData,避免查看/排序读到旧状态
memcpy(lpMsgBuf+1,&hwnd,sizeof(DWORD));
DWORD dHow=SW_NORMAL;
memcpy(lpMsgBuf+1+sizeof(hwnd),&dHow,sizeof(DWORD));
@@ -586,6 +588,7 @@ void CSystemDlg::OnWlistMax()
lpMsgBuf[0]= CMD_WINDOW_TEST;
DWORD hwnd = data->ID;
pListCtrl->SetItemText(nItem,2,"maximized");
data->Data[2] = "maximized"; // 同步 ItemData,避免查看/排序读到旧状态
memcpy(lpMsgBuf+1,&hwnd,sizeof(DWORD));
DWORD dHow=SW_MAXIMIZE;
memcpy(lpMsgBuf+1+sizeof(hwnd),&dHow,sizeof(DWORD));
@@ -608,6 +611,7 @@ void CSystemDlg::OnWlistMin()
lpMsgBuf[0]= CMD_WINDOW_TEST;
DWORD hwnd = data->ID;
pListCtrl->SetItemText(nItem,2,"minimized");
data->Data[2] = "minimized"; // 同步 ItemData,避免查看/排序读到旧状态
memcpy(lpMsgBuf+1,&hwnd,sizeof(DWORD));
DWORD dHow=SW_MINIMIZE;
memcpy(lpMsgBuf+1+sizeof(hwnd),&dHow,sizeof(DWORD));
+1 -1
View File
@@ -46,7 +46,7 @@
// 程序版本号 [建议格式: X.Y.Z]
// 影响:关于对话框、标题栏
#define BRAND_VERSION "1.3.8"
#define BRAND_VERSION "1.3.9"
// 启动画面名称 [建议大写,更有 Logo 感]
// 影响:启动画面 Logo 文字(大号艺术字体渲染)
+47 -66
View File
@@ -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<std::mutex> 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<std::mutex> lock(m_McpTriggeredMutex);
m_McpTriggeredDevices.insert(device_id);
}
bool CWebService::IsMcpTriggered(uint64_t device_id) {
std::lock_guard<std::mutex> lock(m_McpTriggeredMutex);
return m_McpTriggeredDevices.find(device_id) != m_McpTriggeredDevices.end();
}
void CWebService::ClearMcpTriggered(uint64_t device_id) {
std::lock_guard<std::mutex> lock(m_McpTriggeredMutex);
m_McpTriggeredDevices.erase(device_id);
}
bool CWebService::HasActiveSession(uint64_t device_id) {
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
return m_ScreenContexts.find(device_id) != m_ScreenContexts.end();
+59
View File
@@ -13,11 +13,56 @@
#include <random>
#include <ctime>
#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<uint64_t> m_MfcTriggeredDevices;
std::mutex m_MfcTriggeredMutex;
// MCP triggered devices: screen sub-connection owned by an MCP remote control session
std::set<uint64_t> m_McpTriggeredDevices;
std::mutex m_McpTriggeredMutex;
// Web 终端会话状态
struct WebTermSession {
void* ws_ptr; // browser WebSocket
+7 -2
View File
@@ -1987,10 +1987,15 @@ MCP
端口需为 1-65535 的数字=Port must be a number between 1 and 65535
Token 不能为空=Token cannot be empty
MCP 设置已保存。=MCP settings saved.
启用/端口/绑定地址/Token/只读/白名单/持久终端的改动需重启程序生效。=Changes to enable/port/bind/token/read-only/whitelist/persistent terminal take effect after restart.
启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制/文件传输的改动需重启程序生效。=Changes to enable/port/bind address/token/read-only/whitelist/persistent terminal/remote control/file transfer take effect after restart.
只读模式(禁命令执行)=Read-only mode (disable command execution)
命令白名单=Command whitelist
MCP命令执行=MCP command execution
启用持久终端(全命令,无白名单)=Enable persistent terminal (full commands, no whitelist)
持久终端仅在只读模式关闭时生效。=Persistent terminal takes effect only when read-only mode is off.
持久终端与远程控制仅在只读模式关闭时生效。=Persistent terminal and remote control take effect only when read-only mode is off.
MCP持久终端=MCP persistent terminal
MCP远程控制=MCP remote control
MCP文件传输=MCP file transfer
Token=Token
启用远程控制(AI 操控桌面)=Enable remote control (AI controls the desktop)
启用文件传输(download_file)=Enable file transfer (download_file)
+7 -2
View File
@@ -1978,10 +1978,15 @@ MCP
端口需为 1-65535 的数字=連接埠需為 1-65535 的數字
Token 不能为空=Token 不能為空
MCP 设置已保存。=MCP 設定已儲存。
启用/端口/绑定地址/Token/只读/白名单/持久终端的改动需重启程序生效。=啟用/連接埠/綁定位址/Token/唯讀/白名單/持久終端的變更需重新啟動程式後生效。
启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制/文件传输的改动需重启程序生效。=啟用/連接埠/綁定位址/Token/唯讀/白名單/持久終端/遠端控制/檔案傳輸的變更需重新啟動程式後生效。
只读模式(禁命令执行)=唯讀模式(禁命令執行)
命令白名单=命令白名單
MCP命令执行=MCP命令執行
启用持久终端(全命令,无白名单)=啟用持久終端(完整命令,無白名單)
持久终端仅在只读模式关闭时生效。=持久終端僅在唯讀模式關閉時生效。
持久终端与远程控制仅在只读模式关闭时生效。=持久終端與遠端控制僅在唯讀模式關閉時生效。
MCP持久终端=MCP持久終端
MCP远程控制=MCP遠端控制
MCP文件传输=MCP檔案傳輸
Token=Token
启用远程控制(AI 操控桌面)=啟用遠端控制(AI 操控桌面)
启用文件传输(download_file)=啟用檔案傳輸(download_file)