feat(remote): serial/USB/USBIP remote forwarding with virtual serial port
This commit is contained in:
@@ -0,0 +1,944 @@
|
||||
// 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 (用 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<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, 设备在我方
|
||||
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<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 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<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 一样的开设备流程
|
||||
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<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 = {
|
||||
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<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()
|
||||
}
|
||||
|
||||
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 模块)`
|
||||
}
|
||||
Reference in New Issue
Block a user