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:
2026-10-21 18:04:55 +08:00
parent 2b63bf7d0b
commit 2a641b1eff
29 changed files with 1796 additions and 2123 deletions
+26
View File
@@ -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
View File
@@ -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}`)
})
}
// ============ 网盘 ============
+6 -1
View File
@@ -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} 个原生文件`
})