feat(serial): USB 页面下线, 改成串口映射 + 串口助手
## 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 区在, 波特率/数据位/停止位/校验/循环发送/清屏/保存日志 按钮齐全, 方向切换会换设备列表 (我用对方的 = 对方设备, 对方用我的 = 本机设备)。
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -62,7 +62,7 @@ WS 通常几十毫秒内就握手成功 → 主进程广播 `device:wsState` →
|
||||
| 设备在线但通道不通 | `在线 · 192.168.0.7 (连接中…)`(黄点,后台在 backoff 重连) |
|
||||
| 都不可用 | `离线 · 12:05`(灰点) |
|
||||
|
||||
远程功能(终端 / 转发 / USB / 网盘)都走 WS 通道,所以入口按钮在
|
||||
远程功能(终端 / 转发 / 串口 / 网盘)都走 WS 通道,所以入口按钮在
|
||||
`设备离线 && 通道不通` 时才禁用——只看设备在线会把"能连但没连上"的情况误判成可用。
|
||||
|
||||
## 4. 回归测试
|
||||
|
||||
@@ -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`)。
|
||||
理由: 那三个是"一次性操作请求", 关系是"长期绑定", 生命周期和撤销语义都不一样,
|
||||
硬合并会让远程操作每次都要查关系表, 反而更绕。
|
||||
|
||||
@@ -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` 环境变量)。
|
||||
|
||||
+3
-9
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
+141
-79
@@ -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}`)
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 网盘 ============
|
||||
|
||||
@@ -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} 个原生文件`
|
||||
})
|
||||
|
||||
|
||||
@@ -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' 事件统一交给上层处理
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
+4
-4
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
+79
-55
@@ -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) }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+66
-178
@@ -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<ElevationResult
|
||||
|
||||
export type PermIssue =
|
||||
| { kind: 'linux.dialout'; detail: string }
|
||||
| { kind: 'linux.udev'; detail: string; vendorId?: number; productId?: number }
|
||||
| { kind: 'windows.driver'; detail: string; vendorId?: number; productId?: number }
|
||||
| { kind: 'macos.tcc'; detail: string }
|
||||
|
||||
export interface DiagnoseResult {
|
||||
@@ -47,81 +47,66 @@ export interface DiagnoseResult {
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断本机串口可用性。
|
||||
* 重点是 Linux: 查 dialout 组 + socat, 并把"没插设备"和"没权限"分开报。
|
||||
*/
|
||||
export async function diagnoseSerial(): Promise<DiagnoseResult> {
|
||||
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<DiagnoseResult> {
|
||||
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<ElevationResult> {
|
||||
if (platform !== 'linux') {
|
||||
@@ -143,7 +124,7 @@ export async function fixLinuxDialout(): Promise<ElevationResult> {
|
||||
}
|
||||
// 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<ElevationResult> {
|
||||
return r
|
||||
}
|
||||
|
||||
// 写 udev 规则 (Linux); 一次写永久生效
|
||||
export async function fixLinuxUdevRule(vendorId: number, productId: number, mode: 'usb' | 'hid' = 'usb'): Promise<ElevationResult> {
|
||||
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<ElevationResult> {
|
||||
// 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<string>((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<boolean> {
|
||||
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 <peer> -b <busid>` 让内核接管.
|
||||
// 用户机器需要先装 `usbip` 包 + 加载 vhci_hcd 模块. 一次性.
|
||||
|
||||
// Linux: 检查 + 装 usbip + 加载内核模块 (vhci_hcd, usbip_core)
|
||||
export async function diagnoseAndFixUsbAttach(): Promise<ElevationResult> {
|
||||
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 已就绪' }
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
return '本机会创建一个 socat PTY 配对。用 screen / minicom 打开那个路径即可。'
|
||||
}
|
||||
return '本机会创建一个 com0com 虚拟 COM 口 (需要管理员权限安装 com0com)。用 PuTTY / Arduino IDE 打开那个 COM 号即可。'
|
||||
}
|
||||
|
||||
+55
-66
@@ -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 }
|
||||
|
||||
@@ -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<RemoteKind, 'relation'>
|
||||
@@ -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<string, number>()
|
||||
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}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Server (对端发起的, 真实串口在我这) =====
|
||||
interface ServerSession {
|
||||
sessionId: string
|
||||
ownerId: string
|
||||
peerId: string
|
||||
busId: string
|
||||
// 串口: SerialPort 实例
|
||||
serial?: any
|
||||
bytesIn: number
|
||||
bytesOut: number
|
||||
info?: SerialAttachedInfo
|
||||
// 串口关闭 promise (等数据 drain)
|
||||
serialClosing?: Promise<void>
|
||||
attachResolver?: (r: { ok: boolean; reason?: string; info?: SerialAttachedInfo }) => void
|
||||
attachRejecter?: (e: Error) => void
|
||||
}
|
||||
|
||||
export class SerialManager extends EventEmitter {
|
||||
private clientSessions = new Map<string, ClientSession>()
|
||||
private serverSessions = new Map<string, ServerSession>()
|
||||
private chatClient: ChatClient
|
||||
// list 请求的待回包
|
||||
private pendingLists = new Map<string, (r: { ok: boolean; reason?: string; devices?: SerialDeviceInfo[] }) => void>()
|
||||
|
||||
constructor(chatClient: ChatClient) {
|
||||
super()
|
||||
this.chatClient = chatClient
|
||||
}
|
||||
|
||||
// ====== 本机设备列举 ======
|
||||
|
||||
/** 列本机的串口 */
|
||||
async listLocal(): Promise<SerialDeviceInfo[]> {
|
||||
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<SerialFrame, { type: 'attach' }> = { 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<void>((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<SerialFrame, { type: 'data' }>) {
|
||||
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<SerialFrame, { type: 'list' }>) {
|
||||
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<SerialFrame, { type: 'attach' }>) {
|
||||
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<SerialFrame, { type: 'detach' }>) {
|
||||
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<boolean> {
|
||||
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<void>((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<void>((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) 映射到你的机器上`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 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<void>
|
||||
// USB 控制/批量 reqId -> 配对 response
|
||||
pendingCtrl: Map<string, (r: { ok: boolean; data?: Buffer; status?: number }) => void>
|
||||
pendingBulk: Map<string, (r: { ok: boolean; data?: Buffer; status?: number }) => 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<string, ClientSession>()
|
||||
private serverSessions = new Map<string, ServerSession>()
|
||||
private chatClient: ChatClient
|
||||
// list 请求的待回包
|
||||
private pendingLists = new Map<string, (r: { ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }) => void>()
|
||||
|
||||
constructor(chatClient: ChatClient) {
|
||||
super()
|
||||
this.chatClient = chatClient
|
||||
}
|
||||
|
||||
// ====== 本地设备列举 ======
|
||||
|
||||
// 列本机的: 串口 + libusb 设备 (合并)
|
||||
async listLocal(): Promise<UsbDeviceInfo[]> {
|
||||
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<UsbFrame, { type: 'attach' }> = { 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 <peer> -b <busid>` 真正 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<UsbAttachConfig, { kind: 'serial' }>
|
||||
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<UsbAttachConfig, { kind: 'serial' }>
|
||||
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<void>((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<UsbAttachConfig, { kind: 'usb' }>
|
||||
// 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<UsbFrame, { type: 'list' }>) {
|
||||
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<UsbFrame, { type: 'attach' }>) {
|
||||
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<UsbFrame, { type: 'detach' }>) {
|
||||
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<UsbFrame, { type: 'ctrlOut' | 'ctrlIn' }>) {
|
||||
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<UsbFrame, { type: 'bulkOut' | 'bulkIn' }>) {
|
||||
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<boolean> {
|
||||
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<UsbAttachConfig, { kind: 'serial' }>): 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<void>((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<void>((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 <peer> -b <busid>` (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 模块)`
|
||||
}
|
||||
@@ -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<string, { terminal?: boolean; forward?: boolean; usb?: boolean }>
|
||||
/** 每个对端的远程操作授权, key = deviceId, value = { terminal, forward, serial } */
|
||||
remoteAllowPeers: Record<string, { terminal?: boolean; forward?: boolean; serial?: boolean }>
|
||||
/** 端口转发默认 TTL (秒), 上限 24h */
|
||||
forwardDefaultTtlSec: number
|
||||
/** 端口转发默认字节速率上限 (bytes/s), 0 = 不限 */
|
||||
|
||||
+15
-20
@@ -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 || '' }),
|
||||
|
||||
@@ -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)))
|
||||
|
||||
// ---- 初始状态拉取 (监听器已就位, 这里只补数据) ----
|
||||
|
||||
+50
-74
@@ -105,7 +105,7 @@ export interface Settings {
|
||||
preferredInterface?: string
|
||||
preferredAddress?: string
|
||||
remoteEnabled?: boolean
|
||||
remoteAllowPeers?: Record<string, { terminal?: boolean; forward?: boolean; usb?: boolean }>
|
||||
remoteAllowPeers?: Record<string, { terminal?: boolean; forward?: boolean; serial?: boolean }>
|
||||
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<boolean>
|
||||
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<boolean>
|
||||
// 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<boolean>
|
||||
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<DriveListResult>
|
||||
|
||||
@@ -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<Tab>('chat')
|
||||
// 切到别的设备时回到 chat tab
|
||||
watch(() => session.activePeerId, () => { activeTab.value = 'chat' })
|
||||
@@ -172,8 +172,8 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
<button :class="{ active: activeTab === 'forward' }" @click="activeTab = 'forward'" :disabled="!peer.online && !channelAlive">
|
||||
<el-icon><Connection /></el-icon><span>转发</span>
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'usb' }" @click="activeTab = 'usb'" :disabled="!peer.online && !channelAlive">
|
||||
<el-icon><Cellphone /></el-icon><span>USB</span>
|
||||
<button :class="{ active: activeTab === 'serial' }" @click="activeTab = 'serial'" :disabled="!peer.online && !channelAlive">
|
||||
<el-icon><Cellphone /></el-icon><span>串口</span>
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'drive' }" @click="activeTab = 'drive'" :disabled="!peer.online && !channelAlive">
|
||||
<el-icon><FolderOpened /></el-icon><span>网盘</span>
|
||||
@@ -224,8 +224,8 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
<TerminalPanel v-else-if="activeTab === 'terminal'" :peer="peer" :active="activeTab === 'terminal'" />
|
||||
<!-- forward tab -->
|
||||
<ForwardPanel v-else-if="activeTab === 'forward'" :peer="peer" />
|
||||
<!-- usb tab -->
|
||||
<UsbPanel v-else-if="activeTab === 'usb'" :peer="peer" />
|
||||
<!-- serial tab: 串口映射 + 串口助手 -->
|
||||
<SerialPanel v-else-if="activeTab === 'serial'" :peer="peer" />
|
||||
<!-- drive tab: 浏览对方网盘 + 选文件下载 -->
|
||||
<DrivePanel v-else-if="activeTab === 'drive'" :peer="peer" />
|
||||
<!-- sync tab: 本地目录 <-> 对方网盘目录 的手动同步 -->
|
||||
|
||||
@@ -148,9 +148,10 @@ function actionLabel(a: string) {
|
||||
'terminal.close': '终端 关闭',
|
||||
'forward.open': '转发 开启',
|
||||
'forward.close': '转发 关闭',
|
||||
'usb.list': 'USB 列表',
|
||||
'usb.attach': 'USB 附加',
|
||||
'usb.detach': 'USB 分离',
|
||||
'serial.list': '串口列表',
|
||||
'serial.attach': '串口连接',
|
||||
'serial.vcom.create': '虚拟串口创建',
|
||||
'serial.close': '串口关闭',
|
||||
} as Record<string, string>)[a] || a
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ async function reply(ok: boolean) {
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- 远程操作 (terminal / forward / usb) -->
|
||||
<!-- 远程操作 (terminal / forward / serial) -->
|
||||
<div v-else class="approval-card">
|
||||
<header>
|
||||
<strong>{{ current.peerName }}</strong> 请求{{
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
<script setup lang="ts">
|
||||
// 串口面板: **串口映射** + **串口助手**
|
||||
//
|
||||
// 串口映射 —— 把对端 (或本机) 的真实串口, 在本机变成一个"能用串口工具打开"的虚拟口。
|
||||
// Linux/macOS 用 socat 建 PTY 配对, Windows 用 com0com 建 COM 对。
|
||||
// 串口助手 —— 不建虚拟口时, 应用自己当串口助手: 收发的字节流直接显示, 能发文本/HEX。
|
||||
//
|
||||
// 只做串口。USB 字节桥和 USB/IP 透传已经下线了。
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import type {
|
||||
DeviceView, SerialDeviceInfo, SerialSessionInfo, SerialDirection,
|
||||
SerialAttachConfig, SerialParity, SerialDiagnose,
|
||||
} from '@/api'
|
||||
import { useSerialStore } from '@/stores/remote'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps<{ peer: DeviceView | null }>()
|
||||
const store = useSerialStore()
|
||||
|
||||
const platform = window.api?.platform || ''
|
||||
const isLinux = platform === 'linux'
|
||||
|
||||
// ===== 状态 =====
|
||||
const direction = ref<SerialDirection>('self-out')
|
||||
const scanning = ref(false)
|
||||
const peerDevices = ref<SerialDeviceInfo[]>([])
|
||||
const myDevices = ref<SerialDeviceInfo[]>([])
|
||||
const selectedPath = ref('')
|
||||
const creating = ref(false)
|
||||
|
||||
// 串口参数
|
||||
const baudRate = ref(115200)
|
||||
const dataBits = ref<5 | 6 | 7 | 8>(8)
|
||||
const stopBits = ref<1 | 2>(1)
|
||||
const parity = ref<SerialParity>('none')
|
||||
const createVirtual = ref(true)
|
||||
|
||||
// 串口助手
|
||||
const sendText = ref('')
|
||||
const sendMode = ref<'text' | 'hex'>('text')
|
||||
const appendNewline = ref(true)
|
||||
const repeatMs = ref(0) // 0 = 不循环
|
||||
const logLines = ref<{ ts: number; dir: 'rx' | 'tx'; hex: string; ascii: string }[]>([])
|
||||
const MAX_LOG = 500
|
||||
const hexEl = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// 环境信息 (socat / 虚拟口用法)
|
||||
const envInfo = ref<{ socat?: boolean; hint?: string }>({})
|
||||
|
||||
const BAUD_OPTIONS = [9600, 19200, 38400, 57600, 74880, 115200, 230400, 460800, 921600]
|
||||
|
||||
// 当前会话: 我发起的 (client) 或对方发起的 (server) —— 助手两边都能用
|
||||
const myClientSession = computed<SerialSessionInfo | undefined>(() =>
|
||||
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'client')
|
||||
)
|
||||
const serverSession = computed<SerialSessionInfo | undefined>(() =>
|
||||
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'server')
|
||||
)
|
||||
const activeSession = computed<SerialSessionInfo | undefined>(() => myClientSession.value || serverSession.value)
|
||||
|
||||
const vcomPath = computed<string | undefined>(() => myClientSession.value?.info?.userVirtualPath)
|
||||
const remotePath = computed<string | undefined>(() => activeSession.value?.info?.serialPath)
|
||||
|
||||
// 设备列表: 按方向取对方的还是本机的
|
||||
const visibleDevices = computed<SerialDeviceInfo[]>(() =>
|
||||
direction.value === 'self-out' ? peerDevices.value : myDevices.value
|
||||
)
|
||||
|
||||
// ===== 设备列表 =====
|
||||
async function scan() {
|
||||
if (!props.peer) return
|
||||
scanning.value = true
|
||||
try {
|
||||
if (direction.value === 'self-out') {
|
||||
const r = await store.list(props.peer.deviceId)
|
||||
if (!r.ok) {
|
||||
ElMessage.error('拉取对方串口失败: ' + (r.reason || ''))
|
||||
peerDevices.value = []
|
||||
return
|
||||
}
|
||||
peerDevices.value = r.devices || []
|
||||
} else {
|
||||
const r = await store.listLocal()
|
||||
if (!r.ok) {
|
||||
ElMessage.error('列出本机串口失败: ' + (r.reason || ''))
|
||||
myDevices.value = []
|
||||
return
|
||||
}
|
||||
myDevices.value = r.devices || []
|
||||
}
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
const first = visibleDevices.value[0]
|
||||
if (first && !visibleDevices.value.find(d => d.serialPath === selectedPath.value)) {
|
||||
selectedPath.value = first.serialPath
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 连接 =====
|
||||
async function connect() {
|
||||
if (!props.peer) return
|
||||
if (!selectedPath.value) {
|
||||
ElMessage.warning('先选一个串口')
|
||||
return
|
||||
}
|
||||
if (activeSession.value) {
|
||||
ElMessage.warning('已有活跃会话, 请先断开')
|
||||
return
|
||||
}
|
||||
// Linux 上建虚拟口需要 socat —— 提前说清楚, 免得 attach 到一半才失败
|
||||
if (createVirtual.value && isLinux && envInfo.value.socat === false) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'本机没有 socat, 创建虚拟串口会失败。\n\n' +
|
||||
'安装: sudo apt install socat (Debian/Ubuntu) | sudo dnf install socat (Fedora)\n\n' +
|
||||
'要现在去装吗? (也可以先取消, 勾掉"创建本机虚拟串口"只用内置助手)',
|
||||
'缺少 socat',
|
||||
{ confirmButtonText: '去安装', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// 用户点了"去安装" -> 交给系统包管理器 (需要提权, 这里只给命令)
|
||||
ElMessageBox.alert('请在终端执行:\n\n sudo apt install socat\n\n装完回到这里刷新即可。', '安装 socat')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
const config: SerialAttachConfig = {
|
||||
kind: 'serial',
|
||||
baudRate: baudRate.value,
|
||||
dataBits: dataBits.value,
|
||||
stopBits: stopBits.value,
|
||||
parity: parity.value,
|
||||
createVirtual: createVirtual.value,
|
||||
}
|
||||
const r = await store.attach(props.peer.deviceId, {
|
||||
busId: `serial:${selectedPath.value}`,
|
||||
direction: direction.value,
|
||||
config,
|
||||
})
|
||||
creating.value = false
|
||||
if (!r.ok) {
|
||||
ElMessage.error('连接失败: ' + (r.reason || ''))
|
||||
return
|
||||
}
|
||||
ElMessage.success('已连接')
|
||||
await nextTick()
|
||||
hexEl.value?.scrollTo({ top: 0 })
|
||||
}
|
||||
|
||||
async function disconnect(s: SerialSessionInfo) {
|
||||
const r = await store.detach(s.sessionId)
|
||||
if (r.ok) ElMessage.success('已断开')
|
||||
else ElMessage.warning(r.reason || '断开失败')
|
||||
}
|
||||
|
||||
// ===== 串口助手: 收 =====
|
||||
function bytesToHex(bin: string): { hex: string; ascii: string } {
|
||||
let hex = ''
|
||||
let ascii = ''
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
const c = bin.charCodeAt(i)
|
||||
hex += c.toString(16).padStart(2, '0') + ' '
|
||||
ascii += c >= 0x20 && c < 0x7f ? bin[i] : '.'
|
||||
}
|
||||
return { hex: hex.trimEnd(), ascii }
|
||||
}
|
||||
|
||||
function pushLog(dir: 'rx' | 'tx', base64: string) {
|
||||
if (!base64) return
|
||||
let bin = ''
|
||||
try { bin = atob(base64) } catch { return }
|
||||
// 每 16 字节一行, 按行切开
|
||||
for (let i = 0; i < bin.length; i += 16) {
|
||||
const chunk = bin.slice(i, i + 16)
|
||||
const { hex, ascii } = bytesToHex(chunk)
|
||||
logLines.value.unshift({ ts: Date.now(), dir, hex, ascii })
|
||||
}
|
||||
while (logLines.value.length > MAX_LOG) logLines.value.pop()
|
||||
}
|
||||
|
||||
// 收: store 的 buffer (主进程推过来的字节流)
|
||||
watch(() => store.buffers, (b) => {
|
||||
const sid = activeSession.value?.sessionId
|
||||
if (!sid) return
|
||||
const buf = b[sid]
|
||||
if (!buf || buf.length === 0) return
|
||||
for (const item of buf) pushLog(item.dir, item.data)
|
||||
b[sid] = []
|
||||
}, { deep: true })
|
||||
|
||||
// ===== 串口助手: 发 =====
|
||||
function buildPayload(): Uint8Array | null {
|
||||
let raw = sendText.value
|
||||
if (sendMode.value === 'hex') {
|
||||
const clean = raw.replace(/[^0-9a-fA-F]/g, '')
|
||||
if (!clean) return null
|
||||
if (clean.length % 2) {
|
||||
ElMessage.warning('HEX 必须是偶数个字符')
|
||||
return null
|
||||
}
|
||||
const out = new Uint8Array(clean.length / 2)
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16)
|
||||
return out
|
||||
}
|
||||
return new TextEncoder().encode(raw)
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let bin = ''
|
||||
for (const b of bytes) bin += String.fromCharCode(b)
|
||||
return btoa(bin)
|
||||
}
|
||||
|
||||
async function doSend() {
|
||||
const sess = activeSession.value
|
||||
if (!sess) {
|
||||
ElMessage.warning('还没连接串口')
|
||||
return
|
||||
}
|
||||
let bytes = buildPayload()
|
||||
if (!bytes || bytes.length === 0) {
|
||||
ElMessage.warning('没有要发送的内容')
|
||||
return
|
||||
}
|
||||
if (appendNewline.value) {
|
||||
// 调试设备最常见的两个结尾: CRLF 和 LF
|
||||
const nl = new Uint8Array(bytes.length + 1)
|
||||
nl.set(bytes)
|
||||
nl[bytes.length] = 0x0a
|
||||
bytes = nl
|
||||
}
|
||||
const b64 = toBase64(bytes)
|
||||
const ok = await store.send(sess.sessionId, b64)
|
||||
if (!ok) {
|
||||
ElMessage.error('发送失败')
|
||||
return
|
||||
}
|
||||
// 自己发出去的也记一行 (TX) —— 串口助手要能对照"我发了什么/设备回了什么"
|
||||
pushLog('tx', b64)
|
||||
sendText.value = ''
|
||||
}
|
||||
|
||||
// 循环发送
|
||||
let repeatTimer: any = null
|
||||
function toggleRepeat() {
|
||||
if (repeatTimer) {
|
||||
clearInterval(repeatTimer)
|
||||
repeatTimer = null
|
||||
ElMessage.info('已停止循环发送')
|
||||
return
|
||||
}
|
||||
if (repeatMs.value <= 0) {
|
||||
ElMessage.warning('先设置循环间隔 (毫秒)')
|
||||
return
|
||||
}
|
||||
if (!sendText.value) {
|
||||
ElMessage.warning('先填要循环发送的内容')
|
||||
return
|
||||
}
|
||||
repeatTimer = setInterval(() => { void doSendKeepText() }, Math.max(200, repeatMs.value))
|
||||
ElMessage.success(`开始循环发送 (每 ${repeatMs.value}ms)`)
|
||||
}
|
||||
async function doSendKeepText() {
|
||||
const sess = activeSession.value
|
||||
if (!sess) { toggleRepeat(); return }
|
||||
let bytes = buildPayload()
|
||||
if (!bytes || bytes.length === 0) return
|
||||
if (appendNewline.value) {
|
||||
const nl = new Uint8Array(bytes.length + 1)
|
||||
nl.set(bytes)
|
||||
nl[bytes.length] = 0x0a
|
||||
bytes = nl
|
||||
}
|
||||
const b64 = toBase64(bytes)
|
||||
const ok = await store.send(sess.sessionId, b64)
|
||||
if (ok) pushLog('tx', b64)
|
||||
}
|
||||
onUnmounted(() => { if (repeatTimer) clearInterval(repeatTimer) })
|
||||
|
||||
// ===== 日志 =====
|
||||
function clearLog() { logLines.value = [] }
|
||||
|
||||
async function saveLog() {
|
||||
if (!logLines.value.length) {
|
||||
ElMessage.warning('还没有日志')
|
||||
return
|
||||
}
|
||||
// 旧的在前 (logLines 是 unshift 存的, 新的在前) -> 保存时翻回来
|
||||
const text = [...logLines.value].reverse().map(l => {
|
||||
const t = new Date(l.ts).toLocaleTimeString()
|
||||
const tag = l.dir === 'rx' ? 'RX' : 'TX'
|
||||
return `${t} ${tag} ${l.hex.padEnd(48, ' ')} ${l.ascii}`
|
||||
}).join('\n')
|
||||
const r = await window.api.serialSaveLog(text, `serial-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.log`)
|
||||
if (r.ok) ElMessage.success('已保存: ' + r.path)
|
||||
else if (r.reason !== '已取消') ElMessage.error('保存失败: ' + (r.reason || ''))
|
||||
}
|
||||
|
||||
async function copyVcomPath() {
|
||||
if (!vcomPath.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(vcomPath.value)
|
||||
ElMessage.success('路径已复制')
|
||||
} catch {
|
||||
ElMessage.warning('复制失败, 请手动选中复制')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 权限诊断 (Linux 的 dialout 是串口助手最常见的坑) =====
|
||||
const diagnose = ref<SerialDiagnose | null>(null)
|
||||
const diagnosing = ref(false)
|
||||
|
||||
async function runDiagnose() {
|
||||
diagnosing.value = true
|
||||
try {
|
||||
const r = await window.api.serialDiagnose()
|
||||
if (!r.ok || !r.diagnose) {
|
||||
ElMessage.error('诊断失败: ' + (r.reason || ''))
|
||||
return
|
||||
}
|
||||
diagnose.value = r.diagnose
|
||||
const d = r.diagnose
|
||||
const lines = [
|
||||
...d.issues.map(i => `⚠️ ${i.detail}`),
|
||||
...(d.notes.length ? ['', ...d.notes.map(n => `· ${n}`)] : []),
|
||||
].join('\n')
|
||||
if (d.issues.length) {
|
||||
ElMessageBox.alert(lines, '串口诊断', {
|
||||
confirmButtonText: d.issues.some(i => i.kind === 'linux.dialout') ? '尝试修复 (需管理员)' : '知道了',
|
||||
cancelButtonText: '关闭',
|
||||
dangerouslyUseHTMLString: false,
|
||||
}).then(async () => {
|
||||
if (!d.issues.some(i => i.kind === 'linux.dialout')) return
|
||||
const fix = await window.api.serialFixPerms()
|
||||
if (fix.ok) {
|
||||
ElMessage.success('已修复: ' + ((fix.fix?.stdout || '') + (fix.fix?.stderr || '')).trim())
|
||||
} else {
|
||||
ElMessage.warning('修复未完成: ' + ((fix.fix?.stderr || fix.reason || '')).trim())
|
||||
}
|
||||
await scan()
|
||||
}).catch(() => {})
|
||||
} else {
|
||||
ElMessageBox.alert(lines, '串口诊断 (一切正常)', { confirmButtonText: '好', showCancelButton: false })
|
||||
}
|
||||
} finally {
|
||||
diagnosing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 空列表提示: 按平台给, 别给 Linux 用户 Windows 的建议
|
||||
const emptyHint = computed(() => {
|
||||
if (!props.peer) return '请先在左侧选一台设备'
|
||||
if (direction.value === 'self-out') {
|
||||
return isLinux
|
||||
? '对方没报出串口。Linux 上常见原因: 对方 App 不在 dialout 组, 或者设备没插。'
|
||||
: '对方没报出串口。'
|
||||
}
|
||||
if (isLinux) return '本机没有串口。Linux 用户需在 dialout 组, 否则看不到 /dev/ttyUSB* —— 点"诊断权限"看看。'
|
||||
if (platform === 'win32') return '本机没有串口。检查设备管理器里的 COM 口。'
|
||||
return '本机没有串口。macOS 首次访问会弹权限框, 需在 系统设置 → 隐私与安全 中允许。'
|
||||
})
|
||||
|
||||
// ===== 生命周期 =====
|
||||
async function refresh() { await store.refresh() }
|
||||
let timer = 0
|
||||
onMounted(async () => {
|
||||
refresh()
|
||||
const e = await window.api.serialEnvInfo()
|
||||
envInfo.value = e
|
||||
timer = window.setInterval(() => { void refresh() }, 3000)
|
||||
})
|
||||
onUnmounted(() => { if (timer) window.clearInterval(timer) })
|
||||
|
||||
// 切换对端时清掉上一次的日志
|
||||
watch(() => props.peer?.deviceId, () => {
|
||||
logLines.value = []
|
||||
selectedPath.value = ''
|
||||
peerDevices.value = []
|
||||
})
|
||||
// 切换方向时重新列设备
|
||||
watch(direction, () => {
|
||||
selectedPath.value = ''
|
||||
void scan()
|
||||
})
|
||||
|
||||
function directionLabel(d: SerialDirection) {
|
||||
return d === 'self-out' ? '我用对方的' : '对方用我的'
|
||||
}
|
||||
function fmtTime(ts: number) {
|
||||
const d = new Date(ts)
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="serial-panel">
|
||||
<!-- 方向 + 操作 -->
|
||||
<div class="dir-row">
|
||||
<span class="dir-label">方向</span>
|
||||
<div class="dir-toggle">
|
||||
<button :class="{ active: direction === 'self-out' }" @click="direction = 'self-out'">我用对方的</button>
|
||||
<button :class="{ active: direction === 'self-in' }" @click="direction = 'self-in'">对方用我的</button>
|
||||
</div>
|
||||
<el-button size="small" :loading="scanning" @click="scan" :disabled="!peer">
|
||||
{{ direction === 'self-out' ? '拉取对端串口' : '刷新本机串口' }}
|
||||
</el-button>
|
||||
<el-button size="small" :loading="diagnosing" @click="runDiagnose">诊断权限</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!visibleDevices.length" class="empty-tip">
|
||||
<p>{{ emptyHint }}</p>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
:data="visibleDevices"
|
||||
size="small"
|
||||
highlight-current-row
|
||||
@row-click="(row: SerialDeviceInfo) => selectedPath = row.serialPath"
|
||||
>
|
||||
<el-table-column prop="serialPath" label="串口" width="200" />
|
||||
<el-table-column label="VID:PID" width="120">
|
||||
<template #default="{ row }">
|
||||
<code v-if="row.vid">{{ row.vid.toString(16).padStart(4,'0') }}:{{ (row.pid||0).toString(16).padStart(4,'0') }}</code>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="product" label="产品" />
|
||||
<el-table-column prop="manufacturer" label="制造商" width="140" />
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" :disabled="!!activeSession" @click.stop="() => { selectedPath = row.serialPath; connect() }">
|
||||
连接
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 串口参数 + 映射开关 -->
|
||||
<div v-if="visibleDevices.length" class="param-row">
|
||||
<span>波特率</span>
|
||||
<el-select v-model.number="baudRate" size="small" style="width: 110px">
|
||||
<el-option v-for="b in BAUD_OPTIONS" :key="b" :label="String(b)" :value="b" />
|
||||
</el-select>
|
||||
<span>数据位</span>
|
||||
<el-select v-model.number="dataBits" size="small" style="width: 80px">
|
||||
<el-option label="5" :value="5" /><el-option label="6" :value="6" />
|
||||
<el-option label="7" :value="7" /><el-option label="8" :value="8" />
|
||||
</el-select>
|
||||
<span>停止位</span>
|
||||
<el-select v-model.number="stopBits" size="small" style="width: 76px">
|
||||
<el-option label="1" :value="1" /><el-option label="2" :value="2" />
|
||||
</el-select>
|
||||
<span>校验</span>
|
||||
<el-select v-model="parity" size="small" style="width: 90px">
|
||||
<el-option label="无" value="none" /><el-option label="偶" value="even" />
|
||||
<el-option label="奇" value="odd" /><el-option label="标记" value="mark" />
|
||||
<el-option label="空格" value="space" />
|
||||
</el-select>
|
||||
<el-checkbox v-model="createVirtual" style="margin-left: 10px">
|
||||
创建本机虚拟串口 (串口映射)
|
||||
</el-checkbox>
|
||||
<el-button size="small" type="primary" :loading="creating" :disabled="!!activeSession" @click="connect">连接</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 会话状态 -->
|
||||
<div v-if="activeSession" class="attach-box">
|
||||
<div class="attach-row">
|
||||
<span>
|
||||
已连接: <code>{{ activeSession.busId }}</code> · {{ directionLabel(activeSession.direction) }}
|
||||
<span v-if="remotePath"> · 远端 <code>{{ remotePath }}</code></span>
|
||||
</span>
|
||||
<span>收 {{ activeSession.bytesIn }}B / 发 {{ activeSession.bytesOut }}B</span>
|
||||
<div class="spacer" />
|
||||
<el-button size="small" type="danger" @click="disconnect(activeSession)">断开</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟串口: 醒目的路径 + 用法 -->
|
||||
<div v-if="vcomPath" class="vcom-banner">
|
||||
<div class="vcom-banner-row">
|
||||
<span class="vcom-banner-label">本机虚拟串口 (串口工具直接打开这个路径):</span>
|
||||
</div>
|
||||
<div class="vcom-banner-row">
|
||||
<code class="vcom-banner-path">{{ vcomPath }}</code>
|
||||
<el-button size="small" link @click="copyVcomPath">复制路径</el-button>
|
||||
<small class="vcom-hint">{{ baudRate }} 8{{ dataBits }}{{ parity === 'none' ? 'N' : parity[0].toUpperCase() }}{{ stopBits }}</small>
|
||||
</div>
|
||||
<div class="vcom-banner-row">
|
||||
<small class="vcom-hint">{{ envInfo.hint || '用 minicom / screen / PuTTY 打开上面的路径即可。' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="activeSession.side === 'server'" class="vcom-banner">
|
||||
<div class="vcom-banner-row">
|
||||
<small class="vcom-hint">对方已连上你的串口。下面是双向流量 (TX = 你发出的, RX = 对方发来的)。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 串口助手 ===== -->
|
||||
<div class="assistant">
|
||||
<div class="assistant-head">
|
||||
<span class="assistant-title">串口助手</span>
|
||||
<span class="assistant-sub">TX = 我发出 · RX = 设备发回</span>
|
||||
<div class="spacer" />
|
||||
<el-button size="small" @click="clearLog">清屏</el-button>
|
||||
<el-button size="small" @click="saveLog">保存日志</el-button>
|
||||
</div>
|
||||
|
||||
<div ref="hexEl" class="hex-view">
|
||||
<div v-if="!logLines.length" class="hex-empty">
|
||||
{{ activeSession ? '等待串口数据…' : '连接一个串口后开始收发' }}
|
||||
</div>
|
||||
<div v-for="(line, i) in logLines" :key="i" class="hex-line" :class="line.dir">
|
||||
<span class="hex-time">{{ fmtTime(line.ts) }}</span>
|
||||
<span class="hex-dir">{{ line.dir === 'rx' ? 'RX' : 'TX' }}</span>
|
||||
<span class="hex-bytes">{{ line.hex }}</span>
|
||||
<span class="hex-ascii">{{ line.ascii }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="send-row">
|
||||
<el-input
|
||||
v-model="sendText"
|
||||
size="small"
|
||||
class="send-input"
|
||||
:placeholder="sendMode === 'hex' ? 'HEX, 如 A1 03 00 FF' : '要发送的文本'"
|
||||
@keydown.enter.exact.prevent="doSend"
|
||||
/>
|
||||
<el-radio-group v-model="sendMode" size="small">
|
||||
<el-radio-button value="text">文本</el-radio-button>
|
||||
<el-radio-button value="hex">HEX</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-checkbox v-model="appendNewline">追加 LF</el-checkbox>
|
||||
<el-button size="small" type="primary" :disabled="!activeSession" @click="doSend">发送</el-button>
|
||||
</div>
|
||||
<div class="send-row">
|
||||
<span class="dir-label">循环发送</span>
|
||||
<el-input-number v-model="repeatMs" :min="0" :step="100" size="small" controls-position="right" style="width: 110px" />
|
||||
<span class="dir-label">ms</span>
|
||||
<el-button size="small" :disabled="!activeSession" @click="toggleRepeat">
|
||||
{{ repeatTimer ? '停止循环' : '开始循环' }}
|
||||
</el-button>
|
||||
<small style="color:var(--el-text-color-secondary)">循环发送时不会清空输入框</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.serial-panel {
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dir-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.dir-label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.dir-toggle { display: inline-flex; border: 1px solid var(--el-border-color); border-radius: 4px; overflow: hidden; }
|
||||
.dir-toggle button {
|
||||
border: none; background: transparent; padding: 5px 12px; cursor: pointer;
|
||||
font-size: 12px; color: var(--el-text-color-regular);
|
||||
}
|
||||
.dir-toggle button.active { background: var(--el-color-primary); color: #fff; }
|
||||
.empty-tip {
|
||||
padding: 14px; border: 1px dashed var(--el-border-color); border-radius: 6px;
|
||||
color: var(--el-text-color-secondary); font-size: 13px;
|
||||
}
|
||||
.empty-tip p { margin: 0; }
|
||||
.param-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12px; }
|
||||
.attach-box {
|
||||
border: 1px solid var(--el-border-color); border-radius: 6px; padding: 8px 10px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.attach-row { display: flex; align-items: center; gap: 10px; font-size: 12px; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
.vcom-banner {
|
||||
background: var(--el-color-success-light-9); border: 1px solid var(--el-color-success-light-7);
|
||||
border-radius: 6px; padding: 8px 10px;
|
||||
}
|
||||
.vcom-banner-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.vcom-banner-label { font-size: 12px; color: var(--el-color-success-dark-2); }
|
||||
.vcom-banner-path {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; font-weight: 600;
|
||||
background: rgba(0,0,0,.06); padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
.vcom-hint { color: var(--el-text-color-secondary); font-size: 11px; }
|
||||
|
||||
/* 串口助手 */
|
||||
.assistant { display: flex; flex-direction: column; gap: 6px; flex: 1; min-height: 260px; }
|
||||
.assistant-head { display: flex; align-items: center; gap: 8px; }
|
||||
.assistant-title { font-size: 13px; font-weight: 600; }
|
||||
.assistant-sub { font-size: 11px; color: var(--el-text-color-secondary); }
|
||||
.hex-view {
|
||||
flex: 1; min-height: 160px; max-height: 46vh; overflow-y: auto;
|
||||
background: var(--el-fill-color-blank); border: 1px solid var(--el-border-color);
|
||||
border-radius: 6px; padding: 6px 8px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5;
|
||||
}
|
||||
.hex-empty { color: var(--el-text-color-secondary); padding: 10px; font-size: 12px; }
|
||||
.hex-line { display: flex; gap: 8px; white-space: pre; }
|
||||
.hex-line.tx .hex-dir { color: var(--el-color-warning); }
|
||||
.hex-line.rx .hex-dir { color: var(--el-color-primary); }
|
||||
.hex-time { color: var(--el-text-color-secondary); }
|
||||
.hex-dir { font-weight: 600; }
|
||||
.hex-bytes { color: var(--el-text-color-primary); }
|
||||
.hex-ascii { color: var(--el-color-success); }
|
||||
.send-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.send-input { flex: 1; min-width: 220px; }
|
||||
</style>
|
||||
@@ -1,593 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick, h } from 'vue'
|
||||
import type { DeviceView, UsbDeviceInfo, UsbSessionInfo, UsbKind, UsbDirection, UsbAttachConfig, UsbEndpointInfo } from '@/api'
|
||||
import { useUsbStore } from '@/stores/remote'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps<{ peer: DeviceView | null }>()
|
||||
const store = useUsbStore()
|
||||
|
||||
// 串口看不到设备时的提示要按平台给, Windows/macOS 给 Linux 的建议是误导
|
||||
const isLinux = (window.api?.platform || '') === 'linux'
|
||||
const serialEmptyHint = computed(() => {
|
||||
if (isLinux) return 'Linux 用户如未看到 /dev/ttyUSB*, 需要在 dialout 组.'
|
||||
if ((window.api?.platform || '') === 'win32') return 'Windows: 检查设备管理器里的 COM 口; 若用 USB 透传, 需用 Zadig 装 WinUSB 驱动.'
|
||||
return 'macOS: 首次访问串口时系统会弹权限对话框, 需在 系统设置 → 隐私与安全 中允许.'
|
||||
})
|
||||
|
||||
type Mode = 'serial' | 'usb' | 'usbip'
|
||||
const mode = ref<Mode>('serial')
|
||||
const direction = ref<UsbDirection>('self-out')
|
||||
const scanning = ref(false)
|
||||
const peerDevices = ref<UsbDeviceInfo[]>([])
|
||||
const myDevices = ref<UsbDeviceInfo[]>([])
|
||||
const selectedBus = ref<string>('')
|
||||
const baudRate = ref(115200)
|
||||
const creating = ref(false)
|
||||
|
||||
// USB 模式专属
|
||||
const usbIfaceNum = ref(0)
|
||||
const detachKern = ref(true)
|
||||
|
||||
const hexEl = ref<HTMLDivElement | null>(null)
|
||||
const hexBuf = ref<{ ascii: string; hex: string; ts: number }[]>([])
|
||||
const HEX_LIMIT = 200
|
||||
|
||||
// USB 操作历史
|
||||
const usbLog = ref<{ ts: number; tag: string; ok: boolean; text: string; hex?: string }[]>([])
|
||||
const MAX_USB_LOG = 80
|
||||
|
||||
// 当前 active 客户端 session (只展示一个)
|
||||
const myClientSession = computed<UsbSessionInfo | undefined>(() =>
|
||||
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'client')
|
||||
)
|
||||
|
||||
// 当前对端发起的 session (server side, 展示我方被控情况)
|
||||
const serverSession = computed<UsbSessionInfo | undefined>(() =>
|
||||
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'server')
|
||||
)
|
||||
|
||||
// 不同 mode 的设备过滤
|
||||
const visibleDevices = computed<UsbDeviceInfo[]>(() => {
|
||||
const all = direction.value === 'self-out' ? peerDevices.value : myDevices.value
|
||||
return all.filter(d => d.kind === mode.value)
|
||||
})
|
||||
|
||||
const serverSerialBytes = ref<{ direction: 'in' | 'out'; data: string; ts: number }[]>([])
|
||||
|
||||
async function scan() {
|
||||
if (!props.peer) return
|
||||
scanning.value = true
|
||||
if (direction.value === 'self-out') {
|
||||
const r = await store.list(props.peer.deviceId)
|
||||
scanning.value = false
|
||||
if (!r.ok) { ElMessage.error('列出对端设备失败: ' + (r.reason || '')); peerDevices.value = []; return }
|
||||
peerDevices.value = r.devices || []
|
||||
} else {
|
||||
const r = await store.listLocal()
|
||||
scanning.value = false
|
||||
if (!r.ok) {
|
||||
ElMessage.error('列出本机设备失败: ' + (r.reason || ''))
|
||||
myDevices.value = []
|
||||
return
|
||||
}
|
||||
myDevices.value = r.devices || []
|
||||
}
|
||||
// 选第一个匹配的 mode
|
||||
const first = visibleDevices.value[0]
|
||||
if (first && !visibleDevices.value.find(d => d.busId === selectedBus.value)) {
|
||||
selectedBus.value = first.busId
|
||||
}
|
||||
}
|
||||
|
||||
async function attach() {
|
||||
if (!props.peer || !selectedBus.value) return
|
||||
if (myClientSession.value) {
|
||||
ElMessage.warning('已有活跃 session, 请先关闭')
|
||||
return
|
||||
}
|
||||
|
||||
let finalBaudRate = baudRate.value
|
||||
|
||||
// 串口模式: 弹窗选波特率
|
||||
if (isSerial.value) {
|
||||
const BAUD_OPTIONS = [9600, 19200, 38400, 57600, 74880, 115200, 230400, 460800, 921600]
|
||||
const selected = ref(BAUD_OPTIONS.indexOf(baudRate.value) >= 0 ? baudRate.value : 115200)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
h('div', [
|
||||
h('p', { style: 'margin-bottom:10px' }, '选择本机虚拟串口的波特率'),
|
||||
h('el-select', {
|
||||
modelValue: selected.value,
|
||||
'onUpdate:modelValue': (v: number) => { selected.value = v },
|
||||
style: 'width:160px',
|
||||
}, BAUD_OPTIONS.map(b => h('el-option', { key: b, label: String(b), value: b }))),
|
||||
]),
|
||||
'创建虚拟串口',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
customClass: 'baud-box',
|
||||
}
|
||||
)
|
||||
finalBaudRate = selected.value
|
||||
} catch {
|
||||
return // 取消
|
||||
}
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
const config = buildConfig(finalBaudRate)
|
||||
if (!config) { creating.value = false; return }
|
||||
const r = await store.attach(props.peer.deviceId, {
|
||||
direction: direction.value,
|
||||
busId: selectedBus.value,
|
||||
config,
|
||||
})
|
||||
creating.value = false
|
||||
if (!r.ok) {
|
||||
ElMessage.error('附加失败: ' + (r.reason || ''))
|
||||
return
|
||||
}
|
||||
ElMessage.success('已附加')
|
||||
await nextTick()
|
||||
hexEl.value?.scrollTo({ top: 0 })
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'attach', ok: true, text: `${mode.value} attach OK` })
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
}
|
||||
|
||||
function buildConfig(br: number): UsbAttachConfig | null {
|
||||
if (mode.value === 'serial') {
|
||||
return {
|
||||
kind: 'serial',
|
||||
baudRate: br,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
createVirtual: true,
|
||||
}
|
||||
}
|
||||
if (mode.value === 'usb') {
|
||||
return { kind: 'usb', interfaceNumber: usbIfaceNum.value, detachKernelDriver: detachKern.value }
|
||||
}
|
||||
if (mode.value === 'usbip') {
|
||||
return { kind: 'usbip' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function detach(s: UsbSessionInfo) {
|
||||
await store.detach(s.sessionId)
|
||||
ElMessage.success('已关闭')
|
||||
}
|
||||
|
||||
async function sendText() {
|
||||
if (!myClientSession.value || myClientSession.value.kind !== 'serial') return
|
||||
const sid = myClientSession.value.sessionId
|
||||
const text = prompt('输入要发送的文本 (UTF-8):', 'AT\r\n')
|
||||
if (text === null) return
|
||||
const buf = new TextEncoder().encode(text)
|
||||
let bin = ''
|
||||
for (const b of buf) bin += String.fromCharCode(b)
|
||||
await store.serialSend(sid, btoa(bin))
|
||||
ElMessage.success(`已发送 ${buf.length} 字节`)
|
||||
}
|
||||
|
||||
// ===== USB 字节桥面板 =====
|
||||
|
||||
const reqType = ref<'standard' | 'class' | 'vendor' | 'reserved'>('standard')
|
||||
const recipient = ref<'device' | 'interface' | 'endpoint' | 'other'>('device')
|
||||
const directionBit = ref<'host-to-device' | 'device-to-host'>('device-to-host')
|
||||
const bRequest = ref(0)
|
||||
const wValue = ref(0)
|
||||
const wIndex = ref(0)
|
||||
const ctrlData = ref('') // hex 文本
|
||||
const ctrlLength = ref(64)
|
||||
|
||||
const bulkEndpoint = ref(0x81)
|
||||
const bulkData = ref('') // hex 文本
|
||||
const bulkLength = ref(64)
|
||||
const bulkTimeout = ref(5000)
|
||||
const bulkDirection = ref<'in' | 'out'>('in')
|
||||
|
||||
function hexToBase64(hex: string): string {
|
||||
const clean = hex.replace(/[^0-9a-fA-F]/g, '')
|
||||
const padded = clean.length % 2 ? '0' + clean : clean
|
||||
let bin = ''
|
||||
for (let i = 0; i < padded.length; i += 2) {
|
||||
bin += String.fromCharCode(parseInt(padded.substr(i, 2), 16))
|
||||
}
|
||||
return btoa(bin)
|
||||
}
|
||||
|
||||
function base64ToHex(b64: string): string {
|
||||
if (!b64) return ''
|
||||
try {
|
||||
const bin = atob(b64)
|
||||
let hex = ''
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
hex += bin.charCodeAt(i).toString(16).padStart(2, '0') + ' '
|
||||
}
|
||||
return hex.trim()
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function buildRequestType(): number {
|
||||
// bmRequestType: bit7 = direction, bits 6-5 = type, bits 4-0 = recipient
|
||||
let rt = 0
|
||||
if (directionBit.value === 'host-to-device') rt |= 0x00
|
||||
else rt |= 0x80
|
||||
if (reqType.value === 'standard') rt |= 0x00
|
||||
else if (reqType.value === 'class') rt |= 0x20
|
||||
else if (reqType.value === 'vendor') rt |= 0x40
|
||||
else if (reqType.value === 'reserved') rt |= 0x60
|
||||
if (recipient.value === 'device') rt |= 0x00
|
||||
else if (recipient.value === 'interface') rt |= 0x01
|
||||
else if (recipient.value === 'endpoint') rt |= 0x02
|
||||
else if (recipient.value === 'other') rt |= 0x03
|
||||
return rt
|
||||
}
|
||||
|
||||
async function doCtrlIn() {
|
||||
if (!myClientSession.value) return
|
||||
const setup = { requestType: buildRequestType(), request: bRequest.value, value: wValue.value, index: wIndex.value }
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlIn', ok: true, text: `req=0x${bRequest.value.toString(16).padStart(2, '0')} len=${ctrlLength.value}` })
|
||||
const r = await store.ctrlIn(myClientSession.value.sessionId, setup, ctrlLength.value)
|
||||
const hex = r.dataBase64 ? base64ToHex(r.dataBase64) : ''
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlIn', ok: r.ok, text: `status=${r.status}`, hex })
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
if (hex) {
|
||||
hexBuf.value.unshift({ ascii: '', hex, ts: Date.now() })
|
||||
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
async function doCtrlOut() {
|
||||
if (!myClientSession.value) return
|
||||
const setup = { requestType: buildRequestType(), request: bRequest.value, value: wValue.value, index: wIndex.value }
|
||||
const dataB64 = ctrlData.value.trim() ? hexToBase64(ctrlData.value) : undefined
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlOut', ok: true, text: `req=0x${bRequest.value.toString(16).padStart(2, '0')} data=${dataB64 ? ctrlData.value : '<none>'}` })
|
||||
const r = await store.ctrlOut(myClientSession.value.sessionId, setup, dataB64)
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlOut', ok: r.ok, text: `written=${r.status} bytes` })
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
}
|
||||
|
||||
async function doBulkTransfer() {
|
||||
if (!myClientSession.value) return
|
||||
if (bulkDirection.value === 'in') {
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkIn', ok: true, text: `EP=0x${bulkEndpoint.value.toString(16)} len=${bulkLength.value}` })
|
||||
const r = await store.bulkIn(myClientSession.value.sessionId, bulkEndpoint.value, bulkLength.value, bulkTimeout.value)
|
||||
const hex = r.dataBase64 ? base64ToHex(r.dataBase64) : ''
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkIn', ok: r.ok, text: `status=${r.status}`, hex })
|
||||
if (hex) {
|
||||
hexBuf.value.unshift({ ascii: '', hex, ts: Date.now() })
|
||||
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||
}
|
||||
} else {
|
||||
const b64 = bulkData.value.trim() ? hexToBase64(bulkData.value) : ''
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkOut', ok: true, text: `EP=0x${bulkEndpoint.value.toString(16)} data=${bulkData.value}` })
|
||||
const r = await store.bulkOut(myClientSession.value.sessionId, bulkEndpoint.value, b64)
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkOut', ok: r.ok, text: `written=${r.status} bytes` })
|
||||
}
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
}
|
||||
|
||||
// ===== 串口 hex 显示 =====
|
||||
function pushHex(base64: string) {
|
||||
if (!base64) return
|
||||
const bin = atob(base64)
|
||||
let hex = ''
|
||||
let ascii = ''
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
const c = bin.charCodeAt(i)
|
||||
hex += c.toString(16).padStart(2, '0') + ' '
|
||||
ascii += c >= 0x20 && c < 0x7f ? bin[i] : '.'
|
||||
if ((i + 1) % 16 === 0) {
|
||||
hexBuf.value.unshift({ ascii, hex: hex.trimEnd(), ts: Date.now() })
|
||||
hex = ''; ascii = ''
|
||||
}
|
||||
}
|
||||
if (hex) hexBuf.value.unshift({ ascii, hex: hex.trimEnd(), ts: Date.now() })
|
||||
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||
}
|
||||
|
||||
watch(() => store.buffers, (b) => {
|
||||
if (!myClientSession.value) return
|
||||
const sid = myClientSession.value.sessionId
|
||||
const buf = b[sid]
|
||||
if (!buf || buf.length === 0) return
|
||||
const all = buf.join('')
|
||||
b[sid] = []
|
||||
pushHex(all)
|
||||
}, { deep: true })
|
||||
|
||||
function clearHex() { hexBuf.value = [] }
|
||||
function clearUsbLog() { usbLog.value = [] }
|
||||
|
||||
async function refresh() { await store.refresh() }
|
||||
let timer = 0
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
// 轮询保持会话列表和流量计数新鲜
|
||||
timer = window.setInterval(() => { void refresh() }, 3000)
|
||||
})
|
||||
onUnmounted(() => { if (timer) window.clearInterval(timer) })
|
||||
|
||||
function directionLabel(d: UsbDirection) {
|
||||
return d === 'self-out' ? '我用对方的' : '对方用我的'
|
||||
}
|
||||
function modeLabel(m: Mode) {
|
||||
return m === 'serial' ? '串口' : m === 'usb' ? 'USB 字节桥' : 'USB/IP (Linux)'
|
||||
}
|
||||
|
||||
const isSerial = computed(() => mode.value === 'serial')
|
||||
const isUsb = computed(() => mode.value === 'usb')
|
||||
const isUsbip = computed(() => mode.value === 'usbip')
|
||||
|
||||
const endpoints = computed<UsbEndpointInfo[]>(() => myClientSession.value?.info?.endpoints || [])
|
||||
const vcomPath = computed<string | undefined>(() => myClientSession.value?.info?.userVirtualPath)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="usb-panel">
|
||||
<!-- 方向 + 模式 -->
|
||||
<div class="dir-row">
|
||||
<span class="dir-label">方向</span>
|
||||
<div class="dir-toggle">
|
||||
<button :class="{ active: direction === 'self-out' }" @click="direction = 'self-out'">我用对方的</button>
|
||||
<button :class="{ active: direction === 'self-in' }" @click="direction = 'self-in'">对方用我的</button>
|
||||
</div>
|
||||
<span class="dir-label" style="margin-left: 8px">类型</span>
|
||||
<div class="dir-toggle">
|
||||
<button :class="{ active: mode === 'serial' }" @click="mode = 'serial'">串口</button>
|
||||
<button :class="{ active: mode === 'usb' }" @click="mode = 'usb'">USB</button>
|
||||
<button :class="{ active: mode === 'usbip' }" @click="mode = 'usbip'">USB/IP</button>
|
||||
</div>
|
||||
<el-button size="small" :loading="scanning" @click="scan" :disabled="!peer">
|
||||
{{ direction === 'self-out' ? '拉取对端' : '刷新本机' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="isUsbip" class="hint">
|
||||
USB/IP 是 Linux 内核自带的真透明 USB 透传 (设备出现在本机 lsusb).
|
||||
macOS/Windows 默认不支持. Linux 用户需手动装 <code>usbip</code> 包 + 加载 <code>vhci_hcd</code> 模块.
|
||||
设备端需先 <code>usbip bind -b <busid></code>.
|
||||
</div>
|
||||
|
||||
<div v-if="!visibleDevices.length" class="empty-tip">
|
||||
<p v-if="!peer">请先在 sidebar 选择一台设备</p>
|
||||
<p v-else>暂无可用的 {{ modeLabel(mode) }} 设备</p>
|
||||
<small v-if="direction === 'self-in' && mode === 'serial'">{{ serialEmptyHint }}</small>
|
||||
</div>
|
||||
|
||||
<el-table v-else :data="visibleDevices" size="small" highlight-current-row @row-click="(row: UsbDeviceInfo) => selectedBus = row.busId">
|
||||
<el-table-column prop="busId" label="设备" width="220" />
|
||||
<el-table-column label="VID:PID" width="120">
|
||||
<template #default="{ row }">
|
||||
<code>{{ row.vid.toString(16).padStart(4,'0') }}:{{ row.pid.toString(16).padStart(4,'0') }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="product" label="产品" />
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" :disabled="!!myClientSession" @click.stop="() => { selectedBus = row.busId; baudRate = row.baudRate || 9600; attach() }">
|
||||
附加
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="isSerial && visibleDevices.length" class="baud-row">
|
||||
<span>默认波特率</span>
|
||||
<el-select v-model.number="baudRate" size="small" style="width: 120px">
|
||||
<el-option label="9600" :value="9600" />
|
||||
<el-option label="19200" :value="19200" />
|
||||
<el-option label="38400" :value="38400" />
|
||||
<el-option label="57600" :value="57600" />
|
||||
<el-option label="74880" :value="74880" />
|
||||
<el-option label="115200" :value="115200" />
|
||||
<el-option label="230400" :value="230400" />
|
||||
<el-option label="460800" :value="460800" />
|
||||
<el-option label="921600" :value="921600" />
|
||||
</el-select>
|
||||
<small style="color:var(--el-text-color-secondary)">点击附加后弹窗确认</small>
|
||||
</div>
|
||||
|
||||
<div v-if="isUsb && visibleDevices.length" class="baud-row">
|
||||
<span>Interface #</span>
|
||||
<el-input-number v-model="usbIfaceNum" :min="0" :max="15" size="small" controls-position="right" style="width: 90px" />
|
||||
<el-checkbox v-model="detachKern" style="margin-left: 8px">Linux 自动 detach 内核驱动</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 已有 session: 显示状态 + 操作 -->
|
||||
<div v-if="myClientSession" class="attach-box">
|
||||
<div class="attach-row">
|
||||
<span>已附加: <code>{{ myClientSession.busId }}</code> · {{ directionLabel(myClientSession.direction) }} · <code>{{ modeLabel(myClientSession.kind) }}</code></span>
|
||||
<span>流量: in {{ myClientSession.bytesIn }}B / out {{ myClientSession.bytesOut }}B</span>
|
||||
<div class="spacer" />
|
||||
<el-button v-if="isSerial && !vcomPath" size="small" @click="sendText">发送文本</el-button>
|
||||
<el-button v-if="isSerial && !vcomPath" size="small" @click="clearHex">清屏</el-button>
|
||||
<el-button v-if="isUsb" size="small" @click="clearUsbLog">清日志</el-button>
|
||||
<el-button size="small" type="danger" @click="detach(myClientSession)">关闭</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟串口: 醒目的路径展示 -->
|
||||
<div v-if="isSerial && vcomPath" class="vcom-banner">
|
||||
<div class="vcom-banner-row">
|
||||
<span class="vcom-banner-label">本机虚拟串口 (PuTTY / Arduino IDE / screen 直接打开):</span>
|
||||
</div>
|
||||
<div class="vcom-banner-row">
|
||||
<code class="vcom-banner-path">{{ vcomPath }}</code>
|
||||
<small class="vcom-hint">波特率 {{ baudRate }}, 8N1</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 串口: hex 流 -->
|
||||
<div v-if="isSerial" ref="hexEl" class="hex-view">
|
||||
<div v-if="!hexBuf.length" class="hex-empty">等待串口数据…</div>
|
||||
<div v-for="(line, i) in hexBuf" :key="i" class="hex-line">
|
||||
<span class="hex-bytes">{{ line.hex }}</span>
|
||||
<span class="hex-ascii">{{ line.ascii }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USB 字节桥: endpoint 列表 + 控制/批量表单 -->
|
||||
<div v-if="isUsb" class="usb-bridge">
|
||||
<div v-if="endpoints.length" class="ep-list">
|
||||
<strong>Endpoints:</strong>
|
||||
<code v-for="ep in endpoints" :key="ep.endpointNumber" class="ep">
|
||||
EP{{ ep.endpointNumber.toString(16).toUpperCase() }} ({{ ep.direction === 'in' ? 'IN' : 'OUT' }}, {{ ep.transferType }}, {{ ep.packetSize }}B)
|
||||
</code>
|
||||
</div>
|
||||
<div v-else class="ep-list"><em>无 endpoint (可能需要先选择 interface)</em></div>
|
||||
|
||||
<!-- 控制传输 -->
|
||||
<div class="usb-section">
|
||||
<h4>控制传输 (Control Transfer)</h4>
|
||||
<div class="ctrl-row">
|
||||
<el-select v-model="directionBit" size="small" style="width: 130px">
|
||||
<el-option label="device-to-host (IN)" value="device-to-host" />
|
||||
<el-option label="host-to-device (OUT)" value="host-to-device" />
|
||||
</el-select>
|
||||
<el-select v-model="reqType" size="small" style="width: 90px">
|
||||
<el-option label="standard" value="standard" />
|
||||
<el-option label="class" value="class" />
|
||||
<el-option label="vendor" value="vendor" />
|
||||
</el-select>
|
||||
<el-select v-model="recipient" size="small" style="width: 90px">
|
||||
<el-option label="device" value="device" />
|
||||
<el-option label="interface" value="interface" />
|
||||
<el-option label="endpoint" value="endpoint" />
|
||||
<el-option label="other" value="other" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<span>request</span>
|
||||
<el-input-number v-model="bRequest" :min="0" :max="255" size="small" controls-position="right" style="width: 100px" />
|
||||
<span>wValue</span>
|
||||
<el-input-number v-model="wValue" :min="0" :max="65535" size="small" controls-position="right" style="width: 120px" />
|
||||
<span>wIndex</span>
|
||||
<el-input-number v-model="wIndex" :min="0" :max="65535" size="small" controls-position="right" style="width: 120px" />
|
||||
</div>
|
||||
<div v-if="directionBit === 'host-to-device'" class="ctrl-row">
|
||||
<span>data (hex)</span>
|
||||
<el-input v-model="ctrlData" size="small" placeholder="01 02 03 ..." style="flex: 1" />
|
||||
</div>
|
||||
<div v-else class="ctrl-row">
|
||||
<span>length</span>
|
||||
<el-input-number v-model="ctrlLength" :min="1" :max="4096" size="small" controls-position="right" style="width: 100px" />
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<el-button size="small" type="primary" @click="directionBit === 'host-to-device' ? doCtrlOut() : doCtrlIn()">
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量传输 -->
|
||||
<div class="usb-section">
|
||||
<h4>批量 / 中断 传输 (Bulk / Interrupt)</h4>
|
||||
<div class="ctrl-row">
|
||||
<el-radio-group v-model="bulkDirection" size="small">
|
||||
<el-radio-button label="in" value="in">IN (读)</el-radio-button>
|
||||
<el-radio-button label="out" value="out">OUT (写)</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span>EP</span>
|
||||
<el-input-number v-model="bulkEndpoint" :min="0" :max="255" size="small" controls-position="right" style="width: 100px" />
|
||||
<span v-if="bulkDirection === 'in'">length</span>
|
||||
<el-input-number v-if="bulkDirection === 'in'" v-model="bulkLength" :min="1" :max="65536" size="small" controls-position="right" style="width: 100px" />
|
||||
<span>timeout(ms)</span>
|
||||
<el-input-number v-model="bulkTimeout" :min="100" :max="60000" :step="500" size="small" controls-position="right" style="width: 120px" />
|
||||
</div>
|
||||
<div v-if="bulkDirection === 'out'" class="ctrl-row">
|
||||
<span>data (hex)</span>
|
||||
<el-input v-model="bulkData" size="small" placeholder="01 02 03 ..." style="flex: 1" />
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<el-button size="small" type="primary" @click="doBulkTransfer">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<div class="usb-log">
|
||||
<h4>调用日志</h4>
|
||||
<div class="log-list">
|
||||
<div v-for="(l, i) in usbLog" :key="i" class="log-line" :class="{ ok: l.ok, fail: !l.ok }">
|
||||
<span class="log-ts">{{ new Date(l.ts).toLocaleTimeString() }}</span>
|
||||
<span class="log-tag">{{ l.tag }}</span>
|
||||
<span class="log-text">{{ l.text }}</span>
|
||||
<pre v-if="l.hex" class="log-hex">{{ l.hex }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对端发起的 session -->
|
||||
<div v-if="serverSession" class="server-box">
|
||||
<strong>对方正在使用我的设备:</strong>
|
||||
<code>{{ serverSession.busId }}</code> · {{ directionLabel('self-in') }} · {{ modeLabel(serverSession.kind) }}
|
||||
<span style="margin-left: 8px">流量: in {{ serverSession.bytesIn }}B / out {{ serverSession.bytesOut }}B</span>
|
||||
<div class="spacer" />
|
||||
<el-button size="small" type="danger" @click="detach(serverSession)">停止</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.usb-panel { padding: 16px; overflow-y: auto; height: 100%; }
|
||||
.dir-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.dir-label { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.dir-toggle { display: flex; background: var(--el-fill-color-light); border-radius: 6px; padding: 2px; }
|
||||
.dir-toggle button { background: transparent; border: none; padding: 5px 12px; font-size: 13px; border-radius: 4px; cursor: pointer; color: var(--el-text-color-regular); }
|
||||
.dir-toggle button.active { background: var(--el-color-primary); color: #fff; font-weight: 500; }
|
||||
.hint { background: var(--el-fill-color-light); padding: 8px 12px; border-radius: 6px; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 12px; }
|
||||
.hint code { background: #1e1e1e10; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
|
||||
.empty-tip { padding: 24px; text-align: center; color: var(--el-text-color-secondary); }
|
||||
.empty-tip small { display: block; margin-top: 8px; font-size: 11px; }
|
||||
.baud-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; font-size: 12px; color: var(--el-text-color-secondary); flex-wrap: wrap; }
|
||||
.vcom-row { margin: 8px 0; padding: 8px 12px; background: var(--el-color-primary-light-9); border: 1px solid var(--el-color-primary-light-5); border-radius: 6px; }
|
||||
.vcom-row .vcom-options { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.vcom-banner { background: var(--el-color-success-light-9); border-top: 1px solid var(--el-color-success-light-5); padding: 12px 16px; }
|
||||
.vcom-banner-row { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||||
.vcom-banner-row:last-child { margin-bottom: 0; }
|
||||
.vcom-banner-label { font-size: 12px; color: var(--el-text-color-secondary); font-weight: 500; }
|
||||
.vcom-banner-path { font-family: ui-monospace, Consolas, monospace; font-size: 16px; font-weight: 600; color: var(--el-color-success); background: #fff; padding: 4px 10px; border-radius: 4px; border: 1px solid var(--el-color-success-light-5); user-select: all; }
|
||||
.vcom-hint { color: var(--el-text-color-secondary); font-size: 11px; }
|
||||
.attach-box { margin-top: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; overflow: hidden; }
|
||||
.attach-row { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--el-fill-color-light); font-size: 12px; flex-wrap: wrap; }
|
||||
.attach-row .spacer { flex: 1; }
|
||||
.server-box { margin-top: 12px; padding: 8px 12px; background: var(--el-color-warning-light-9); border: 1px solid var(--el-color-warning-light-5); border-radius: 6px; display: flex; align-items: center; font-size: 12px; }
|
||||
.server-box .spacer { flex: 1; }
|
||||
|
||||
.hex-view {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: #0d0d0d;
|
||||
color: #d4d4d4;
|
||||
height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
.hex-empty { color: #888; text-align: center; padding: 24px; }
|
||||
.hex-line { display: flex; gap: 12px; line-height: 1.4; }
|
||||
.hex-bytes { color: #79c0ff; min-width: 360px; word-break: break-all; }
|
||||
.hex-ascii { color: #ffa657; }
|
||||
|
||||
.usb-bridge { padding: 12px; background: var(--el-bg-color); }
|
||||
.ep-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; font-size: 12px; }
|
||||
.ep { background: var(--el-fill-color-light); padding: 2px 6px; border-radius: 3px; font-size: 11px; }
|
||||
.usb-section { border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; margin-top: 12px; }
|
||||
.usb-section h4 { margin: 0 0 8px 0; font-size: 13px; font-weight: 600; }
|
||||
.ctrl-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; font-size: 12px; }
|
||||
.usb-log { margin-top: 16px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
|
||||
.usb-log h4 { margin: 0 0 8px 0; font-size: 13px; font-weight: 600; }
|
||||
.log-list { font-family: ui-monospace, Consolas, monospace; font-size: 11px; max-height: 200px; overflow-y: auto; background: #0d0d0d; padding: 6px; border-radius: 4px; }
|
||||
.log-line { display: flex; gap: 8px; padding: 2px 0; line-height: 1.4; }
|
||||
.log-line.fail { color: #f97583; }
|
||||
.log-line.ok { color: #85e89d; }
|
||||
.log-ts { color: #888; }
|
||||
.log-tag { color: #79c0ff; min-width: 70px; }
|
||||
.log-text { color: #d4d4d4; flex: 1; word-break: break-word; }
|
||||
.log-hex { margin: 2px 0 2px 80px; color: #79c0ff; white-space: pre-wrap; word-break: break-all; font-size: 11px; }
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TerminalSessionInfo, ForwardSessionInfo, UsbDeviceInfo, UsbSessionInfo, UsbKind, UsbAttachConfig, ApprovalRequestView, AuditEntry } from '@/api'
|
||||
import type { TerminalSessionInfo, ForwardSessionInfo, SerialDeviceInfo, SerialSessionInfo, SerialDirection, SerialAttachConfig, ApprovalRequestView, AuditEntry } from '@/api'
|
||||
|
||||
interface TerminalChannelState {
|
||||
buffer: string[] // 字节 base64 数组, UI 端自行解码
|
||||
@@ -144,29 +144,29 @@ export const useForwardStore = defineStore('forward', () => {
|
||||
return { client, server, sessions, refresh, open, close, onOpened, onClosed }
|
||||
})
|
||||
|
||||
export const useUsbStore = defineStore('usb', () => {
|
||||
const devicesByPeer = ref<Record<string, UsbDeviceInfo[]>>({})
|
||||
const myLocalDevices = ref<UsbDeviceInfo[]>([])
|
||||
const client = ref<UsbSessionInfo[]>([]) // 我发起的
|
||||
const server = ref<UsbSessionInfo[]>([]) // 对方发起的
|
||||
export const useSerialStore = defineStore('serial', () => {
|
||||
const devicesByPeer = ref<Record<string, SerialDeviceInfo[]>>({})
|
||||
const myLocalDevices = ref<SerialDeviceInfo[]>([])
|
||||
const client = ref<SerialSessionInfo[]>([]) // 我发起的
|
||||
const server = ref<SerialSessionInfo[]>([]) // 对方发起的
|
||||
const sessions = computed(() => [...client.value, ...server.value])
|
||||
// sessionId -> base64 字符串缓冲 (串口 hex 显示用)
|
||||
const buffers = ref<Record<string, string[]>>({})
|
||||
// sessionId -> base64 缓冲 (串口助手的收发显示用). dir 区分是设备发来的还是我发出去的
|
||||
const buffers = ref<Record<string, { data: string; dir: 'rx' | 'tx' }[]>>({})
|
||||
|
||||
async function list(peerId: string) {
|
||||
const r = await window.api.usbList(peerId)
|
||||
const r = await window.api.serialList(peerId)
|
||||
if (r.ok && r.devices) devicesByPeer.value[peerId] = r.devices
|
||||
return r
|
||||
}
|
||||
|
||||
async function listLocal() {
|
||||
const r = await window.api.usbListLocal()
|
||||
const r = await window.api.serialListLocal()
|
||||
if (r.ok && r.devices) myLocalDevices.value = r.devices
|
||||
return r
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const r = await window.api.usbListSessions()
|
||||
const r = await window.api.serialListSessions()
|
||||
if (Array.isArray(r)) {
|
||||
client.value = r
|
||||
server.value = []
|
||||
@@ -176,50 +176,32 @@ export const useUsbStore = defineStore('usb', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function attach(peerId: string, args: { busId: string; direction?: 'self-out' | 'self-in'; config: UsbAttachConfig }) {
|
||||
const r = await window.api.usbAttach(peerId, args.busId, { direction: args.direction, config: args.config })
|
||||
async function attach(peerId: string, args: { busId: string; direction?: SerialDirection; config: SerialAttachConfig }) {
|
||||
const r = await window.api.serialAttach(peerId, args.busId, { direction: args.direction, config: args.config })
|
||||
if (r.ok) await refresh()
|
||||
return r
|
||||
}
|
||||
|
||||
// 串口: 发送字节
|
||||
async function serialSend(sessionId: string, dataBase64: string) {
|
||||
return window.api.usbSerialSend(sessionId, dataBase64)
|
||||
}
|
||||
|
||||
// USB: 控制传输
|
||||
async function ctrlOut(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, dataBase64?: string) {
|
||||
return window.api.usbCtrlOut(sessionId, setup, dataBase64)
|
||||
}
|
||||
async function ctrlIn(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number) {
|
||||
const r = await window.api.usbCtrlIn(sessionId, setup, length)
|
||||
return { ok: r.ok, dataBase64: r.dataBase64, status: r.status }
|
||||
}
|
||||
|
||||
// USB: 批量传输
|
||||
async function bulkOut(sessionId: string, endpoint: number, dataBase64: string) {
|
||||
return window.api.usbBulkOut(sessionId, endpoint, dataBase64)
|
||||
}
|
||||
async function bulkIn(sessionId: string, endpoint: number, length: number, timeoutMs?: number) {
|
||||
const r = await window.api.usbBulkIn(sessionId, endpoint, length, timeoutMs)
|
||||
return { ok: r.ok, dataBase64: r.dataBase64, status: r.status }
|
||||
// 串口助手: 发送字节
|
||||
async function send(sessionId: string, dataBase64: string) {
|
||||
return window.api.serialSend(sessionId, dataBase64)
|
||||
}
|
||||
|
||||
async function detach(sessionId: string) {
|
||||
const r = await window.api.usbDetach(sessionId)
|
||||
const r = await window.api.serialDetach(sessionId)
|
||||
await refresh()
|
||||
return r
|
||||
}
|
||||
|
||||
function onOutput(p: { sessionId: string; peerId: string; data: string }) {
|
||||
function onOutput(p: { sessionId: string; peerId: string; dir: 'rx' | 'tx'; data: string }) {
|
||||
const buf = buffers.value[p.sessionId] || (buffers.value[p.sessionId] = [])
|
||||
buf.push(p.data)
|
||||
buf.push({ data: p.data, dir: p.dir || 'rx' })
|
||||
}
|
||||
|
||||
function takeBuffer(sessionId: string): string {
|
||||
function takeBuffer(sessionId: string): { data: string; dir: 'rx' | 'tx' }[] {
|
||||
const buf = buffers.value[sessionId]
|
||||
if (!buf) return ''
|
||||
const out = buf.join('')
|
||||
if (!buf) return []
|
||||
const out = [...buf]
|
||||
buffers.value[sessionId] = []
|
||||
return out
|
||||
}
|
||||
@@ -245,8 +227,7 @@ export const useUsbStore = defineStore('usb', () => {
|
||||
return {
|
||||
devicesByPeer, myLocalDevices, client, server, sessions, buffers,
|
||||
list, listLocal, refresh, attach,
|
||||
serialSend, ctrlOut, ctrlIn, bulkOut, bulkIn,
|
||||
detach, onOutput, takeBuffer, onOpened, onClosed,
|
||||
send, detach, onOutput, takeBuffer, onOpened, onClosed,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user