// USB / 串口 共享 (3 种模式): // 1. 'serial' — 双向字节流. server 端 SerialPort.open, client 端收发 hex 流. // 覆盖所有 USB-串口适配器 (CH340/CP210x/FTDI) 和原生 COM/tty. // 2. 'usb' — libusb 字节桥. server 端 node-usb open + claim interface, // client 端发 controlTransfer / bulkTransfer 请求, server 返回结果. // 不是透明 USB, 但能远程调自定义 USB 设备 (单片机/编程器/调试器). // 3. 'usbip' — Linux only, 调 `usbip attach` 让内核接管. 真透明透传, 但需要内核模块. import { EventEmitter } from 'node:events' import { randomUUID } from 'node:crypto' import { platform } from 'node:process' import type { DeviceInfo, UsbFrame, UsbDeviceInfo, WsFrame, UsbDirection, UsbAttachConfig, UsbAttachedInfo, UsbEndpointInfo, } from '../protocol' import type { ChatClient } from '../chat-client' import { getSettings } from '../settings' import { shouldAsk, enqueueApproval, markApproved } from './approval' import { recordAudit } from '../db' import { diagnoseAndFixUsbAttach } from '../permissions' import { createVirtualSerialPair, type VirtualSerialInfo } from './vcom' // 串口: 试着 require (可缺失 — 走别的路径) let spMod: any = null try { spMod = require('serialport') } catch { /* ignored */ } // libusb: 试着 require let usbMod: any = null try { usbMod = require('usb') } catch { /* ignored */ } const MAX_DATA_CHUNK = 32 * 1024 export interface UsbManagerEvents { approvalRequested: (req: { peerId: string; peerName: string; kind: 'usb'; detail: string; requestId: string }) => void sessionOpened: (info: { sessionId: string; peerId: string; direction: UsbDirection; busId: string; kind: 'serial'|'usb'|'usbip'; info?: UsbAttachedInfo; side: 'client'|'server' }) => void sessionClosed: (info: { sessionId: string; peerId: string; reason?: string; bytesIn: number; bytesOut: number; side: 'client'|'server' }) => void sessionError: (info: { sessionId: string; peerId: string; error: string }) => void /** 串口/UART 字节流 (仅 serial 模式). UI 用来显示 hex. */ output: (sessionId: string, bytes: Buffer, peerId: string) => void /** USB 设备事件 (仅 usb 模式). 透传给 UI. */ usbEvent: (sessionId: string, peerId: string, payload: { controlIn?: boolean; bulkIn?: boolean; ok: boolean; data?: Buffer; status?: number }) => void } // ===== Client (我发起的, 自己是 '我') ===== interface ClientSession { sessionId: string peerId: string direction: UsbDirection busId: string kind: 'serial'|'usb'|'usbip' info?: UsbAttachedInfo bytesIn: number bytesOut: number createdAt: number // 串口: 本机的"虚拟串口" (self-out + createVirtual). 我们的 SerialPort 实例 + 清理回调 localVirtual?: { port: any // SerialPort 实例 (我们开的内部那端) userPath: string // 给用户 app 用的路径 cleanup: () => Promise } } // ===== Server (对端发起的, 我打开真实设备) ===== interface ServerSession { sessionId: string ownerId: string peerId: string busId: string kind: 'serial'|'usb'|'usbip' // 串口: SerialPort 实例 serial?: any // USB: 句柄 + interface usbDev?: any usbInterface?: number bytesIn: number bytesOut: number // 设备元信息 (attach 成功时填, 给 UI 看的 endpoint 列表等) info?: UsbAttachedInfo // 串口 关闭 promise (等数据 drain) serialClosing?: Promise // USB 控制/批量 reqId -> 配对 response pendingCtrl: Map void> pendingBulk: Map void> // client 端正在等的 attach 结果 attachResolver?: (r: { ok: boolean; reason?: string; info?: UsbAttachedInfo }) => void attachRejecter?: (e: Error) => void // USB detach 流程: 我们 detach 时不让 OS 拉回去 — 留给 client detachResolver?: (r: { ok: boolean; reason?: string }) => void } export class UsbBridgeManager extends EventEmitter { private clientSessions = new Map() private serverSessions = new Map() private chatClient: ChatClient // list 请求的待回包 private pendingLists = new Map void>() constructor(chatClient: ChatClient) { super() this.chatClient = chatClient } // ====== 本地设备列举 ====== // 列本机的: 串口 + libusb 设备 (合并) async listLocal(): Promise { const out: UsbDeviceInfo[] = [] // 串口 if (spMod) { try { const ports: any[] = await spMod.SerialPort.list() for (const p of ports) { out.push({ busId: `serial:${p.path}`, vid: p.vendorId ? parseInt(p.vendorId, 16) || 0 : 0, pid: p.productId ? parseInt(p.productId, 16) || 0 : 0, deviceClass: 0x02, deviceSubclass: 0x02, product: p.friendlyName || p.productId, manufacturer: p.manufacturer, serialNumber: p.serialNumber, kind: 'serial', serialPath: p.path, baudRate: 115200, }) } } catch (e: any) { console.warn('[usb] serialport.list failed:', e?.message) } } // libusb (用 webusb.getDevices, 不会 claim interface, 安全) if (usbMod) { try { // 用 webusb.getDevices() 不需要 detach kernel driver, 是只读枚举 const devices: any[] = await usbMod.webusb.getDevices() for (const d of devices) { const busId = `usb:${pad4(d.vendorId)}:${pad4(d.productId)}:${d.serialNumber || d.productName || d.productName || '0'}` // 避免和已加的串口重复 (USB-串口适配器在 OS 看来是串口) const vid = d.vendorId || 0 const pid = d.productId || 0 const isDuplicate = out.some(s => s.vid === vid && s.pid === pid && s.kind === 'serial') if (isDuplicate) continue out.push({ busId, vid, pid, deviceClass: d.deviceClass || 0, deviceSubclass: d.deviceSubclass || 0, product: d.productName, manufacturer: d.manufacturerName, serialNumber: d.serialNumber, kind: 'usb', }) } } catch (e: any) { console.warn('[usb] webusb.getDevices failed:', e?.message) } } // Linux: 追加 usbip (用系统命令列可分享的设备) if (platform === 'linux') { try { const { exec: execSync } = await import('node:child_process') const { promisify } = await import('node:util') const execAsync = promisify(execSync as any) const { stdout } = await execAsync('usbip list -l 2>/dev/null || true') // 格式: " - busid 1-1 (1234:5678) 01:00:00 3D /sys/..." // vendor:vendor product const lines = stdout.split(/\r?\n/) for (const line of lines) { const m = line.match(/busid\s+(\S+)\s+\(([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\)/) if (m) { out.push({ busId: `usbip:${m[1]}`, vid: parseInt(m[2], 16), pid: parseInt(m[3], 16), deviceClass: 0, deviceSubclass: 0, kind: 'usbip', }) } } } catch {} } return out } // 问对端设备列表 async listOnPeer(peerId: string): Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }> { const reqId = randomUUID() const sent = this.chatClient.send({ type: 'usb', payload: { type: 'list', reqId } } as WsFrame, peerId) if (!sent) return { ok: false, reason: '对方离线' } return new Promise((resolve) => { const timer = setTimeout(() => { this.pendingLists.delete(reqId) resolve({ ok: false, reason: '超时' }) }, 15_000) this.pendingLists.set(reqId, (r) => { clearTimeout(timer) resolve(r) }) }) } // ====== 我方作为控制端 (发起 attach) ====== // attach: 三种模式按 config.kind 走不同路径 async attach(peerId: string, opts: { direction?: UsbDirection; busId: string; config: UsbAttachConfig }): Promise<{ ok: boolean; reason?: string; sessionId?: string }> { const s = getSettings() if (!s.remoteEnabled) return { ok: false, reason: '远程功能已关闭' } const direction: UsbDirection = opts.direction || 'self-out' if (!opts.busId) return { ok: false, reason: 'busId 不能为空' } const kind = opts.config.kind // self-in 模式: 我方是 server, 需要我自己先把设备 open 好, 再让对端 attach // self-out 模式: 我方是 client, 对端 open 设备, 我方收字节/调 USB if (direction === 'self-in') { // 我方是设备持有方: 走 server attach return this.serverAttach(peerId, opts.busId, kind, opts.config) } else { // 我方是控制方: 走 client attach return this.clientAttach(peerId, opts.busId, kind, opts.config) } } // self-out: 控制端是 client; 设备在对端 private clientAttach(peerId: string, busId: string, kind: 'serial'|'usb'|'usbip', config: UsbAttachConfig): Promise<{ ok: boolean; reason?: string; sessionId?: string }> { const sessionId = randomUUID() const frame: Extract = { type: 'attach', sessionId, direction: 'self-out', busId, config } const sent = this.chatClient.send({ type: 'usb', payload: frame } as WsFrame, peerId) if (!sent) return Promise.resolve({ ok: false, reason: '对方离线' }) const sess: ClientSession = { sessionId, peerId, direction: 'self-out', busId, kind, bytesIn: 0, bytesOut: 0, createdAt: Date.now(), } this.clientSessions.set(sessionId, sess) // 不写 audit: 还没成功, 也不在失败语义里; 真正的 result 在 waitAttached 里写 return new Promise((resolve) => { const timer = setTimeout(() => { if (this.clientSessions.has(sessionId)) { this.cleanupClientSession(sessionId, '对方 30s 内未响应') resolve({ ok: false, reason: '对方 30s 内未响应' }) } }, 30_000) // 单独的 resolver: 控制端由 'attached' 帧 (被控端 attach-ack) resolve sess.info = undefined const waitAttached = (p: any) => { if (p.sessionId !== sessionId) return this.chatClient.off('usb:attached' as any, waitAttached as any) clearTimeout(timer) if (p.ok) { sess.info = p.info // usbip: 调 `usbip attach -r -b ` 真正 attach if (kind === 'usbip') { this.shellUsbipAttach(peerId, busId).then((r) => { if (!r.ok) { this.cleanupClientSession(sessionId, r.reason || 'usbip attach 失败') resolve({ ok: false, reason: r.reason }) return } this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: p.info, side: 'client' }) recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out' } }) resolve({ ok: true, sessionId }) }).catch((e) => { this.cleanupClientSession(sessionId, e?.message || 'usbip 失败') resolve({ ok: false, reason: e?.message || 'usbip 失败' }) }) } else { // serial + createVirtual: 先建本机虚拟串口, 再开内部端, 再 resolve if (kind === 'serial') { const sCfg = config as Extract if (sCfg.createVirtual) { this.setupLocalVirtualSerial(sessionId, sCfg).then((r: { ok: boolean; reason?: string; userPath?: string }) => { if (!r.ok) { this.cleanupClientSession(sessionId, r.reason || '虚拟串口创建失败') resolve({ ok: false, reason: r.reason }) return } sess.info = { ...(p.info || {}), kind: 'serial', userVirtualPath: r.userPath } as any this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: sess.info, side: 'client' }) recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out', virtualPath: r.userPath } }) resolve({ ok: true, sessionId }) }).catch((e: Error) => { this.cleanupClientSession(sessionId, e?.message || '虚拟串口异常') resolve({ ok: false, reason: e?.message || '虚拟串口异常' }) }) return } } this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: p.info, side: 'client' }) recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out' } }) resolve({ ok: true, sessionId }) } } else { this.clientSessions.delete(sessionId) resolve({ ok: false, reason: p.reason || '对方拒绝' }) } } this.chatClient.on('usb:attached' as any, waitAttached as any) }) } // self-in: 我方是 server, 设备在我方 private async serverAttach(peerId: string, busId: string, kind: 'serial'|'usb'|'usbip', config: UsbAttachConfig): 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: randomUUID(), result: 'error', payload: { phase: 'no-device', busId, kind } }) return { ok: false, reason: '本机找不到该设备' } } // 2. 开设备 const sessionId = randomUUID() const sess: ServerSession = { sessionId, ownerId: peerId, peerId, busId, kind, bytesIn: 0, bytesOut: 0, pendingCtrl: new Map(), pendingBulk: new Map(), } this.serverSessions.set(sessionId, sess) try { if (kind === 'serial') { const c = config as Extract const sp = new spMod.SerialPort({ path: dev.serialPath!, baudRate: c.baudRate || 115200, dataBits: c.dataBits || 8, stopBits: c.stopBits || 1, parity: c.parity || 'none', autoOpen: false, }) await new Promise((resolve, reject) => { sp.open((err: any) => err ? reject(err) : resolve()) }) sess.serial = sp // 接收字节 → 发给 client sp.on('data', (buf: Buffer) => { if (buf.length === 0) return sess.bytesIn += buf.length this.sendChunked(peerId, sessionId, 'dev->host', buf, false) this.emit('output', sessionId, buf, peerId) }) sp.on('close', () => { this.sendControl(peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true }) this.cleanupServerSession(sessionId, 'serial-closed') }) sp.on('error', (e: any) => { this.emit('sessionError', { sessionId, peerId, error: e.message }) recordAudit({ action: 'usb.attach', source: peerId, sessionId, result: 'error', payload: { kind, error: e.message } }) }) sess.info = { kind: 'serial', serialPath: dev.serialPath, vid: dev.vid, pid: dev.pid, product: dev.product, manufacturer: dev.manufacturer, serialNumber: dev.serialNumber } } else if (kind === 'usb') { if (!usbMod) throw new Error('node-usb 未安装') const c = config as Extract // find by vendorId/productId const dev2: any = await usbMod.webusb.findDeviceByIds(dev.vid, dev.pid).catch(() => null) if (!dev2) throw new Error('libusb 找不到该设备') await dev2.open() const configVal = c.configurationValue ?? dev2.configuration?.configurationValue ?? 1 await dev2.selectConfiguration(configVal) const ifaceNum = c.interfaceNumber ?? 0 if (platform === 'linux' && c.detachKernelDriver !== false) { try { await dev2.detachKernelDriver(ifaceNum) } catch {} } await dev2.claimInterface(ifaceNum) sess.usbDev = dev2 sess.usbInterface = ifaceNum // 枚举 endpoints const endpoints: UsbEndpointInfo[] = [] const cfg = dev2.configuration if (cfg) { const iface = cfg.interfaces?.find((x: any) => x.interfaceNumber === ifaceNum) const alt = iface?.alternate for (const ep of alt?.endpoints || []) { endpoints.push({ endpointNumber: ep.endpointNumber, direction: ep.direction === 'in' ? 'in' : 'out', transferType: ep.type || 'bulk', packetSize: ep.packetSize || 0, }) } } sess.info = { kind: 'usb', endpoints, vid: dev.vid, pid: dev.pid, product: dev.product, manufacturer: dev.manufacturer, serialNumber: dev.serialNumber } } else if (kind === 'usbip') { // self-in 模式我不直接 attach; 我只是把设备标记为 share (调 `usbip bind` 替代: 由 peer 端 attach) // 实际 server attach (本端用对端设备) 不支持 self-in 模式 // 因为 usbip 的 server 是持有设备方, attach 是 client 端 // 这里 self-in 实际意义: 我把设备给对端, 我这边只是 declare share // 直接在 device-holder 端 (我) 不需要做什么, 真正 attach 是 client 端 (`usbip attach -r <对方>`) // 我们只需要告诉对方: 这是个 usbip 设备, 你来 attach sess.info = { kind: 'usbip', vid: dev.vid, pid: dev.pid } } this.emit('sessionOpened', { sessionId, peerId, direction: 'self-in', busId, kind, info: sess.info, side: 'server' }) recordAudit({ action: 'usb.attach', source: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-in' } }) return { ok: true, sessionId } } catch (e: any) { this.serverSessions.delete(sessionId) recordAudit({ action: 'usb.attach', source: peerId, sessionId, result: 'error', payload: { phase: 'open', error: String(e?.message || e), kind } }) return { ok: false, reason: `打开设备失败: ${e?.message || e}` } } } // ====== 我方作为被控端 (收到 attach) ====== handleIncoming(peer: DeviceInfo, f: UsbFrame) { switch (f.type) { case 'list': return this.handlePeerList(peer, f) case 'devices': { const r = this.pendingLists.get(f.reqId) if (r) { r({ ok: true, devices: f.devices }); this.pendingLists.delete(f.reqId) } return } case 'attach': return this.handleAttachReq(peer, f) case 'attached': { // 控制端收到被控端的 ack this.chatClient.emit('usb:attached' as any, f) return } case 'detach': return this.handleDetachReq(peer, f) case 'detached': { this.chatClient.emit('usb:detached' as any, f) return } case 'data': { // 串口字节流 const sess = this.serverSessions.get(f.sessionId) || this.clientSessions.get(f.sessionId) as any if (!sess) return if (f.dir === 'dev->host') { // 设备 → 控制端: 远端从真实串口读到的字节 // 1) 落到 server-side session (self-in): 我方是设备持有方, 我方自己读到了; 这个分支不会到这里 (我方自己读的数据不走 WS) // 2) 落到 client-side session (self-out): 我方是控制方, 远端发过来的字节要写到我方的虚拟串口 / hex 缓冲 const cs = this.clientSessions.get(f.sessionId) if (cs && cs.kind === 'serial') { if (f.fin) { // 半关信号: 关掉我方虚拟串口 if (cs.localVirtual) { try { if (cs.localVirtual.port.isOpen) cs.localVirtual.port.close() } catch {} } return } const buf = Buffer.from(f.data || '', 'base64') cs.bytesIn += buf.length // 优先写虚拟串口 (用户 app 在读) if (cs.localVirtual && cs.localVirtual.port.isOpen) { try { cs.localVirtual.port.write(buf) } catch (e: any) { this.emit('sessionError', { sessionId: cs.sessionId, peerId: cs.peerId, error: e.message }) } } // 同时给 UI 缓冲 (hex 显示) this.emit('output', f.sessionId, buf, cs.peerId) } return } // host->dev: 控制端 -> 设备 if (this.serverSessions.has(f.sessionId)) { const s = this.serverSessions.get(f.sessionId)! if (s.kind === 'serial' && s.serial) { if (f.fin) { try { s.serial.close() } catch {} ; return } const buf = Buffer.from(f.data || '', 'base64') s.bytesOut += buf.length try { s.serial.write(buf) } catch (e: any) { this.emit('sessionError', { sessionId: s.sessionId, peerId: s.peerId, error: e.message }) } } } else { // client 端 (我们发起 self-in): 我方持有设备, client 这边收 host->dev 字节 (对端发来的) // 但 client session 的 host->dev 是对端 app -> 我方; 不直接写本地 // self-in: 我方持有设备, 对端是控制方, 对端写 host->dev 字节会被我方 (server) 收 // client 这边忽略 } return } case 'ctrlOut': case 'ctrlIn': { const s = this.serverSessions.get(f.sessionId) if (!s || s.kind !== 'usb' || !s.usbDev) { this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: f.sessionId, reqId: f.reqId, ok: false, status: -1 }) return } this.handleCtrlReq(s, peer, f) return } case 'bulkOut': case 'bulkIn': { const s = this.serverSessions.get(f.sessionId) if (!s || s.kind !== 'usb' || !s.usbDev) { this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: f.sessionId, reqId: f.reqId, ok: false, status: -1 }) return } this.handleBulkReq(s, peer, f) return } case 'ctrlResult': { const s = this.clientSessions.get(f.sessionId) as any if (s && s.kind === 'usb') { // 不会到这里: client 端 (我们 self-out) 直接等 server 返回 } // 控制端 client: 收到被控端的 ctrlResult → resolve const sess = this.clientSessions.get(f.sessionId) as any if (sess && sess.kind === 'usb') { // 实际上 client 端我们不存 pendingCtrl, 因为是 process 直接 await // 走 usbEvent 事件 } this.emit('usbEvent', f.sessionId, peer.deviceId, { ok: f.ok, data: f.data ? Buffer.from(f.data, 'base64') : undefined, status: f.status, controlIn: true }) return } case 'bulkResult': { this.emit('usbEvent', f.sessionId, peer.deviceId, { ok: f.ok, data: f.data ? Buffer.from(f.data, 'base64') : undefined, status: f.status, bulkIn: true }) return } case 'error': { this.emit('sessionError', { sessionId: f.sessionId, peerId: peer.deviceId, error: f.reason }) return } case 'ack': { // 通用 ack return } } } // 被控端处理 list 请求 private async handlePeerList(peer: DeviceInfo, f: Extract) { const s = getSettings() if (!s.remoteEnabled) return if (shouldAsk(peer.deviceId, 'usb')) { const reqId = randomUUID() const detail = '对方请求查看本机的 USB / 串口 设备列表' const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail, ts: Date.now() }) this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail }) const verdict = await reqPromise if (!verdict.ok) return } const devices = await this.listLocal() this.sendControl(peer.deviceId, { type: 'devices', reqId: f.reqId, devices }) recordAudit({ action: 'usb.list', source: peer.deviceId, result: 'ok', payload: { count: devices.length } }) } // 被控端处理 attach 请求 private async handleAttachReq(peer: DeviceInfo, f: Extract) { const s = getSettings() if (!s.remoteEnabled) { this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '对方关闭了远程功能' }) return } if (shouldAsk(peer.deviceId, 'usb')) { const detail = describeAttach(f.direction, f.config, f.busId) const reqId = randomUUID() const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail, ts: Date.now() }) this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail }) const verdict = await reqPromise if (!verdict.ok) { this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '用户拒绝' }) recordAudit({ action: 'usb.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'denied' }) return } if (verdict.remember) markApproved(peer.deviceId, 'usb') } // 走和 self-in 一样的开设备流程 const r = await this.serverAttach(peer.deviceId, f.busId, f.config.kind, f.config) this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: r.ok, reason: r.reason, info: r.ok ? this.serverSessions.get(f.sessionId)?.info : undefined }) if (!r.ok) { recordAudit({ action: 'usb.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { phase: 'open', reason: r.reason } }) } } private handleDetachReq(peer: DeviceInfo, f: Extract) { const sess = this.serverSessions.get(f.sessionId) if (!sess) { this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: false, reason: 'session 不存在' }) return } this.cleanupServerSession(f.sessionId, f.reason || 'closed-by-peer') this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: true }) } // USB 控制传输: 对方发请求 → 我执行 → 回 ctrlResult private async handleCtrlReq(sess: ServerSession, peer: DeviceInfo, f: Extract) { try { const dev = sess.usbDev const setup: any = { requestType: f.setup.requestType, recipient: (f.setup.requestType & 0x1f) as any, // low 5 bits request: f.setup.request, value: f.setup.value, index: f.setup.index, } if (f.type === 'ctrlOut') { const data = f.data ? Buffer.from(f.data, 'base64') : null const written = await dev.nativeControlTransferOut(setup, 5000, data ? new Uint8Array(data) : null) sess.bytesOut += written || 0 this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, status: written || 0 }) this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, status: written || 0, controlIn: false }) } else { const buf = await dev.nativeControlTransferIn(setup, 5000, f.length) if (buf) { const b = Buffer.from(buf) sess.bytesIn += b.length this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, data: b.toString('base64'), status: b.length }) this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, data: b, status: b.length, controlIn: true }) } else { this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: 0 }) } } } catch (e: any) { this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: -1 }) this.emit('sessionError', { sessionId: sess.sessionId, peerId: peer.deviceId, error: `ctrl: ${e?.message || e}` }) } } private async handleBulkReq(sess: ServerSession, peer: DeviceInfo, f: Extract) { try { const dev = sess.usbDev if (f.type === 'bulkOut') { const buf = Buffer.from(f.data, 'base64') const written = await dev.nativeTransferOut(f.endpoint, f.type === 'bulkOut' ? 5000 : 5000, new Uint8Array(buf)) sess.bytesOut += written || 0 this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, status: written || 0 }) this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, status: written || 0, bulkIn: false }) } else { const timeout = f.timeoutMs || 5000 const buf = await dev.nativeTransferIn(f.endpoint, timeout, f.length) if (buf) { const b = Buffer.from(buf) sess.bytesIn += b.length this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, data: b.toString('base64'), status: b.length }) this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, data: b, status: b.length, bulkIn: true }) } else { this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: 0 }) } } } catch (e: any) { this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: -1 }) this.emit('sessionError', { sessionId: sess.sessionId, peerId: peer.deviceId, error: `bulk: ${e?.message || e}` }) } } // ====== 我方作为控制端, 给对端发 control/bulk 请求 ====== async usbCtrlTransfer(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, data?: string): Promise<{ ok: boolean; data?: Buffer; status?: number }> { const sess = this.clientSessions.get(sessionId) if (!sess) return { ok: false, status: -1 } const reqId = randomUUID() if (data) { this.sendControl(sess.peerId, { type: 'ctrlOut', sessionId, reqId, setup, data }) } else { // 没有 data = host 想读; 但读要 length — 暂时用 0 (调用方应该用 ctrlIn) this.sendControl(sess.peerId, { type: 'ctrlOut', sessionId, reqId, setup }) } return new Promise((resolve) => { const timer = setTimeout(() => { resolve({ ok: false, status: -1 }) }, 5000) const onEvt = (p: any) => { if (p.controlIn !== true) return // 注意: 我们 emit usbEvent 是用 (sessionId, peerId, payload) 三参, 这里 listener 形参 1 是 sessionId, 但我们只比较 sessionId // 简化: 一个内部 map } void onEvt // 直接挂在 self: 用 'once' 等一个标记 const handler = (sid: string, _pid: string, payload: any) => { if (sid !== sessionId) return if (payload.status === undefined) return clearTimeout(timer) this.off('usbEvent', handler as any) resolve({ ok: payload.ok, data: payload.data, status: payload.status }) } this.on('usbEvent', handler as any) }) } async usbCtrlIn(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number): Promise<{ ok: boolean; data?: Buffer; status?: number }> { const sess = this.clientSessions.get(sessionId) if (!sess) return { ok: false, status: -1 } const reqId = randomUUID() this.sendControl(sess.peerId, { type: 'ctrlIn', sessionId, reqId, setup, length }) return new Promise((resolve) => { const timer = setTimeout(() => { resolve({ ok: false, status: -1 }) }, 5000) const handler = (sid: string, _pid: string, payload: any) => { if (sid !== sessionId) return clearTimeout(timer) this.off('usbEvent', handler as any) resolve({ ok: payload.ok, data: payload.data, status: payload.status }) } this.on('usbEvent', handler as any) }) } async usbBulkOut(sessionId: string, endpoint: number, data: string): Promise<{ ok: boolean; status?: number }> { const sess = this.clientSessions.get(sessionId) if (!sess) return { ok: false, status: -1 } const reqId = randomUUID() this.sendControl(sess.peerId, { type: 'bulkOut', sessionId, reqId, endpoint, data }) return new Promise((resolve) => { const timer = setTimeout(() => resolve({ ok: false, status: -1 }), 5000) const handler = (sid: string, _pid: string, payload: any) => { if (sid !== sessionId) return clearTimeout(timer) this.off('usbEvent', handler as any) resolve({ ok: payload.ok, status: payload.status }) } this.on('usbEvent', handler as any) }) } async usbBulkIn(sessionId: string, endpoint: number, length: number, timeoutMs = 5000): Promise<{ ok: boolean; data?: Buffer; status?: number }> { const sess = this.clientSessions.get(sessionId) if (!sess) return { ok: false, status: -1 } const reqId = randomUUID() this.sendControl(sess.peerId, { type: 'bulkIn', sessionId, reqId, endpoint, length, timeoutMs }) return new Promise((resolve) => { const timer = setTimeout(() => resolve({ ok: false, status: -1 }), timeoutMs + 1000) const handler = (sid: string, _pid: string, payload: any) => { if (sid !== sessionId) return clearTimeout(timer) this.off('usbEvent', handler as any) resolve({ ok: payload.ok, data: payload.data, status: payload.status }) } this.on('usbEvent', handler as any) }) } // 串口: 发送字节 (client 端) async sendSerialBytes(sessionId: string, buf: Buffer): Promise { const sess = this.clientSessions.get(sessionId) if (!sess || sess.kind !== 'serial') return false sess.bytesOut += buf.length this.sendChunked(sess.peerId, sessionId, 'host->dev', buf, false) return true } // 串口: 在本机创建虚拟串口, 打开内部端做桥接 (self-out + createVirtual) private async setupLocalVirtualSerial(sessionId: string, config: Extract): Promise<{ ok: boolean; reason?: string; userPath?: string }> { if (!spMod) return { ok: false, reason: 'serialport 未安装' } const sess = this.clientSessions.get(sessionId) if (!sess) return { ok: false, reason: 'session 不存在' } // 1. 创建 PTY 配对 / com0com let vcom: VirtualSerialInfo try { vcom = await createVirtualSerialPair({ userPath: config.virtualName }) } catch (e: any) { return { ok: false, reason: `创建虚拟串口失败: ${e?.message || e}` } } // 2. 用我们传的 baudRate 打开内部端 const baudRate = config.baudRate || 115200 let sp: any try { sp = new spMod.SerialPort({ path: vcom.internalPath, baudRate, dataBits: config.dataBits || 8, stopBits: config.stopBits || 1, parity: config.parity || 'none', autoOpen: false, }) await new Promise((resolve, reject) => { sp.open((err: any) => err ? reject(err) : resolve()) }) } catch (e: any) { await vcom.cleanup().catch(() => {}) return { ok: false, reason: `打开内部虚拟端失败: ${e?.message || e}` } } // 3. 数据桥接 // 内部端收到字节 (用户 app 写进来的) → WS host->dev → 远端写真实串口 sp.on('data', (buf: Buffer) => { if (buf.length === 0) return sess.bytesOut += buf.length this.sendChunked(sess.peerId, sessionId, 'host->dev', buf, false) this.emit('output', sessionId, buf, sess.peerId) }) sp.on('close', () => { // 内部端被关 (用户 app 关闭了虚拟串口?) -> 通知远端 this.sendControl(sess.peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true }) this.cleanupClientSession(sessionId, 'virtual-serial-closed-by-user-app') }) sp.on('error', (e: any) => { this.emit('sessionError', { sessionId, peerId: sess.peerId, error: `virtual-serial: ${e.message}` }) }) // 4. 存到 session, detach 时关 sess.localVirtual = { port: sp, userPath: vcom.userPath, cleanup: async () => { try { sp.removeAllListeners() } catch {} try { if (sp.isOpen) await new Promise((r) => sp.close(() => r())) } catch {} await vcom.cleanup().catch(() => {}) }, } recordAudit({ action: 'usb.vcom.create', target: sess.peerId, sessionId, result: 'ok', payload: { userPath: vcom.userPath, internalPath: vcom.internalPath, platform: vcom.platform } }) return { ok: true, userPath: vcom.userPath } } // ====== detach ====== async detach(sessionId: string, reason = 'user'): Promise<{ ok: boolean; reason?: string }> { if (this.clientSessions.has(sessionId)) { const sess = this.clientSessions.get(sessionId)! // usbip 模式: detach 本地 if (sess.kind === 'usbip') { try { const { exec: execSync } = await import('node:child_process') const { promisify } = await import('node:util') const execAsync = promisify(execSync as any) await execAsync('usbip detach --all 2>/dev/null || true').catch(() => {}) } catch {} } // 发 detach 给对端 this.sendControl(sess.peerId, { type: 'detach', sessionId, reason }) this.cleanupClientSession(sessionId, reason) return { ok: true } } if (this.serverSessions.has(sessionId)) { const sess = this.serverSessions.get(sessionId)! this.cleanupServerSession(sessionId, reason) // 通知对端 this.sendControl(sess.peerId, { type: 'detached', sessionId, ok: true, reason }) return { ok: true } } return { ok: false, reason: 'session 不存在' } } cleanupClientSession(sessionId: string, reason = 'user') { const s = this.clientSessions.get(sessionId) if (!s) return // 关掉本机虚拟串口 (如果有) if (s.localVirtual) { s.localVirtual.cleanup().catch(() => {}) } this.clientSessions.delete(sessionId) this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'client' }) recordAudit({ action: 'usb.close', target: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } }) } cleanupServerSession(sessionId: string, reason = 'user') { const s = this.serverSessions.get(sessionId) if (!s) return // 关掉所有 pending 请求 for (const r of s.pendingCtrl.values()) r({ ok: false, status: -1 }) for (const r of s.pendingBulk.values()) r({ ok: false, status: -1 }) s.pendingCtrl.clear(); s.pendingBulk.clear() // 关设备 if (s.serial) { try { s.serial.close() } catch {} } if (s.usbDev) { try { s.usbDev.close() } catch (e: any) { console.warn('[usb] close device:', e?.message) } } this.serverSessions.delete(sessionId) this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'server' }) recordAudit({ action: 'usb.close', source: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } }) } // ====== 维护 ====== listClientSessions() { return Array.from(this.clientSessions.values()).map(s => ({ sessionId: s.sessionId, peerId: s.peerId, direction: s.direction, busId: s.busId, kind: s.kind, info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: s.createdAt, side: 'client' as const, })) } listServerSessions() { return Array.from(this.serverSessions.values()).map(s => ({ sessionId: s.sessionId, peerId: s.peerId, direction: 'self-in' as UsbDirection, busId: s.busId, kind: s.kind, info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: Date.now(), side: 'server' as const, })) } onPeerDisconnected(peerId: string) { for (const [sid, s] of this.clientSessions) { if (s.peerId === peerId) this.cleanupClientSession(sid, 'peer-disconnected') } for (const [sid, s] of this.serverSessions) { if (s.peerId === peerId) this.cleanupServerSession(sid, 'peer-disconnected') } } stop() { for (const sid of this.clientSessions.keys()) this.cleanupClientSession(sid, 'shutdown') for (const sid of this.serverSessions.keys()) this.cleanupServerSession(sid, 'shutdown') } // ====== 工具 ====== private sendControl(peerId: string, payload: UsbFrame) { return this.chatClient.send({ type: 'usb', payload } as WsFrame, peerId) } private sendChunked(peerId: string, sessionId: string, dir: 'host->dev' | 'dev->host', buf: Buffer, fin: boolean) { let remaining = buf while (remaining.length > 0) { const chunk = remaining.subarray(0, MAX_DATA_CHUNK) remaining = remaining.subarray(MAX_DATA_CHUNK) const lastAndFin = fin && remaining.length === 0 this.sendControl(peerId, { type: 'data', sessionId, dir, data: chunk.toString('base64'), fin: lastAndFin }) } if (fin && buf.length === 0) { this.sendControl(peerId, { type: 'data', sessionId, dir, data: '', fin: true }) } } private async shellUsbipAttach(peerLanIp: string, busId: string): Promise<{ ok: boolean; reason?: string }> { if (platform !== 'linux') return { ok: false, reason: 'usbip 仅 Linux 可用' } // 1. 确保 vhci_hcd 加载 const fix = await diagnoseAndFixUsbAttach() if (!fix.ok) return { ok: false, reason: fix.stderr } // 2. 调 `usbip attach -r -b ` (busId 要去掉 "usbip:" 前缀) const realBusId = busId.startsWith('usbip:') ? busId.slice(6) : busId try { const { exec: execSync } = await import('node:child_process') const { promisify } = await import('node:util') const execAsync = promisify(execSync as any) const { stdout, stderr } = await execAsync(`usbip attach -r ${peerLanIp} -b ${realBusId}`) return { ok: true, reason: stdout + (stderr || '') } } catch (e: any) { return { ok: false, reason: `usbip attach 失败: ${e?.message || e}; 输出: ${e?.stdout || ''} ${e?.stderr || ''}` } } } } function pad4(n: number): string { return (n || 0).toString(16).padStart(4, '0').toLowerCase() } 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 模块)` }