From 2a641b1eff0eaaf523fb1776a51a8449a57ee548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timeless=E5=B0=8F=E5=B8=85?= <1018008088@qq.com> Date: Wed, 21 Oct 2026 18:04:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(serial):=20USB=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=B8=8B=E7=BA=BF,=20=E6=94=B9=E6=88=90=E4=B8=B2=E5=8F=A3?= =?UTF-8?q?=E6=98=A0=E5=B0=84=20+=20=E4=B8=B2=E5=8F=A3=E5=8A=A9=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## USB 整体下线 把 USB 功能真删掉, 不是只藏 UI: - 删 `src/main/remote/usb.ts` + `src/renderer/src/components/remote/UsbPanel.vue` - 删 `usb` (node-usb) 和 `node-hid` 两个依赖 —— 查过, 本来就没被真正用起来 - `asarUnpack` 去掉 node-usb/usb/node-hid 的规则 - ChatView 的 tab 由 "USB" 改成 "串口" ## 串口映射 + 串口助手 (新) `src/main/remote/serial.ts` (取代 usb.ts) 只做串口, 一个文件两件事: - **串口映射**: 把一端的真实串口映射成另一端可用的虚拟串口 (Linux/macOS 用 socat 建 PTY 配对, Windows 用 com0com 建 COM 对)。 方向做成 "我用对方的" / "对方用我的" 两种, 两边都能发起。 - **串口助手**: 不建虚拟口时, 应用自己当助手 —— 字节流直接显示, 能发文本/HEX, 能追加 LF, 能循环发送, 能清屏/存日志。 `src/renderer/src/components/remote/SerialPanel.vue` (新) 把两者做在一个面板里: 设备表 (串口 / VID:PID / 产品 / 制造商)、串口参数 (波特率/数据位/停止位/校验)、 TX/RX 时间戳 + hex + ascii 视图、虚拟口路径横幅 (可一键复制)。 ## 串口助手要能调 Linux Linux 上调串口最常见的两个坑, 都做成产品功能: - **没权限**: 诊断 dialout 组 + socat + 串口列表, 一键提权修复 (`sudo usermod -aG dialout $USER`, 提示要重新登录才生效)。 "设备没插" 和 "没权限" 分开报错, 不再混成一句 "打开失败"。 - **缺 socat**: 建虚拟口前先探 socat, 没有就弹安装指引 (apt/dnf), 免得 attach 做到一半才失败。 平台化文案也分开了: Linux 提示 dialout, Windows 提示设备管理器, macOS 提示隐私授权。 ## 线上格式一个字节没动 (兼容性关键) 局域网里常有一台机器跑旧版本, 所以: - `PROTOCOL_VERSION` 仍然 = 1 - WS 帧类型仍然叫 `'usb'` - 只把内部类型改名 (`SerialDeviceInfo` / `SerialAttachConfig` / ...) 代价: 混版本时已下线的 USB 字节桥不可用。换来: 新旧版本聊天照常, 串口映射也照常, 不会因为升协议被踢下线。**别手贱顺手改对** (AGENTS.md 坑 #10)。 ## 其它 - 主进程 `output` 事件带方向 (rx/tx), 助手才能分清"我发的"和"设备回的"; 客户端自己发出的字节不回显成 rx (无回环, S5 用例守住) - 虚拟串口 (vcom) 模式下 app 不读内部端, 避免和 socat 抢数据 - `src/main/permissions.ts` 重写成串口专用, 删掉 USB/HID 相关诊断与修复 - 保留 `src/main/remote/vcom.ts` (socat/com0com 配对) 不动, 与 serial.ts 分离 ## 验证 - npm run typecheck ✅ - npm run selftest ✅ 79 通过 / 0 失败 (USB 用例整体换成 S1-S9 串口用例) - npm run build ✅ - npm run package:linux ✅ 新 deb (84MB) - npm run selftest:package ✅ 4/4 (改成校验 serialport 原生绑定已解包) 真机 (CDP 驱动真实窗口) 验证过: 串口 tab 能打开, 设备表列到 32 个 /dev/ttyS*, 助手区 + hex 区在, 波特率/数据位/停止位/校验/循环发送/清屏/保存日志 按钮齐全, 方向切换会换设备列表 (我用对方的 = 对方设备, 对方用我的 = 本机设备)。 --- AGENTS.md | 18 +- docs/connection-status.md | 2 +- docs/relation-and-sync.md | 4 +- docs/remote-terminal-forward-usb.md | 10 + package.json | 12 +- scripts/selftest/cdp-shot.cjs | 26 + scripts/selftest/main.ts | 220 ++-- scripts/selftest/packaging-check.cjs | 7 +- src/main/chat-server.ts | 4 +- src/main/db.ts | 8 +- src/main/index.ts | 8 +- src/main/ipc.ts | 134 ++- src/main/permissions.ts | 244 ++--- src/main/protocol.ts | 121 +-- src/main/remote/approval.ts | 8 +- src/main/remote/forward.ts | 2 +- src/main/remote/serial.ts | 654 ++++++++++++ src/main/remote/terminal.ts | 2 +- src/main/remote/usb.ts | 965 ------------------ src/main/settings.ts | 6 +- src/preload/index.ts | 35 +- src/renderer/src/App.vue | 12 +- src/renderer/src/api.ts | 124 +-- src/renderer/src/components/ChatView.vue | 12 +- src/renderer/src/components/SettingsView.vue | 7 +- .../src/components/remote/ApprovalDialog.vue | 2 +- .../src/components/remote/SerialPanel.vue | 614 +++++++++++ .../src/components/remote/UsbPanel.vue | 593 ----------- src/renderer/src/stores/remote.ts | 65 +- 29 files changed, 1796 insertions(+), 2123 deletions(-) create mode 100644 src/main/remote/serial.ts delete mode 100644 src/main/remote/usb.ts create mode 100644 src/renderer/src/components/remote/SerialPanel.vue delete mode 100644 src/renderer/src/components/remote/UsbPanel.vue diff --git a/AGENTS.md b/AGENTS.md index f79f14f..4ac8db2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ LAN instant-messaging desktop app. Stack: **Electron 32 + Vue 3 + TypeScript + V | Typecheck main+preload | `npm run typecheck:node` | | Typecheck renderer | `npm run typecheck:web` | | Typecheck both | `npm run typecheck` | -| Remote feature self-test (terminal / forward / USB) | `npm run selftest` | +| Remote feature self-test (terminal / forward / serial) | `npm run selftest` | | Sync-drive end-to-end test (relation -> sync -> delete propagation) | `npm run selftest:sync` | | Packaged-artifact smoke check (asar unpack + real pty spawn) | `npm run selftest:package` (run after `npm run package:*`) | | Real-Electron nativeImage check (thumbnail encode / transparency / clipboard) | `npm run selftest:image` (needs a display) | @@ -21,7 +21,7 @@ LAN instant-messaging desktop app. Stack: **Electron 32 + Vue 3 + TypeScript + V All `package:*` scripts bake in `ELECTRON_MIRROR` + `ELECTRON_BUILDER_BINARIES_MIRROR` (npmmirror.com). Do not remove unless on the open internet. -`npm run selftest` runs the remote (terminal / port-forward / USB) self-test: one process, two virtual +`npm run selftest` runs the remote (terminal / port-forward / serial) self-test: one process, two virtual nodes, real `ChatServer` + `ChatClient` + remote managers. See `scripts/selftest/`. `npm run selftest:sync` runs the sync-drive end-to-end test (`scripts/selftest/sync-e2e.ts`): two virtual @@ -150,7 +150,19 @@ UDP discovery on **47800**, chat WS on **47900**, file HTTP on **47901**. All th (本地文件优先, 退回 `dataBase64`), 不是气泡里那个缩略图。 缩略图缺失时 (老消息 / 老版本对端) 渲染层退到 `image:thumb` 现场生成, 失败才显示文件卡片。 -10. **未读/角标: 正在看的会话必须立刻清未读, 且不能被 `savedPath` 拦下来.** +10. **USB 字节桥和 USB/IP 已下线, 只剩串口.** v0.2.0 把 `src/main/remote/usb.ts` 换成了 + `src/main/remote/serial.ts` —— 只做**串口映射** (Linux/macOS 用 socat 建 PTY 配对, + Windows 用 com0com) + **串口助手** (应用自己收发字节: 文本/HEX、循环发送、保存日志)。 + 删掉的: node-usb 的 control/bulk 字节桥、USB/IP 真透传, 以及 `node-usb` / `node-hid` + 两个原生依赖 (它们其实压根没被真正用到)。 + **线上格式一个字节都没动** —— WS 帧类型仍然叫 `'usb'`, `PROTOCOL_VERSION` 仍是 1, + 内部类型全改名成 `Serial*`。原因: 局域网里常有一台机器还跑着旧版本, 升协议版本会让 + 两边连不上 (连文字消息都发不了), 代价太大。现在新旧版本之间: 聊天照常, **串口映射也照常** + (老版本本来就支持 `kind:'serial'`), 只有 USB 字节桥在混版本时用不了 —— 而那功能已下线。 + 代价: 协议里那个 `'usb'` 帧名和实际内容对不上, 改的时候别手贱"顺手改对" —— 会把 + 所有还在跑旧版本的对端一起踢下线。 + +11. **未读/角标: 正在看的会话必须立刻清未读, 且不能被 `savedPath` 拦下来.** 踩过的坑: `App.vue` 的 `message:received` 里先判断 `if (!savedPath) return` (等文件落地再算未读), 结果**文字消息没有 `savedPath`** —— 正好在"清未读"之前就 return 了, 于是用户明明在看这个会话, 角标数字却一直挂着。文件/图片消息因为第二帧带 `savedPath` 才碰巧能清掉, 所以只有文字消息暴露这个 bug。 diff --git a/docs/connection-status.md b/docs/connection-status.md index dee476e..cd3656c 100644 --- a/docs/connection-status.md +++ b/docs/connection-status.md @@ -62,7 +62,7 @@ WS 通常几十毫秒内就握手成功 → 主进程广播 `device:wsState` → | 设备在线但通道不通 | `在线 · 192.168.0.7 (连接中…)`(黄点,后台在 backoff 重连) | | 都不可用 | `离线 · 12:05`(灰点) | -远程功能(终端 / 转发 / USB / 网盘)都走 WS 通道,所以入口按钮在 +远程功能(终端 / 转发 / 串口 / 网盘)都走 WS 通道,所以入口按钮在 `设备离线 && 通道不通` 时才禁用——只看设备在线会把"能连但没连上"的情况误判成可用。 ## 4. 回归测试 diff --git a/docs/relation-and-sync.md b/docs/relation-and-sync.md index 74e3038..b231be2 100644 --- a/docs/relation-and-sync.md +++ b/docs/relation-and-sync.md @@ -21,7 +21,7 @@ 问题: 1. **发现 = 信任**。同网段任何进程发个广播包就成了"联系人", 无法选择"我只跟谁建立关系"。 -2. **网盘凭据全局广播**, 而远程终端/USB 却有授权闸门 —— **两套标准**。网盘等于对整个局域网敞开。 +2. **网盘凭据全局广播**, 而远程终端/串口 却有授权闸门 —— **两套标准**。网盘等于对整个局域网敞开。 3. **没有确认环节**, 无法表达"我认识这个人, 我要跟他同步文件夹"。 4. **授权粒度不统一**: 远程操作有 per-peer 白名单, 网盘和消息完全没有。 5. **删除无法传播**。没有 manifest 就没有"这个文件之前存在过、现在被谁删了"的记录。 @@ -45,7 +45,7 @@ | `drive` | 允许对方浏览我的网盘 (共享目录) | | `sync` | 允许与对方同步文件 (走该关系专属的同步区) | -远程终端 / 端口转发 / USB **没有**并进关系层, 它们仍然走原来的 +远程终端 / 端口转发 / 串口 **没有**并进关系层, 它们仍然走原来的 `settings.remoteAllowPeers` 白名单 + 弹窗闸门 (`src/main/remote/approval.ts`)。 理由: 那三个是"一次性操作请求", 关系是"长期绑定", 生命周期和撤销语义都不一样, 硬合并会让远程操作每次都要查关系表, 反而更绕。 diff --git a/docs/remote-terminal-forward-usb.md b/docs/remote-terminal-forward-usb.md index ca8615c..23b35e2 100644 --- a/docs/remote-terminal-forward-usb.md +++ b/docs/remote-terminal-forward-usb.md @@ -1,5 +1,15 @@ # 远程终端、端口转发、USB 透传 规划文档 +> **⚠️ 现状 (v0.2.0)**: 本文是当初的**设计文档**, 其中"USB 透传"那部分**已经下线** —— +> node-usb 的 control/bulk 字节桥和 USB/IP 真透传都删了, `node-usb` / `node-hid` 两个原生 +> 依赖也摘掉了。现在只保留**串口**: +> - 串口映射 (Linux/macOS 用 socat 建 PTY 配对, Windows 用 com0com) —— 实现见 +> `src/main/remote/serial.ts` + `src/main/remote/vcom.ts` +> - 串口助手 (应用自己收发字节) —— UI 见 `src/renderer/src/components/remote/SerialPanel.vue` +> +> **线上协议格式没变** (WS 帧类型仍叫 `'usb'`, 版本仍是 1), 所以新旧版本之间串口照样能用。 +> 下文凡是讲 USB 字节桥 / USB/IP 的部分, 都只当历史文档看。 + > 目标:在 LocalNetMsg 中实现三类“远程使用对方设备”的能力——**对方机器的本地终端(shell)**、**TCP 端口转发**、**USB 设备透传**。本文先定整体架构、协议、库选型,再分阶段落地。 > > 适配范围:Windows / macOS / Linux 三端。被控端的 shell 按平台决定:Windows → PowerShell(默认 `pwsh.exe`,回退 `powershell.exe`),macOS → zsh(回退 bash),Linux → bash(按 `SHELL` 环境变量)。 diff --git a/package.json b/package.json index fe105c0..31e3a41 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "better-sqlite3": true, "electron": true, "esbuild": true, - "node-hid": true, "@serialport/bindings-cpp": true, "vue-demi": true }, @@ -22,9 +21,9 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json", "typecheck:web": "vue-tsc --noEmit -p src/renderer/tsconfig.json", "typecheck": "npm run typecheck:node && npm run typecheck:web", - "selftest:build": "esbuild scripts/selftest/main.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/selftest.cjs --alias:electron=./scripts/selftest/electron-stub.cjs --alias:@lydell/node-pty=./scripts/selftest/pty-shim.cjs --external:better-sqlite3 --external:serialport --external:usb --external:node-hid --external:sudo-prompt --external:ws", + "selftest:build": "esbuild scripts/selftest/main.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/selftest.cjs --alias:electron=./scripts/selftest/electron-stub.cjs --alias:@lydell/node-pty=./scripts/selftest/pty-shim.cjs --external:better-sqlite3 --external:serialport --external:sudo-prompt --external:ws", "selftest": "npm run selftest:build && cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/.out/selftest.cjs", - "selftest:sync:build": "esbuild scripts/selftest/sync-e2e.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/sync-e2e.cjs --alias:electron=./scripts/selftest/electron-stub.cjs --external:better-sqlite3 --external:serialport --external:usb --external:node-hid --external:sudo-prompt --external:ws", + "selftest:sync:build": "esbuild scripts/selftest/sync-e2e.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/sync-e2e.cjs --alias:electron=./scripts/selftest/electron-stub.cjs --external:better-sqlite3 --external:serialport --external:sudo-prompt --external:ws", "selftest:sync": "npm run selftest:sync:build && cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/.out/sync-e2e.cjs", "selftest:package": "cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/packaging-check.cjs", "selftest:image:build": "esbuild src/main/image.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/image-native.cjs --external:electron", @@ -46,11 +45,9 @@ "element-plus": "^2.8.4", "highlight.js": "^11.10.0", "markdown-it": "^14.3.0", - "node-hid": "^3.3.0", "pinia": "^2.2.4", "serialport": "^13.0.0", "sudo-prompt": "^9.2.1", - "usb": "^3.0.1", "ws": "^8.18.0" }, "devDependencies": { @@ -85,10 +82,7 @@ "asarUnpack": [ "**/node_modules/@lydell/node-pty/**/*", "**/node_modules/@lydell/node-pty-*/**/*", - "**/node_modules/@node-usb/**/*", - "**/node_modules/usb/prebuilds/**/*", - "**/node_modules/@serialport/**/prebuilds/**/*", - "**/node_modules/node-hid/prebuilds/**/*" + "**/node_modules/@serialport/**/prebuilds/**/*" ], "extraResources": [ { diff --git a/scripts/selftest/cdp-shot.cjs b/scripts/selftest/cdp-shot.cjs index 1163dc1..1499ffa 100644 --- a/scripts/selftest/cdp-shot.cjs +++ b/scripts/selftest/cdp-shot.cjs @@ -142,6 +142,32 @@ async function main() { console.log('提示条: ' + JSON.stringify(toast)) await shot('03-after-copy.png') + // 5) 切到串口 tab -> 看看串口面板 + const tab = await evaluate(`(() => { + const btns = [...document.querySelectorAll('.header-tabs button')] + const b = btns.find(x => x.innerText.trim() === '串口') + if (!b) return { found: false, tabs: btns.map(x => x.innerText.trim()) } + b.click() + return { found: true } + })()`) + console.log('串口 tab: ' + JSON.stringify(tab)) + await sleep(1500) + const panel = await evaluate(`(() => { + const p = document.querySelector('.serial-panel') + if (!p) return { found: false } + return { + found: true, + hasDeviceTable: !!p.querySelector('.el-table'), + rows: p.querySelectorAll('.el-table tbody tr').length, + hasAssistant: !!p.querySelector('.assistant'), + hasHexView: !!p.querySelector('.hex-view'), + buttons: [...p.querySelectorAll('button')].map(b => b.innerText.trim()).filter(Boolean), + text: p.innerText.slice(0, 300), + } + })()`) + console.log('串口面板: ' + JSON.stringify(panel, null, 2)) + await shot('04-serial-panel.png') + ws.close() console.log('done') } diff --git a/scripts/selftest/main.ts b/scripts/selftest/main.ts index 9baa022..ec981c6 100644 --- a/scripts/selftest/main.ts +++ b/scripts/selftest/main.ts @@ -1,4 +1,4 @@ -// LocalNetMsg 远程功能自测 (terminal / forward / usb) +// LocalNetMsg 远程功能自测 (terminal / forward / serial) // 在一个进程里跑两个虚拟节点 (A = 控制端, B = 被控端), 走真实的 // ChatServer + ChatClient + 三个 remote manager, 接线方式与 src/main/ipc.ts 完全一致. // @@ -31,7 +31,7 @@ import { listPendingApprovals, replyApproval } from '../../src/main/remote/appro import { defaultCwd, defaultShell } from '../../src/main/remote/shell' import { TerminalManager } from '../../src/main/remote/terminal' import { ForwardManager } from '../../src/main/remote/forward' -import { UsbBridgeManager, toUsbControlSetup } from '../../src/main/remote/usb' +import { SerialManager } from '../../src/main/remote/serial' import { updateSettings } from '../../src/main/settings' import { listAudit } from '../../src/main/db' import { @@ -43,9 +43,10 @@ import { incrUnread, clearUnread, getUnread, totalUnread } from '../../src/main/ import { unreadActionForIncoming } from '../../src/renderer/src/utils/unread' import electronStub from './electron-stub.cjs' import type { - DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, UsbFrame, + DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, SerialFrame, RelationRequestFrame, RelationAcceptFrame, RelationRejectFrame, } from '../../src/main/protocol' +import { PROTOCOL_VERSION } from '../../src/main/protocol' // ============ 小型测试框架 ============ interface CaseResult { name: string; ok: boolean; detail: string; ms: number } @@ -81,7 +82,7 @@ class Node { fileServer: FileServer terminal: TerminalManager forward: ForwardManager - usb: UsbBridgeManager + serial: SerialManager events: UiEvent[] = [] driveDir = '' driveToken = '' @@ -103,7 +104,7 @@ class Node { constructor(id: string, name: string) { this.info = { - deviceId: id, name, hostname: name, platform: 'linux', version: 1, + deviceId: id, name, hostname: name, platform: 'linux', version: PROTOCOL_VERSION, appVersion: '0.1.0', address: '127.0.0.1', chatPort: 0, filePort: 0, ts: Date.now(), } this.server = new ChatServer(0) @@ -124,7 +125,7 @@ class Node { }) this.terminal = new TerminalManager(this.client, id) this.forward = new ForwardManager(this.client) - this.usb = new UsbBridgeManager(this.client) + this.serial = new SerialManager(this.client) } async start() { @@ -175,8 +176,8 @@ class Node { .catch((e) => console.warn(`[${this.info.name}] terminal handle error:`, e?.message)) } else if (frame.type === 'forward') { this.forward.handleIncoming(peer, frame.payload as ForwardFrame) - } else if (frame.type === 'usb') { - this.usb.handleIncoming(peer, frame.payload as UsbFrame) + } else if (frame.type === 'usb') { // 线上帧类型没改 (兼容老版本) + this.serial.handleIncoming(peer, frame.payload as SerialFrame) } else if (frame.type === 'relation:request') { this.relationService.handleRelationRequest(peer, frame.payload as RelationRequestFrame) .catch((e) => console.warn(`[${this.info.name}] relation request error:`, e?.message)) @@ -202,14 +203,14 @@ class Node { this.events.push({ kind: 'forward:opened', sessionId: i.sessionId, peerId: i.peerId })) this.forward.on('sessionClosed', (i: any) => this.events.push({ kind: 'forward:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason })) - this.usb.on('output', (sessionId, bytes, peerId) => - this.events.push({ kind: 'usb:output', sessionId, peerId, data: bytes.toString('base64') })) - this.usb.on('sessionOpened', (i: any) => - this.events.push({ kind: 'usb:opened', sessionId: i.sessionId, peerId: i.peerId })) - this.usb.on('sessionClosed', (i: any) => - this.events.push({ kind: 'usb:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason })) - this.usb.on('sessionError', (i: any) => - this.events.push({ kind: 'usb:error', sessionId: i.sessionId, peerId: i.peerId, text: i.error })) + this.serial.on('output', (sessionId, bytes, peerId, dir) => + this.events.push({ kind: 'serial:output', sessionId, peerId, dir, data: bytes.toString('base64') })) + this.serial.on('sessionOpened', (i: any) => + this.events.push({ kind: 'serial:opened', sessionId: i.sessionId, peerId: i.peerId })) + this.serial.on('sessionClosed', (i: any) => + this.events.push({ kind: 'serial:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason })) + this.serial.on('sessionError', (i: any) => + this.events.push({ kind: 'serial:error', sessionId: i.sessionId, peerId: i.peerId, text: i.error })) this.client.on('error', (_peer, err) => console.warn(`[${this.info.name}] ws error: ${err.message}`)) @@ -240,7 +241,7 @@ class Node { try { this.client.stop() } catch {} try { this.terminal.stop() } catch {} try { this.forward.stop() } catch {} - try { this.usb.stop() } catch {} + try { this.serial.stop() } catch {} } } @@ -499,8 +500,8 @@ async function main() { updateSettings({ remoteEnabled: true, remoteAllowPeers: { - [A.info.deviceId]: { terminal: true, forward: true, usb: true }, - [B.info.deviceId]: { terminal: true, forward: true, usb: true }, + [A.info.deviceId]: { terminal: true, forward: true, serial: true }, + [B.info.deviceId]: { terminal: true, forward: true, serial: true }, }, }) @@ -626,57 +627,15 @@ async function main() { } } - // ============ USB / 串口 ============ - console.log('\n\x1b[1m[USB / 串口]\x1b[0m') - if (want('usb')) { - await runCase('U1 列对方设备: 请求-响应链路通', async () => { - const r = await A.usb.listOnPeer(B.info.deviceId) + // ============ 串口 (映射 + 串口助手) ============ + console.log('\n\x1b[1m[串口]\x1b[0m') + if (want('serial') || want('usb')) { + await runCase('S1 列对方串口: 请求-响应链路通', async () => { + const r = await A.serial.listOnPeer(B.info.deviceId) assert(r.ok, `list 失败: ${r.reason}`) assert(Array.isArray(r.devices), 'devices 不是数组') - }) - - await runCase('U2 USB 模式设备枚举 (libusb) 能列出真机设备', async () => { - const devs = await B.usb.listLocal() - const usbDevs = devs.filter(d => d.kind === 'usb') - console.log(` (枚举到 ${devs.length} 个设备, 其中 usb ${usbDevs.length} 个)`) - assert(usbDevs.length > 0, '一个 libusb 设备都没枚举到 —— USB 模式不可用') - }) - - await runCase('U2b 内核占用的设备: attach 失败原因能正确回传到控制端', async () => { - const devs = await B.usb.listLocal() - const usbDevs = devs.filter(d => d.kind === 'usb') - assert(usbDevs.length > 0, '没有可用的 usb 设备') - // 这台机器上的设备都被内核驱动占着 (键鼠/摄像头/蓝牙), 测试里不让它抢驱动 - const pick = usbDevs.find(d => /hub/i.test(d.product || '')) || usbDevs[0] - console.log(` (选用设备 ${pick.busId} ${pick.product || ''})`) - const r = await A.usb.attach(B.info.deviceId, { - busId: pick.busId, direction: 'self-out', - config: { kind: 'usb', configurationValue: 1, interfaceNumber: 0, detachKernelDriver: false }, - }) - // 期望: 要么成功, 要么失败原因能传回来 (而不是超时/静默) - if (!r.ok) { - console.log(` (按预期被内核占用挡住: ${r.reason})`) - assert(!!r.reason && r.reason.length > 0, '失败时没有给出原因') - } - }) - - await runCase('U2c 控制传输 setup 翻译正确, 真机 GET_DESCRIPTOR 能拿到数据', async () => { - const usbMod: any = require('usb') - const devs = await usbMod.webusb.loadDevices() - assert(devs.length > 0, 'libusb 枚举不到设备') - const dev = devs.find((d: any) => d.vendorId === 0x0c45 && d.productId === 0x6366) || devs[0] - await dev.open() - try { - // 用应用里同一套翻译函数 (bmRequestType 数值 -> node-usb v3 字符串枚举) - const setup = toUsbControlSetup({ requestType: 0x80, request: 0x06, value: 0x0100, index: 0x0000 }) - const buf = await dev.nativeControlTransferIn(setup, 3000, 18) - assert(!!buf && buf.length === 18, `描述符长度不对: ${buf?.length}`) - const hexd = Buffer.from(buf).toString('hex') - console.log(` (descriptor: ${hexd})`) - assert(hexd.startsWith('1201'), '不是合法的设备描述符') - } finally { - await dev.close().catch(() => {}) - } + // 只该有串口 —— USB 字节桥/USB-IP 已下线 + assert(r.devices!.every(d => !!d.serialPath), '设备项必须有 serialPath') }) // 串口双向字节流: 用 socat 造一个 PTY 对冒充"真串口" @@ -688,24 +647,24 @@ async function main() { const pair = await startSocatSerial(vcomDir) socatProc = pair.proc // 被控端持有 "user" 端 (当真实串口), 测试这边拿 "internal" 端当外部设备 - B.usb.listLocal = async () => ([{ - busId: `serial:${pair.userPath}`, vid: 0, pid: 0, deviceClass: 0x02, deviceSubclass: 0x02, - product: 'selftest-pty', manufacturer: 'socat', kind: 'serial', serialPath: pair.userPath, baudRate: 115200, + B.serial.listLocal = async () => ([{ + busId: `serial:${pair.userPath}`, serialPath: pair.userPath, + vid: 0, pid: 0, product: 'selftest-pty', manufacturer: 'socat', baudRate: 115200, }] as any) - await runCase('U3 attach 串口: 控制端能拿到 attached ok', async () => { - const r = await A.usb.attach(B.info.deviceId, { + await runCase('S2 attach 串口: 控制端能拿到 attached ok', async () => { + const r = await A.serial.attach(B.info.deviceId, { busId: `serial:${pair.userPath}`, direction: 'self-out', config: { kind: 'serial', baudRate: 115200 }, }) assert(r.ok, `attach 被拒: ${r.reason}`) }) - await runCase('U4 串口双向字节流: 控制端 -> 设备', async () => { - const sess = A.usb.listClientSessions()[0] - assert(!!sess, '控制端没有 usb session') + await runCase('S3 串口双向字节流: 控制端 -> 设备', async () => { + const sess = A.serial.listClientSessions()[0] + assert(!!sess, '控制端没有 session') devEnd = openPty(pair.internalPath) - const ok = await A.usb.sendSerialBytes(sess.sessionId, Buffer.from('HELLO-FROM-CONTROL\n')) + const ok = await A.serial.sendSerialBytes(sess.sessionId, Buffer.from('HELLO-FROM-CONTROL\n')) assert(ok, 'sendSerialBytes 失败') // 设备端应该收到 let seen = '' @@ -716,18 +675,121 @@ async function main() { assert(seen.includes('HELLO-FROM-CONTROL'), `设备端没收到: ${JSON.stringify(seen)}`) }) - await runCase('U5 串口双向字节流: 设备 -> 控制端', async () => { + await runCase('S4 串口双向字节流: 设备 -> 控制端 (RX)', async () => { assert(!!devEnd, '设备端没准备好') A.clearEvents() devEnd.write('HELLO-FROM-DEVICE\n') - await waitFor(() => A.findEvents('usb:output').map(e => decode(e.data!)).join('').includes('HELLO-FROM-DEVICE'), 6000) + await waitFor(() => A.findEvents('serial:output').map(e => decode(e.data!)).join('').includes('HELLO-FROM-DEVICE'), 6000) .catch(() => { throw new Error('控制端没收到设备发来的字节') }) + // 方向必须标成 rx —— 串口助手要靠它区分"我发的"和"设备回的" + const ev = A.findEvents('serial:output').find(e => decode(e.data!).includes('HELLO-FROM-DEVICE')) + assert(ev && (ev as any).dir === 'rx', `方向应为 rx, 实际 ${(ev as any)?.dir}`) + }) + + await runCase('S5 串口助手: 发出去的字节不会被当成 rx 回显 (不回环, 否则助手的收发会混淆)', async () => { + A.clearEvents() + const sess = A.serial.listClientSessions()[0] + await A.serial.sendSerialBytes(sess.sessionId, Buffer.from('PING')) + // 设备端应该收到 + let seen = '' + for (let i = 0; i < 30 && !seen.includes('PING'); i++) { + await sleep(100) + seen += devEnd!.read() + } + assert(seen.includes('PING'), `设备端没收到: ${JSON.stringify(seen)}`) + // 我发出去的字节不该在我这边又冒出来 (那会被误显示成"设备回的") + const echoed = A.findEvents('serial:output').map(e => decode(e.data!)).join('') + assert(!echoed.includes('PING'), `发出去的字节被回显了: ${JSON.stringify(echoed)}`) + }) + + await runCase('S6 断开: 两端 session 都被清掉', async () => { + const sess = A.serial.listClientSessions()[0] + assert(!!sess, '没有 session 可断') + const r = await A.serial.detach(sess.sessionId, 'test') + assert(r.ok, 'detach 失败') + await sleep(300) + assert(A.serial.listClientSessions().length === 0, '控制端 session 没清掉') }) } finally { try { devEnd?.close() } catch {} try { socatProc?.kill() } catch {} try { rmSync(vcomDir, { recursive: true, force: true }) } catch {} } + + // ---- 串口映射 (本机虚拟串口) ---- + if (want('vcom')) { + const vcomDir2 = join(tmpdir(), `lnm-selftest-vcom2-${Date.now()}`) + mkdirSync(vcomDir2, { recursive: true }) + let socatProc2: ChildProcess | null = null + try { + const pair = await startSocatSerial(vcomDir2) + socatProc2 = pair.proc + B.serial.listLocal = async () => ([{ + busId: `serial:${pair.userPath}`, serialPath: pair.userPath, + product: 'selftest-pty', baudRate: 115200, + }] as any) + + await runCase('S7 串口映射: 控制端建出本机虚拟串口 (socat PTY)', async () => { + const r = await A.serial.attach(B.info.deviceId, { + busId: `serial:${pair.userPath}`, direction: 'self-out', + config: { kind: 'serial', baudRate: 115200, createVirtual: true }, + }) + assert(r.ok, `attach 被拒: ${r.reason}`) + const sess = A.serial.listClientSessions()[0] + const vpath = sess?.info?.userVirtualPath + assert(!!vpath, '没拿到本机虚拟串口路径') + assert(existsSync(vpath!), `虚拟串口路径不存在: ${vpath}`) + console.log(` (虚拟串口: ${vpath})`) + }) + + await runCase('S7b 串口映射: 往虚拟口写的字节方向标记为 tx', async () => { + const sess = A.serial.listClientSessions()[0] + const vpath = sess?.info?.userVirtualPath! + A.clearEvents() + // 模拟"用户工具往虚拟口写数据" + const fd = openSync(vpath, fs.constants.O_RDWR | fs.constants.O_NONBLOCK) + try { writeSync(fd, 'FROM-USER-TOOL') } finally { try { closeSync(fd) } catch {} } + await waitFor(() => A.findEvents('serial:output').length > 0, 5000) + .catch(() => { throw new Error('虚拟口写入没产生字节流事件') }) + const ev = A.findEvents('serial:output')[0] as any + assert(ev.dir === 'tx', `方向应为 tx, 实际 ${ev.dir}`) + assert(decode(ev.data!).includes('FROM-USER-TOOL'), '内容不对') + }) + + await runCase('S8 串口映射: 远端字节能流到本机虚拟口 (给 PuTTY/screen 用)', async () => { + const sess = A.serial.listClientSessions()[0] + const vpath = sess?.info?.userVirtualPath! + // 从被控端的"真实串口"灌一串数据, 看它有没有出现在本机虚拟口 + const dev = openPty(pair.internalPath) + try { + dev.write('THROUGH-VCOM\n') + await waitFor(() => { + const fd = openSync(vpath, fs.constants.O_RDWR | fs.constants.O_NONBLOCK) + try { + const buf = Buffer.alloc(256) + const n = readSync(fd, buf, 0, buf.length, null) + return n > 0 && buf.slice(0, n).toString('utf8').includes('THROUGH-VCOM') + } catch { return false } finally { try { closeSync(fd) } catch {} } + }, 6000).catch(() => { throw new Error('字节没流到本机虚拟串口') }) + } finally { + dev.close() + } + }) + } finally { + try { socatProc2?.kill() } catch {} + try { rmSync(vcomDir2, { recursive: true, force: true }) } catch {} + } + } + + await runCase('S9 串口错误提示: 打不开设备时要能指导用户 (Linux 权限/占用/不存在)', async () => { + const { describeOpenError } = await import('../../src/main/remote/serial') + const perm = describeOpenError('/dev/ttyUSB0', new Error('EACCES: permission denied')) + assert(/dialout/.test(perm), `Linux 权限错误应提示 dialout, 实际: ${perm}`) + const busy = describeOpenError('/dev/ttyUSB0', new Error('Resource temporarily unavailable')) + assert(/占用/.test(busy), `占用错误应提示"被别的程序占用", 实际: ${busy}`) + const gone = describeOpenError('/dev/ttyUSB0', new Error('ENOENT: no such file or directory')) + assert(/不存在|拔出/.test(gone), `不存在错误应提示设备没了, 实际: ${gone}`) + }) } // ============ 网盘 ============ diff --git a/scripts/selftest/packaging-check.cjs b/scripts/selftest/packaging-check.cjs index 296657c..dda5e87 100644 --- a/scripts/selftest/packaging-check.cjs +++ b/scripts/selftest/packaging-check.cjs @@ -65,7 +65,7 @@ async function main() { }) // 2) 其它原生模块也要解包 (Electron 的 smart unpack 不一定覆盖到) - await check('其它原生模块已解包 (node-hid / serialport / node-usb)', () => { + await check('其它原生模块已解包 (serialport)', () => { const nm = path.join(unpacked, 'node_modules') assert(fs.existsSync(nm), 'unpacked 里没有 node_modules') const found = [] @@ -79,6 +79,11 @@ async function main() { } walk(nm, 0) assert(found.length > 0, '没有任何 .node 被解包') + // 串口功能依赖 serialport 的原生绑定, 必须在 (USB 字节桥已下线, 不再需要 node-hid / node-usb) + assert( + found.some(f => f.includes('@serialport')), + `serialport 的原生绑定没被解包, 串口功能会起不来: ${found.join(', ')}`, + ) return `${found.length} 个原生文件` }) diff --git a/src/main/chat-server.ts b/src/main/chat-server.ts index ab34060..24ee608 100644 --- a/src/main/chat-server.ts +++ b/src/main/chat-server.ts @@ -11,7 +11,7 @@ export interface ChatServerEvents { recall: (from: DeviceInfo, messageId: string) => void typing: (from: DeviceInfo) => void read: (from: DeviceInfo, upToMessageId: string) => void - // 任意 ws 帧 (含 terminal/forward/usb 等), 让上层路由分发 + // 任意 ws 帧 (含 terminal/forward/serial 等), 让上层路由分发 frame: (from: DeviceInfo, frame: WsFrame) => void } @@ -99,7 +99,7 @@ export class ChatServer extends EventEmitter { case 'typing': if (peer) this.emit('typing', peer) break - // terminal / forward / usb 由 'frame' 事件统一交给上层处理 + // terminal / forward / serial 由 'frame' 事件统一交给上层处理 } }) diff --git a/src/main/db.ts b/src/main/db.ts index 1da5610..79064a1 100644 --- a/src/main/db.ts +++ b/src/main/db.ts @@ -44,7 +44,7 @@ CREATE TABLE IF NOT EXISTS unread ( count INTEGER NOT NULL DEFAULT 0 ); --- 远程操作审计 (terminal / forward / usb) +-- 远程操作审计 (terminal / forward / serial) CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, @@ -263,7 +263,7 @@ export function totalUnread(): number { return r.s } -// 审计日志 (远程操作: terminal / forward / usb) +// 审计日志 (远程操作: terminal / forward / serial) export interface AuditRow { id: number ts: number @@ -323,11 +323,11 @@ export function pruneAudit(olderThanMs: number): number { return r.changes } -// 远程操作白名单 (按 deviceId -> { terminal, forward, usb }) +// 远程操作白名单 (按 deviceId -> { terminal, forward, serial }) // 写入 settings.json (electron-store) 而不是 DB, 便于跨设备查看与回滚 export interface RemoteAllowRow { device_id: string terminal: number // 0/1 forward: number - usb: number + serial: number } diff --git a/src/main/index.ts b/src/main/index.ts index 6f42a40..d8fa8b8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,7 +16,7 @@ import { ChatClient } from './chat-client' import { FileServer } from './file-server' import { TerminalManager } from './remote/terminal' import { ForwardManager } from './remote/forward' -import { UsbBridgeManager } from './remote/usb' +import { SerialManager } from './remote/serial' import { registerIpc, bindNetworkContext, collectWsState } from './ipc' import { listDevices, totalUnread, getUnread, pruneAudit } from './db' import { RelationStore } from './relations' @@ -87,7 +87,7 @@ async function main() { }) const terminal = new TerminalManager(chatClient, self.deviceId) const forward = new ForwardManager(chatClient) - const usb = new UsbBridgeManager(chatClient) + const serial = new SerialManager(chatClient) const chatPort = await chatServer.start() const filePort = await fileServer.start() @@ -102,12 +102,12 @@ async function main() { () => fileServer.stop(), () => terminal.stop(), () => forward.stop(), - () => usb.stop(), + () => serial.stop(), ) await discovery.start() bindNetworkContext({ - self, discovery, chatServer, chatClient, fileServer, terminal, forward, usb, + self, discovery, chatServer, chatClient, fileServer, terminal, forward, serial, relations, manifestStore, relationService, }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index cb692fa..1a8ce45 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -28,12 +28,13 @@ import { rebuildMenu } from './tray' import { notify, setBadgeCount } from './notify' import { TerminalManager } from './remote/terminal' import { ForwardManager } from './remote/forward' -import { UsbBridgeManager } from './remote/usb' +import { SerialManager } from './remote/serial' +import { diagnoseSerial, diagnoseAndFixSerial, hasSocat, virtualSerialHint } from './permissions' import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval' import { computeWsState } from './ws-state' import { makeThumb, makeThumbFromFile, makeThumbFromBuffer, copyToClipboard, readImageDataUrl } from './image' import type { ImageSource } from './image-core' -import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig, WsFrame } from './protocol' +import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, SerialFrame, SerialDirection, SerialAttachConfig, WsFrame } from './protocol' import { DEFAULT_PORTS } from './protocol' interface NetCtx { @@ -44,7 +45,7 @@ interface NetCtx { fileServer: FileServer terminal: TerminalManager forward: ForwardManager - usb: UsbBridgeManager + serial: SerialManager relations: RelationStore manifestStore: ManifestStore relationService: RelationService @@ -273,8 +274,9 @@ export function bindNetworkContext(c: NetCtx) { ctx!.forward.handleIncoming(peer, frame.payload as ForwardFrame) return } + // 线上帧类型还是 'usb' (兼容老版本), 载荷是串口专用的 if (frame.type === 'usb') { - ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame) + ctx!.serial.handleIncoming(peer, frame.payload as SerialFrame) return } if (routeRelationFrame(peer, frame)) return @@ -294,8 +296,9 @@ export function bindNetworkContext(c: NetCtx) { ctx!.forward.handleIncoming(peer, frame.payload as ForwardFrame) return } + // 线上帧类型还是 'usb' (兼容老版本), 载荷是串口专用的 if (frame.type === 'usb') { - ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame) + ctx!.serial.handleIncoming(peer, frame.payload as SerialFrame) return } if (routeRelationFrame(peer, frame)) return @@ -409,7 +412,7 @@ export function bindNetworkContext(c: NetCtx) { } }) - // ===== Remote: terminal / forward / usb 事件桥接 ===== + // ===== Remote: terminal / forward / serial 事件桥接 ===== c.terminal.on('output', (sessionId: string, bytes: Buffer, peerId: string) => { // 控制端从被控端收到 output -> 给 UI broadcastToRenderer('remote:terminal:output', { sessionId, peerId, data: bytes.toString('base64') }) @@ -437,27 +440,27 @@ export function bindNetworkContext(c: NetCtx) { broadcastToRenderer('remote:approval:requested', { ...req, kind: 'forward' }) }) - c.usb.on('output', (sessionId: string, bytes: Buffer, peerId: string) => { - broadcastToRenderer('remote:usb:output', { sessionId, peerId, data: bytes.toString('base64') }) + c.serial.on('output', (sessionId: string, bytes: Buffer, peerId: string, dir: 'rx' | 'tx') => { + broadcastToRenderer('remote:serial:output', { sessionId, peerId, dir, data: bytes.toString('base64') }) }) - c.usb.on('sessionOpened', (info) => { - broadcastToRenderer('remote:usb:opened', info) + c.serial.on('sessionOpened', (info) => { + broadcastToRenderer('remote:serial:opened', info) }) - c.usb.on('sessionClosed', (info) => { - broadcastToRenderer('remote:usb:closed', info) + c.serial.on('sessionClosed', (info) => { + broadcastToRenderer('remote:serial:closed', info) }) - c.usb.on('sessionError', (info) => { - broadcastToRenderer('remote:usb:error', info) + c.serial.on('sessionError', (info) => { + broadcastToRenderer('remote:serial:error', info) }) - c.usb.on('approvalRequested', (req) => { - broadcastToRenderer('remote:approval:requested', { ...req, kind: 'usb' }) + c.serial.on('approvalRequested', (req) => { + broadcastToRenderer('remote:approval:requested', { ...req, kind: 'serial' }) }) // 对端 ws 断开 -> 清理该对端的所有 session c.chatClient.on('close', (peer) => { c.terminal.onPeerDisconnected(peer.deviceId) c.forward.onPeerDisconnected(peer.deviceId) - c.usb.onPeerDisconnected(peer.deviceId) + c.serial.onPeerDisconnected(peer.deviceId) }) } @@ -1481,67 +1484,88 @@ export function registerIpc() { } }) - // ===== 远程: usb (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传) ===== + // ===== 远程: 串口 (映射 + 串口助手) ===== - ipcMain.handle('usb:list', async (_e, args: { peerId: string }) => { + // 列对方的串口 + ipcMain.handle('serial:list', async (_e, args: { peerId: string }) => { if (!ctx) return { ok: false, reason: 'no ctx' } - return ctx.usb.listOnPeer(args.peerId) + return ctx.serial.listOnPeer(args.peerId) }) - // 列本机 (串口 + libusb + linux usbip) — self-in 模式用 - ipcMain.handle('usb:listLocal', async () => { - if (!ctx) return { ok: false, reason: 'no ctx' } + // 列本机串口 — self-in 方向用 + ipcMain.handle('serial:listLocal', async () => { + if (!ctx) return { ok: false, reason: 'no ctx', devices: [] } try { - const devices = await ctx.usb.listLocal() + const devices = await ctx.serial.listLocal() return { ok: true, devices } } catch (e: any) { return { ok: false, reason: String(e?.message || e), devices: [] } } }) - // attach: 由 renderer 把 UsbAttachConfig 传过来 - ipcMain.handle('usb:attach', async (_e, args: { peerId: string; direction?: UsbDirection; busId: string; config: UsbAttachConfig }) => { + // attach + ipcMain.handle('serial:attach', async (_e, args: { peerId: string; direction?: SerialDirection; busId: string; config: SerialAttachConfig }) => { if (!ctx) return { ok: false, reason: 'no ctx' } - return ctx.usb.attach(args.peerId, { direction: args.direction, busId: args.busId, config: args.config }) + return ctx.serial.attach(args.peerId, { direction: args.direction, busId: args.busId, config: args.config }) }) - // 串口: 发送字节 (client 端) - ipcMain.handle('usb:serialSend', (_e, args: { sessionId: string; dataBase64: string }) => { + // 串口助手: 发送字节 + ipcMain.handle('serial:send', (_e, args: { sessionId: string; dataBase64: string }) => { if (!ctx) return false const buf = Buffer.from(args.dataBase64, 'base64') - return ctx.usb.sendSerialBytes(args.sessionId, buf) + return ctx.serial.sendSerialBytes(args.sessionId, buf) }) - // USB 控制传输 (control OUT / control IN) - ipcMain.handle('usb:ctrlOut', async (_e, args: { sessionId: string; setup: { requestType: number; request: number; value: number; index: number }; dataBase64?: string }) => { - if (!ctx) return { ok: false, status: -1 } - return ctx.usb.usbCtrlTransfer(args.sessionId, args.setup, args.dataBase64) - }) - ipcMain.handle('usb:ctrlIn', async (_e, args: { sessionId: string; setup: { requestType: number; request: number; value: number; index: number }; length: number }) => { - if (!ctx) return { ok: false, status: -1 } - return ctx.usb.usbCtrlIn(args.sessionId, args.setup, args.length) - }) - - // USB 批量传输 (bulk OUT / bulk IN) - ipcMain.handle('usb:bulkOut', async (_e, args: { sessionId: string; endpoint: number; dataBase64: string }) => { - if (!ctx) return { ok: false, status: -1 } - return ctx.usb.usbBulkOut(args.sessionId, args.endpoint, args.dataBase64) - }) - ipcMain.handle('usb:bulkIn', async (_e, args: { sessionId: string; endpoint: number; length: number; timeoutMs?: number }) => { - if (!ctx) return { ok: false, status: -1 } - return ctx.usb.usbBulkIn(args.sessionId, args.endpoint, args.length, args.timeoutMs) - }) - - ipcMain.handle('usb:detach', async (_e, args: { sessionId: string }) => { + ipcMain.handle('serial:detach', async (_e, args: { sessionId: string }) => { if (!ctx) return { ok: false, reason: 'no ctx' } - return ctx.usb.detach(args.sessionId, 'user') + return ctx.serial.detach(args.sessionId, 'user') }) - ipcMain.handle('usb:listSessions', () => { + ipcMain.handle('serial:listSessions', () => { if (!ctx) return { client: [], server: [] } return { - client: ctx.usb.listClientSessions(), - server: ctx.usb.listServerSessions(), + client: ctx.serial.listClientSessions(), + server: ctx.serial.listServerSessions(), + } + }) + + // 串口权限诊断 (Linux 的 dialout 组是串口助手最常见的坑) + ipcMain.handle('serial:diagnose', async () => { + try { + return { ok: true, diagnose: await diagnoseSerial() } + } catch (e: any) { + return { ok: false, reason: String(e?.message || e) } + } + }) + + // 一键修复: 把当前用户加进 dialout 组 (Linux) — 需要提权 + ipcMain.handle('serial:fixPerms', async () => { + try { + const r = await diagnoseAndFixSerial() + return { ok: r.diagnose.ok, diagnose: r.diagnose, fix: r.fix } + } catch (e: any) { + return { ok: false, reason: String(e?.message || e) } + } + }) + + // 环境提示: 有没有 socat (建虚拟串口用) + 虚拟串口怎么用 + ipcMain.handle('serial:envInfo', async () => { + return { ok: true, socat: await hasSocat(), hint: virtualSerialHint() } + }) + + // 串口助手: 保存日志到文件 + ipcMain.handle('serial:saveLog', async (_e, args: { content: string; suggestedName?: string }) => { + try { + const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] + const defaultName = args.suggestedName || `serial-${Date.now()}.log` + const r = w + ? await dialog.showSaveDialog(w, { defaultPath: join(app.getPath('documents'), defaultName), filters: [{ name: '日志', extensions: ['log', 'txt'] }] }) + : await dialog.showSaveDialog({ defaultPath: defaultName }) + if (r.canceled || !r.filePath) return { ok: false, reason: '已取消' } + await fsp.writeFile(r.filePath, args.content || '', 'utf8') + return { ok: true, path: r.filePath } + } catch (e: any) { + return { ok: false, reason: String(e?.message || e) } } }) diff --git a/src/main/permissions.ts b/src/main/permissions.ts index ccc4f60..1e7dc2c 100644 --- a/src/main/permissions.ts +++ b/src/main/permissions.ts @@ -1,11 +1,13 @@ -// 跨平台权限辅助: 调 sudo-prompt 提权; 仅在确实需要时 (Linux 串口/HID/USB) -// Windows 的串口/HID 大多数情况不需要 admin; macOS 系统弹窗 OS 接管, 我们不参与. +// 跨平台权限辅助: 调 sudo-prompt 提权。 +// +// 只服务**串口** (USB 字节桥/USB-IP 已下线)。 +// 串口助手最常见的翻车点就是 Linux 的权限: +// - 找不到串口 -> 设备没插 / udev 没建节点 +// - 找到了但打不开 -> 当前用户不在 dialout 组 (Permission denied) +// 这两件事必须分开说, 否则用户会一直以为是设备坏了。 import { platform } from 'node:process' import { exec } from 'node:child_process' import { promisify } from 'node:util' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' const execAsync = promisify(exec) @@ -36,8 +38,6 @@ function runElevated(cmd: string, name = 'LocalNetMsg'): Promise { const issues: PermIssue[] = [] const notes: string[] = [] - let serialCount = 0 + const ports: { path: string; manufacturer?: string; product?: string }[] = [] + + // 1. 列串口 (serialport 可能没装) try { - // 不在主进程 require serialport (可能没装); 试一下 // eslint-disable-next-line @typescript-eslint/no-require-imports const spMod = require('serialport') - const ports = await spMod.SerialPort.list() - serialCount = ports.length - if (platform === 'linux' && serialCount === 0) { - issues.push({ - kind: 'linux.dialout', - detail: '未列出任何串口. Linux 用户通常需要在 dialout 组才能访问 /dev/ttyUSB* / /dev/ttyACM*', - }) + const list: any[] = await spMod.SerialPort.list() + for (const p of list) { + ports.push({ path: p.path, manufacturer: p.manufacturer, product: p.friendlyName || p.productId }) } } catch (e: any) { notes.push(`serialport 加载失败: ${e?.message || e}`) } + notes.push(ports.length + ? `发现 ${ports.length} 个串口: ${ports.map(p => p.path).join(', ')}` + : '没有发现任何串口') - return { - platform: platform as NodeJS.Platform, - ok: issues.length === 0, - issues, - notes, - } -} - -export async function diagnoseUsb(vendorId?: number, productId?: number): Promise { - const issues: PermIssue[] = [] - const notes: string[] = [] - let found = 0 - - // Linux: 测一下能否读 /sys/bus/usb/devices (普通用户可读, 但创建设备文件需要 udev) + // 2. Linux: dialout 组 —— 串口助手最常见的翻车点 if (platform === 'linux') { + let inDialout = false try { - const { stdout } = await execAsync('ls /sys/bus/usb/devices 2>/dev/null | wc -l') - found = parseInt(stdout.trim() || '0', 10) - if (vendorId && productId) { - const id = `${formatHex(vendorId)}:${formatHex(productId)}` - try { - await execAsync(`ls /sys/bus/usb/devices/*/idVendor 2>/dev/null | xargs -I{} sh -c 'cat {} | tr -d "\\n"; echo " {}"' | grep -i "^${id}" || true`) - } catch {} - } + const { stdout } = await execAsync('id -Gn 2>/dev/null || groups 2>/dev/null || true') + inDialout = /\bdialout\b/.test(stdout) } catch {} - if (found === 0) { - notes.push('无法读取 /sys/bus/usb/devices — 不太常见, 请检查 udev 是否运行') - } else if (vendorId && productId) { - // 设备存在但能否被 node-usb 打开? 试一下 - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const usbMod = require('usb') - const device = await usbMod.webusb.findDeviceByIds(vendorId, productId).catch(() => null) - if (!device) { - issues.push({ - kind: 'linux.udev', - detail: `未找到设备 ${formatHex(vendorId)}:${formatHex(productId)}. 可能需要 udev 规则 (例如 SUBSYSTEM=="usb", ATTR{idVendor}=="...").`, - vendorId, productId, - }) - } - } catch (e: any) { - issues.push({ - kind: 'linux.udev', - detail: `USB 设备访问失败: ${e?.message || e}`, - vendorId, productId, - }) - } + if (!inDialout) { + const fix = 'sudo usermod -aG dialout $USER (改完要重新登录, 或重启该设备上的 App)' + issues.push({ + kind: 'linux.dialout', + detail: ports.length + ? `当前用户不在 dialout 组, 打开 ${ports.slice(0, 3).map(p => p.path).join(', ')} 会被拒 (Permission denied)。修复: ${fix}` + : `当前用户不在 dialout 组 —— 插上 USB-串口设备 (/dev/ttyUSB*, /dev/ttyACM*) 后大概率打不开。修复: ${fix}`, + }) + } else { + notes.push('已在 dialout 组') } + + // 3. socat: 建本机虚拟串口 (串口映射) 靠它 + try { + const { stdout } = await execAsync('which socat 2>/dev/null || true') + notes.push(stdout.trim() + ? `socat: ${stdout.trim()}` + : '未找到 socat —— 创建本机虚拟串口 (串口映射) 会失败。安装: sudo apt install socat (Debian/Ubuntu) | sudo dnf install socat (Fedora)') + } catch {} + + // 4. 内置串口 (/dev/ttyS*) 的设备节点一直在, 常被误当成"没有设备" + notes.push('注: /dev/ttyS0 这类内置口即使没插线缆, 设备节点也一直存在') } if (platform === 'win32') { - notes.push('Windows: USB/HID 通常无需 admin; 若 node-hid 找不到设备, 用 Zadig 安装 WinUSB 驱动 (一次性, 需 admin)') + notes.push('Windows: 串口 (COM) 一般免 admin; 若设备管理器里看不到口, 可能是驱动没装') } if (platform === 'darwin') { - notes.push('macOS: 首次访问 USB 设备时系统会弹权限对话框, 在 系统设置 → 隐私与安全 中允许') + notes.push('macOS: 首次访问串口时系统会弹权限对话框, 需在 系统设置 → 隐私与安全 中允许') } return { @@ -132,10 +117,6 @@ export async function diagnoseUsb(vendorId?: number, productId?: number): Promis } } -function formatHex(v: number) { - return v.toString(16).padStart(4, '0').toLowerCase() -} - // 把用户加入 dialout 组 (Linux); 一次性, 需要登出登入生效 export async function fixLinuxDialout(): Promise { if (platform !== 'linux') { @@ -143,7 +124,7 @@ export async function fixLinuxDialout(): Promise { } // 1. 检查用户是否已在 dialout try { - const { stdout } = await execAsync('groups') + const { stdout } = await execAsync('id -Gn 2>/dev/null || groups 2>/dev/null || true') if (/\bdialout\b/.test(stdout)) { return { ok: true, stdout: '已在 dialout 组, 无需操作' } } @@ -157,81 +138,7 @@ export async function fixLinuxDialout(): Promise { return r } -// 写 udev 规则 (Linux); 一次写永久生效 -export async function fixLinuxUdevRule(vendorId: number, productId: number, mode: 'usb' | 'hid' = 'usb'): Promise { - if (platform !== 'linux') { - return { ok: false, stderr: '仅 Linux 需要此修复' } - } - if (!Number.isInteger(vendorId) || !Number.isInteger(productId)) { - return { ok: false, stderr: 'vid/pid 非法' } - } - const vid = formatHex(vendorId) - const pid = formatHex(productId) - // 现有规则文件: /etc/udev/rules.d/99-localnetmsg.rules - const RULE_FILE = '/etc/udev/rules.d/99-localnetmsg.rules' - const modeLine = mode === 'hid' - ? `KERNEL=="hidraw*", ATTRS{idVendor}=="${vid}", ATTRS{idProduct}=="${pid}", MODE="0666", TAG+="uaccess"` - : `SUBSYSTEM=="usb", ATTR{idVendor}=="${vid}", ATTR{idProduct}=="${pid}", MODE="0666", TAG+="uaccess"` - - // 用临时脚本追加, 再 mv (避免 echo > /etc ... 的转义问题) - const tmpFile = join(tmpdir(), `lnm-udev-${Date.now()}.rules`) - const banner = `\n# LocalNetMsg auto-rule for ${vid}:${pid} (${new Date().toISOString()})\n${modeLine}\n` - writeFileSync(tmpFile, banner) - - // 检查是否已存在 - let existing = '' - try { existing = readFileSync(RULE_FILE, 'utf8') } catch {} - if (existing.includes(`ATTR{idVendor}=="${vid}"`) && existing.includes(`ATTR{idProduct}=="${pid}"`)) { - return { ok: true, stdout: '规则已存在, 无需再次添加' } - } - - // 1. 复制临时文件到 /etc/udev/rules.d/ (需要 root) - const cpCmd = `cat '${tmpFile}' >> '${RULE_FILE}' && udevadm control --reload-rules && udevadm trigger` - const r = await runElevated(cpCmd) - if (!r.ok) return r - return { ok: true, stdout: `已写入 ${RULE_FILE}; 已 reload udev 规则.\n${modeLine}\n请重新插拔设备 或 等待几秒后刷新列表.` } -} - -// Windows: 串口 (COM) 一般免 admin; HID/raw USB 偶尔需要装 WinUSB 驱动 (一次性, 由 Zadig CLI 或 wdi-simple 自动装) -// 这里优先尝试自动装; 失败也给一句"自动修失败, 你可以..."的提示, 不留"请手动下载"的死路 -export async function fixWindowsHID(vendorId?: number, productId?: number): Promise { - // 1. 优先尝试用本机已存在的 Zadig.exe (PATH 或 resources/zadig.exe) - const candidates = [ - join(process.resourcesPath || '', 'zadig.exe'), - 'C:\\Program Files\\Zadig\\Zadig.exe', - ] - let zadigPath: string | null = null - for (const p of candidates) { - try { if (existsSync(p)) { zadigPath = p; break } } catch {} - } - if (!zadigPath) { - // 没有 Zadig: 静默下载到 userData 并运行 (不再让用户手动下) - try { - const dl = await import('node:https') - const dest = join(process.env.APPDATA || process.env.HOME || tmpdir(), 'LocalNetMsg', 'zadig.exe') - zadigPath = await new Promise((resolve, reject) => { - const file = require('node:fs').createWriteStream(dest) - const req = dl.get('https://github.com/pbatard/libwdi/releases/download/v1.5.1/zadig-2.9.exe', (res: any) => { - if (res.statusCode !== 200) { reject(new Error(`下载失败: ${res.statusCode}`)); return } - res.pipe(file) - file.on('finish', () => file.close(() => resolve(dest))) - }) - req.on('error', reject) - }) - } catch (e: any) { - return { ok: false, stderr: `自动获取驱动安装器失败: ${e?.message || e}. 设备可能仍可用 — 内置 HID 类驱动对常见设备已足够.` } - } - } - // 2. 用 Zadig CLI 安装 WinUSB (需要 admin) - const cmd = vendorId && productId - ? `"${zadigPath}" --mode=install --vid=${vendorId} --pid=${productId} --driver=winusb` - : `"${zadigPath}" --mode=install --driver=winusb` - const r = await runElevated(cmd) - if (r.ok) return { ...r, stdout: (r.stdout || '') + '\nWinUSB 驱动已安装, 请重新插拔设备.' } - return { ...r, stderr: (r.stderr || '') + '\n自动装驱动失败 — 内置 HID 类驱动对常见键鼠手柄已足够, 可直接重试.' } -} - -// 诊断 + 一键修复 串口 权限 +/** 诊断 + 一键修复 串口权限 */ export async function diagnoseAndFixSerial(): Promise<{ diagnose: DiagnoseResult; fix?: ElevationResult }> { const d = await diagnoseSerial() let fix: ElevationResult | undefined @@ -241,43 +148,24 @@ export async function diagnoseAndFixSerial(): Promise<{ diagnose: DiagnoseResult return { diagnose: d, fix } } -export async function diagnoseAndFixUsb(vendorId?: number, productId?: number): Promise<{ diagnose: DiagnoseResult; fix?: ElevationResult }> { - const d = await diagnoseUsb(vendorId, productId) - let fix: ElevationResult | undefined - const issue = d.issues.find(i => i.kind === 'linux.udev') - if (issue && issue.kind === 'linux.udev' && vendorId && productId) { - fix = await fixLinuxUdevRule(vendorId, productId, 'usb') - } else if (d.issues.some(i => i.kind === 'windows.driver')) { - fix = await fixWindowsHID(vendorId, productId) +/** Linux 上有没有 socat (建虚拟串口用) —— UI 用来提前提示, 免得 attach 到一半才报错 */ +export async function hasSocat(): Promise { + if (platform !== 'linux' && platform !== 'darwin') return true + try { + const { stdout } = await execAsync('which socat 2>/dev/null || true') + return !!stdout.trim() + } catch { + return false } - return { diagnose: d, fix } } -// ====== USB/IP 真透传 (Linux only) ====== -// -// 老实说: USB/IP 是 Linux kernel 自带的 USB-over-IP, 真透明 (设备出现在本机 lsusb). -// 我们的应用只是封装: 列设备 → 调 `usbip attach -r -b ` 让内核接管. -// 用户机器需要先装 `usbip` 包 + 加载 vhci_hcd 模块. 一次性. - -// Linux: 检查 + 装 usbip + 加载内核模块 (vhci_hcd, usbip_core) -export async function diagnoseAndFixUsbAttach(): Promise { - if (platform !== 'linux') return { ok: true, stdout: '非 Linux 不支持 USB/IP' } - // 检查 usbip 命令 - try { - const { stdout } = await execAsync('which usbip || true') - if (!stdout.trim()) { - const install = await runElevated('sh -c "if command -v apt-get >/dev/null 2>&1; then apt-get update && apt-get install -y usbip; elif command -v dnf >/dev/null 2>&1; then dnf install -y usbip; elif command -v yum >/dev/null 2>&1; then yum install -y usbip; else echo NO_PKG_MGR; fi"') - if (!install.ok) return { ok: false, stderr: '未找到 usbip 且自动安装失败: ' + install.stderr } - } - } catch {} - // 检查 /dev/vhci_hcd 存在 (vhci_hcd 模块加载标志) - try { - await execAsync('test -e /dev/vhci_hcd') - } catch { - const m1 = await runElevated('modprobe usbip_core') - const m2 = await runElevated('modprobe vhci_hcd') - if (!m2.ok) return { ok: false, stderr: '加载 vhci_hcd 模块失败: ' + m2.stderr } - await runElevated('sh -c "echo usbip_core > /etc/modules-load.d/usbip.conf && echo vhci_hcd >> /etc/modules-load.d/usbip.conf"').catch(() => {}) +/** 虚拟串口能放在哪 (给 UI 提示用) */ +export function virtualSerialHint(): string { + if (platform === 'linux') { + return '本机会创建一个 socat PTY 配对 (/tmp/lnm-vcom-*/user)。用 minicom / screen / picocom 打开那个路径即可。' } - return { ok: true, stdout: 'usbip + vhci_hcd 已就绪' } -} \ No newline at end of file + if (platform === 'darwin') { + return '本机会创建一个 socat PTY 配对。用 screen / minicom 打开那个路径即可。' + } + return '本机会创建一个 com0com 虚拟 COM 口 (需要管理员权限安装 com0com)。用 PuTTY / Arduino IDE 打开那个 COM 号即可。' +} diff --git a/src/main/protocol.ts b/src/main/protocol.ts index 62d47a0..39998e7 100644 --- a/src/main/protocol.ts +++ b/src/main/protocol.ts @@ -40,7 +40,10 @@ export type WsFrame = | { type: 'read'; upTo: string; ts: number } // 接收方发, upTo = 看到的最后一条 messageId; ≤ upTo 的发送方消息视为已读 | { type: 'terminal'; payload: TerminalFrame } // 远程终端 (双向多路复用) | { type: 'forward'; payload: ForwardFrame } // 端口转发 - | { type: 'usb'; payload: UsbFrame } // USB 设备透传 + // 线上帧类型**故意还是叫 'usb'** —— 为了跟老版本对端兼容。 + // 载荷已经是串口专用的 (SerialFrame); 老版本收到的也是同样的 'usb' 帧 + kind:'serial', + // 所以**新旧版本之间的串口映射照样能用**, 断的只有 USB 字节桥 (那个功能下线了)。 + | { type: 'usb'; payload: SerialFrame } // 串口映射 | { type: 'relation:request'; payload: RelationRequestFrame } // 我想跟你建立同步关系 | { type: 'relation:accept'; payload: RelationAcceptFrame } // 我同意了 | { type: 'relation:reject'; payload: RelationRejectFrame } // 我拒绝了 @@ -129,64 +132,67 @@ export type ForwardFrame = | { type: 'close'; sessionId: string; reason?: string; bytesIn?: number; bytesOut?: number } | { type: 'ack'; sessionId: string; ok: boolean; reason?: string } -// ============ USB / 串口 共享 ============ +// ============ 串口 (serial) ============ // -// 老实说: 纯 Node.js 在 Windows 上做不到 "USB 设备完全透明地变成本机 USB" — 那是商业软件 -// (VirtualHere / USB Network Gate / FlexiHub) 或 Linux usbip + WSL 才有的能力. +// 只做一件事: **串口映射** —— 把一端的真实串口, 映射到另一端可以当本地串口用. // -// 我们提供三种能力, 按场景取用: -// 1. 'serial' — 真正的双向字节流转发, 适合 USB-串口适配器 (CH340/CP210x/FTDI) 和原生串口. -// 在 OS 看来就是普通的串口, 桥接完整 (无协议). -// 2. 'usb' — libusb 字节桥: server 端 node-usb open + claim interface, client 端发 -// controlTransfer / bulkTransfer 请求并收结果. 适合访问自定义 USB 设备 -// (单片机, 编程器, 调试器). 不是透明 USB — 是远程调设备. -// 3. 'usbip' — Linux only, 调系统 usbip CLI 做真透明 USB 透传. 用户机器必须装了 -// usbip + 加载 vhci_hcd 模块. macOS/Windows 默认不可用, UI 会隐藏. +// self-out: 我用对方的串口 (对端打开真实串口, 字节流转发给我) +// → 我这边可以: +// a) 建一个本机虚拟串口 (Linux/macOS: socat PTY; Windows: com0com), +// 本机任何工具 (PuTTY / minicom / screen / 自己的脚本) 直接打开它 +// b) 或者不建虚拟口, 应用自己当"串口助手"收发字节 +// self-in: 对方用我的串口 (我打开真实串口, 字节流转发给对方) +// +// **USB 字节桥 (libusb control/bulk) 和 USB/IP 透传已下线** (v0.2.0 移除). +// 纯 Node.js 在 Windows/macOS 上做不了"USB 设备完全透明地变成本机 USB" +// (那是 VirtualHere / USB Network Gate / FlexiHub 的商业能力), 字节桥又只对 +// 会写控制传输的人有意义, 使用面太窄 —— 不如把串口这条真正好用的路做扎实. +// +// **协议版本没动 (仍是 1), 线上帧类型也没动 (仍叫 'usb')**: 局域网里常有一台机器 +// 还跑着旧版本, 升协议版本会让两边连不上 (连文字消息都发不了), 代价太大。 +// 现在新旧版本之间: 聊天照常, **串口映射也照常能用** (老版本本来就支持 kind:'serial'), +// 只有 USB 字节桥在混版本时用不了 —— 而那个功能本来就下线了. +// 所以: 内部类型全部改名成 Serial*, 线上格式一个字节都没动. -export interface UsbDeviceInfo { - busId: string // 总线 id, 用于唯一识别 + attach - vid: number - pid: number - deviceClass: number - deviceSubclass: number +export interface SerialDeviceInfo { + busId: string // 'serial:/dev/ttyUSB0' —— 唯一标识 + attach 用 + serialPath: string // 真实路径: COM3 / /dev/ttyUSB0 / /dev/ttyACM0 + vid?: number + pid?: number product?: string manufacturer?: string serialNumber?: string - port?: string // node-hid 的 path (HID 模式) - /** 'serial' = 字节流转发, 'usb' = libusb 字节桥 (control/bulk), 'usbip' = Linux 透明透传 */ - kind: 'serial' | 'usb' | 'usbip' - serialPath?: string // 串口路径, e.g. COM3 / /dev/ttyUSB0 - baudRate?: number // 串口默认波特率 + baudRate?: number // 建议波特率 (串口本身不携带这个信息, 默认给个常见的) } /** - * USB 方向: 同 Forward - * 'self-out': 控制端使用对端的设备 (对端打开真实设备) - * 'self-in': 对端使用控制端的设备 (控制端打开真实设备) + * 方向: 同 Forward + * 'self-out': 控制端使用对端的设备 (对端打开真实串口) + * 'self-in': 对端使用控制端的设备 (控制端打开真实串口) */ -export type UsbDirection = 'self-out' | 'self-in' +export type SerialDirection = 'self-out' | 'self-in' -// attach 时附带的配置 (按 kind 区分) -export type UsbAttachConfig = - | { kind: 'serial'; baudRate?: number; dataBits?: 5|6|7|8; stopBits?: 1|2; parity?: 'none'|'even'|'odd'|'mark'|'space'; createVirtual?: boolean; virtualName?: string } - | { kind: 'usb'; configurationValue?: number; interfaceNumber?: number; detachKernelDriver?: boolean } - | { kind: 'usbip' } +export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space' -// attached 帧带回的设备元信息 (主要用于 usb 模式, 让 UI 知道有哪些 endpoint) -export interface UsbEndpointInfo { - endpointNumber: number - direction: 'in' | 'out' - transferType: 'control' | 'bulk' | 'interrupt' | 'isochronous' - packetSize: number +// attach 时附带的配置 +export interface SerialAttachConfig { + kind: 'serial' + baudRate?: number + dataBits?: 5 | 6 | 7 | 8 + stopBits?: 1 | 2 + parity?: SerialParity + /** 在控制端本机建一个虚拟串口 (串口映射) */ + createVirtual?: boolean + /** 用户想指定虚拟口的名字 (e.g. "COM5" / "/tmp/mydev") */ + virtualName?: string } -export interface UsbAttachedInfo { - kind: 'serial' | 'usb' | 'usbip' - // usb 模式: 设备的 endpoint 列表 - endpoints?: UsbEndpointInfo[] - // 串口模式: 远端真实串口路径 +// attached 帧带回的设备元信息 +export interface SerialAttachedInfo { + kind: 'serial' + // 远端真实串口路径 serialPath?: string - // 串口模式: 本机虚拟串口路径 (createVirtual=true 时填) + // 控制端本机虚拟串口路径 (createVirtual=true 时填) userVirtualPath?: string // 设备基础信息 vid?: number @@ -196,34 +202,17 @@ export interface UsbAttachedInfo { serialNumber?: string } -// USB 控制传输 setup 包 (libusb 风格) -export interface UsbControlSetup { - requestType: number // bmRequestType: 方向 | 类型 | 接收方 - request: number // bRequest - value: number // wValue - index: number // wIndex -} - -export type UsbFrame = +export type SerialFrame = | { type: 'list'; reqId: string } - | { type: 'devices'; reqId: string; devices: UsbDeviceInfo[] } - | { type: 'attach'; sessionId: string; direction: UsbDirection; busId: string; config: UsbAttachConfig } - | { type: 'attached'; sessionId: string; ok: boolean; reason?: string; info?: UsbAttachedInfo } + | { type: 'devices'; reqId: string; devices: SerialDeviceInfo[] } + | { type: 'attach'; sessionId: string; direction: SerialDirection; busId: string; config: SerialAttachConfig } + | { type: 'attached'; sessionId: string; ok: boolean; reason?: string; info?: SerialAttachedInfo } | { type: 'detach'; sessionId: string; reason?: string } | { type: 'detached'; sessionId: string; ok: boolean; reason?: string } - // 字节流 (serial 模式: 双向, 半关时 fin=true; usb 模式: 不用, 用 control/bulk) + // 字节流 (双向; fin=true 表示对端那半边关了) | { type: 'data'; sessionId: string; dir: 'host->dev' | 'dev->host'; data: string /* base64 */; fin?: boolean } - // USB 控制传输 (request-response, 用 reqId 配对) - | { type: 'ctrlOut'; sessionId: string; reqId: string; setup: UsbControlSetup; data?: string } - | { type: 'ctrlIn'; sessionId: string; reqId: string; setup: UsbControlSetup; length: number } - | { type: 'ctrlResult';sessionId: string; reqId: string; ok: boolean; data?: string; status?: number } - - // USB 批量/中断传输 (request-response, 用 reqId 配对) - | { type: 'bulkOut'; sessionId: string; reqId: string; endpoint: number; data: string } - | { type: 'bulkIn'; sessionId: string; reqId: string; endpoint: number; length: number; timeoutMs?: number } - | { type: 'bulkResult';sessionId: string; reqId: string; ok: boolean; data?: string; status?: number } | { type: 'ack'; reqId?: string; sessionId?: string; ok: boolean; reason?: string } | { type: 'error'; sessionId: string; reason: string } diff --git a/src/main/remote/approval.ts b/src/main/remote/approval.ts index 0fbcef4..a158d45 100644 --- a/src/main/remote/approval.ts +++ b/src/main/remote/approval.ts @@ -1,4 +1,4 @@ -// 远程操作授权闸门: 决定一个对端能不能开 terminal/forward/usb +// 远程操作授权闸门: 决定一个对端能不能开 terminal/forward/serial // 规则: // 1. settings.remoteEnabled === false -> 全部拒绝 // 2. settings.remoteAllowPeers[peerId][kind] === true -> 自动放行 @@ -8,7 +8,7 @@ import { BrowserWindow } from 'electron' import { getSettings } from '../settings' -export type RemoteKind = 'terminal' | 'forward' | 'usb' | 'relation' +export type RemoteKind = 'terminal' | 'forward' | 'serial' | 'relation' /** 需要走"远程操作白名单"的种类 (relation 走关系层自己的授权, 不在这张表里) */ export type RemoteGateKind = Exclude @@ -20,7 +20,7 @@ interface Pending { /** * 用户对授权请求的回复。 - * terminal/forward/usb 只用到 ok + remember; + * terminal/forward/serial 只用到 ok + remember; * relation (建立同步关系) 还会带上用户勾选的授权范围和他自己的同步目录。 */ export interface ApprovalReply { @@ -39,7 +39,7 @@ const approvedAt = new Map() const APPROVE_TTL_MS = 24 * 3600_000 function cacheKey(peerId: string, kind: RemoteGateKind, nonce?: string) { - // nonce 用于 usb.attach 这种需要每次重新确认的场景 + // nonce 用于 attach 这种需要每次重新确认的场景 return nonce ? `${peerId}::${kind}::${nonce}` : `${peerId}::${kind}` } diff --git a/src/main/remote/forward.ts b/src/main/remote/forward.ts index 179afff..284ee45 100644 --- a/src/main/remote/forward.ts +++ b/src/main/remote/forward.ts @@ -575,7 +575,7 @@ export class ForwardManager extends EventEmitter { for (const sid of this.serverSessions.keys()) this.cleanupServerSession(sid, 'shutdown') } - resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'usb') { + resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'serial') { resetApproval(peerId, kind) } } \ No newline at end of file diff --git a/src/main/remote/serial.ts b/src/main/remote/serial.ts new file mode 100644 index 0000000..13a0aad --- /dev/null +++ b/src/main/remote/serial.ts @@ -0,0 +1,654 @@ +// 串口映射 + 串口助手 (主进程侧) +// +// 只做串口。USB 字节桥 (libusb control/bulk) 和 USB/IP 透传在 v0.2.0 下线了 —— +// 纯 Node.js 在 Windows/macOS 上做不了真透明 USB, 而字节桥只对会写控制传输的人 +// 有意义, 使用面太窄。不如把串口这条真正好用的路做扎实。 +// +// 线上帧类型仍然是 'usb' (见 protocol.ts 的说明): 为了跟老版本对端兼容, +// 内部只做串口, 但线上一个字节都没改 —— 新旧版本之间串口映射照样能用。 +// +// 两种方向: +// self-out: 我用对方的串口 —— 对端打开真实串口, 字节流转发给我。 +// 我这边可以 (a) 建一个本机虚拟串口做"串口映射", 本机任何工具直接打开; +// (b) 不建虚拟口, 应用自己当串口助手收发字节。 +// self-in: 对方用我的串口 —— 我打开真实串口, 字节流转发给对方。 +import { EventEmitter } from 'node:events' +import { randomUUID } from 'node:crypto' +import { platform } from 'node:process' +import type { + DeviceInfo, SerialFrame, SerialDeviceInfo, WsFrame, SerialDirection, + SerialAttachConfig, SerialAttachedInfo, +} from '../protocol' +import type { ChatClient } from '../chat-client' +import { getSettings } from '../settings' +import { shouldAsk, enqueueApproval, markApproved } from './approval' +import { recordAudit } from '../db' +import { createVirtualSerialPair, type VirtualSerialInfo } from './vcom' + +// 串口: 试着 require (可缺失 — 那就列不出设备) +let spMod: any = null +try { spMod = require('serialport') } catch { /* ignored */ } + +const MAX_DATA_CHUNK = 32 * 1024 + +export interface SerialManagerEvents { + approvalRequested: (req: { peerId: string; peerName: string; kind: 'serial'; detail: string; requestId: string }) => void + sessionOpened: (info: { + sessionId: string; peerId: string; direction: SerialDirection; busId: string + info?: SerialAttachedInfo; side: 'client' | 'server' + }) => void + sessionClosed: (info: { sessionId: string; peerId: string; reason?: string; bytesIn: number; bytesOut: number; side: 'client' | 'server' }) => void + sessionError: (info: { sessionId: string; peerId: string; error: string }) => void + /** 串口字节流 —— UI 用它显示收发 (串口助手的流量视图) + * dir: 'rx' = 设备发来的 (对端设备 -> 我), 'tx' = 我发出去的 */ + output: (sessionId: string, bytes: Buffer, peerId: string, dir: 'rx' | 'tx') => void +} + +// ===== Client (我发起的, 设备在对端) ===== +interface ClientSession { + sessionId: string + peerId: string + direction: SerialDirection + busId: string + info?: SerialAttachedInfo + bytesIn: number + bytesOut: number + createdAt: number + // 串口映射: 本机的虚拟串口 (self-out + createVirtual). 我们持有内部那端 + localVirtual?: { + port: any // SerialPort 实例 (内部端) + userPath: string // 给用户工具用的路径 (COM5 / /tmp/lnm-vcom-x/user) + cleanup: () => Promise + } +} + +// ===== Server (对端发起的, 真实串口在我这) ===== +interface ServerSession { + sessionId: string + ownerId: string + peerId: string + busId: string + // 串口: SerialPort 实例 + serial?: any + bytesIn: number + bytesOut: number + info?: SerialAttachedInfo + // 串口关闭 promise (等数据 drain) + serialClosing?: Promise + attachResolver?: (r: { ok: boolean; reason?: string; info?: SerialAttachedInfo }) => void + attachRejecter?: (e: Error) => void +} + +export class SerialManager extends EventEmitter { + private clientSessions = new Map() + private serverSessions = new Map() + private chatClient: ChatClient + // list 请求的待回包 + private pendingLists = new Map void>() + + constructor(chatClient: ChatClient) { + super() + this.chatClient = chatClient + } + + // ====== 本机设备列举 ====== + + /** 列本机的串口 */ + async listLocal(): Promise { + const out: SerialDeviceInfo[] = [] + if (!spMod) return out + try { + const ports: any[] = await spMod.SerialPort.list() + for (const p of ports) { + out.push({ + busId: `serial:${p.path}`, + serialPath: p.path, + vid: p.vendorId ? parseInt(p.vendorId, 16) || 0 : undefined, + pid: p.productId ? parseInt(p.productId, 16) || 0 : undefined, + product: p.friendlyName || p.productId, + manufacturer: p.manufacturer, + serialNumber: p.serialNumber, + baudRate: 115200, + }) + } + } catch (e: any) { + console.warn('[serial] serialport.list failed:', e?.message) + } + return out + } + + /** 问对端要它的串口列表 */ + async listOnPeer(peerId: string): Promise<{ ok: boolean; reason?: string; devices?: SerialDeviceInfo[] }> { + const reqId = randomUUID() + const sent = this.chatClient.send({ type: 'usb', payload: { type: 'list', reqId } } as WsFrame, peerId) + if (!sent) return { ok: false, reason: '对方离线' } + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pendingLists.delete(reqId) + resolve({ ok: false, reason: '超时' }) + }, 15_000) + this.pendingLists.set(reqId, (r) => { + clearTimeout(timer) + resolve(r) + }) + }) + } + + // ====== attach ====== + + /** + * attach: + * self-out -> 对端打开真实串口, 我收字节 (可选: 在我本机建虚拟串口做映射) + * self-in -> 我打开真实串口, 对端收字节 + */ + async attach(peerId: string, opts: { + direction?: SerialDirection + busId: string + config: SerialAttachConfig + }): Promise<{ ok: boolean; reason?: string; sessionId?: string }> { + const s = getSettings() + if (!s.remoteEnabled) return { ok: false, reason: '远程功能已关闭' } + const direction: SerialDirection = opts.direction || 'self-out' + if (!opts.busId) return { ok: false, reason: 'busId 不能为空' } + if (opts.config?.kind !== 'serial') return { ok: false, reason: '只支持串口' } + if (direction === 'self-in') { + // 我方是设备持有方: 我打开真实串口 + return this.serverAttach(peerId, opts.busId, opts.config) + } + // 我方是控制方: 对端打开真实串口 + return this.clientAttach(peerId, opts.busId, opts.config) + } + + /** self-out: 控制端是 client; 设备在对端 */ + private clientAttach(peerId: string, busId: string, config: SerialAttachConfig): Promise<{ ok: boolean; reason?: string; sessionId?: string }> { + const sessionId = randomUUID() + const frame: Extract = { type: 'attach', sessionId, direction: 'self-out', busId, config } + const sent = this.chatClient.send({ type: 'usb', payload: frame } as WsFrame, peerId) + if (!sent) return Promise.resolve({ ok: false, reason: '对方离线' }) + + const sess: ClientSession = { + sessionId, peerId, direction: 'self-out', busId, + bytesIn: 0, bytesOut: 0, createdAt: Date.now(), + } + this.clientSessions.set(sessionId, sess) + + return new Promise((resolve) => { + const timer = setTimeout(() => { + if (this.clientSessions.has(sessionId)) { + this.cleanupClientSession(sessionId, '对方 30s 内未响应') + resolve({ ok: false, reason: '对方 30s 内未响应' }) + } + }, 30_000) + // 单独的 resolver: 控制端由 'attached' 帧 (被控端 attach-ack) resolve + sess.info = undefined + const waitAttached = (p: any) => { + if (p.sessionId !== sessionId) return + this.chatClient.off('serial:attached' as any, waitAttached as any) + clearTimeout(timer) + if (!p.ok) { + this.clientSessions.delete(sessionId) + resolve({ ok: false, reason: p.reason || '对方拒绝' }) + return + } + sess.info = p.info + // 串口映射: 先建本机虚拟串口, 再 resolve + if (config.createVirtual) { + this.setupLocalVirtualSerial(sessionId, config).then((r: { ok: boolean; reason?: string; userPath?: string }) => { + if (!r.ok) { + this.cleanupClientSession(sessionId, r.reason || '虚拟串口创建失败') + resolve({ ok: false, reason: r.reason }) + return + } + sess.info = { ...(p.info || {}), kind: 'serial', userVirtualPath: r.userPath } as any + this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, info: sess.info, side: 'client' }) + recordAudit({ action: 'serial.attach', target: peerId, sessionId, result: 'ok', payload: { busId, direction: 'self-out', virtualPath: r.userPath } }) + resolve({ ok: true, sessionId }) + }).catch((e: Error) => { + this.cleanupClientSession(sessionId, e?.message || '虚拟串口异常') + resolve({ ok: false, reason: e?.message || '虚拟串口异常' }) + }) + return + } + this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, info: p.info, side: 'client' }) + recordAudit({ action: 'serial.attach', target: peerId, sessionId, result: 'ok', payload: { busId, direction: 'self-out' } }) + resolve({ ok: true, sessionId }) + } + this.chatClient.on('serial:attached' as any, waitAttached as any) + }) + } + + /** self-in: 我方是 server, 真实串口在我方 */ + private async serverAttach(peerId: string, busId: string, config: SerialAttachConfig, sessionId: string = randomUUID()): Promise<{ ok: boolean; reason?: string; sessionId?: string }> { + // 1. 解析 busId 找到本机真实路径 + const local = await this.listLocal() + const dev = local.find(d => d.busId === busId) + if (!dev) { + recordAudit({ action: 'serial.attach', target: peerId, sessionId, result: 'error', payload: { phase: 'no-device', busId } }) + return { ok: false, reason: '本机找不到该串口' } + } + if (!spMod) { + return { ok: false, reason: 'serialport 模块未安装' } + } + + // 2. 开串口 + const sess: ServerSession = { + sessionId, ownerId: peerId, peerId, busId, + bytesIn: 0, bytesOut: 0, + } + this.serverSessions.set(sessionId, sess) + + try { + const sp = new spMod.SerialPort({ + path: dev.serialPath, + baudRate: config.baudRate || 115200, + dataBits: config.dataBits || 8, + stopBits: config.stopBits || 1, + parity: config.parity || 'none', + autoOpen: false, + }) + await new Promise((resolve, reject) => { + sp.open((err: any) => err ? reject(err) : resolve()) + }) + sess.serial = sp + // 串口读到的字节 -> 发给对端 + sp.on('data', (buf: Buffer) => { + if (!buf || buf.length === 0) return + sess.bytesIn += buf.length + this.sendChunked(peerId, sessionId, 'dev->host', buf, false) + this.emit('output', sessionId, buf, peerId, 'rx') + }) + sp.on('close', () => { + this.sendControl(peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true }) + this.cleanupServerSession(sessionId, 'serial-closed') + }) + sp.on('error', (e: any) => { + this.emit('sessionError', { sessionId, peerId, error: e.message }) + recordAudit({ action: 'serial.attach', source: peerId, sessionId, result: 'error', payload: { error: e.message } }) + }) + sess.info = { + kind: 'serial', + serialPath: dev.serialPath, + vid: dev.vid, pid: dev.pid, + product: dev.product, manufacturer: dev.manufacturer, serialNumber: dev.serialNumber, + } + } catch (e: any) { + this.serverSessions.delete(sessionId) + const reason = describeOpenError(dev.serialPath, e) + recordAudit({ action: 'serial.attach', target: peerId, sessionId, result: 'error', payload: { phase: 'open', reason } }) + return { ok: false, reason } + } + + return { ok: true, sessionId } + } + + // ====== 帧处理 ====== + + handleIncoming(peer: DeviceInfo, f: SerialFrame) { + switch (f.type) { + case 'list': + this.handlePeerList(peer, f) + return + case 'devices': { + const cb = this.pendingLists.get(f.reqId) + if (cb) { + this.pendingLists.delete(f.reqId) + cb({ ok: true, devices: f.devices }) + } + return + } + case 'attach': + this.handleAttachReq(peer, f) + return + case 'attached': { + // 控制端等的就是这个 (clientAttach 监听的是 chatClient 上的同名事件) + this.chatClient.emit('serial:attached' as any, f) + return + } + case 'detach': + this.handleDetachReq(peer, f) + return + case 'detached': { + this.chatClient.emit('serial:detached' as any, f) + return + } + case 'data': { + this.handleData(f) + return + } + case 'error': { + this.emit('sessionError', { sessionId: f.sessionId, peerId: peer.deviceId, error: f.reason }) + return + } + case 'ack': + return + } + } + + private handleData(f: Extract) { + const cs = this.clientSessions.get(f.sessionId) + const ss = this.serverSessions.get(f.sessionId) + if (!cs && !ss) return + + if (f.dir === 'dev->host') { + // 设备 -> 控制端 + if (cs) { + if (f.fin) { + // 半关信号: 关掉我方虚拟串口 + if (cs.localVirtual) { + try { if (cs.localVirtual.port.isOpen) cs.localVirtual.port.close() } catch {} + } + return + } + const buf = Buffer.from(f.data || '', 'base64') + cs.bytesIn += buf.length + // 优先写虚拟串口 (用户工具在读) + if (cs.localVirtual && cs.localVirtual.port.isOpen) { + try { cs.localVirtual.port.write(buf) } catch (e: any) { + this.emit('sessionError', { sessionId: cs.sessionId, peerId: cs.peerId, error: e.message }) + } + } + // 同时给 UI 缓冲 (串口助手的流量视图) + this.emit('output', f.sessionId, buf, cs.peerId, 'rx') + } + return + } + + // host->dev: 控制端 -> 设备 + if (ss) { + if (f.fin) { try { ss.serial?.close() } catch {}; return } + const buf = Buffer.from(f.data || '', 'base64') + ss.bytesOut += buf.length + try { ss.serial?.write(buf) } catch (e: any) { + this.emit('sessionError', { sessionId: ss.sessionId, peerId: ss.peerId, error: e.message }) + } + } + } + + /** 被控端处理 list 请求 */ + private async handlePeerList(peer: DeviceInfo, f: Extract) { + const s = getSettings() + if (!s.remoteEnabled) return + if (shouldAsk(peer.deviceId, 'serial')) { + const reqId = randomUUID() + const detail = '对方请求查看本机的串口列表' + const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'serial', detail, ts: Date.now() }) + this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'serial', detail }) + const verdict = await reqPromise + if (!verdict.ok) return + if (verdict.remember) markApproved(peer.deviceId, 'serial') + } + const devices = await this.listLocal() + this.sendControl(peer.deviceId, { type: 'devices', reqId: f.reqId, devices }) + recordAudit({ action: 'serial.list', source: peer.deviceId, result: 'ok', payload: { count: devices.length } }) + } + + /** 被控端处理 attach 请求 */ + private async handleAttachReq(peer: DeviceInfo, f: Extract) { + const s = getSettings() + if (!s.remoteEnabled) { + this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '对方关闭了远程功能' }) + return + } + if (shouldAsk(peer.deviceId, 'serial')) { + const detail = describeAttach(f.direction, f.config, f.busId) + const reqId = randomUUID() + const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'serial', detail, ts: Date.now() }) + this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'serial', detail }) + const verdict = await reqPromise + if (!verdict.ok) { + this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '用户拒绝' }) + recordAudit({ action: 'serial.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'denied' }) + return + } + if (verdict.remember) markApproved(peer.deviceId, 'serial') + } + + // 老版本对端可能还在请求 USB 字节桥 —— 明确拒掉, 别让它卡在超时里 + if (f.config?.kind !== 'serial') { + this.sendControl(peer.deviceId, { + type: 'attached', sessionId: f.sessionId, ok: false, + reason: `对方请求的是 ${f.config?.kind || '未知'} 模式, 本版本已下线该功能 (只支持串口)`, + }) + recordAudit({ action: 'serial.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { phase: 'unsupported-kind', kind: f.config?.kind } }) + return + } + + // 走和 self-in 一样的开串口流程 (沿用对端给的 sessionId, 两端 key 必须一致) + const r = await this.serverAttach(peer.deviceId, f.busId, f.config, f.sessionId) + this.sendControl(peer.deviceId, { + type: 'attached', sessionId: f.sessionId, ok: r.ok, reason: r.reason, + info: r.ok ? this.serverSessions.get(f.sessionId)?.info : undefined, + }) + if (!r.ok) { + recordAudit({ action: 'serial.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { phase: 'open', reason: r.reason } }) + } + } + + private handleDetachReq(peer: DeviceInfo, f: Extract) { + const sess = this.serverSessions.get(f.sessionId) + if (!sess) { + this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: false, reason: 'session 不存在' }) + return + } + this.cleanupServerSession(f.sessionId, f.reason || 'closed-by-peer') + this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: true }) + } + + // ====== 串口助手: 发字节 ====== + + /** + * 发字节。 + * client session (self-out): 我的字节 -> 远端设备 (host->dev) + * server session (self-in): 我的字节 -> 对端视图 (当作设备吐出来的, dev->host) + */ + async sendSerialBytes(sessionId: string, buf: Buffer): Promise { + if (!buf || buf.length === 0) return false + const cs = this.clientSessions.get(sessionId) + if (cs) { + cs.bytesOut += buf.length + this.sendChunked(cs.peerId, sessionId, 'host->dev', buf, false) + return true + } + const ss = this.serverSessions.get(sessionId) + if (ss) { + ss.bytesOut += buf.length + this.sendChunked(ss.peerId, sessionId, 'dev->host', buf, false) + return true + } + return false + } + + /** 串口映射: 在本机创建虚拟串口, 打开内部端做桥接 (self-out + createVirtual) */ + private async setupLocalVirtualSerial(sessionId: string, config: SerialAttachConfig): Promise<{ ok: boolean; reason?: string; userPath?: string }> { + if (!spMod) return { ok: false, reason: 'serialport 未安装' } + const sess = this.clientSessions.get(sessionId) + if (!sess) return { ok: false, reason: 'session 不存在' } + + // 1. 创建 PTY 配对 (Linux/macOS) / com0com (Windows) + let vcom: VirtualSerialInfo + try { + vcom = await createVirtualSerialPair({ userPath: config.virtualName }) + } catch (e: any) { + return { ok: false, reason: `创建虚拟串口失败: ${e?.message || e}` } + } + + // 2. 用我们传的 baudRate 打开内部端 + const baudRate = config.baudRate || 115200 + let sp: any + try { + sp = new spMod.SerialPort({ + path: vcom.internalPath, + baudRate, + dataBits: config.dataBits || 8, + stopBits: config.stopBits || 1, + parity: config.parity || 'none', + autoOpen: false, + }) + await new Promise((resolve, reject) => { + sp.open((err: any) => err ? reject(err) : resolve()) + }) + } catch (e: any) { + await vcom.cleanup().catch(() => {}) + return { ok: false, reason: `打开内部虚拟端失败: ${e?.message || e}` } + } + + // 3. 数据桥接 + // 内部端收到字节 (用户工具写进来的) -> WS host->dev -> 远端写真实串口 + sp.on('data', (buf: Buffer) => { + if (!buf || buf.length === 0) return + sess.bytesOut += buf.length + this.sendChunked(sess.peerId, sessionId, 'host->dev', buf, false) + this.emit('output', sessionId, buf, sess.peerId, 'tx') + }) + sp.on('close', () => { + // 内部端被关 (用户工具关闭了虚拟串口?) -> 通知远端 + this.sendControl(sess.peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true }) + this.cleanupClientSession(sessionId, 'virtual-serial-closed-by-user-app') + }) + sp.on('error', (e: any) => { + this.emit('sessionError', { sessionId, peerId: sess.peerId, error: `virtual-serial: ${e.message}` }) + }) + + // 4. 存到 session, detach 时关 + sess.localVirtual = { + port: sp, + userPath: vcom.userPath, + cleanup: async () => { + try { sp.removeAllListeners() } catch {} + try { if (sp.isOpen) await new Promise((r) => sp.close(() => r())) } catch {} + await vcom.cleanup().catch(() => {}) + }, + } + + recordAudit({ action: 'serial.vcom.create', target: sess.peerId, sessionId, result: 'ok', payload: { userPath: vcom.userPath, internalPath: vcom.internalPath, platform: vcom.platform } }) + return { ok: true, userPath: vcom.userPath } + } + + // ====== detach ====== + + async detach(sessionId: string, reason = 'user'): Promise<{ ok: boolean; reason?: string }> { + const cs = this.clientSessions.get(sessionId) + if (cs) { + // 发 detach 给对端 + this.sendControl(cs.peerId, { type: 'detach', sessionId, reason }) + this.cleanupClientSession(sessionId, reason) + return { ok: true } + } + const ss = this.serverSessions.get(sessionId) + if (ss) { + this.cleanupServerSession(sessionId, reason) + // 通知对端 + this.sendControl(ss.peerId, { type: 'detached', sessionId, ok: true, reason }) + return { ok: true } + } + return { ok: false, reason: 'session 不存在' } + } + + cleanupClientSession(sessionId: string, reason: string) { + const s = this.clientSessions.get(sessionId) + if (!s) return + // 关掉本机虚拟串口 (如果有) + if (s.localVirtual) { + s.localVirtual.cleanup().catch(() => {}) + } + this.clientSessions.delete(sessionId) + this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'client' }) + recordAudit({ action: 'serial.close', target: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } }) + } + + cleanupServerSession(sessionId: string, reason: string) { + const s = this.serverSessions.get(sessionId) + if (!s) return + // 关串口 + if (s.serial) { + try { s.serial.close() } catch {} + } + this.serverSessions.delete(sessionId) + this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'server' }) + recordAudit({ action: 'serial.close', source: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } }) + } + + // ====== 维护 ====== + + listClientSessions() { + return Array.from(this.clientSessions.values()).map(s => ({ + sessionId: s.sessionId, peerId: s.peerId, direction: s.direction, busId: s.busId, + info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: s.createdAt, side: 'client' as const, + })) + } + + listServerSessions() { + return Array.from(this.serverSessions.values()).map(s => ({ + sessionId: s.sessionId, peerId: s.peerId, direction: 'self-in' as SerialDirection, busId: s.busId, + info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: Date.now(), side: 'server' as const, + })) + } + + onPeerDisconnected(peerId: string) { + for (const [sid, s] of this.clientSessions) { + if (s.peerId === peerId) this.cleanupClientSession(sid, 'peer-disconnected') + } + for (const [sid, s] of this.serverSessions) { + if (s.peerId === peerId) this.cleanupServerSession(sid, 'peer-disconnected') + } + } + + stop() { + for (const sid of this.clientSessions.keys()) this.cleanupClientSession(sid, 'shutdown') + for (const sid of this.serverSessions.keys()) this.cleanupServerSession(sid, 'shutdown') + } + + // ====== 工具 ====== + + private sendControl(peerId: string, payload: SerialFrame) { + return this.chatClient.send({ type: 'usb', payload } as WsFrame, peerId) + } + + private sendChunked(peerId: string, sessionId: string, dir: 'host->dev' | 'dev->host', buf: Buffer, fin: boolean) { + let remaining = buf + while (remaining.length > 0) { + const chunk = remaining.subarray(0, MAX_DATA_CHUNK) + remaining = remaining.subarray(MAX_DATA_CHUNK) + const lastAndFin = fin && remaining.length === 0 + this.sendControl(peerId, { type: 'data', sessionId, dir, data: chunk.toString('base64'), fin: lastAndFin }) + } + if (fin && buf.length === 0) { + this.sendControl(peerId, { type: 'data', sessionId, dir, data: '', fin: true }) + } + } +} + +/** + * 开串口失败的原因要能指导用户 —— 尤其 Linux 的权限问题。 + * 串口助手最常见的翻车点就是 "对方打开 /dev/ttyUSB0 失败: Permission denied", + * 而解法 (加 dialout 组) 跟 Windows/macOS 完全不同。 + */ +export function describeOpenError(path: string | undefined, e: any): string { + const raw = String(e?.message || e || '未知错误') + const isPermission = /EACCES|EPERM|permission denied|access denied/i.test(raw) + if (isPermission && platform === 'linux') { + return `${raw}\n\n该串口 (${path || ''}) 需要权限。Linux 上把当前用户加入 dialout 组即可:\n` + + ` sudo usermod -aG dialout $USER\n` + + `然后**重新登录** (或重启该设备上的 App) 让组权限生效。` + } + if (isPermission && platform === 'darwin') { + return `${raw}\n\nmacOS 首次访问串口会弹系统权限框, 需要在 系统设置 → 隐私与安全 中允许。` + } + if (/busy|EBUSY|Resource temporarily unavailable/i.test(raw)) { + return `${raw}\n\n该串口正被别的程序占用 (minicom / screen / PuTTY / 另一个终端窗口)。先关掉占用它的程序。` + } + if (/ENOENT|no such file/i.test(raw)) { + return `${raw}\n\n串口 ${path || ''} 不存在 —— 设备可能已拔出, 或者 udev 还没建出节点 (等几秒再刷新)。` + } + return raw +} + +function describeAttach(dir: SerialDirection, config: SerialAttachConfig, busId: string): string { + const br = config.baudRate || 115200 + if (dir === 'self-out') { + return config.createVirtual + ? `对方想用你的串口 ${busId} (${br} 8N1), 并在**自己机器上**建一个虚拟串口来映射` + : `对方想用你的串口 ${busId} (${br} 8N1), 直接收发字节` + } + return `对方想把自己的串口 ${busId} (${br} 8N1) 映射到你的机器上` +} diff --git a/src/main/remote/terminal.ts b/src/main/remote/terminal.ts index ad72af4..05b5383 100644 --- a/src/main/remote/terminal.ts +++ b/src/main/remote/terminal.ts @@ -317,7 +317,7 @@ export class TerminalManager extends EventEmitter { } // 用户在 UI 上点 "拒绝所有未决请求" - resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'usb') { + resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'serial') { resetApproval(peerId, kind) } } \ No newline at end of file diff --git a/src/main/remote/usb.ts b/src/main/remote/usb.ts deleted file mode 100644 index b330b04..0000000 --- a/src/main/remote/usb.ts +++ /dev/null @@ -1,965 +0,0 @@ -// USB / 串口 共享 (3 种模式): -// 1. 'serial' — 双向字节流. server 端 SerialPort.open, client 端收发 hex 流. -// 覆盖所有 USB-串口适配器 (CH340/CP210x/FTDI) 和原生 COM/tty. -// 2. 'usb' — libusb 字节桥. server 端 node-usb open + claim interface, -// client 端发 controlTransfer / bulkTransfer 请求, server 返回结果. -// 不是透明 USB, 但能远程调自定义 USB 设备 (单片机/编程器/调试器). -// 3. 'usbip' — Linux only, 调 `usbip attach` 让内核接管. 真透明透传, 但需要内核模块. -import { EventEmitter } from 'node:events' -import { randomUUID } from 'node:crypto' -import { platform } from 'node:process' -import type { - DeviceInfo, UsbFrame, UsbDeviceInfo, WsFrame, UsbDirection, - UsbAttachConfig, UsbAttachedInfo, UsbEndpointInfo, -} from '../protocol' -import type { ChatClient } from '../chat-client' -import { getSettings } from '../settings' -import { shouldAsk, enqueueApproval, markApproved } from './approval' -import { recordAudit } from '../db' -import { diagnoseAndFixUsbAttach } from '../permissions' -import { createVirtualSerialPair, type VirtualSerialInfo } from './vcom' - -// 串口: 试着 require (可缺失 — 走别的路径) -let spMod: any = null -try { spMod = require('serialport') } catch { /* ignored */ } - -// libusb: 试着 require -let usbMod: any = null -try { usbMod = require('usb') } catch { /* ignored */ } - -const MAX_DATA_CHUNK = 32 * 1024 - -export interface UsbManagerEvents { - approvalRequested: (req: { peerId: string; peerName: string; kind: 'usb'; detail: string; requestId: string }) => void - sessionOpened: (info: { sessionId: string; peerId: string; direction: UsbDirection; busId: string; kind: 'serial'|'usb'|'usbip'; info?: UsbAttachedInfo; side: 'client'|'server' }) => void - sessionClosed: (info: { sessionId: string; peerId: string; reason?: string; bytesIn: number; bytesOut: number; side: 'client'|'server' }) => void - sessionError: (info: { sessionId: string; peerId: string; error: string }) => void - /** 串口/UART 字节流 (仅 serial 模式). UI 用来显示 hex. */ - output: (sessionId: string, bytes: Buffer, peerId: string) => void - /** USB 设备事件 (仅 usb 模式). 透传给 UI. */ - usbEvent: (sessionId: string, peerId: string, payload: { controlIn?: boolean; bulkIn?: boolean; ok: boolean; data?: Buffer; status?: number }) => void -} - -// ===== Client (我发起的, 自己是 '我') ===== -interface ClientSession { - sessionId: string - peerId: string - direction: UsbDirection - busId: string - kind: 'serial'|'usb'|'usbip' - info?: UsbAttachedInfo - bytesIn: number - bytesOut: number - createdAt: number - // 串口: 本机的"虚拟串口" (self-out + createVirtual). 我们的 SerialPort 实例 + 清理回调 - localVirtual?: { - port: any // SerialPort 实例 (我们开的内部那端) - userPath: string // 给用户 app 用的路径 - cleanup: () => Promise - } -} - -// ===== Server (对端发起的, 我打开真实设备) ===== -interface ServerSession { - sessionId: string - ownerId: string - peerId: string - busId: string - kind: 'serial'|'usb'|'usbip' - // 串口: SerialPort 实例 - serial?: any - // USB: 句柄 + interface - usbDev?: any - usbInterface?: number - bytesIn: number - bytesOut: number - // 设备元信息 (attach 成功时填, 给 UI 看的 endpoint 列表等) - info?: UsbAttachedInfo - // 串口 关闭 promise (等数据 drain) - serialClosing?: Promise - // USB 控制/批量 reqId -> 配对 response - pendingCtrl: Map void> - pendingBulk: Map void> - // client 端正在等的 attach 结果 - attachResolver?: (r: { ok: boolean; reason?: string; info?: UsbAttachedInfo }) => void - attachRejecter?: (e: Error) => void - // USB detach 流程: 我们 detach 时不让 OS 拉回去 — 留给 client - detachResolver?: (r: { ok: boolean; reason?: string }) => void -} - -export class UsbBridgeManager extends EventEmitter { - private clientSessions = new Map() - private serverSessions = new Map() - private chatClient: ChatClient - // list 请求的待回包 - private pendingLists = new Map void>() - - constructor(chatClient: ChatClient) { - super() - this.chatClient = chatClient - } - - // ====== 本地设备列举 ====== - - // 列本机的: 串口 + libusb 设备 (合并) - async listLocal(): Promise { - const out: UsbDeviceInfo[] = [] - // 串口 - if (spMod) { - try { - const ports: any[] = await spMod.SerialPort.list() - for (const p of ports) { - out.push({ - busId: `serial:${p.path}`, - vid: p.vendorId ? parseInt(p.vendorId, 16) || 0 : 0, - pid: p.productId ? parseInt(p.productId, 16) || 0 : 0, - deviceClass: 0x02, deviceSubclass: 0x02, - product: p.friendlyName || p.productId, - manufacturer: p.manufacturer, - serialNumber: p.serialNumber, - kind: 'serial', - serialPath: p.path, - baudRate: 115200, - }) - } - } catch (e: any) { - console.warn('[usb] serialport.list failed:', e?.message) - } - } - // libusb (只读枚举, 不 claim interface, 安全) - if (usbMod) { - try { - // 注意: node-usb v3 的 webusb.getDevices() 是 WebUSB 语义 —— 只返回"本进程已授权/已打开"的设备, - // 枚举系统上已插着的设备必须用 loadDevices() (内部走 nativeGetDevices) - const webusb: any = usbMod.webusb - const devices: any[] = typeof webusb?.loadDevices === 'function' - ? await webusb.loadDevices() - : await webusb.getDevices() - for (const d of devices) { - const busId = `usb:${pad4(d.vendorId)}:${pad4(d.productId)}:${d.serialNumber || d.productName || d.productName || '0'}` - // 避免和已加的串口重复 (USB-串口适配器在 OS 看来是串口) - const vid = d.vendorId || 0 - const pid = d.productId || 0 - const isDuplicate = out.some(s => s.vid === vid && s.pid === pid && s.kind === 'serial') - if (isDuplicate) continue - out.push({ - busId, - vid, - pid, - deviceClass: d.deviceClass || 0, - deviceSubclass: d.deviceSubclass || 0, - product: d.productName, - manufacturer: d.manufacturerName, - serialNumber: d.serialNumber, - kind: 'usb', - }) - } - } catch (e: any) { - console.warn('[usb] webusb.getDevices failed:', e?.message) - } - } - // Linux: 追加 usbip (用系统命令列可分享的设备) - if (platform === 'linux') { - try { - const { exec: execSync } = await import('node:child_process') - const { promisify } = await import('node:util') - const execAsync = promisify(execSync as any) - const { stdout } = await execAsync('usbip list -l 2>/dev/null || true') - // 格式: " - busid 1-1 (1234:5678) 01:00:00 3D /sys/..." - // vendor:vendor product - const lines = stdout.split(/\r?\n/) - for (const line of lines) { - const m = line.match(/busid\s+(\S+)\s+\(([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\)/) - if (m) { - out.push({ - busId: `usbip:${m[1]}`, - vid: parseInt(m[2], 16), - pid: parseInt(m[3], 16), - deviceClass: 0, - deviceSubclass: 0, - kind: 'usbip', - }) - } - } - } catch {} - } - return out - } - - // 问对端设备列表 - async listOnPeer(peerId: string): Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }> { - const reqId = randomUUID() - const sent = this.chatClient.send({ type: 'usb', payload: { type: 'list', reqId } } as WsFrame, peerId) - if (!sent) return { ok: false, reason: '对方离线' } - return new Promise((resolve) => { - const timer = setTimeout(() => { - this.pendingLists.delete(reqId) - resolve({ ok: false, reason: '超时' }) - }, 15_000) - this.pendingLists.set(reqId, (r) => { - clearTimeout(timer) - resolve(r) - }) - }) - } - - // ====== 我方作为控制端 (发起 attach) ====== - - // attach: 三种模式按 config.kind 走不同路径 - async attach(peerId: string, opts: { direction?: UsbDirection; busId: string; config: UsbAttachConfig }): Promise<{ ok: boolean; reason?: string; sessionId?: string }> { - const s = getSettings() - if (!s.remoteEnabled) return { ok: false, reason: '远程功能已关闭' } - const direction: UsbDirection = opts.direction || 'self-out' - if (!opts.busId) return { ok: false, reason: 'busId 不能为空' } - const kind = opts.config.kind - // self-in 模式: 我方是 server, 需要我自己先把设备 open 好, 再让对端 attach - // self-out 模式: 我方是 client, 对端 open 设备, 我方收字节/调 USB - if (direction === 'self-in') { - // 我方是设备持有方: 走 server attach - return this.serverAttach(peerId, opts.busId, kind, opts.config) - } else { - // 我方是控制方: 走 client attach - return this.clientAttach(peerId, opts.busId, kind, opts.config) - } - } - - // self-out: 控制端是 client; 设备在对端 - private clientAttach(peerId: string, busId: string, kind: 'serial'|'usb'|'usbip', config: UsbAttachConfig): Promise<{ ok: boolean; reason?: string; sessionId?: string }> { - const sessionId = randomUUID() - const frame: Extract = { type: 'attach', sessionId, direction: 'self-out', busId, config } - const sent = this.chatClient.send({ type: 'usb', payload: frame } as WsFrame, peerId) - if (!sent) return Promise.resolve({ ok: false, reason: '对方离线' }) - - const sess: ClientSession = { - sessionId, peerId, direction: 'self-out', busId, kind, - bytesIn: 0, bytesOut: 0, createdAt: Date.now(), - } - this.clientSessions.set(sessionId, sess) - // 不写 audit: 还没成功, 也不在失败语义里; 真正的 result 在 waitAttached 里写 - - return new Promise((resolve) => { - const timer = setTimeout(() => { - if (this.clientSessions.has(sessionId)) { - this.cleanupClientSession(sessionId, '对方 30s 内未响应') - resolve({ ok: false, reason: '对方 30s 内未响应' }) - } - }, 30_000) - // 单独的 resolver: 控制端由 'attached' 帧 (被控端 attach-ack) resolve - sess.info = undefined - const waitAttached = (p: any) => { - if (p.sessionId !== sessionId) return - this.chatClient.off('usb:attached' as any, waitAttached as any) - clearTimeout(timer) - if (p.ok) { - sess.info = p.info - // usbip: 调 `usbip attach -r -b ` 真正 attach - if (kind === 'usbip') { - this.shellUsbipAttach(peerId, busId).then((r) => { - if (!r.ok) { - this.cleanupClientSession(sessionId, r.reason || 'usbip attach 失败') - resolve({ ok: false, reason: r.reason }) - return - } - this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: p.info, side: 'client' }) - recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out' } }) - resolve({ ok: true, sessionId }) - }).catch((e) => { - this.cleanupClientSession(sessionId, e?.message || 'usbip 失败') - resolve({ ok: false, reason: e?.message || 'usbip 失败' }) - }) - } else { - // serial + createVirtual: 先建本机虚拟串口, 再开内部端, 再 resolve - if (kind === 'serial') { - const sCfg = config as Extract - if (sCfg.createVirtual) { - this.setupLocalVirtualSerial(sessionId, sCfg).then((r: { ok: boolean; reason?: string; userPath?: string }) => { - if (!r.ok) { - this.cleanupClientSession(sessionId, r.reason || '虚拟串口创建失败') - resolve({ ok: false, reason: r.reason }) - return - } - sess.info = { ...(p.info || {}), kind: 'serial', userVirtualPath: r.userPath } as any - this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: sess.info, side: 'client' }) - recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out', virtualPath: r.userPath } }) - resolve({ ok: true, sessionId }) - }).catch((e: Error) => { - this.cleanupClientSession(sessionId, e?.message || '虚拟串口异常') - resolve({ ok: false, reason: e?.message || '虚拟串口异常' }) - }) - return - } - } - this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: p.info, side: 'client' }) - recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out' } }) - resolve({ ok: true, sessionId }) - } - } else { - this.clientSessions.delete(sessionId) - resolve({ ok: false, reason: p.reason || '对方拒绝' }) - } - } - this.chatClient.on('usb:attached' as any, waitAttached as any) - }) - } - - // self-in: 我方是 server, 设备在我方 - // sessionId: 由发起方决定 —— 对端 attach 请求里带的 id 必须原样沿用, - // 否则 server session 的 key 和控制端的 key 对不上, 数据帧会被当成"无此 session"丢掉 - private async serverAttach(peerId: string, busId: string, kind: 'serial'|'usb'|'usbip', config: UsbAttachConfig, sessionId: string = randomUUID()): Promise<{ ok: boolean; reason?: string; sessionId?: string }> { - // 1. 解析 busId 找到本机真实路径/handle - const local = await this.listLocal() - const dev = local.find(d => d.busId === busId) - if (!dev) { - recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'error', payload: { phase: 'no-device', busId, kind } }) - return { ok: false, reason: '本机找不到该设备' } - } - - // 2. 开设备 - const sess: ServerSession = { - sessionId, ownerId: peerId, peerId, busId, kind, - bytesIn: 0, bytesOut: 0, - pendingCtrl: new Map(), pendingBulk: new Map(), - } - this.serverSessions.set(sessionId, sess) - - try { - if (kind === 'serial') { - const c = config as Extract - const sp = new spMod.SerialPort({ - path: dev.serialPath!, - baudRate: c.baudRate || 115200, - dataBits: c.dataBits || 8, - stopBits: c.stopBits || 1, - parity: c.parity || 'none', - autoOpen: false, - }) - await new Promise((resolve, reject) => { - sp.open((err: any) => err ? reject(err) : resolve()) - }) - sess.serial = sp - // 接收字节 → 发给 client - sp.on('data', (buf: Buffer) => { - if (buf.length === 0) return - sess.bytesIn += buf.length - this.sendChunked(peerId, sessionId, 'dev->host', buf, false) - this.emit('output', sessionId, buf, peerId) - }) - sp.on('close', () => { - this.sendControl(peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true }) - this.cleanupServerSession(sessionId, 'serial-closed') - }) - sp.on('error', (e: any) => { - this.emit('sessionError', { sessionId, peerId, error: e.message }) - recordAudit({ action: 'usb.attach', source: peerId, sessionId, result: 'error', payload: { kind, error: e.message } }) - }) - sess.info = { kind: 'serial', serialPath: dev.serialPath, vid: dev.vid, pid: dev.pid, product: dev.product, manufacturer: dev.manufacturer, serialNumber: dev.serialNumber } - } else if (kind === 'usb') { - if (!usbMod) throw new Error('node-usb 未安装') - const c = config as Extract - // find by vendorId/productId - const dev2: any = await usbMod.webusb.findDeviceByIds(dev.vid, dev.pid).catch(() => null) - if (!dev2) throw new Error('libusb 找不到该设备') - await dev2.open() - const ifaceNum = c.interfaceNumber ?? 0 - // 顺序很关键: 设备若被内核驱动占着 (HID/uvcvideo/btusb...), 必须先 detach 再 selectConfiguration, - // 否则 selectConfiguration 直接 EBUSY ("device is busy") - if (platform === 'linux' && c.detachKernelDriver !== false) { - try { await dev2.detachKernelDriver(ifaceNum) } catch {} - } - const configVal = c.configurationValue ?? dev2.configuration?.configurationValue ?? 1 - await dev2.selectConfiguration(configVal) - await dev2.claimInterface(ifaceNum) - sess.usbDev = dev2 - sess.usbInterface = ifaceNum - // 枚举 endpoints - const endpoints: UsbEndpointInfo[] = [] - const cfg = dev2.configuration - if (cfg) { - const iface = cfg.interfaces?.find((x: any) => x.interfaceNumber === ifaceNum) - const alt = iface?.alternate - for (const ep of alt?.endpoints || []) { - endpoints.push({ - endpointNumber: ep.endpointNumber, - direction: ep.direction === 'in' ? 'in' : 'out', - transferType: ep.type || 'bulk', - packetSize: ep.packetSize || 0, - }) - } - } - sess.info = { kind: 'usb', endpoints, vid: dev.vid, pid: dev.pid, product: dev.product, manufacturer: dev.manufacturer, serialNumber: dev.serialNumber } - } else if (kind === 'usbip') { - // self-in 模式我不直接 attach; 我只是把设备标记为 share (调 `usbip bind` 替代: 由 peer 端 attach) - // 实际 server attach (本端用对端设备) 不支持 self-in 模式 - // 因为 usbip 的 server 是持有设备方, attach 是 client 端 - // 这里 self-in 实际意义: 我把设备给对端, 我这边只是 declare share - // 直接在 device-holder 端 (我) 不需要做什么, 真正 attach 是 client 端 (`usbip attach -r <对方>`) - // 我们只需要告诉对方: 这是个 usbip 设备, 你来 attach - sess.info = { kind: 'usbip', vid: dev.vid, pid: dev.pid } - } - this.emit('sessionOpened', { sessionId, peerId, direction: 'self-in', busId, kind, info: sess.info, side: 'server' }) - recordAudit({ action: 'usb.attach', source: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-in' } }) - return { ok: true, sessionId } - } catch (e: any) { - this.serverSessions.delete(sessionId) - recordAudit({ action: 'usb.attach', source: peerId, sessionId, result: 'error', payload: { phase: 'open', error: String(e?.message || e), kind } }) - return { ok: false, reason: `打开设备失败: ${e?.message || e}` } - } - } - - // ====== 我方作为被控端 (收到 attach) ====== - - handleIncoming(peer: DeviceInfo, f: UsbFrame) { - switch (f.type) { - case 'list': return this.handlePeerList(peer, f) - case 'devices': { - const r = this.pendingLists.get(f.reqId) - if (r) { r({ ok: true, devices: f.devices }); this.pendingLists.delete(f.reqId) } - return - } - case 'attach': return this.handleAttachReq(peer, f) - case 'attached': { - // 控制端收到被控端的 ack - this.chatClient.emit('usb:attached' as any, f) - return - } - case 'detach': return this.handleDetachReq(peer, f) - case 'detached': { - this.chatClient.emit('usb:detached' as any, f) - return - } - case 'data': { - // 串口字节流 - const sess = this.serverSessions.get(f.sessionId) || this.clientSessions.get(f.sessionId) as any - if (!sess) return - if (f.dir === 'dev->host') { - // 设备 → 控制端: 远端从真实串口读到的字节 - // 1) 落到 server-side session (self-in): 我方是设备持有方, 我方自己读到了; 这个分支不会到这里 (我方自己读的数据不走 WS) - // 2) 落到 client-side session (self-out): 我方是控制方, 远端发过来的字节要写到我方的虚拟串口 / hex 缓冲 - const cs = this.clientSessions.get(f.sessionId) - if (cs && cs.kind === 'serial') { - if (f.fin) { - // 半关信号: 关掉我方虚拟串口 - if (cs.localVirtual) { - try { if (cs.localVirtual.port.isOpen) cs.localVirtual.port.close() } catch {} - } - return - } - const buf = Buffer.from(f.data || '', 'base64') - cs.bytesIn += buf.length - // 优先写虚拟串口 (用户 app 在读) - if (cs.localVirtual && cs.localVirtual.port.isOpen) { - try { cs.localVirtual.port.write(buf) } catch (e: any) { - this.emit('sessionError', { sessionId: cs.sessionId, peerId: cs.peerId, error: e.message }) - } - } - // 同时给 UI 缓冲 (hex 显示) - this.emit('output', f.sessionId, buf, cs.peerId) - } - return - } - // host->dev: 控制端 -> 设备 - if (this.serverSessions.has(f.sessionId)) { - const s = this.serverSessions.get(f.sessionId)! - if (s.kind === 'serial' && s.serial) { - if (f.fin) { try { s.serial.close() } catch {} ; return } - const buf = Buffer.from(f.data || '', 'base64') - s.bytesOut += buf.length - try { s.serial.write(buf) } catch (e: any) { - this.emit('sessionError', { sessionId: s.sessionId, peerId: s.peerId, error: e.message }) - } - } - } else { - // client 端 (我们发起 self-in): 我方持有设备, client 这边收 host->dev 字节 (对端发来的) - // 但 client session 的 host->dev 是对端 app -> 我方; 不直接写本地 - // self-in: 我方持有设备, 对端是控制方, 对端写 host->dev 字节会被我方 (server) 收 - // client 这边忽略 - } - return - } - case 'ctrlOut': - case 'ctrlIn': { - const s = this.serverSessions.get(f.sessionId) - if (!s || s.kind !== 'usb' || !s.usbDev) { - this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: f.sessionId, reqId: f.reqId, ok: false, status: -1 }) - return - } - this.handleCtrlReq(s, peer, f) - return - } - case 'bulkOut': - case 'bulkIn': { - const s = this.serverSessions.get(f.sessionId) - if (!s || s.kind !== 'usb' || !s.usbDev) { - this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: f.sessionId, reqId: f.reqId, ok: false, status: -1 }) - return - } - this.handleBulkReq(s, peer, f) - return - } - case 'ctrlResult': { - const s = this.clientSessions.get(f.sessionId) as any - if (s && s.kind === 'usb') { - // 不会到这里: client 端 (我们 self-out) 直接等 server 返回 - } - // 控制端 client: 收到被控端的 ctrlResult → resolve - const sess = this.clientSessions.get(f.sessionId) as any - if (sess && sess.kind === 'usb') { - // 实际上 client 端我们不存 pendingCtrl, 因为是 process 直接 await - // 走 usbEvent 事件 - } - this.emit('usbEvent', f.sessionId, peer.deviceId, { ok: f.ok, data: f.data ? Buffer.from(f.data, 'base64') : undefined, status: f.status, controlIn: true }) - return - } - case 'bulkResult': { - this.emit('usbEvent', f.sessionId, peer.deviceId, { ok: f.ok, data: f.data ? Buffer.from(f.data, 'base64') : undefined, status: f.status, bulkIn: true }) - return - } - case 'error': { - this.emit('sessionError', { sessionId: f.sessionId, peerId: peer.deviceId, error: f.reason }) - return - } - case 'ack': { - // 通用 ack - return - } - } - } - - // 被控端处理 list 请求 - private async handlePeerList(peer: DeviceInfo, f: Extract) { - const s = getSettings() - if (!s.remoteEnabled) return - if (shouldAsk(peer.deviceId, 'usb')) { - const reqId = randomUUID() - const detail = '对方请求查看本机的 USB / 串口 设备列表' - const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail, ts: Date.now() }) - this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail }) - const verdict = await reqPromise - if (!verdict.ok) return - } - const devices = await this.listLocal() - this.sendControl(peer.deviceId, { type: 'devices', reqId: f.reqId, devices }) - recordAudit({ action: 'usb.list', source: peer.deviceId, result: 'ok', payload: { count: devices.length } }) - } - - // 被控端处理 attach 请求 - private async handleAttachReq(peer: DeviceInfo, f: Extract) { - const s = getSettings() - if (!s.remoteEnabled) { - this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '对方关闭了远程功能' }) - return - } - if (shouldAsk(peer.deviceId, 'usb')) { - const detail = describeAttach(f.direction, f.config, f.busId) - const reqId = randomUUID() - const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail, ts: Date.now() }) - this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail }) - const verdict = await reqPromise - if (!verdict.ok) { - this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '用户拒绝' }) - recordAudit({ action: 'usb.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'denied' }) - return - } - if (verdict.remember) markApproved(peer.deviceId, 'usb') - } - - // 走和 self-in 一样的开设备流程 (沿用对端给的 sessionId, 两端 key 必须一致) - const r = await this.serverAttach(peer.deviceId, f.busId, f.config.kind, f.config, f.sessionId) - this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: r.ok, reason: r.reason, info: r.ok ? this.serverSessions.get(f.sessionId)?.info : undefined }) - if (!r.ok) { - recordAudit({ action: 'usb.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { phase: 'open', reason: r.reason } }) - } - } - - private handleDetachReq(peer: DeviceInfo, f: Extract) { - const sess = this.serverSessions.get(f.sessionId) - if (!sess) { - this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: false, reason: 'session 不存在' }) - return - } - this.cleanupServerSession(f.sessionId, f.reason || 'closed-by-peer') - this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: true }) - } - - // USB 控制传输: 对方发请求 → 我执行 → 回 ctrlResult - private async handleCtrlReq(sess: ServerSession, peer: DeviceInfo, f: Extract) { - try { - const dev = sess.usbDev - const setup: any = toUsbControlSetup(f.setup) - if (f.type === 'ctrlOut') { - const data = f.data ? Buffer.from(f.data, 'base64') : null - const written = await dev.nativeControlTransferOut(setup, 5000, data ? new Uint8Array(data) : null) - sess.bytesOut += written || 0 - this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, status: written || 0 }) - this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, status: written || 0, controlIn: false }) - } else { - const buf = await dev.nativeControlTransferIn(setup, 5000, f.length) - if (buf) { - const b = Buffer.from(buf) - sess.bytesIn += b.length - this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, data: b.toString('base64'), status: b.length }) - this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, data: b, status: b.length, controlIn: true }) - } else { - this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: 0 }) - } - } - } catch (e: any) { - this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: -1 }) - this.emit('sessionError', { sessionId: sess.sessionId, peerId: peer.deviceId, error: `ctrl: ${e?.message || e}` }) - } - } - - private async handleBulkReq(sess: ServerSession, peer: DeviceInfo, f: Extract) { - try { - const dev = sess.usbDev - if (f.type === 'bulkOut') { - const buf = Buffer.from(f.data, 'base64') - const written = await dev.nativeTransferOut(f.endpoint, f.type === 'bulkOut' ? 5000 : 5000, new Uint8Array(buf)) - sess.bytesOut += written || 0 - this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, status: written || 0 }) - this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, status: written || 0, bulkIn: false }) - } else { - const timeout = f.timeoutMs || 5000 - const buf = await dev.nativeTransferIn(f.endpoint, timeout, f.length) - if (buf) { - const b = Buffer.from(buf) - sess.bytesIn += b.length - this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, data: b.toString('base64'), status: b.length }) - this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, data: b, status: b.length, bulkIn: true }) - } else { - this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: 0 }) - } - } - } catch (e: any) { - this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: -1 }) - this.emit('sessionError', { sessionId: sess.sessionId, peerId: peer.deviceId, error: `bulk: ${e?.message || e}` }) - } - } - - // ====== 我方作为控制端, 给对端发 control/bulk 请求 ====== - - async usbCtrlTransfer(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, data?: string): Promise<{ ok: boolean; data?: Buffer; status?: number }> { - const sess = this.clientSessions.get(sessionId) - if (!sess) return { ok: false, status: -1 } - const reqId = randomUUID() - if (data) { - this.sendControl(sess.peerId, { type: 'ctrlOut', sessionId, reqId, setup, data }) - } else { - // 没有 data = host 想读; 但读要 length — 暂时用 0 (调用方应该用 ctrlIn) - this.sendControl(sess.peerId, { type: 'ctrlOut', sessionId, reqId, setup }) - } - return new Promise((resolve) => { - const timer = setTimeout(() => { - resolve({ ok: false, status: -1 }) - }, 5000) - const onEvt = (p: any) => { - if (p.controlIn !== true) return - // 注意: 我们 emit usbEvent 是用 (sessionId, peerId, payload) 三参, 这里 listener 形参 1 是 sessionId, 但我们只比较 sessionId - // 简化: 一个内部 map - } - void onEvt - // 直接挂在 self: 用 'once' 等一个标记 - const handler = (sid: string, _pid: string, payload: any) => { - if (sid !== sessionId) return - if (payload.status === undefined) return - clearTimeout(timer) - this.off('usbEvent', handler as any) - resolve({ ok: payload.ok, data: payload.data, status: payload.status }) - } - this.on('usbEvent', handler as any) - }) - } - - async usbCtrlIn(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number): Promise<{ ok: boolean; data?: Buffer; status?: number }> { - const sess = this.clientSessions.get(sessionId) - if (!sess) return { ok: false, status: -1 } - const reqId = randomUUID() - this.sendControl(sess.peerId, { type: 'ctrlIn', sessionId, reqId, setup, length }) - return new Promise((resolve) => { - const timer = setTimeout(() => { - resolve({ ok: false, status: -1 }) - }, 5000) - const handler = (sid: string, _pid: string, payload: any) => { - if (sid !== sessionId) return - clearTimeout(timer) - this.off('usbEvent', handler as any) - resolve({ ok: payload.ok, data: payload.data, status: payload.status }) - } - this.on('usbEvent', handler as any) - }) - } - - async usbBulkOut(sessionId: string, endpoint: number, data: string): Promise<{ ok: boolean; status?: number }> { - const sess = this.clientSessions.get(sessionId) - if (!sess) return { ok: false, status: -1 } - const reqId = randomUUID() - this.sendControl(sess.peerId, { type: 'bulkOut', sessionId, reqId, endpoint, data }) - return new Promise((resolve) => { - const timer = setTimeout(() => resolve({ ok: false, status: -1 }), 5000) - const handler = (sid: string, _pid: string, payload: any) => { - if (sid !== sessionId) return - clearTimeout(timer) - this.off('usbEvent', handler as any) - resolve({ ok: payload.ok, status: payload.status }) - } - this.on('usbEvent', handler as any) - }) - } - - async usbBulkIn(sessionId: string, endpoint: number, length: number, timeoutMs = 5000): Promise<{ ok: boolean; data?: Buffer; status?: number }> { - const sess = this.clientSessions.get(sessionId) - if (!sess) return { ok: false, status: -1 } - const reqId = randomUUID() - this.sendControl(sess.peerId, { type: 'bulkIn', sessionId, reqId, endpoint, length, timeoutMs }) - return new Promise((resolve) => { - const timer = setTimeout(() => resolve({ ok: false, status: -1 }), timeoutMs + 1000) - const handler = (sid: string, _pid: string, payload: any) => { - if (sid !== sessionId) return - clearTimeout(timer) - this.off('usbEvent', handler as any) - resolve({ ok: payload.ok, data: payload.data, status: payload.status }) - } - this.on('usbEvent', handler as any) - }) - } - - // 串口: 发送字节 (client 端) - async sendSerialBytes(sessionId: string, buf: Buffer): Promise { - const sess = this.clientSessions.get(sessionId) - if (!sess || sess.kind !== 'serial') return false - sess.bytesOut += buf.length - this.sendChunked(sess.peerId, sessionId, 'host->dev', buf, false) - return true - } - - // 串口: 在本机创建虚拟串口, 打开内部端做桥接 (self-out + createVirtual) - private async setupLocalVirtualSerial(sessionId: string, config: Extract): Promise<{ ok: boolean; reason?: string; userPath?: string }> { - if (!spMod) return { ok: false, reason: 'serialport 未安装' } - const sess = this.clientSessions.get(sessionId) - if (!sess) return { ok: false, reason: 'session 不存在' } - - // 1. 创建 PTY 配对 / com0com - let vcom: VirtualSerialInfo - try { - vcom = await createVirtualSerialPair({ userPath: config.virtualName }) - } catch (e: any) { - return { ok: false, reason: `创建虚拟串口失败: ${e?.message || e}` } - } - - // 2. 用我们传的 baudRate 打开内部端 - const baudRate = config.baudRate || 115200 - let sp: any - try { - sp = new spMod.SerialPort({ - path: vcom.internalPath, - baudRate, - dataBits: config.dataBits || 8, - stopBits: config.stopBits || 1, - parity: config.parity || 'none', - autoOpen: false, - }) - await new Promise((resolve, reject) => { - sp.open((err: any) => err ? reject(err) : resolve()) - }) - } catch (e: any) { - await vcom.cleanup().catch(() => {}) - return { ok: false, reason: `打开内部虚拟端失败: ${e?.message || e}` } - } - - // 3. 数据桥接 - // 内部端收到字节 (用户 app 写进来的) → WS host->dev → 远端写真实串口 - sp.on('data', (buf: Buffer) => { - if (buf.length === 0) return - sess.bytesOut += buf.length - this.sendChunked(sess.peerId, sessionId, 'host->dev', buf, false) - this.emit('output', sessionId, buf, sess.peerId) - }) - sp.on('close', () => { - // 内部端被关 (用户 app 关闭了虚拟串口?) -> 通知远端 - this.sendControl(sess.peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true }) - this.cleanupClientSession(sessionId, 'virtual-serial-closed-by-user-app') - }) - sp.on('error', (e: any) => { - this.emit('sessionError', { sessionId, peerId: sess.peerId, error: `virtual-serial: ${e.message}` }) - }) - - // 4. 存到 session, detach 时关 - sess.localVirtual = { - port: sp, - userPath: vcom.userPath, - cleanup: async () => { - try { sp.removeAllListeners() } catch {} - try { if (sp.isOpen) await new Promise((r) => sp.close(() => r())) } catch {} - await vcom.cleanup().catch(() => {}) - }, - } - - recordAudit({ action: 'usb.vcom.create', target: sess.peerId, sessionId, result: 'ok', payload: { userPath: vcom.userPath, internalPath: vcom.internalPath, platform: vcom.platform } }) - return { ok: true, userPath: vcom.userPath } - } - - // ====== detach ====== - - async detach(sessionId: string, reason = 'user'): Promise<{ ok: boolean; reason?: string }> { - if (this.clientSessions.has(sessionId)) { - const sess = this.clientSessions.get(sessionId)! - // usbip 模式: detach 本地 - if (sess.kind === 'usbip') { - try { - const { exec: execSync } = await import('node:child_process') - const { promisify } = await import('node:util') - const execAsync = promisify(execSync as any) - await execAsync('usbip detach --all 2>/dev/null || true').catch(() => {}) - } catch {} - } - // 发 detach 给对端 - this.sendControl(sess.peerId, { type: 'detach', sessionId, reason }) - this.cleanupClientSession(sessionId, reason) - return { ok: true } - } - if (this.serverSessions.has(sessionId)) { - const sess = this.serverSessions.get(sessionId)! - this.cleanupServerSession(sessionId, reason) - // 通知对端 - this.sendControl(sess.peerId, { type: 'detached', sessionId, ok: true, reason }) - return { ok: true } - } - return { ok: false, reason: 'session 不存在' } - } - - cleanupClientSession(sessionId: string, reason = 'user') { - const s = this.clientSessions.get(sessionId) - if (!s) return - // 关掉本机虚拟串口 (如果有) - if (s.localVirtual) { - s.localVirtual.cleanup().catch(() => {}) - } - this.clientSessions.delete(sessionId) - this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'client' }) - recordAudit({ action: 'usb.close', target: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } }) - } - - cleanupServerSession(sessionId: string, reason = 'user') { - const s = this.serverSessions.get(sessionId) - if (!s) return - // 关掉所有 pending 请求 - for (const r of s.pendingCtrl.values()) r({ ok: false, status: -1 }) - for (const r of s.pendingBulk.values()) r({ ok: false, status: -1 }) - s.pendingCtrl.clear(); s.pendingBulk.clear() - // 关设备 - if (s.serial) { - try { s.serial.close() } catch {} - } - if (s.usbDev) { - try { s.usbDev.close() } catch (e: any) { console.warn('[usb] close device:', e?.message) } - } - this.serverSessions.delete(sessionId) - this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'server' }) - recordAudit({ action: 'usb.close', source: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } }) - } - - // ====== 维护 ====== - - listClientSessions() { - return Array.from(this.clientSessions.values()).map(s => ({ - sessionId: s.sessionId, peerId: s.peerId, direction: s.direction, busId: s.busId, - kind: s.kind, info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: s.createdAt, side: 'client' as const, - })) - } - - listServerSessions() { - return Array.from(this.serverSessions.values()).map(s => ({ - sessionId: s.sessionId, peerId: s.peerId, direction: 'self-in' as UsbDirection, busId: s.busId, - kind: s.kind, info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: Date.now(), side: 'server' as const, - })) - } - - onPeerDisconnected(peerId: string) { - for (const [sid, s] of this.clientSessions) { - if (s.peerId === peerId) this.cleanupClientSession(sid, 'peer-disconnected') - } - for (const [sid, s] of this.serverSessions) { - if (s.peerId === peerId) this.cleanupServerSession(sid, 'peer-disconnected') - } - } - - stop() { - for (const sid of this.clientSessions.keys()) this.cleanupClientSession(sid, 'shutdown') - for (const sid of this.serverSessions.keys()) this.cleanupServerSession(sid, 'shutdown') - } - - // ====== 工具 ====== - - private sendControl(peerId: string, payload: UsbFrame) { - return this.chatClient.send({ type: 'usb', payload } as WsFrame, peerId) - } - - private sendChunked(peerId: string, sessionId: string, dir: 'host->dev' | 'dev->host', buf: Buffer, fin: boolean) { - let remaining = buf - while (remaining.length > 0) { - const chunk = remaining.subarray(0, MAX_DATA_CHUNK) - remaining = remaining.subarray(MAX_DATA_CHUNK) - const lastAndFin = fin && remaining.length === 0 - this.sendControl(peerId, { type: 'data', sessionId, dir, data: chunk.toString('base64'), fin: lastAndFin }) - } - if (fin && buf.length === 0) { - this.sendControl(peerId, { type: 'data', sessionId, dir, data: '', fin: true }) - } - } - - private async shellUsbipAttach(peerLanIp: string, busId: string): Promise<{ ok: boolean; reason?: string }> { - if (platform !== 'linux') return { ok: false, reason: 'usbip 仅 Linux 可用' } - // 1. 确保 vhci_hcd 加载 - const fix = await diagnoseAndFixUsbAttach() - if (!fix.ok) return { ok: false, reason: fix.stderr } - // 2. 调 `usbip attach -r -b ` (busId 要去掉 "usbip:" 前缀) - const realBusId = busId.startsWith('usbip:') ? busId.slice(6) : busId - try { - const { exec: execSync } = await import('node:child_process') - const { promisify } = await import('node:util') - const execAsync = promisify(execSync as any) - const { stdout, stderr } = await execAsync(`usbip attach -r ${peerLanIp} -b ${realBusId}`) - return { ok: true, reason: stdout + (stderr || '') } - } catch (e: any) { - return { ok: false, reason: `usbip attach 失败: ${e?.message || e}; 输出: ${e?.stdout || ''} ${e?.stderr || ''}` } - } - } -} - -function pad4(n: number): string { - return (n || 0).toString(16).padStart(4, '0').toLowerCase() -} - -// 协议里传的是标准 bmRequestType (bit7 方向 / bit6-5 类型 / bit4-0 recipient), -// 而 node-usb v3 的 control transfer 参数用的是字符串枚举, 这里翻译一下 -export function toUsbControlSetup(setup: { requestType: number; request: number; value: number; index: number }) { - const bm = setup.requestType | 0 - const typeBits = (bm >> 5) & 0x03 - const requestType = typeBits === 1 ? 'class' : typeBits === 2 ? 'vendor' : 'standard' - const rcptBits = bm & 0x1f - const recipient = - rcptBits === 0 ? 'device' : - rcptBits === 1 ? 'interface' : - rcptBits === 2 ? 'endpoint' : 'other' - return { - requestType, - recipient, - request: setup.request | 0, - value: setup.value | 0, - index: setup.index | 0, - } -} - -function describeAttach(dir: UsbDirection, config: UsbAttachConfig, busId: string): string { - if (config.kind === 'serial') { - return dir === 'self-out' - ? `对方请求使用本机的串口 ${config.baudRate || 9600} baud` - : `对方请求把自己的串口暴露给本机 (${config.baudRate || 9600} baud)` - } - if (config.kind === 'usb') { - return dir === 'self-out' - ? `对方请求通过本机的 USB 设备 ${busId} 发送控制/批量传输` - : `对方请求把自己的 USB 设备 ${busId} 共享给本机` - } - return `对方请求 USB/IP 透传 ${busId} (需要本机 vhci_hcd 模块)` -} diff --git a/src/main/settings.ts b/src/main/settings.ts index eaaf567..7d2d6ec 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -14,10 +14,10 @@ export interface Settings { preferredInterface: string /** 备选: 用户也可指定精确地址 (避免接口名漂移) */ preferredAddress: string - /** 远程功能总开关 (false = 完全禁用 terminal/forward/usb, 即使 allowPeers 里有白名单) */ + /** 远程功能总开关 (false = 完全禁用 terminal/forward/serial, 即使 allowPeers 里有白名单) */ remoteEnabled: boolean - /** 每个对端的远程操作授权, key = deviceId, value = { terminal, forward, usb } */ - remoteAllowPeers: Record + /** 每个对端的远程操作授权, key = deviceId, value = { terminal, forward, serial } */ + remoteAllowPeers: Record /** 端口转发默认 TTL (秒), 上限 24h */ forwardDefaultTtlSec: number /** 端口转发默认字节速率上限 (bytes/s), 0 = 不限 */ diff --git a/src/preload/index.ts b/src/preload/index.ts index baf139a..8658e88 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -92,26 +92,21 @@ contextBridge.exposeInMainWorld('api', { forwardClose: (sessionId: string) => ipcRenderer.invoke('forward:close', { sessionId }), forwardListSessions: () => ipcRenderer.invoke('forward:listSessions'), - // 远程: USB / 串口 (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传) - usbList: (peerId: string) => ipcRenderer.invoke('usb:list', { peerId }), - usbListLocal: () => ipcRenderer.invoke('usb:listLocal'), - usbAttach: (peerId: string, busId: string, opts: { direction?: 'self-out' | 'self-in'; config: any }) => - ipcRenderer.invoke('usb:attach', { peerId, busId, ...(opts || {}) }), - // 串口: 发送字节 - usbSerialSend: (sessionId: string, dataBase64: string) => - ipcRenderer.invoke('usb:serialSend', { sessionId, dataBase64 }), - // USB: 控制传输 - usbCtrlOut: (sessionId: string, setup: any, dataBase64?: string) => - ipcRenderer.invoke('usb:ctrlOut', { sessionId, setup, dataBase64 }), - usbCtrlIn: (sessionId: string, setup: any, length: number) => - ipcRenderer.invoke('usb:ctrlIn', { sessionId, setup, length }), - // USB: 批量传输 - usbBulkOut: (sessionId: string, endpoint: number, dataBase64: string) => - ipcRenderer.invoke('usb:bulkOut', { sessionId, endpoint, dataBase64 }), - usbBulkIn: (sessionId: string, endpoint: number, length: number, timeoutMs?: number) => - ipcRenderer.invoke('usb:bulkIn', { sessionId, endpoint, length, timeoutMs }), - usbDetach: (sessionId: string) => ipcRenderer.invoke('usb:detach', { sessionId }), - usbListSessions: () => ipcRenderer.invoke('usb:listSessions'), + // 远程: 串口 (映射 + 串口助手) + // 串口: 列表 / 映射 / 助手 + serialList: (peerId: string) => ipcRenderer.invoke('serial:list', { peerId }), + serialListLocal: () => ipcRenderer.invoke('serial:listLocal'), + serialAttach: (peerId: string, busId: string, opts: { direction?: 'self-out' | 'self-in'; config: any }) => + ipcRenderer.invoke('serial:attach', { peerId, busId, ...(opts || {}) }), + serialSend: (sessionId: string, dataBase64: string) => + ipcRenderer.invoke('serial:send', { sessionId, dataBase64 }), + serialDetach: (sessionId: string) => ipcRenderer.invoke('serial:detach', { sessionId }), + serialListSessions: () => ipcRenderer.invoke('serial:listSessions'), + serialDiagnose: () => ipcRenderer.invoke('serial:diagnose'), + serialFixPerms: () => ipcRenderer.invoke('serial:fixPerms'), + serialEnvInfo: () => ipcRenderer.invoke('serial:envInfo'), + serialSaveLog: (content: string, suggestedName?: string) => + ipcRenderer.invoke('serial:saveLog', { content, suggestedName }), // 网盘: 浏览对方共享目录 + 选文件下载 driveList: (peerId: string, path?: string) => ipcRenderer.invoke('drive:list', { peerId, path: path || '' }), diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 6c4bf77..92fd889 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -3,7 +3,7 @@ import { onMounted, ref, computed, watch, onUnmounted } from 'vue' import { useDeviceStore } from '@/stores/device' import { useMessageStore } from '@/stores/message' import { useSessionStore } from '@/stores/session' -import { useTerminalStore, useForwardStore, useUsbStore, useApprovalStore } from '@/stores/remote' +import { useTerminalStore, useForwardStore, useSerialStore, useApprovalStore } from '@/stores/remote' import Sidebar from '@/components/Sidebar.vue' import ChatView from '@/components/ChatView.vue' import SettingsView from '@/components/SettingsView.vue' @@ -18,7 +18,7 @@ const message = useMessageStore() const session = useSessionStore() const terminal = useTerminalStore() const forward = useForwardStore() -const usb = useUsbStore() +const serial = useSerialStore() const approval = useApprovalStore() const showSettings = ref(false) @@ -181,10 +181,10 @@ onMounted(async () => { unsubs.push(window.api.on('remote:terminal:closed', (p) => terminal.onClosed(p))) unsubs.push(window.api.on('remote:forward:opened', (p) => forward.onOpened(p))) unsubs.push(window.api.on('remote:forward:closed', (p) => forward.onClosed(p))) - unsubs.push(window.api.on('remote:usb:opened', (p) => usb.onOpened(p))) - unsubs.push(window.api.on('remote:usb:closed', (p) => usb.onClosed(p))) - unsubs.push(window.api.on('remote:usb:output', (p) => usb.onOutput(p))) - unsubs.push(window.api.on('remote:usb:error', (p) => { /* surface via console for now */ console.warn('[usb]', p) })) + unsubs.push(window.api.on('remote:serial:opened', (p) => serial.onOpened(p))) + unsubs.push(window.api.on('remote:serial:closed', (p) => serial.onClosed(p))) + unsubs.push(window.api.on('remote:serial:output', (p) => serial.onOutput(p))) + unsubs.push(window.api.on('remote:serial:error', (p) => { /* surface via console for now */ console.warn('[serial]', p) })) unsubs.push(window.api.on('remote:approval:requested', (req) => approval.add(req))) // ---- 初始状态拉取 (监听器已就位, 这里只补数据) ---- diff --git a/src/renderer/src/api.ts b/src/renderer/src/api.ts index 1555d2c..01dcbc4 100644 --- a/src/renderer/src/api.ts +++ b/src/renderer/src/api.ts @@ -105,7 +105,7 @@ export interface Settings { preferredInterface?: string preferredAddress?: string remoteEnabled?: boolean - remoteAllowPeers?: Record + remoteAllowPeers?: Record forwardDefaultTtlSec?: number forwardMaxBytesPerSec?: number terminalReadOnlyByDefault?: boolean @@ -167,36 +167,26 @@ export interface ForwardSessionInfo { side: 'client' | 'server' } -// 远程: USB / 串口 (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传) -export type UsbDirection = 'self-out' | 'self-in' -export type UsbKind = 'serial' | 'usb' | 'usbip' +// 远程: 串口 (映射 + 串口助手) +export type SerialDirection = 'self-out' | 'self-in' -export interface UsbDeviceInfo { - busId: string - vid: number - pid: number - deviceClass: number - deviceSubclass: number +export interface SerialDeviceInfo { + busId: string // 'serial:/dev/ttyUSB0' + serialPath: string // COM3 / /dev/ttyUSB0 / /dev/ttyACM0 + vid?: number + pid?: number product?: string manufacturer?: string serialNumber?: string - kind: UsbKind - serialPath?: string baudRate?: number } -export interface UsbEndpointInfo { - endpointNumber: number - direction: 'in' | 'out' - transferType: 'control' | 'bulk' | 'interrupt' | 'isochronous' - packetSize: number -} - -export interface UsbAttachedInfo { - kind: UsbKind - endpoints?: UsbEndpointInfo[] +export interface SerialAttachedInfo { + kind: 'serial' + /** 远端真实串口路径 */ serialPath?: string - userVirtualPath?: string // 本机虚拟串口路径 (createVirtual=true 时填) + /** 本机虚拟串口路径 (串口映射, createVirtual=true 时填) */ + userVirtualPath?: string vid?: number pid?: number product?: string @@ -204,56 +194,45 @@ export interface UsbAttachedInfo { serialNumber?: string } -export type UsbAttachConfig = - // serial: 字节流转发 - | { - kind: 'serial' - baudRate?: number - dataBits?: 5 | 6 | 7 | 8 - stopBits?: 1 | 2 - parity?: 'none' | 'even' | 'odd' | 'mark' | 'space' - /** 在本机创建一个虚拟串口, 桥接到远端的真实串口 — 用户可在 PuTTY/Arduino IDE 打开该路径 */ - createVirtual?: boolean - /** 可选: 用户指定的虚拟串口名 (Windows: "COM5"; Linux/macOS: 留空自动分配) */ - virtualName?: string - } - // usb: libusb 字节桥 - | { - kind: 'usb' - configurationValue?: number - interfaceNumber?: number - detachKernelDriver?: boolean - } - // usbip: Linux only, 真透明透传 (需内核模块) - | { - kind: 'usbip' - } +export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space' -export interface UsbSessionInfo { +export interface SerialAttachConfig { + kind: 'serial' + baudRate?: number + dataBits?: 5 | 6 | 7 | 8 + stopBits?: 1 | 2 + parity?: SerialParity + /** 在本机创建一个虚拟串口, 桥接到远端的真实串口 — 本机任何工具都能打开那个路径 */ + createVirtual?: boolean + /** 可选: 用户指定的虚拟串口名 (Windows: "COM5"; Linux/macOS: 留空自动分配) */ + virtualName?: string +} + +export interface SerialDiagnose { + platform: string + ok: boolean + issues: { kind: string; detail: string }[] + notes: string[] +} + +export interface SerialSessionInfo { sessionId: string peerId: string - direction: UsbDirection + direction: SerialDirection busId: string - kind: UsbKind - info?: UsbAttachedInfo + info?: SerialAttachedInfo bytesIn: number bytesOut: number createdAt: number side: 'client' | 'server' } -export interface UsbTransferResult { - ok: boolean - data?: number[] // bytes (UI 解码为 Uint8Array, 因为 renderer 端没 Buffer) - status?: number -} - // 远程: 授权请求 export interface ApprovalRequestView { requestId: string peerId: string peerName: string - kind: 'terminal' | 'forward' | 'usb' | 'relation' + kind: 'terminal' | 'forward' | 'serial' | 'relation' detail: string ts: number /** relation: 对方想要的能力 */ @@ -398,24 +377,21 @@ export type LnmApiExtra = { forwardClose: (sessionId: string) => Promise forwardListSessions: () => Promise<{ client: ForwardSessionInfo[]; server: ForwardSessionInfo[] }> - usbList: (peerId: string) => Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }> - usbListLocal: () => Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }> - usbAttach: (peerId: string, busId: string, opts: { direction?: UsbDirection; config: UsbAttachConfig }) => + serialList: (peerId: string) => Promise<{ ok: boolean; reason?: string; devices?: SerialDeviceInfo[] }> + serialListLocal: () => Promise<{ ok: boolean; reason?: string; devices?: SerialDeviceInfo[] }> + serialAttach: (peerId: string, busId: string, opts: { direction?: SerialDirection; config: SerialAttachConfig }) => Promise<{ ok: boolean; reason?: string; sessionId?: string }> // 串口: 发送字节 - usbSerialSend: (sessionId: string, dataBase64: string) => Promise - // USB: 控制传输 - usbCtrlOut: (sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, dataBase64?: string) => - Promise<{ ok: boolean; status?: number }> - usbCtrlIn: (sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number) => - Promise<{ ok: boolean; dataBase64?: string; status?: number }> - // USB: 批量传输 - usbBulkOut: (sessionId: string, endpoint: number, dataBase64: string) => - Promise<{ ok: boolean; status?: number }> - usbBulkIn: (sessionId: string, endpoint: number, length: number, timeoutMs?: number) => - Promise<{ ok: boolean; dataBase64?: string; status?: number }> - usbDetach: (sessionId: string) => Promise<{ ok: boolean; reason?: string }> - usbListSessions: () => Promise<{ client: UsbSessionInfo[]; server: UsbSessionInfo[] }> + serialSend: (sessionId: string, dataBase64: string) => Promise + serialDetach: (sessionId: string) => Promise<{ ok: boolean; reason?: string }> + serialListSessions: () => Promise<{ client: SerialSessionInfo[]; server: SerialSessionInfo[] }> + /** 串口权限诊断 (Linux 的 dialout 组是串口助手最常见的坑) */ + serialDiagnose: () => Promise<{ ok: boolean; diagnose?: SerialDiagnose; reason?: string }> + /** 一键修复: 把当前用户加进 dialout 组 (Linux, 需要提权) */ + serialFixPerms: () => Promise<{ ok: boolean; diagnose?: SerialDiagnose; fix?: { ok: boolean; stdout?: string; stderr?: string }; reason?: string }> + /** 环境信息: 有没有 socat / 虚拟串口怎么用 */ + serialEnvInfo: () => Promise<{ ok: boolean; socat?: boolean; hint?: string }> + serialSaveLog: (content: string, suggestedName?: string) => Promise<{ ok: boolean; path?: string; reason?: string }> // 网盘 driveList: (peerId: string, path?: string) => Promise diff --git a/src/renderer/src/components/ChatView.vue b/src/renderer/src/components/ChatView.vue index a003534..c8fbd86 100644 --- a/src/renderer/src/components/ChatView.vue +++ b/src/renderer/src/components/ChatView.vue @@ -7,7 +7,7 @@ import MessageItem from './MessageItem.vue' import MessageInput from './MessageInput.vue' import TerminalPanel from './remote/TerminalPanel.vue' import ForwardPanel from './remote/ForwardPanel.vue' -import UsbPanel from './remote/UsbPanel.vue' +import SerialPanel from './remote/SerialPanel.vue' import DrivePanel from './remote/DrivePanel.vue' import SyncPanel from './remote/SyncPanel.vue' import type { DeviceView } from '@/api' @@ -22,7 +22,7 @@ const device = useDeviceStore() const message = useMessageStore() const session = useSessionStore() -type Tab = 'chat' | 'terminal' | 'forward' | 'usb' | 'drive' | 'sync' +type Tab = 'chat' | 'terminal' | 'forward' | 'serial' | 'drive' | 'sync' const activeTab = ref('chat') // 切到别的设备时回到 chat tab watch(() => session.activePeerId, () => { activeTab.value = 'chat' }) @@ -172,8 +172,8 @@ async function onGlobalDrop(e: DragEvent) { -