feat(remote): serial/USB/USBIP remote forwarding with virtual serial port

This commit is contained in:
2026-07-17 18:13:55 +08:00
parent 565d044f18
commit d12c74649a
13 changed files with 4491 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
// 跨平台权限辅助: 调 sudo-prompt 提权; 仅在确实需要时 (Linux 串口/HID/USB)
// Windows 的串口/HID 大多数情况不需要 admin; macOS 系统弹窗 OS 接管, 我们不参与.
import { platform } from 'node:process'
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const execAsync = promisify(exec)
// sudo-prompt 是 CJS, 默认导出
// @ts-ignore - 没有 .d.ts
import sudo from 'sudo-prompt'
interface ElevationResult { ok: boolean; stdout?: string; stderr?: string; code?: number }
function runElevated(cmd: string, name = 'LocalNetMsg'): Promise<ElevationResult> {
return new Promise((resolve) => {
try {
sudo.exec(cmd, { name }, (error?: Error | undefined, stdout?: string | Buffer, stderr?: string | Buffer) => {
const out = stdout ? String(stdout) : ''
const errOut = stderr ? String(stderr) : ''
if (error) {
const code = (error as any).code
resolve({ ok: false, stdout: out, stderr: errOut, code })
} else {
resolve({ ok: true, stdout: out, stderr: errOut })
}
})
} catch (e: any) {
resolve({ ok: false, stderr: String(e?.message || e) })
}
})
}
export type PermIssue =
| { kind: 'linux.dialout'; detail: string }
| { kind: 'linux.udev'; detail: string; vendorId?: number; productId?: number }
| { kind: 'windows.driver'; detail: string; vendorId?: number; productId?: number }
| { kind: 'macos.tcc'; detail: string }
export interface DiagnoseResult {
platform: NodeJS.Platform
ok: boolean
issues: PermIssue[]
notes: string[]
}
export async function diagnoseSerial(): Promise<DiagnoseResult> {
const issues: PermIssue[] = []
const notes: string[] = []
let serialCount = 0
try {
// 不在主进程 require serialport (可能没装); 试一下
// eslint-disable-next-line @typescript-eslint/no-require-imports
const spMod = require('serialport')
const ports = await spMod.SerialPort.list()
serialCount = ports.length
if (platform === 'linux' && serialCount === 0) {
issues.push({
kind: 'linux.dialout',
detail: '未列出任何串口. Linux 用户通常需要在 dialout 组才能访问 /dev/ttyUSB* / /dev/ttyACM*',
})
}
} catch (e: any) {
notes.push(`serialport 加载失败: ${e?.message || e}`)
}
return {
platform: platform as NodeJS.Platform,
ok: issues.length === 0,
issues,
notes,
}
}
export async function diagnoseUsb(vendorId?: number, productId?: number): Promise<DiagnoseResult> {
const issues: PermIssue[] = []
const notes: string[] = []
let found = 0
// Linux: 测一下能否读 /sys/bus/usb/devices (普通用户可读, 但创建设备文件需要 udev)
if (platform === 'linux') {
try {
const { stdout } = await execAsync('ls /sys/bus/usb/devices 2>/dev/null | wc -l')
found = parseInt(stdout.trim() || '0', 10)
if (vendorId && productId) {
const id = `${formatHex(vendorId)}:${formatHex(productId)}`
try {
await execAsync(`ls /sys/bus/usb/devices/*/idVendor 2>/dev/null | xargs -I{} sh -c 'cat {} | tr -d "\\n"; echo " {}"' | grep -i "^${id}" || true`)
} catch {}
}
} catch {}
if (found === 0) {
notes.push('无法读取 /sys/bus/usb/devices — 不太常见, 请检查 udev 是否运行')
} else if (vendorId && productId) {
// 设备存在但能否被 node-usb 打开? 试一下
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const usbMod = require('usb')
const device = await usbMod.webusb.findDeviceByIds(vendorId, productId).catch(() => null)
if (!device) {
issues.push({
kind: 'linux.udev',
detail: `未找到设备 ${formatHex(vendorId)}:${formatHex(productId)}. 可能需要 udev 规则 (例如 SUBSYSTEM=="usb", ATTR{idVendor}=="...").`,
vendorId, productId,
})
}
} catch (e: any) {
issues.push({
kind: 'linux.udev',
detail: `USB 设备访问失败: ${e?.message || e}`,
vendorId, productId,
})
}
}
}
if (platform === 'win32') {
notes.push('Windows: USB/HID 通常无需 admin; 若 node-hid 找不到设备, 用 Zadig 安装 WinUSB 驱动 (一次性, 需 admin)')
}
if (platform === 'darwin') {
notes.push('macOS: 首次访问 USB 设备时系统会弹权限对话框, 在 系统设置 → 隐私与安全 中允许')
}
return {
platform: platform as NodeJS.Platform,
ok: issues.length === 0,
issues,
notes,
}
}
function formatHex(v: number) {
return v.toString(16).padStart(4, '0').toLowerCase()
}
// 把用户加入 dialout 组 (Linux); 一次性, 需要登出登入生效
export async function fixLinuxDialout(): Promise<ElevationResult> {
if (platform !== 'linux') {
return { ok: false, stderr: '仅 Linux 需要此修复' }
}
// 1. 检查用户是否已在 dialout
try {
const { stdout } = await execAsync('groups')
if (/\bdialout\b/.test(stdout)) {
return { ok: true, stdout: '已在 dialout 组, 无需操作' }
}
} catch {}
// 2. 提权 usermod
const userCmd = process.env.USER || process.env.LOGNAME || '$USER'
const r = await runElevated(`usermod -aG dialout ${userCmd}`)
if (r.ok) {
return { ...r, stdout: (r.stdout || '') + '\n已加入 dialout 组. 请 登出并重新登录 系统后生效.' }
}
return r
}
// 写 udev 规则 (Linux); 一次写永久生效
export async function fixLinuxUdevRule(vendorId: number, productId: number, mode: 'usb' | 'hid' = 'usb'): Promise<ElevationResult> {
if (platform !== 'linux') {
return { ok: false, stderr: '仅 Linux 需要此修复' }
}
if (!Number.isInteger(vendorId) || !Number.isInteger(productId)) {
return { ok: false, stderr: 'vid/pid 非法' }
}
const vid = formatHex(vendorId)
const pid = formatHex(productId)
// 现有规则文件: /etc/udev/rules.d/99-localnetmsg.rules
const RULE_FILE = '/etc/udev/rules.d/99-localnetmsg.rules'
const modeLine = mode === 'hid'
? `KERNEL=="hidraw*", ATTRS{idVendor}=="${vid}", ATTRS{idProduct}=="${pid}", MODE="0666", TAG+="uaccess"`
: `SUBSYSTEM=="usb", ATTR{idVendor}=="${vid}", ATTR{idProduct}=="${pid}", MODE="0666", TAG+="uaccess"`
// 用临时脚本追加, 再 mv (避免 echo > /etc ... 的转义问题)
const tmpFile = join(tmpdir(), `lnm-udev-${Date.now()}.rules`)
const banner = `\n# LocalNetMsg auto-rule for ${vid}:${pid} (${new Date().toISOString()})\n${modeLine}\n`
writeFileSync(tmpFile, banner)
// 检查是否已存在
let existing = ''
try { existing = readFileSync(RULE_FILE, 'utf8') } catch {}
if (existing.includes(`ATTR{idVendor}=="${vid}"`) && existing.includes(`ATTR{idProduct}=="${pid}"`)) {
return { ok: true, stdout: '规则已存在, 无需再次添加' }
}
// 1. 复制临时文件到 /etc/udev/rules.d/ (需要 root)
const cpCmd = `cat '${tmpFile}' >> '${RULE_FILE}' && udevadm control --reload-rules && udevadm trigger`
const r = await runElevated(cpCmd)
if (!r.ok) return r
return { ok: true, stdout: `已写入 ${RULE_FILE}; 已 reload udev 规则.\n${modeLine}\n请重新插拔设备 或 等待几秒后刷新列表.` }
}
// Windows: 串口 (COM) 一般免 admin; HID/raw USB 偶尔需要装 WinUSB 驱动 (一次性, 由 Zadig CLI 或 wdi-simple 自动装)
// 这里优先尝试自动装; 失败也给一句"自动修失败, 你可以..."的提示, 不留"请手动下载"的死路
export async function fixWindowsHID(vendorId?: number, productId?: number): Promise<ElevationResult> {
// 1. 优先尝试用本机已存在的 Zadig.exe (PATH 或 resources/zadig.exe)
const candidates = [
join(process.resourcesPath || '', 'zadig.exe'),
'C:\\Program Files\\Zadig\\Zadig.exe',
]
let zadigPath: string | null = null
for (const p of candidates) {
try { if (existsSync(p)) { zadigPath = p; break } } catch {}
}
if (!zadigPath) {
// 没有 Zadig: 静默下载到 userData 并运行 (不再让用户手动下)
try {
const dl = await import('node:https')
const dest = join(process.env.APPDATA || process.env.HOME || tmpdir(), 'LocalNetMsg', 'zadig.exe')
zadigPath = await new Promise<string>((resolve, reject) => {
const file = require('node:fs').createWriteStream(dest)
const req = dl.get('https://github.com/pbatard/libwdi/releases/download/v1.5.1/zadig-2.9.exe', (res: any) => {
if (res.statusCode !== 200) { reject(new Error(`下载失败: ${res.statusCode}`)); return }
res.pipe(file)
file.on('finish', () => file.close(() => resolve(dest)))
})
req.on('error', reject)
})
} catch (e: any) {
return { ok: false, stderr: `自动获取驱动安装器失败: ${e?.message || e}. 设备可能仍可用 — 内置 HID 类驱动对常见设备已足够.` }
}
}
// 2. 用 Zadig CLI 安装 WinUSB (需要 admin)
const cmd = vendorId && productId
? `"${zadigPath}" --mode=install --vid=${vendorId} --pid=${productId} --driver=winusb`
: `"${zadigPath}" --mode=install --driver=winusb`
const r = await runElevated(cmd)
if (r.ok) return { ...r, stdout: (r.stdout || '') + '\nWinUSB 驱动已安装, 请重新插拔设备.' }
return { ...r, stderr: (r.stderr || '') + '\n自动装驱动失败 — 内置 HID 类驱动对常见键鼠手柄已足够, 可直接重试.' }
}
// 诊断 + 一键修复 串口 权限
export async function diagnoseAndFixSerial(): Promise<{ diagnose: DiagnoseResult; fix?: ElevationResult }> {
const d = await diagnoseSerial()
let fix: ElevationResult | undefined
if (d.issues.some(i => i.kind === 'linux.dialout')) {
fix = await fixLinuxDialout()
}
return { diagnose: d, fix }
}
export async function diagnoseAndFixUsb(vendorId?: number, productId?: number): Promise<{ diagnose: DiagnoseResult; fix?: ElevationResult }> {
const d = await diagnoseUsb(vendorId, productId)
let fix: ElevationResult | undefined
const issue = d.issues.find(i => i.kind === 'linux.udev')
if (issue && issue.kind === 'linux.udev' && vendorId && productId) {
fix = await fixLinuxUdevRule(vendorId, productId, 'usb')
} else if (d.issues.some(i => i.kind === 'windows.driver')) {
fix = await fixWindowsHID(vendorId, productId)
}
return { diagnose: d, fix }
}
// ====== USB/IP 真透传 (Linux only) ======
//
// 老实说: USB/IP 是 Linux kernel 自带的 USB-over-IP, 真透明 (设备出现在本机 lsusb).
// 我们的应用只是封装: 列设备 → 调 `usbip attach -r <peer> -b <busid>` 让内核接管.
// 用户机器需要先装 `usbip` 包 + 加载 vhci_hcd 模块. 一次性.
// Linux: 检查 + 装 usbip + 加载内核模块 (vhci_hcd, usbip_core)
export async function diagnoseAndFixUsbAttach(): Promise<ElevationResult> {
if (platform !== 'linux') return { ok: true, stdout: '非 Linux 不支持 USB/IP' }
// 检查 usbip 命令
try {
const { stdout } = await execAsync('which usbip || true')
if (!stdout.trim()) {
const install = await runElevated('sh -c "if command -v apt-get >/dev/null 2>&1; then apt-get update && apt-get install -y usbip; elif command -v dnf >/dev/null 2>&1; then dnf install -y usbip; elif command -v yum >/dev/null 2>&1; then yum install -y usbip; else echo NO_PKG_MGR; fi"')
if (!install.ok) return { ok: false, stderr: '未找到 usbip 且自动安装失败: ' + install.stderr }
}
} catch {}
// 检查 /dev/vhci_hcd 存在 (vhci_hcd 模块加载标志)
try {
await execAsync('test -e /dev/vhci_hcd')
} catch {
const m1 = await runElevated('modprobe usbip_core')
const m2 = await runElevated('modprobe vhci_hcd')
if (!m2.ok) return { ok: false, stderr: '加载 vhci_hcd 模块失败: ' + m2.stderr }
await runElevated('sh -c "echo usbip_core > /etc/modules-load.d/usbip.conf && echo vhci_hcd >> /etc/modules-load.d/usbip.conf"').catch(() => {})
}
return { ok: true, stdout: 'usbip + vhci_hcd 已就绪' }
}
+121
View File
@@ -0,0 +1,121 @@
// 远程操作授权闸门: 决定一个对端能不能开 terminal/forward/usb
// 规则:
// 1. settings.remoteEnabled === false -> 全部拒绝
// 2. settings.remoteAllowPeers[peerId][kind] === true -> 自动放行
// 3. 否则弹窗 (主进程打开一个 BrowserWindow 询问); 用户可勾选 "记住此对端"
// 4. 同一对端 + 同一 kind 24h 内只弹一次 (除非显式 reset)
import { BrowserWindow } from 'electron'
import { getSettings } from '../settings'
export type RemoteKind = 'terminal' | 'forward' | 'usb'
interface Pending {
resolve: (ok: boolean, remember?: boolean) => void
timer: NodeJS.Timeout
}
const pending = new Map<string, Pending>()
const approvedAt = new Map<string, number>()
const APPROVE_TTL_MS = 24 * 3600_000
function cacheKey(peerId: string, kind: RemoteKind, nonce?: string) {
// nonce 用于 usb.attach 这种需要每次重新确认的场景
return nonce ? `${peerId}::${kind}::${nonce}` : `${peerId}::${kind}`
}
export function isAutoAllowed(peerId: string, kind: RemoteKind): boolean {
const s = getSettings()
if (!s.remoteEnabled) return false
const allow = s.remoteAllowPeers?.[peerId]?.[kind]
return !!allow
}
export function isRemembered(peerId: string, kind: RemoteKind): boolean {
const k = cacheKey(peerId, kind)
const ts = approvedAt.get(k)
if (!ts) return false
if (Date.now() - ts > APPROVE_TTL_MS) {
approvedAt.delete(k)
return false
}
return true
}
export function markApproved(peerId: string, kind: RemoteKind) {
approvedAt.set(cacheKey(peerId, kind), Date.now())
}
export function resetApproval(peerId: string, kind?: RemoteKind) {
for (const k of approvedAt.keys()) {
if (k.startsWith(`${peerId}::`)) {
if (!kind || k.startsWith(`${peerId}::${kind}`)) approvedAt.delete(k)
}
}
}
// 同步决策: 自动允许 / 缓存允许 -> 直接 true; 否则 false (UI 需要主动 ask)
export function shouldAsk(peerId: string, kind: RemoteKind): boolean {
if (isAutoAllowed(peerId, kind)) return false
if (isRemembered(peerId, kind)) {
markApproved(peerId, kind)
return false
}
return true
}
const ASK_TIMEOUT_MS = 30_000
// 让 UI 弹一个授权对话框; resolve(true) 同意, resolve(false) 拒绝/超时
// 这里用 BrowserWindow 直接构造, 渲染层可以通过 IPC 拿到队列并显示
export interface ApprovalRequest {
requestId: string
peerId: string
peerName: string
kind: RemoteKind
detail?: string
ts: number
}
const waitingQueue: ApprovalRequest[] = []
const waitersByReq = new Map<string, Pending>()
export function enqueueApproval(req: ApprovalRequest): Promise<{ ok: boolean; remember?: boolean }> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
pending.delete(req.requestId)
waitersByReq.delete(req.requestId)
// 从队列移除
const i = waitingQueue.findIndex(x => x.requestId === req.requestId)
if (i >= 0) waitingQueue.splice(i, 1)
resolve({ ok: false })
}, ASK_TIMEOUT_MS)
pending.set(req.requestId, { resolve: (ok, remember) => resolve({ ok, remember }), timer })
waitersByReq.set(req.requestId, pending.get(req.requestId)!)
waitingQueue.push(req)
})
}
export function listPendingApprovals(): ApprovalRequest[] {
return [...waitingQueue]
}
export function replyApproval(requestId: string, ok: boolean, remember?: boolean): boolean {
const p = pending.get(requestId)
if (!p) return false
clearTimeout(p.timer)
pending.delete(requestId)
waitersByReq.delete(requestId)
const i = waitingQueue.findIndex(x => x.requestId === requestId)
if (i >= 0) waitingQueue.splice(i, 1)
p.resolve(ok, remember)
return true
}
export function focusMain() {
const w = BrowserWindow.getAllWindows()[0]
if (!w) return
if (w.isMinimized()) w.restore()
w.show()
w.focus()
}
+565
View File
@@ -0,0 +1,565 @@
// 端口转发: 双向支持
// self-out (默认): 控制端开 127.0.0.1 TCP server, 被控端连自己的目标 — 把对方服务代理到本地
// self-in: 被控端开 127.0.0.1 TCP server, 控制端连自己的目标 — 把本地服务暴露到对方
// 单流: 同时只支持一个 TCP 连接 (MVP, 多流需要扩展协议加 streamId)
import { EventEmitter } from 'node:events'
import { randomUUID } from 'node:crypto'
import { createServer, createConnection, Server, Socket } from 'node:net'
import type { DeviceInfo, ForwardFrame, WsFrame, ForwardDirection } from '../protocol'
import type { ChatClient } from '../chat-client'
import { getSettings } from '../settings'
import { shouldAsk, markApproved, enqueueApproval, resetApproval } from './approval'
import { recordAudit } from '../db'
type OpenFrame = Extract<ForwardFrame, { type: 'open' }>
type DataFrame = Extract<ForwardFrame, { type: 'data' }>
type CloseF = Extract<ForwardFrame, { type: 'close' }>
type AckFrame = Extract<ForwardFrame, { type: 'ack' }>
// 仅防本地端口转发到自己三端口 (死循环 / 桥接冲突), 其他一概不拦
const SELF_PORTS = new Set([47800, 47900, 47901])
const MAX_FRAME_BYTES = 64 * 1024
interface ClientSession {
sessionId: string
peerId: string
direction: ForwardDirection
listenPort?: number // 自-out: 控制端 listenPort; 自-in: 对方 listenPort
targetHost: string
targetPort: number
// TCP server (自-out): 我方 127.0.0.1
// TCP client (自-in): 我方 → 我方 target
server?: Server
client?: Socket
activeStream?: Socket
expiresAt: number
bytesIn: number
bytesOut: number
speed?: { tokens: number; last: number }
}
interface ServerSession {
sessionId: string
ownerId: string
peerId: string
direction: ForwardDirection
listenPort?: number // 自-in: 对方 listenPort (我方 bind); 自-out: 我方 listenPort (我方 bind? no, 自-out peer bind)
targetHost: string
targetPort: number
// TCP server (自-in): 我方 127.0.0.1
// TCP client (自-out): 我方 → 我方 target
server?: Server
client?: Socket
activeStream?: Socket
expiresAt: number
bytesIn: number
bytesOut: number
}
export interface ForwardManagerEvents {
approvalRequested: (req: { peerId: string; peerName: string; kind: 'forward'; detail: string; requestId: string }) => void
sessionOpened: (info: { sessionId: string; peerId: string; direction: ForwardDirection; listenPort?: number; targetHost: string; targetPort: number; side: 'client' | 'server' }) => void
sessionClosed: (info: { sessionId: string; peerId: string; direction: ForwardDirection; reason?: string; bytesIn: number; bytesOut: number; side: 'client' | 'server' }) => void
sessionError: (info: { sessionId: string; peerId: string; error: string }) => void
}
export class ForwardManager extends EventEmitter {
private clientSessions = new Map<string, ClientSession>()
private serverSessions = new Map<string, ServerSession>()
private chatClient: ChatClient
constructor(chatClient: ChatClient) {
super()
this.chatClient = chatClient
}
// ===== 控制端 =====
async openOnPeer(peerId: string, opts: {
direction?: ForwardDirection,
listenPort: number,
targetHost: string,
targetPort: number,
ttlSec?: number,
}): Promise<{ ok: boolean; reason?: string; sessionId?: string }> {
const direction: ForwardDirection = opts.direction || 'self-out'
const s = getSettings()
if (!s.remoteEnabled) return { ok: false, reason: '远程功能已关闭' }
if (!Number.isInteger(opts.listenPort) || opts.listenPort < 1 || opts.listenPort > 65535) {
return { ok: false, reason: '监听端口非法' }
}
if (!Number.isInteger(opts.targetPort) || opts.targetPort < 1 || opts.targetPort > 65535) {
return { ok: false, reason: '目标端口非法' }
}
// 两端都不能指向自身端口
if (SELF_PORTS.has(opts.listenPort)) {
return { ok: false, reason: `监听端口 ${opts.listenPort} 是本机占用, 不能转发` }
}
if (SELF_PORTS.has(opts.targetPort)) {
return { ok: false, reason: `目标端口 ${opts.targetPort} 是本机占用, 不能转发` }
}
const ttl = Math.min(Math.max(opts.ttlSec ?? s.forwardDefaultTtlSec, 30), 24 * 3600)
const sessionId = randomUUID()
if (direction === 'self-out') {
// 控制端 = server (bind 127.0.0.1:listenPort); 对方 = client (连自己的 targetHost:targetPort)
const session: ClientSession = {
sessionId,
peerId,
direction,
listenPort: opts.listenPort,
targetHost: opts.targetHost,
targetPort: opts.targetPort,
expiresAt: Date.now() + ttl * 1000,
bytesIn: 0, bytesOut: 0,
}
this.clientSessions.set(sessionId, session)
try {
session.server = await this.bindLocalServer(sessionId, opts.listenPort)
} catch (e: any) {
this.clientSessions.delete(sessionId)
recordAudit({ action: 'forward.open', target: peerId, sessionId, result: 'error', payload: { phase: 'local-bind', error: String(e?.message || e) } })
return { ok: false, reason: `本地监听失败: ${e?.message || e}` }
}
const frame: OpenFrame = { type: 'open', sessionId, direction, listenPort: opts.listenPort, targetHost: opts.targetHost, targetPort: opts.targetPort, ttlSec: ttl }
if (!this.chatClient.send({ type: 'forward', payload: frame } as WsFrame, peerId)) {
try { session.server.close() } catch {}
this.clientSessions.delete(sessionId)
return { ok: false, reason: '对方离线' }
}
} else {
// self-in: 控制端 = client (连自己的 targetHost:targetPort); 对方 = server (bind)
const session: ClientSession = {
sessionId,
peerId,
direction,
listenPort: opts.listenPort, // 对方 listenPort
targetHost: opts.targetHost,
targetPort: opts.targetPort,
expiresAt: Date.now() + ttl * 1000,
bytesIn: 0, bytesOut: 0,
}
this.clientSessions.set(sessionId, session)
const frame: OpenFrame = { type: 'open', sessionId, direction, listenPort: opts.listenPort, targetHost: opts.targetHost, targetPort: opts.targetPort, ttlSec: ttl }
if (!this.chatClient.send({ type: 'forward', payload: frame } as WsFrame, peerId)) {
this.clientSessions.delete(sessionId)
return { ok: false, reason: '对方离线' }
}
}
// 等对方 ack
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (this.clientSessions.has(sessionId)) {
this.cleanupClientSession(sessionId, '对方 30s 内未响应')
resolve({ ok: false, reason: '对方 30s 内未响应' })
}
}, 30_000)
const onAck = (ok: boolean, reason?: string) => {
clearTimeout(timer)
this.off(`ack:${sessionId}`, onAck as any)
if (!ok) {
this.cleanupClientSession(sessionId, reason || '对方拒绝')
resolve({ ok: false, reason: reason || '对方拒绝' })
return
}
const sess = this.clientSessions.get(sessionId)
if (sess && sess.direction === 'self-in') {
// self-in: 我方主动连自己的 target
try {
sess.client = this.connectLocalClient(sessionId, sess.peerId, sess.targetHost, sess.targetPort)
} catch (e: any) {
this.cleanupClientSession(sessionId, `本地连接目标失败: ${e?.message || e}`)
resolve({ ok: false, reason: `本地连接目标失败: ${e?.message || e}` })
return
}
}
setTimeout(() => this.cleanupClientSession(sessionId, 'ttl-expired'), ttl * 1000).unref()
resolve({ ok: true, sessionId })
}
this.on(`ack:${sessionId}`, onAck as any)
})
}
private bindLocalServer(sessionId: string, port: number): Promise<Server> {
return new Promise((resolve, reject) => {
const server = createServer((sock: Socket) => {
const sess = this.clientSessions.get(sessionId)
if (!sess) { sock.destroy(); return }
// 单流: 同一 session 只允许一个活跃连接
if (sess.activeStream) {
try { sock.write('Forward: 抢占中, 新连接已替换\r\n') } catch {}
try { sock.destroy() } catch {}
return
}
sess.activeStream = sock
console.log(`[forward] client connected to 127.0.0.1:${port} (self-out session=${sessionId.slice(0, 8)})`)
sock.on('data', (buf: Buffer) => {
if (buf.length === 0) return
sess.bytesIn += buf.length
if (!this.applyRateLimit(sess, buf.length)) return
// 字节: 本地客户端 → 控制端 → WS (c2s) → 对方 client → 对方目标
this.sendChunked(sess.peerId, sessionId, 'c2s', buf, false)
})
sock.on('close', () => {
if (sess.activeStream === sock) sess.activeStream = undefined
console.log(`[forward] client disconnected (session=${sessionId.slice(0, 8)})`)
// 通知对方: c2s 半关
const f: ForwardFrame = { type: 'data', sessionId, dir: 'c2s', data: '', fin: true }
this.chatClient.send({ type: 'forward', payload: f } as WsFrame, sess.peerId)
})
sock.on('error', () => { try { sock.destroy() } catch {} })
})
server.on('error', (e) => { try { server.close() } catch {} ; reject(e) })
server.listen(port, '127.0.0.1', () => resolve(server))
})
}
// self-in: 我方 (控制端) 连自己的 targetHost:targetPort
private connectLocalClient(sessionId: string, peerId: string, host: string, port: number): Socket {
const sess = this.clientSessions.get(sessionId)
if (!sess) throw new Error('session not found')
const sock = createConnection({ host, port })
console.log(`[forward] self-in: control connecting to ${host}:${port} (session=${sessionId.slice(0, 8)})`)
sock.on('connect', () => {
sess.activeStream = sock
console.log(`[forward] self-in: control connected to ${host}:${port}`)
})
sock.on('data', (buf: Buffer) => {
if (buf.length === 0) return
sess.bytesIn += buf.length // bytesIn 此时含义: 控制端 TCP client 收到的字节
if (!this.applyRateLimit(sess, buf.length)) return
// self-in: 控制端 TCP client (本地 target) → WS (s2c) → 对方 server → 对方 client (访问 127.0.0.1:listenPort 的 app)
this.sendChunked(peerId, sessionId, 's2c', buf, false)
})
sock.on('close', () => {
if (sess.activeStream === sock) sess.activeStream = undefined
console.log(`[forward] self-in: control local client closed`)
const f: ForwardFrame = { type: 'data', sessionId, dir: 's2c', data: '', fin: true }
this.chatClient.send({ type: 'forward', payload: f } as WsFrame, peerId)
this.cleanupClientSession(sessionId, 'local-target-closed')
})
sock.on('error', (e) => {
console.warn(`[forward] self-in local client error: ${e.message}`)
this.emit('sessionError', { sessionId, peerId, error: e.message })
try { sock.destroy() } catch {}
})
return sock
}
private sendChunked(peerId: string, sessionId: string, dir: 'c2s' | 's2c', buf: Buffer, fin: boolean) {
let remaining = buf
while (remaining.length > 0) {
const chunk = remaining.subarray(0, MAX_FRAME_BYTES)
remaining = remaining.subarray(MAX_FRAME_BYTES)
const lastAndFin = fin && remaining.length === 0
const f: ForwardFrame = { type: 'data', sessionId, dir, data: chunk.toString('base64'), fin: lastAndFin }
this.chatClient.send({ type: 'forward', payload: f } as WsFrame, peerId)
}
if (fin && buf.length === 0) {
const f: ForwardFrame = { type: 'data', sessionId, dir, data: '', fin: true }
this.chatClient.send({ type: 'forward', payload: f } as WsFrame, peerId)
}
}
private applyRateLimit(sess: ClientSession, bytes: number): boolean {
const s = getSettings()
if (!s.forwardMaxBytesPerSec || s.forwardMaxBytesPerSec <= 0) return true
if (!sess.speed) sess.speed = { tokens: s.forwardMaxBytesPerSec, last: Date.now() }
const now = Date.now()
const dt = (now - sess.speed.last) / 1000
sess.speed.tokens = Math.min(s.forwardMaxBytesPerSec, sess.speed.tokens + dt * s.forwardMaxBytesPerSec)
sess.speed.last = now
if (sess.speed.tokens < bytes) return false
sess.speed.tokens -= bytes
return true
}
cleanupClientSession(sessionId: string, reason = 'user') {
const s = this.clientSessions.get(sessionId)
if (!s) return
try { s.server?.close() } catch {}
try { s.client?.destroy() } catch {}
try { s.activeStream?.destroy() } catch {}
this.clientSessions.delete(sessionId)
// 通知对方
const f: CloseF = { type: 'close', sessionId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut }
this.chatClient.send({ type: 'forward', payload: f } as WsFrame, s.peerId)
this.emit('sessionClosed', { sessionId, peerId: s.peerId, direction: s.direction, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'client' })
recordAudit({ action: 'forward.close', target: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } })
}
// UI 列出 (控制端) 全部 session: 包括 self-out (我 server) 和 self-in (我 client)
listClientSessions() {
return Array.from(this.clientSessions.values()).map(s => ({
sessionId: s.sessionId,
peerId: s.peerId,
direction: s.direction,
listenPort: s.listenPort,
targetHost: s.targetHost,
targetPort: s.targetPort,
bytesIn: s.bytesIn,
bytesOut: s.bytesOut,
expiresAt: s.expiresAt,
side: 'client' as const,
}))
}
// UI 列出 (被控端) 全部 session: self-out (我 client 连自己 target) 和 self-in (我 server bind)
listServerSessions() {
return Array.from(this.serverSessions.values()).map(s => ({
sessionId: s.sessionId,
peerId: s.peerId,
direction: s.direction,
listenPort: s.listenPort,
targetHost: s.targetHost,
targetPort: s.targetPort,
bytesIn: s.bytesIn,
bytesOut: s.bytesOut,
expiresAt: s.expiresAt,
side: 'server' as const,
}))
}
// ===== 被控端 / 控制端 共用: 根据 sessionId 落点分发 =====
handleIncoming(peer: DeviceInfo, f: ForwardFrame) {
switch (f.type) {
case 'open':
// open 永远是被控端收到
return this.handleOpen(peer, f)
case 'data': {
// 数据帧: 哪个 side 持有 sessionId 就交给哪个 side 处理
if (this.serverSessions.has(f.sessionId)) return this.handleServerData(peer, f)
if (this.clientSessions.has(f.sessionId)) return this.handleClientData(peer, f)
// 未知 session, 忽略 (可能已经关闭)
return
}
case 'close': {
// close 两边都可能收到 (任意一方主动关闭)
if (this.serverSessions.has(f.sessionId)) {
this.cleanupServerSession(f.sessionId, f.reason || 'closed-by-peer')
return
}
if (this.clientSessions.has(f.sessionId)) {
this.cleanupClientSession(f.sessionId, f.reason || 'closed-by-peer')
return
}
return
}
case 'ack':
this.emit(`ack:${f.sessionId}`, f.ok, f.reason)
return
}
}
// 控制端 (clientSessions) 收到的 data 帧 → 写到 activeStream (TCP server 已 accept 的连接 或 self-in 下的 TCP client)
private handleClientData(_peer: DeviceInfo, f: DataFrame) {
const sess = this.clientSessions.get(f.sessionId)
if (!sess || !sess.activeStream) return
if (f.fin) {
try { sess.activeStream.end() } catch {}
return
}
try {
const buf = Buffer.from(f.data, 'base64')
sess.bytesOut += buf.length
if (!sess.activeStream.destroyed) sess.activeStream.write(buf)
} catch (e: any) {
console.warn('[forward] client-side write failed:', e?.message)
}
}
private async handleOpen(peer: DeviceInfo, f: OpenFrame) {
const s = getSettings()
if (!s.remoteEnabled) {
this.sendAck(peer.deviceId, f.sessionId, false, '对方关闭了远程功能')
recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'denied', payload: { reason: 'remote-disabled' } })
return
}
if (SELF_PORTS.has(f.listenPort) || SELF_PORTS.has(f.targetPort)) {
this.sendAck(peer.deviceId, f.sessionId, false, '端口指向本机自身端口')
recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'denied', payload: { reason: 'self-port' } })
return
}
if (shouldAsk(peer.deviceId, 'forward')) {
const detail = f.direction === 'self-in'
? `对方请求把自己的 ${f.targetHost}:${f.targetPort} 暴露到我方 127.0.0.1:${f.listenPort}`
: `对方请求访问我方 ${f.targetHost}:${f.targetPort} (经由对方 127.0.0.1:${f.listenPort})`
const reqId = randomUUID()
const reqPromise = enqueueApproval({
requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'forward', detail, ts: Date.now(),
})
this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'forward', detail })
const verdict = await reqPromise
if (!verdict.ok) {
this.sendAck(peer.deviceId, f.sessionId, false, '用户拒绝')
recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'denied' })
return
}
if (verdict.remember) markApproved(peer.deviceId, 'forward')
}
const session: ServerSession = {
sessionId: f.sessionId,
ownerId: peer.deviceId,
peerId: peer.deviceId,
direction: f.direction,
listenPort: f.listenPort,
targetHost: f.targetHost,
targetPort: f.targetPort,
expiresAt: Date.now() + f.ttlSec * 1000,
bytesIn: 0, bytesOut: 0,
}
if (f.direction === 'self-out') {
// self-out: 我方 = client (连自己的 targetHost:targetPort)
const sock = createConnection({ host: f.targetHost, port: f.targetPort })
sock.on('connect', () => {
session.client = sock
session.activeStream = sock
this.serverSessions.set(f.sessionId, session)
this.sendAck(peer.deviceId, f.sessionId, true)
this.emit('sessionOpened', { sessionId: f.sessionId, peerId: peer.deviceId, direction: f.direction, listenPort: f.listenPort, targetHost: f.targetHost, targetPort: f.targetPort, side: 'server' })
setTimeout(() => this.cleanupServerSession(f.sessionId, 'ttl-expired'), f.ttlSec * 1000).unref()
recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'ok', payload: { direction: f.direction, targetHost: f.targetHost, targetPort: f.targetPort } })
})
sock.on('data', (buf: Buffer) => {
if (buf.length === 0) return
session.bytesOut += buf.length
// self-out: 我方 (server side) client 收到目标 → s2c → 控制端 → 控制端 server → 控制端 app
this.sendChunked(peer.deviceId, f.sessionId, 's2c', buf, false)
})
const onEnd = (reason: string) => { try { sock.destroy() } catch {} ; this.cleanupServerSession(f.sessionId, reason) }
sock.on('close', () => onEnd('target-closed'))
sock.on('error', (e: Error) => {
this.sendAck(peer.deviceId, f.sessionId, false, `连目标失败: ${e.message}`)
this.emit('sessionError', { sessionId: f.sessionId, peerId: peer.deviceId, error: e.message })
recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { error: e.message } })
onEnd('target-error')
})
} else {
// self-in: 我方 = server (bind 127.0.0.1:listenPort)
try {
const server = await this.bindServerSideServer(session, peer)
session.server = server
this.serverSessions.set(f.sessionId, session)
this.sendAck(peer.deviceId, f.sessionId, true)
this.emit('sessionOpened', { sessionId: f.sessionId, peerId: peer.deviceId, direction: f.direction, listenPort: f.listenPort, targetHost: f.targetHost, targetPort: f.targetPort, side: 'server' })
setTimeout(() => this.cleanupServerSession(f.sessionId, 'ttl-expired'), f.ttlSec * 1000).unref()
recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'ok', payload: { direction: f.direction, listenPort: f.listenPort } })
} catch (e: any) {
this.sendAck(peer.deviceId, f.sessionId, false, `本地监听失败: ${e?.message || e}`)
recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { phase: 'local-bind', error: String(e?.message || e) } })
}
}
}
private bindServerSideServer(session: ServerSession, peer: DeviceInfo): Promise<Server> {
return new Promise((resolve, reject) => {
const port = session.listenPort!
const server = createServer((sock: Socket) => {
if (session.activeStream) {
try { sock.write('Forward: 抢占中, 新连接已替换\r\n') } catch {}
try { sock.destroy() } catch {}
return
}
session.activeStream = sock
console.log(`[forward] self-in: peer-side app connected to 127.0.0.1:${port}`)
sock.on('data', (buf: Buffer) => {
if (buf.length === 0) return
session.bytesIn += buf.length
// 对方 app → 我方 server → WS (c2s) → 控制端 client → 控制端目标
this.sendChunked(peer.deviceId, session.sessionId, 'c2s', buf, false)
})
sock.on('close', () => {
if (session.activeStream === sock) session.activeStream = undefined
const f: ForwardFrame = { type: 'data', sessionId: session.sessionId, dir: 'c2s', data: '', fin: true }
this.chatClient.send({ type: 'forward', payload: f } as WsFrame, peer.deviceId)
})
sock.on('error', () => { try { sock.destroy() } catch {} })
})
server.on('error', (e) => { try { server.close() } catch {} ; reject(e) })
server.listen(port, '127.0.0.1', () => resolve(server))
})
}
private handleServerData(_peer: DeviceInfo, f: DataFrame) {
const sess = this.serverSessions.get(f.sessionId)
if (!sess) return
if (f.fin) {
// 半关信号: 关掉我方相关的 socket
if (sess.direction === 'self-out' && sess.client) { try { sess.client.end() } catch {} }
if (sess.direction === 'self-in' && sess.activeStream) { try { sess.activeStream.end() } catch {} }
return
}
try {
const buf = Buffer.from(f.data, 'base64')
sess.bytesIn += buf.length
if (sess.direction === 'self-out') {
// self-out: 我方是 client → 写给自己的目标
if (sess.client) sess.client.write(buf)
} else {
// self-in: 我方是 server → 写给访问我方 127.0.0.1:listenPort 的 app
if (sess.activeStream) sess.activeStream.write(buf)
}
} catch (e: any) {
console.warn('[forward] write failed:', e?.message)
}
}
cleanupServerSession(sessionId: string, reason: string) {
const sess = this.serverSessions.get(sessionId)
if (!sess) return
try { sess.client?.destroy() } catch {}
try { sess.server?.close() } catch {}
try { sess.activeStream?.destroy() } catch {}
this.serverSessions.delete(sessionId)
this.emit('sessionClosed', { sessionId, peerId: sess.peerId, direction: sess.direction, reason, bytesIn: sess.bytesIn, bytesOut: sess.bytesOut, side: 'server' })
recordAudit({ action: 'forward.close', source: sess.peerId, sessionId, result: 'closed', bytesIn: sess.bytesIn, bytesOut: sess.bytesOut })
}
private sendAck(peerId: string, sessionId: string, ok: boolean, reason?: string) {
const f: AckFrame = { type: 'ack', sessionId, ok, reason }
this.chatClient.send({ type: 'forward', payload: f } as WsFrame, peerId)
}
// ===== 控制端收到对端发来的 data 帧 (s2c) → 写给本地 stream =====
handleIncomingFromClient(_peerId: string, sessionId: string, dir: 's2c', data: string, fin?: boolean) {
const sess = this.clientSessions.get(sessionId)
if (!sess) return
if (!sess.activeStream) return
if (fin) {
try { sess.activeStream.end() } catch {}
return
}
try {
const buf = Buffer.from(data, 'base64')
sess.bytesOut += buf.length
sess.activeStream.write(buf)
} catch (e: any) {
console.warn('[forward] write local failed:', e?.message)
}
}
// ===== 维护 =====
onPeerDisconnected(peerId: string) {
for (const [sid, sess] of this.clientSessions) {
if (sess.peerId === peerId) this.cleanupClientSession(sid, 'peer-disconnected')
}
for (const [sid, sess] of this.serverSessions) {
if (sess.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')
}
resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'usb') {
resetApproval(peerId, kind)
}
}
+54
View File
@@ -0,0 +1,54 @@
// 跨平台默认 shell 选择
import { platform } from 'node:process'
import { execSync } from 'node:child_process'
export interface ShellSpec {
file: string
args: string[]
label: string
}
function which(bin: string): string | null {
try {
const cmd = platform === 'win32' ? `where ${bin}` : `command -v ${bin}`
const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim().split(/\r?\n/)[0]
return out || null
} catch {
return null
}
}
export function defaultShell(): ShellSpec {
switch (platform) {
case 'win32': {
// 优先 PowerShell 7 (pwsh), 否则 Windows PowerShell 5 (powershell)
const pwsh = which('pwsh.exe') || which('pwsh')
if (pwsh) return { file: pwsh, args: ['-NoLogo'], label: 'PowerShell 7' }
const ps5 = which('powershell.exe') || which('powershell')
if (ps5) return { file: ps5, args: ['-NoLogo'], label: 'Windows PowerShell' }
return { file: 'powershell.exe', args: ['-NoLogo'], label: 'PowerShell' }
}
case 'darwin':
case 'linux': {
const envShell = process.env.SHELL
if (envShell) {
const name = envShell.split('/').pop() || envShell
return { file: envShell, args: [], label: name }
}
if (platform === 'darwin') {
const zsh = which('zsh')
if (zsh) return { file: zsh, args: [], label: 'zsh' }
const bash = which('bash')
if (bash) return { file: bash, args: ['-l'], label: 'bash' }
} else {
const bash = which('bash')
if (bash) return { file: bash, args: ['-l'], label: 'bash' }
const sh = which('sh')
if (sh) return { file: sh, args: [], label: 'sh' }
}
return { file: '/bin/sh', args: [], label: 'sh' }
}
default:
return { file: '/bin/sh', args: [], label: 'sh' }
}
}
+315
View File
@@ -0,0 +1,315 @@
// 远程终端: 控制端发起 open, 被控端 spawn 本地 PTY, 双向字节流 (base64) 走现有 WS
import { EventEmitter } from 'node:events'
import { randomUUID } from 'node:crypto'
// @ts-ignore - @lydell/node-pty 没有自带 .d.ts, 这里只使用其运行时 API
import nodePty from '@lydell/node-pty'
import type { DeviceInfo, TerminalFrame, WsFrame } from '../protocol'
import type { ChatClient } from '../chat-client'
import { defaultShell } from './shell'
import { shouldAsk, markApproved, enqueueApproval, resetApproval } from './approval'
import { recordAudit } from '../db'
// @lydell/node-pty 1.x 导出: const pty = require('@lydell/node-pty'); pty.spawn(...)
const pty: { spawn: (file: string, args: string[] | string, opts?: any) => any } = (nodePty as any).default ?? nodePty
export interface TerminalManagerEvents {
// UI 收到这些事件做弹窗 / 显示状态
approvalRequested: (req: { peerId: string; peerName: string; kind: 'terminal'; detail: string; requestId: string }) => void
sessionOpened: (sessionId: string, peerId: string, info: { shell: string; rows: number; cols: number }) => void
sessionClosed: (sessionId: string, peerId: string, reason?: string) => void
output: (sessionId: string, bytes: Buffer, peerId: string) => void
}
interface Session {
sessionId: string
ownerId: string // 谁开的 (控制端 deviceId)
peerId: string // 被控端 deviceId (= self 本机, 当本机是 server)
proc: any // IPty from @lydell/node-pty
readOnly: boolean
shell: string
cols: number
rows: number
createdAt: number
}
type OpenFrame = Extract<TerminalFrame, { type: 'open' }>
type InputFrame = Extract<TerminalFrame, { type: 'input' }>
type ResizeFrame = Extract<TerminalFrame, { type: 'resize' }>
type CloseFrame = Extract<TerminalFrame, { type: 'close' }>
type AckFrame = Extract<TerminalFrame, { type: 'ack' }>
export class TerminalManager extends EventEmitter {
// 作为被控端 (server): 接收对端的 open
private sessions = new Map<string, Session>()
// 作为控制端 (client): 自己发起的 session
private outgoing = new Map<string, { peerId: string; createdAt: number; rows: number; cols: number; shell: string; readOnly: boolean }>()
private chatClient: ChatClient
private selfId: string
constructor(chatClient: ChatClient, selfId: string) {
super()
this.chatClient = chatClient
this.selfId = selfId
}
// ===== 控制端 =====
// 发起一次 open, 等被控端 ack; 被控端同意后立刻开始收 output
async openOnPeer(peerId: string, opts: { rows: number; cols: number; shell?: string; readOnly?: boolean } = { rows: 24, cols: 80 }): Promise<{ ok: boolean; reason?: string; sessionId?: string }> {
const sessionId = randomUUID()
const frame: OpenFrame = { type: 'open', sessionId, rows: opts.rows, cols: opts.cols, ...(opts.shell ? { shell: opts.shell } : {}), readOnly: !!opts.readOnly }
const sent = this.chatClient.send({ type: 'terminal', payload: frame } as WsFrame, peerId)
if (!sent) {
recordAudit({ action: 'terminal.open', target: peerId, result: 'error', payload: { reason: 'ws closed' } })
return { ok: false, reason: '对方离线' }
}
this.outgoing.set(sessionId, {
peerId,
createdAt: Date.now(),
rows: opts.rows,
cols: opts.cols,
shell: opts.shell || '',
readOnly: !!opts.readOnly,
})
// 等待 ack: 由 handleIncoming 的 'ack' 分支 resolve
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (this.outgoing.has(sessionId)) {
this.outgoing.delete(sessionId)
resolve({ ok: false, reason: '超时 (对方 30s 内未响应)' })
}
}, 30_000)
const onResolve = (ok: boolean, reason?: string) => {
clearTimeout(timer)
this.off(`ack:${sessionId}`, onResolve as any)
resolve({ ok, reason, sessionId })
}
this.on(`ack:${sessionId}`, onResolve as any)
})
}
sendInput(sessionId: string, bytes: Buffer | string) {
const info = this.outgoing.get(sessionId)
if (!info) return false
if (info.readOnly) return false
const b = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes, 'utf8')
const frame: InputFrame = { type: 'input', sessionId, data: b.toString('base64') }
return this.chatClient.send({ type: 'terminal', payload: frame } as WsFrame, info.peerId)
}
resize(sessionId: string, rows: number, cols: number) {
const info = this.outgoing.get(sessionId)
if (!info) return false
info.rows = rows; info.cols = cols
const frame: ResizeFrame = { type: 'resize', sessionId, rows, cols }
return this.chatClient.send({ type: 'terminal', payload: frame } as WsFrame, info.peerId)
}
close(sessionId: string, reason = 'user') {
const info = this.outgoing.get(sessionId)
if (!info) return
this.outgoing.delete(sessionId)
const frame: CloseFrame = { type: 'close', sessionId, reason }
this.chatClient.send({ type: 'terminal', payload: frame } as WsFrame, info.peerId)
}
// ===== 被控端 =====
// 收到来自对端的 terminal 帧
async handleIncoming(peer: DeviceInfo, f: TerminalFrame) {
switch (f.type) {
case 'open':
return this.handleOpen(peer, f)
case 'input':
return this.handleInput(peer, f)
case 'resize':
return this.handleResize(peer, f)
case 'close':
return this.handleClose(peer, f)
case 'ack':
// 控制端收到我们的 open 后, ack 给对端
this.emit(`ack:${f.sessionId}`, f.ok, f.reason)
return
case 'output':
return // 协议层不应该出现
}
}
private async handleOpen(peer: DeviceInfo, f: OpenFrame) {
// 1. 授权闸门
if (shouldAsk(peer.deviceId, 'terminal')) {
const reqId = randomUUID()
const reqPromise = enqueueApproval({
requestId: reqId,
peerId: peer.deviceId,
peerName: peer.name,
kind: 'terminal',
detail: `对方请求打开终端 (rows=${f.rows} cols=${f.cols}${f.readOnly ? ', 只读' : ''})`,
ts: Date.now(),
})
this.emit('approvalRequested', {
requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'terminal',
detail: `请求打开终端 (rows=${f.rows} cols=${f.cols}${f.readOnly ? ', 只读' : ''})`,
})
const verdict = await reqPromise
if (!verdict.ok) {
this.sendAck(peer.deviceId, f.sessionId, false, '用户拒绝')
recordAudit({ action: 'terminal.open', source: peer.deviceId, sessionId: f.sessionId, result: 'denied', payload: { rows: f.rows, cols: f.cols } })
return
}
if (verdict.remember) markApproved(peer.deviceId, 'terminal')
}
// 2. spawn pty
try {
const shell = defaultShell()
const proc = pty.spawn(shell.file, shell.args, {
name: 'xterm-256color',
cols: f.cols || 80,
rows: f.rows || 24,
cwd: process.env.HOME || process.env.USERPROFILE || process.cwd(),
env: { ...process.env, TERM: 'xterm-256color', LANG: process.env.LANG || 'en_US.UTF-8', COLORTERM: 'truecolor' } as any,
encoding: null,
})
const session: Session = {
sessionId: f.sessionId,
ownerId: peer.deviceId,
peerId: peer.deviceId,
proc,
readOnly: !!f.readOnly,
shell: shell.label,
cols: f.cols || 80,
rows: f.rows || 24,
createdAt: Date.now(),
}
this.sessions.set(f.sessionId, session)
this.sendAck(peer.deviceId, f.sessionId, true)
this.emit('sessionOpened', f.sessionId, peer.deviceId, { shell: shell.label, rows: session.rows, cols: session.cols })
proc.onData((data: string | Buffer) => {
// encoding:null 时 data 是 Buffer; encoding:'utf8' 时是 string
// 我们以 Buffer 形式发给对端, 对端 xterm.write 直接接 Uint8Array
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8')
const out: Extract<TerminalFrame, { type: 'output' }> = { type: 'output', sessionId: f.sessionId, data: buf.toString('base64') }
this.chatClient.send({ type: 'terminal', payload: out } as WsFrame, peer.deviceId)
this.emit('output', f.sessionId, buf, peer.deviceId)
})
proc.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
const reason = `退出 (code=${exitCode}${signal ? `, signal=${signal}` : ''})`
this.sessions.delete(f.sessionId)
const out: CloseFrame = { type: 'close', sessionId: f.sessionId, reason }
this.chatClient.send({ type: 'terminal', payload: out } as WsFrame, peer.deviceId)
this.emit('sessionClosed', f.sessionId, peer.deviceId, reason)
recordAudit({ action: 'terminal.exit', source: peer.deviceId, sessionId: f.sessionId, result: 'closed', payload: { exitCode, signal } })
})
recordAudit({ action: 'terminal.open', source: peer.deviceId, sessionId: f.sessionId, result: 'ok', payload: { shell: shell.label, rows: session.rows, cols: session.cols } })
} catch (e: any) {
this.sendAck(peer.deviceId, f.sessionId, false, `spawn 失败: ${e?.message || e}`)
recordAudit({ action: 'terminal.open', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { error: String(e?.message || e) } })
}
}
private handleInput(_peer: DeviceInfo, f: InputFrame) {
const s = this.sessions.get(f.sessionId)
if (!s) return
if (s.readOnly) return
try {
const buf = Buffer.from(f.data, 'base64')
s.proc.write(buf)
} catch (e: any) {
console.warn('[terminal] write failed:', e?.message)
}
}
private handleResize(_peer: DeviceInfo, f: ResizeFrame) {
const s = this.sessions.get(f.sessionId)
if (!s) return
try {
s.proc.resize(Math.max(2, f.cols), Math.max(2, f.rows))
s.rows = f.rows; s.cols = f.cols
} catch (e: any) {
console.warn('[terminal] resize failed:', e?.message)
}
}
private handleClose(peer: DeviceInfo, f: CloseFrame) {
const s = this.sessions.get(f.sessionId)
if (!s) return
try { s.proc.kill() } catch {}
this.sessions.delete(f.sessionId)
this.emit('sessionClosed', f.sessionId, peer.deviceId, f.reason)
recordAudit({ action: 'terminal.close', source: peer.deviceId, sessionId: f.sessionId, result: 'closed', payload: { reason: f.reason } })
}
// 控制端收到被控端的 output -> 转发给 UI
onRemoteOutput(cb: (sessionId: string, bytes: Buffer, peerId: string) => void) {
this.on('output', cb)
}
private sendAck(peerId: string, sessionId: string, ok: boolean, reason?: string) {
const frame: AckFrame = { type: 'ack', sessionId, ok, reason }
this.chatClient.send({ type: 'terminal', payload: frame } as WsFrame, peerId)
}
// ===== 维护 =====
stop() {
for (const s of this.sessions.values()) {
try { s.proc.kill() } catch {}
}
this.sessions.clear()
this.outgoing.clear()
}
// 被控端 ws 断线 -> 关掉所有属于这个对端的 session
onPeerDisconnected(peerId: string) {
for (const [sid, s] of this.sessions) {
if (s.peerId === peerId) {
try { s.proc.kill() } catch {}
this.sessions.delete(sid)
this.emit('sessionClosed', sid, peerId, 'peer-disconnected')
}
}
// 控制端: 告知 UI (renderer 通过 device:wsState 也会刷新)
for (const [sid, info] of this.outgoing) {
if (info.peerId === peerId) {
this.outgoing.delete(sid)
}
}
}
// 列出本机作为被控端当前所有 session (UI: 谁正在用我)
listServerSessions() {
return Array.from(this.sessions.values()).map(s => ({
sessionId: s.sessionId,
peerId: s.peerId,
shell: s.shell,
rows: s.rows,
cols: s.cols,
readOnly: s.readOnly,
createdAt: s.createdAt,
}))
}
listClientSessions() {
return Array.from(this.outgoing.entries()).map(([sid, info]) => ({
sessionId: sid,
peerId: info.peerId,
shell: info.shell,
rows: info.rows,
cols: info.cols,
readOnly: info.readOnly,
createdAt: info.createdAt,
}))
}
hasClientSession(sessionId: string) {
return this.outgoing.has(sessionId)
}
// 用户在 UI 上点 "拒绝所有未决请求"
resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'usb') {
resetApproval(peerId, kind)
}
}
+944
View File
@@ -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 模块)`
}
+249
View File
@@ -0,0 +1,249 @@
// 虚拟串口对 (一对 PTY/COM, 一头给本机 app, 一头给我们读)
// Linux/macOS: 调 socat 创建 PTY 配对 (link 到稳定路径, 不需 root)
// Windows: 自动下载 com0com 装, 然后用 setupc.exe 创建 COM 配对 (需 admin)
import { spawn, exec as execCb, ChildProcess } from 'node:child_process'
import { existsSync, mkdirSync, unlinkSync, rmdirSync, createWriteStream, statSync, openSync, readSync, closeSync } from 'node:fs'
import { promises as fsp } from 'node:fs'
import { promisify } from 'node:util'
import { platform } from 'node:process'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomBytes } from 'node:crypto'
// @ts-ignore - sudo-prompt 是 CJS, 没自带 .d.ts
import sudo from 'sudo-prompt'
const execAsync = promisify(execCb)
export interface VirtualSerialInfo {
// 给用户 app 用的路径 (e.g. COM5, /tmp/lnm-vcom-xxx/user)
userPath: string
// 我们自己开的另一头 (socat 模式, 这是内部 link; com0com 模式, 这是 COM6 这种)
internalPath: string
platform: 'linux' | 'darwin' | 'win32'
cleanup: () => Promise<void>
}
const DEFAULT_BAUD = 115200
function runElevated(cmd: string): Promise<{ ok: boolean; stdout?: string; stderr?: string }> {
return new Promise((resolve) => {
try {
sudo.exec(cmd, { name: 'LocalNetMsg' }, (error?: Error | undefined, stdout?: string | Buffer, stderr?: string | Buffer) => {
const out = stdout ? String(stdout) : ''
const errOut = stderr ? String(stderr) : ''
if (error) resolve({ ok: false, stdout: out, stderr: errOut || error.message })
else resolve({ ok: true, stdout: out, stderr: errOut })
})
} catch (e: any) {
resolve({ ok: false, stderr: String(e?.message || e) })
}
})
}
export async function createVirtualSerialPair(opts: {
userPath?: string // 可选: 用户指定名字 (e.g. "COM5"); 不指定则自动分配
}): Promise<VirtualSerialInfo> {
if (platform === 'linux' || platform === 'darwin') {
return createViaSocat(opts)
}
if (platform === 'win32') {
return createViaCom0com(opts)
}
throw new Error(`当前平台不支持虚拟串口: ${platform}`)
}
async function checkSocat(): Promise<string> {
// 1. PATH
try {
const { stdout } = await execAsync('which socat || true')
if (stdout.trim()) return stdout.trim()
} catch {}
// 2. macOS 常见位置
if (platform === 'darwin') {
const candidates = ['/opt/homebrew/bin/socat', '/usr/local/bin/socat', '/usr/bin/socat']
for (const c of candidates) if (existsSync(c)) return c
}
// 3. Linux 常见位置
if (platform === 'linux') {
const candidates = ['/usr/bin/socat', '/usr/local/bin/socat', '/snap/bin/socat']
for (const c of candidates) if (existsSync(c)) return c
}
throw new Error('需要 socat 来创建虚拟串口. Linux: apt install socat (Debian/Ubuntu) | dnf install socat (Fedora). macOS: brew install socat')
}
async function createViaSocat(opts: { userPath?: string }): Promise<VirtualSerialInfo> {
const socatPath = await checkSocat()
const id = randomBytes(3).toString('hex')
const dir = join(tmpdir(), `lnm-vcom-${id}`)
mkdirSync(dir, { recursive: true })
// 用户端口路径: 如果用户指定就用, 否则用 dir 下的 link
const userPath = opts.userPath || join(dir, 'user')
// 内部端口路径: dir 下的另一个 link
const internalPath = join(dir, 'internal')
// socat 命令: 两个 PTY, 双向, raw 模式 (无字符处理)
// -d -d 输出详细日志到 stderr
const proc: ChildProcess = spawn(socatPath, [
'-d', '-d',
`pty,raw,echo=0,link=${userPath}`,
`pty,raw,echo=0,link=${internalPath}`,
], { stdio: ['ignore', 'pipe', 'pipe'], detached: false })
// 收集 stderr (出错时给提示)
let stderrBuf = ''
proc.stderr?.on('data', (b: Buffer) => { stderrBuf += b.toString() })
// 等待 socat 把两个 link 都建好 (socat 是先 fork, 然后再 link)
let retries = 50
while (retries > 0) {
if (existsSync(userPath) && existsSync(internalPath)) break
if (proc.exitCode !== null) {
// socat 退出了
try { rmdirSync(dir) } catch {}
throw new Error(`socat 启动失败: ${stderrBuf.trim() || '未知错误'}`)
}
await new Promise(r => setTimeout(r, 100))
retries--
}
if (!existsSync(userPath) || !existsSync(internalPath)) {
try { proc.kill() } catch {}
try { rmdirSync(dir) } catch {}
throw new Error('socat 启动超时 (5s 内未创建 link). 详细: ' + (stderrBuf.trim() || '无'))
}
return {
userPath,
internalPath,
platform: platform as 'linux' | 'darwin',
cleanup: async () => {
try { proc.kill() } catch {}
await new Promise(r => setTimeout(r, 100))
try { unlinkSync(userPath) } catch {}
try { unlinkSync(internalPath) } catch {}
try { rmdirSync(dir) } catch {}
},
}
}
// ============== Windows com0com ==============
async function findCom0comSetupc(): Promise<string | null> {
const candidates = [
'C:\\Program Files\\com0com\\setupc.exe',
'C:\\Program Files (x86)\\com0com\\setupc.exe',
]
for (const p of candidates) {
if (existsSync(p)) return p
}
return null
}
async function downloadCom0comInstaller(): Promise<string> {
const destDir = join(homedir(), 'AppData', 'Local', 'LocalNetMsg', 'tools')
await fsp.mkdir(destDir, { recursive: true })
const dest = join(destDir, 'com0com-setup.exe')
if (existsSync(dest) && statSync(dest).size > 100_000) {
return dest // 已下载
}
// com0com 3.0.0 signed installer
const url = 'https://sourceforge.net/projects/com0com/files/com0com/3.0.0/com0com-3.0.0.0-i386-and-x64-signed.exe/download'
const https = await import('node:https')
await new Promise<void>((resolve, reject) => {
const file = createWriteStream(dest)
const get = (u: string) => (https as any).get(u, (res: any) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
file.close(); get(res.headers.location); return
}
if (res.statusCode !== 200) { reject(new Error('HTTP ' + res.statusCode)); return }
res.pipe(file)
file.on('finish', () => file.close(() => resolve()))
})
get(url).on('error', reject)
})
return dest
}
async function installCom0com(): Promise<string> {
// 检查是否已装
const existing = await findCom0comSetupc()
if (existing) return existing
// 下载
const installer = await downloadCom0comInstaller()
// 静默安装 (需要 admin); com0com 安装器支持 /S
const r = await runElevated(`"${installer}" /S`)
if (!r.ok) {
throw new Error('com0com 安装失败: ' + (r.stderr || r.stdout || '未知错误'))
}
// 等安装完
let retries = 30
while (retries > 0) {
const p = await findCom0comSetupc()
if (p) return p
await new Promise(r => setTimeout(r, 500))
retries--
}
throw new Error('com0com 安装超时, 找不到 setupc.exe')
}
async function findFreeComNumber(start = 10, end = 99): Promise<number> {
// 列出已用的 COM 端口名
const used = new Set<number>()
try {
const { stdout } = await execAsync('powershell -NoProfile -Command "[System.IO.Ports.SerialPort]::GetPortNames()"')
for (const m of stdout.matchAll(/COM(\d+)/gi)) {
used.add(parseInt(m[1], 10))
}
} catch {}
// 也检查 com0com 已有的对
const setupc = await findCom0comSetupc()
if (setupc) {
try {
const out = await runElevated(`"${setupc}" list`)
for (const m of (out.stdout || '').matchAll(/CNC(\d+)/gi)) {
const n = parseInt(m[1], 10)
// com0com 用内部名 CNC0, CNC1; OS 看到的可能是 COM10, COM11 这种
// 我们不太能从 CNC 编号反推 COM 编号, 留个安全余量
if (n > 5) used.add(n)
}
} catch {}
}
for (let i = start; i <= end; i++) {
if (!used.has(i)) return i
}
throw new Error('找不到空闲的 COM 端口号')
}
async function createViaCom0com(opts: { userPath?: string }): Promise<VirtualSerialInfo> {
const setupc = await installCom0com()
// 找两个连续的 COM 号
const num1 = await findFreeComNumber(10, 90)
const num2 = await findFreeComNumber(num1 + 1, 99)
if (num2 <= num1) throw new Error('找不到两个连续的 COM 端口号')
const name1 = `CNC${num1}`
const name2 = `CNC${num2}`
// 创建端口对, 给用户用 COM{num1}, 我们用 COM{num2}
const r = await runElevated(`"${setupc}" install ${name1}=${name2} Permanent`)
if (!r.ok) {
throw new Error('创建 com0com 端口对失败: ' + (r.stderr || r.stdout || '未知错误'))
}
const userPath = opts.userPath || `COM${num1}`
const internalPath = `COM${num2}`
return {
userPath,
internalPath,
platform: 'win32',
cleanup: async () => {
// 移除端口对
await runElevated(`"${setupc}" remove ${name1}`).catch(() => {})
},
}
}
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useApprovalStore } from '@/stores/remote'
const store = useApprovalStore()
const remember = ref(false)
const queue = computed(() => store.queue)
const current = computed(() => queue.value[0])
async function reply(ok: boolean) {
if (!current.value) return
await store.reply(current.value.requestId, ok, remember.value)
remember.value = false
}
</script>
<template>
<Teleport to="body">
<div v-if="current" class="approval-mask">
<div class="approval-card">
<header>
<strong>{{ current.peerName }}</strong> 请求{{
current.kind === 'terminal' ? '打开终端' :
current.kind === 'forward' ? '建立端口转发' :
'附加 USB / 串口'
}}
</header>
<div class="detail">{{ current.detail }}</div>
<div class="warn">
本次操作在 <b>你的本机</b> 上执行; 对方只能看到你授权范围内的内容
</div>
<footer>
<label class="remember">
<input type="checkbox" v-model="remember" />
24h 内自动允许同类型请求
</label>
<div class="spacer" />
<button class="btn-reject" @click="reply(false)">拒绝</button>
<button class="btn-allow" @click="reply(true)">允许</button>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.approval-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.approval-card {
width: 420px;
background: var(--el-bg-color);
border-radius: 12px;
padding: 18px 20px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
}
header { font-size: 15px; margin-bottom: 8px; }
.detail { font-size: 13px; color: var(--el-text-color-regular); padding: 8px 0; }
.warn { font-size: 12px; color: var(--el-color-warning); background: rgba(230, 162, 60, 0.08); padding: 8px 10px; border-radius: 6px; }
footer { display: flex; align-items: center; margin-top: 12px; gap: 8px; }
.remember { font-size: 12px; display: flex; align-items: center; gap: 4px; color: var(--el-text-color-secondary); }
.spacer { flex: 1; }
.btn-reject, .btn-allow {
padding: 6px 14px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 13px;
}
.btn-reject { background: var(--el-fill-color-light); color: var(--el-text-color-regular); }
.btn-allow { background: var(--el-color-primary); color: #fff; }
.btn-allow:hover { filter: brightness(1.05); }
</style>
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { DeviceView, ForwardSessionInfo, ForwardDirection } from '@/api'
import { useForwardStore } from '@/stores/remote'
import { ElMessage, ElMessageBox } from 'element-plus'
const props = defineProps<{ peer: DeviceView | null }>()
const store = useForwardStore()
const direction = ref<ForwardDirection>('self-out')
const listenPort = ref(5180)
const targetHost = ref('127.0.0.1')
const targetPort = ref(80)
const ttlSec = ref(3600)
const creating = ref(false)
async function refresh() { await store.refresh() }
onMounted(refresh)
// 当前会话 = 我方为 client (我发起的) + 对方为 client (我接收的) 中所有涉及当前 peer 的
const myClient = computed(() => store.client.filter(s => s.peerId === props.peer?.deviceId))
const peerClient = computed(() => store.server.filter(s => s.peerId === props.peer?.deviceId))
function directionLabel(d: ForwardDirection) {
return d === 'self-out' ? '我方代理对方' : '对方代理我方'
}
async function createForward() {
if (!props.peer) return
if (!Number.isInteger(listenPort.value) || listenPort.value < 1 || listenPort.value > 65535) {
ElMessage.warning('监听端口非法')
return
}
if (!Number.isInteger(targetPort.value) || targetPort.value < 1 || targetPort.value > 65535) {
ElMessage.warning('目标端口非法')
return
}
creating.value = true
const r = await store.open(props.peer.deviceId, {
direction: direction.value,
listenPort: listenPort.value,
targetHost: targetHost.value || '127.0.0.1',
targetPort: targetPort.value,
ttlSec: ttlSec.value,
})
creating.value = false
if (!r.ok) {
ElMessage.error('转发失败: ' + (r.reason || ''))
return
}
ElMessage.success('已建立转发')
}
async function stop(s: ForwardSessionInfo) {
await store.close(s.sessionId)
ElMessage.success('已关闭')
}
function fmtBytes(n: number) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
}
function ttlLabel(expiresAt: number) {
const ms = expiresAt - Date.now()
if (ms <= 0) return '已到期'
const sec = Math.round(ms / 1000)
if (sec < 60) return `${sec}s`
if (sec < 3600) return `${Math.round(sec / 60)}m`
return `${(sec / 3600).toFixed(1)}h`
}
</script>
<template>
<div class="forward-panel">
<div class="create-box">
<h3>新建端口转发</h3>
<div class="dir-row">
<label class="dir-label">方向</label>
<div class="dir-toggle">
<button :class="{ active: direction === 'self-out' }" @click="direction = 'self-out'">
我方代理对方
</button>
<button :class="{ active: direction === 'self-in' }" @click="direction = 'self-in'">
对方代理我方
</button>
</div>
<span class="dir-hint">
<template v-if="direction === 'self-out'">我开 127.0.0.1 端口 对方的目标端口</template>
<template v-else>对方开 127.0.0.1 端口 我方的目标端口</template>
</span>
</div>
<div class="form-row">
<template v-if="direction === 'self-out'">
<label>我方监听</label>
<el-input v-model.number="listenPort" size="small" style="width: 120px">
<template #prepend>127.0.0.1:</template>
</el-input>
<span class="arrow"></span>
<label>对方目标</label>
<el-input v-model="targetHost" placeholder="127.0.0.1" size="small" style="width: 130px" />
<span>:</span>
<el-input v-model.number="targetPort" placeholder="80" size="small" style="width: 80px" />
</template>
<template v-else>
<label>对方监听</label>
<el-input v-model.number="listenPort" size="small" style="width: 120px">
<template #prepend>127.0.0.1:</template>
</el-input>
<span class="arrow"></span>
<label>我方目标</label>
<el-input v-model="targetHost" placeholder="127.0.0.1" size="small" style="width: 130px" />
<span>:</span>
<el-input v-model.number="targetPort" placeholder="80" size="small" style="width: 80px" />
</template>
<span>TTL</span>
<el-input v-model.number="ttlSec" placeholder="秒" size="small" style="width: 80px" />
<el-button type="primary" :loading="creating" size="small" @click="createForward" :disabled="!peer">
建立
</el-button>
</div>
</div>
<div class="section">
<h3>我发起的 ({{ myClient.length }})</h3>
<p class="section-hint">我方代理对方的服务 (self-out) / 把我的服务暴露给对方 (self-in)任何一方都能关闭</p>
<el-table v-if="myClient.length" :data="myClient" size="small" stripe>
<el-table-column label="方向" width="120">
<template #default="{ row }">
<el-tag size="small" :type="row.direction === 'self-out' ? 'primary' : 'success'">{{ directionLabel(row.direction) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="入口">
<template #default="{ row }">
<code>127.0.0.1:{{ row.listenPort }}</code>
</template>
</el-table-column>
<el-table-column label="目标">
<template #default="{ row }">
<code>{{ row.targetHost }}:{{ row.targetPort }}</code>
</template>
</el-table-column>
<el-table-column label="in/out" width="160">
<template #default="{ row }">{{ fmtBytes(row.bytesIn) }} / {{ fmtBytes(row.bytesOut) }}</template>
</el-table-column>
<el-table-column label="剩余" width="80">
<template #default="{ row }">{{ ttlLabel(row.expiresAt) }}</template>
</el-table-column>
<el-table-column label="" width="80">
<template #default="{ row }">
<el-button size="small" link type="danger" @click="stop(row)">关闭</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无我发起的转发" :image-size="50" />
</div>
<div class="section">
<h3>对方发起的 ({{ peerClient.length }})</h3>
<p class="section-hint">对方正在使用 / 暴露的转发. 你也可以关闭.</p>
<el-table v-if="peerClient.length" :data="peerClient" size="small" stripe>
<el-table-column label="方向" width="120">
<template #default="{ row }">
<el-tag size="small" :type="row.direction === 'self-out' ? 'primary' : 'success'">{{ directionLabel(row.direction) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="入口">
<template #default="{ row }">
<code>127.0.0.1:{{ row.listenPort }}</code>
</template>
</el-table-column>
<el-table-column label="目标">
<template #default="{ row }">
<code>{{ row.targetHost }}:{{ row.targetPort }}</code>
</template>
</el-table-column>
<el-table-column label="in/out" width="160">
<template #default="{ row }">{{ fmtBytes(row.bytesIn) }} / {{ fmtBytes(row.bytesOut) }}</template>
</el-table-column>
<el-table-column label="" width="80">
<template #default="{ row }">
<el-button size="small" link type="danger" @click="stop(row)">关闭</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="对方没发起任何转发" :image-size="50" />
</div>
</div>
</template>
<style scoped>
.forward-panel { padding: 16px; overflow-y: auto; height: 100%; }
.create-box { background: var(--el-fill-color-light); padding: 14px; border-radius: 8px; margin-bottom: 16px; }
.create-box h3, .section h3 { font-size: 14px; margin: 0 0 8px 0; }
.section { margin-bottom: 16px; }
.section-hint { font-size: 12px; color: var(--el-text-color-secondary); margin: 0 0 8px 0; }
.dir-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
.dir-label { font-size: 12px; color: var(--el-text-color-secondary); width: 32px; }
.dir-toggle { display: flex; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 6px; padding: 2px; }
.dir-toggle button { background: transparent; border: none; padding: 5px 12px; font-size: 13px; border-radius: 4px; cursor: pointer; color: var(--el-text-color-regular); }
.dir-toggle button.active { background: var(--el-color-primary); color: #fff; font-weight: 500; }
.dir-hint { font-size: 12px; color: var(--el-text-color-secondary); }
.form-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.form-row label { font-size: 12px; color: var(--el-text-color-secondary); margin-right: 4px; }
.form-row .arrow { font-size: 16px; color: var(--el-color-primary); margin: 0 6px; }
</style>
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
// @ts-ignore
import { Terminal } from '@xterm/xterm'
// @ts-ignore
import { FitAddon } from '@xterm/addon-fit'
// @ts-ignore
import { WebLinksAddon } from '@xterm/addon-web-links'
// @ts-ignore
import '@xterm/xterm/css/xterm.css'
import type { DeviceView } from '@/api'
import { useTerminalStore } from '@/stores/remote'
import { ElMessage } from 'element-plus'
const props = defineProps<{ peer: DeviceView; active?: boolean }>()
const emit = defineEmits<{ (e: 'closed'): void }>()
const store = useTerminalStore()
const hostEl = ref<HTMLDivElement | null>(null)
const loading = ref(false)
let term: Terminal | null = null
let fit: FitAddon | null = null
let ro: ResizeObserver | null = null
const sessionId = ref<string | null>(null)
const sessionInfo = ref<{ shell: string; rows: number; cols: number; readOnly: boolean } | null>(null)
const state = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle')
let outputQueue: Uint8Array[] = []
let flushTimer: any = null
let outputPollTimer: any = null
function b64ToBytes(b64: string): Uint8Array {
const bin = atob(b64)
const out = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
return out
}
function flushOutput() {
flushTimer = null
if (!term || outputQueue.length === 0) return
let total = 0
for (const b of outputQueue) total += b.length
if (total === 0) { outputQueue = []; return }
const combined = new Uint8Array(total)
let off = 0
for (const b of outputQueue) { combined.set(b, off); off += b.length }
outputQueue = []
try { term.write(combined) } catch (e) { console.warn('[terminal] write failed', e) }
}
function scheduleFlush() {
if (flushTimer) return
flushTimer = setTimeout(flushOutput, 16)
}
async function openSession() {
if (!props.peer) return
loading.value = true
const r = await store.open(props.peer.deviceId, { rows: 24, cols: 80 })
loading.value = false
if (!r.ok) {
ElMessage.error('打开终端失败: ' + (r.reason || ''))
return
}
sessionId.value = r.sessionId!
state.value = 'connecting'
await nextTick()
initTerm(r.sessionId!)
}
function initTerm(sid: string) {
if (!hostEl.value) {
console.warn('[terminal] hostEl not ready, retry')
setTimeout(() => initTerm(sid), 50)
return
}
term = new Terminal({
fontSize: 13,
fontFamily: 'Consolas, "Courier New", monospace',
theme: { background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#d4d4d4', selectionBackground: '#264f78' },
cursorBlink: true,
convertEol: true,
scrollback: 5000,
})
fit = new FitAddon()
term.loadAddon(fit)
term.loadAddon(new WebLinksAddon())
term.open(hostEl.value)
requestAnimationFrame(() => { try { fit?.fit() } catch {} })
term.onData((data: string) => {
if (!sessionId.value) return
const b64 = btoa(unescape(encodeURIComponent(data)))
store.input(sessionId.value, b64)
})
ro = new ResizeObserver(() => { try { fit?.fit() } catch {} })
ro.observe(hostEl.value)
state.value = 'open'
// 启动轮询: 从 store 拿缓冲, 写到 xterm. 轮询兜底, 万一 watch 没触发也能跑
if (outputPollTimer) clearInterval(outputPollTimer)
outputPollTimer = setInterval(() => {
if (!sessionId.value || !term) return
const sid = sessionId.value
const buf = store.buffers[sid]
if (!buf || buf.length === 0) return
for (const b64 of buf) {
try { outputQueue.push(b64ToBytes(b64)) } catch {}
}
store.buffers[sid] = []
scheduleFlush()
}, 30)
}
watch(() => sessionInfo.value, (s) => {
if (s && term) {
try { term.resize(s.cols, s.rows) } catch {}
ElMessage.success(`已连接到 ${s.shell}`)
}
})
// tab 切回时 refit
watch(() => props.active, (a) => {
if (a && term && fit) requestAnimationFrame(() => { try { fit?.fit() } catch {} })
})
function closeSession(reason?: string) {
if (sessionId.value) {
store.close(sessionId.value, reason)
sessionId.value = null
}
if (term) { try { term.dispose() } catch {} ; term = null }
state.value = 'closed'
emit('closed')
}
onBeforeUnmount(() => {
if (outputPollTimer) clearInterval(outputPollTimer)
if (flushTimer) clearTimeout(flushTimer)
if (ro) ro.disconnect()
if (term) { try { term.dispose() } catch {} }
closeSession('component-unmount')
})
defineExpose({ openSession, closeSession })
</script>
<template>
<div class="term-panel">
<div class="term-header">
<span class="peer-name">{{ peer?.name }}</span>
<span class="state" :class="state">{{
state === 'idle' ? '未连接' :
state === 'connecting' ? '连接中…' :
state === 'open' ? `已连接 · ${sessionInfo?.shell || 'shell'}` :
'已关闭'
}}</span>
<div class="spacer" />
<el-button v-if="state === 'idle'" type="primary" :loading="loading" size="small" @click="openSession">
打开终端
</el-button>
<el-button v-else size="small" @click="closeSession">关闭</el-button>
</div>
<div ref="hostEl" class="term-host" />
<div v-if="state === 'idle'" class="term-empty">
<div class="empty-tip">
点击 "打开终端" <b>{{ peer?.name }}</b> 上启动一个本地 shell 会话
<br><br>
<small>对方需要先在弹窗中同意; 24h 内可设置自动允许</small>
</div>
</div>
</div>
</template>
<style scoped>
.term-panel { display: flex; flex-direction: column; height: 100%; background: #1e1e1e; }
.term-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #2d2d2d; border-bottom: 1px solid #1a1a1a; color: #d4d4d4; }
.peer-name { font-weight: 600; }
.state { font-size: 12px; padding: 2px 8px; border-radius: 8px; background: #555; color: #ccc; }
.state.open { background: #2d7a2d; color: #fff; }
.state.connecting { background: #7a6d2d; color: #fff; }
.state.closed { background: #7a2d2d; color: #fff; }
.spacer { flex: 1; }
.term-host { flex: 1; min-height: 0; padding: 4px; overflow: hidden; }
.term-host :deep(.xterm) { height: 100%; }
.term-empty { position: absolute; inset: 40px 0 0 0; display: flex; align-items: center; justify-content: center; pointer-events: none; color: #888; text-align: center; padding: 24px; }
.empty-tip { background: #2d2d2d; border: 1px solid #444; padding: 16px 24px; border-radius: 8px; pointer-events: auto; }
</style>
@@ -0,0 +1,579 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick, h } from 'vue'
import type { DeviceView, UsbDeviceInfo, UsbSessionInfo, UsbKind, UsbDirection, UsbAttachConfig, UsbEndpointInfo } from '@/api'
import { useUsbStore } from '@/stores/remote'
import { ElMessage, ElMessageBox } from 'element-plus'
const props = defineProps<{ peer: DeviceView | null }>()
const store = useUsbStore()
type Mode = 'serial' | 'usb' | 'usbip'
const mode = ref<Mode>('serial')
const direction = ref<UsbDirection>('self-out')
const scanning = ref(false)
const peerDevices = ref<UsbDeviceInfo[]>([])
const myDevices = ref<UsbDeviceInfo[]>([])
const selectedBus = ref<string>('')
const baudRate = ref(115200)
const creating = ref(false)
// USB 模式专属
const usbIfaceNum = ref(0)
const detachKern = ref(true)
const hexEl = ref<HTMLDivElement | null>(null)
const hexBuf = ref<{ ascii: string; hex: string; ts: number }[]>([])
const HEX_LIMIT = 200
// USB 操作历史
const usbLog = ref<{ ts: number; tag: string; ok: boolean; text: string; hex?: string }[]>([])
const MAX_USB_LOG = 80
// 当前 active 客户端 session (只展示一个)
const myClientSession = computed<UsbSessionInfo | undefined>(() =>
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'client')
)
// 当前对端发起的 session (server side, 展示我方被控情况)
const serverSession = computed<UsbSessionInfo | undefined>(() =>
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'server')
)
// 不同 mode 的设备过滤
const visibleDevices = computed<UsbDeviceInfo[]>(() => {
const all = direction.value === 'self-out' ? peerDevices.value : myDevices.value
return all.filter(d => d.kind === mode.value)
})
const serverSerialBytes = ref<{ direction: 'in' | 'out'; data: string; ts: number }[]>([])
async function scan() {
if (!props.peer) return
scanning.value = true
if (direction.value === 'self-out') {
const r = await store.list(props.peer.deviceId)
scanning.value = false
if (!r.ok) { ElMessage.error('列出对端设备失败: ' + (r.reason || '')); peerDevices.value = []; return }
peerDevices.value = r.devices || []
} else {
const r = await store.listLocal()
scanning.value = false
if (!r.ok) {
ElMessage.error('列出本机设备失败: ' + (r.reason || ''))
myDevices.value = []
return
}
myDevices.value = r.devices || []
}
// 选第一个匹配的 mode
const first = visibleDevices.value[0]
if (first && !visibleDevices.value.find(d => d.busId === selectedBus.value)) {
selectedBus.value = first.busId
}
}
async function attach() {
if (!props.peer || !selectedBus.value) return
if (myClientSession.value) {
ElMessage.warning('已有活跃 session, 请先关闭')
return
}
let finalBaudRate = baudRate.value
// 串口模式: 弹窗选波特率
if (isSerial.value) {
const BAUD_OPTIONS = [9600, 19200, 38400, 57600, 74880, 115200, 230400, 460800, 921600]
const selected = ref(BAUD_OPTIONS.indexOf(baudRate.value) >= 0 ? baudRate.value : 115200)
try {
await ElMessageBox.confirm(
h('div', [
h('p', { style: 'margin-bottom:10px' }, '选择本机虚拟串口的波特率'),
h('el-select', {
modelValue: selected.value,
'onUpdate:modelValue': (v: number) => { selected.value = v },
style: 'width:160px',
}, BAUD_OPTIONS.map(b => h('el-option', { key: b, label: String(b), value: b }))),
]),
'创建虚拟串口',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
customClass: 'baud-box',
}
)
finalBaudRate = selected.value
} catch {
return // 取消
}
}
creating.value = true
const config = buildConfig(finalBaudRate)
if (!config) { creating.value = false; return }
const r = await store.attach(props.peer.deviceId, {
direction: direction.value,
busId: selectedBus.value,
config,
})
creating.value = false
if (!r.ok) {
ElMessage.error('附加失败: ' + (r.reason || ''))
return
}
ElMessage.success('已附加')
await nextTick()
hexEl.value?.scrollTo({ top: 0 })
usbLog.value.unshift({ ts: Date.now(), tag: 'attach', ok: true, text: `${mode.value} attach OK` })
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
}
function buildConfig(br: number): UsbAttachConfig | null {
if (mode.value === 'serial') {
return {
kind: 'serial',
baudRate: br,
dataBits: 8,
stopBits: 1,
parity: 'none',
createVirtual: true,
}
}
if (mode.value === 'usb') {
return { kind: 'usb', interfaceNumber: usbIfaceNum.value, detachKernelDriver: detachKern.value }
}
if (mode.value === 'usbip') {
return { kind: 'usbip' }
}
return null
}
async function detach(s: UsbSessionInfo) {
await store.detach(s.sessionId)
ElMessage.success('已关闭')
}
async function sendText() {
if (!myClientSession.value || myClientSession.value.kind !== 'serial') return
const sid = myClientSession.value.sessionId
const text = prompt('输入要发送的文本 (UTF-8):', 'AT\r\n')
if (text === null) return
const buf = new TextEncoder().encode(text)
let bin = ''
for (const b of buf) bin += String.fromCharCode(b)
await store.serialSend(sid, btoa(bin))
ElMessage.success(`已发送 ${buf.length} 字节`)
}
// ===== USB 字节桥面板 =====
const reqType = ref<'standard' | 'class' | 'vendor' | 'reserved'>('standard')
const recipient = ref<'device' | 'interface' | 'endpoint' | 'other'>('device')
const directionBit = ref<'host-to-device' | 'device-to-host'>('device-to-host')
const bRequest = ref(0)
const wValue = ref(0)
const wIndex = ref(0)
const ctrlData = ref('') // hex 文本
const ctrlLength = ref(64)
const bulkEndpoint = ref(0x81)
const bulkData = ref('') // hex 文本
const bulkLength = ref(64)
const bulkTimeout = ref(5000)
const bulkDirection = ref<'in' | 'out'>('in')
function hexToBase64(hex: string): string {
const clean = hex.replace(/[^0-9a-fA-F]/g, '')
const padded = clean.length % 2 ? '0' + clean : clean
let bin = ''
for (let i = 0; i < padded.length; i += 2) {
bin += String.fromCharCode(parseInt(padded.substr(i, 2), 16))
}
return btoa(bin)
}
function base64ToHex(b64: string): string {
if (!b64) return ''
try {
const bin = atob(b64)
let hex = ''
for (let i = 0; i < bin.length; i++) {
hex += bin.charCodeAt(i).toString(16).padStart(2, '0') + ' '
}
return hex.trim()
} catch { return '' }
}
function buildRequestType(): number {
// bmRequestType: bit7 = direction, bits 6-5 = type, bits 4-0 = recipient
let rt = 0
if (directionBit.value === 'host-to-device') rt |= 0x00
else rt |= 0x80
if (reqType.value === 'standard') rt |= 0x00
else if (reqType.value === 'class') rt |= 0x20
else if (reqType.value === 'vendor') rt |= 0x40
else if (reqType.value === 'reserved') rt |= 0x60
if (recipient.value === 'device') rt |= 0x00
else if (recipient.value === 'interface') rt |= 0x01
else if (recipient.value === 'endpoint') rt |= 0x02
else if (recipient.value === 'other') rt |= 0x03
return rt
}
async function doCtrlIn() {
if (!myClientSession.value) return
const setup = { requestType: buildRequestType(), request: bRequest.value, value: wValue.value, index: wIndex.value }
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlIn', ok: true, text: `req=0x${bRequest.value.toString(16).padStart(2, '0')} len=${ctrlLength.value}` })
const r = await store.ctrlIn(myClientSession.value.sessionId, setup, ctrlLength.value)
const hex = r.dataBase64 ? base64ToHex(r.dataBase64) : ''
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlIn', ok: r.ok, text: `status=${r.status}`, hex })
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
if (hex) {
hexBuf.value.unshift({ ascii: '', hex, ts: Date.now() })
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
}
}
async function doCtrlOut() {
if (!myClientSession.value) return
const setup = { requestType: buildRequestType(), request: bRequest.value, value: wValue.value, index: wIndex.value }
const dataB64 = ctrlData.value.trim() ? hexToBase64(ctrlData.value) : undefined
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlOut', ok: true, text: `req=0x${bRequest.value.toString(16).padStart(2, '0')} data=${dataB64 ? ctrlData.value : '<none>'}` })
const r = await store.ctrlOut(myClientSession.value.sessionId, setup, dataB64)
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlOut', ok: r.ok, text: `written=${r.status} bytes` })
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
}
async function doBulkTransfer() {
if (!myClientSession.value) return
if (bulkDirection.value === 'in') {
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkIn', ok: true, text: `EP=0x${bulkEndpoint.value.toString(16)} len=${bulkLength.value}` })
const r = await store.bulkIn(myClientSession.value.sessionId, bulkEndpoint.value, bulkLength.value, bulkTimeout.value)
const hex = r.dataBase64 ? base64ToHex(r.dataBase64) : ''
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkIn', ok: r.ok, text: `status=${r.status}`, hex })
if (hex) {
hexBuf.value.unshift({ ascii: '', hex, ts: Date.now() })
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
}
} else {
const b64 = bulkData.value.trim() ? hexToBase64(bulkData.value) : ''
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkOut', ok: true, text: `EP=0x${bulkEndpoint.value.toString(16)} data=${bulkData.value}` })
const r = await store.bulkOut(myClientSession.value.sessionId, bulkEndpoint.value, b64)
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkOut', ok: r.ok, text: `written=${r.status} bytes` })
}
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
}
// ===== 串口 hex 显示 =====
function pushHex(base64: string) {
if (!base64) return
const bin = atob(base64)
let hex = ''
let ascii = ''
for (let i = 0; i < bin.length; i++) {
const c = bin.charCodeAt(i)
hex += c.toString(16).padStart(2, '0') + ' '
ascii += c >= 0x20 && c < 0x7f ? bin[i] : '.'
if ((i + 1) % 16 === 0) {
hexBuf.value.unshift({ ascii, hex: hex.trimEnd(), ts: Date.now() })
hex = ''; ascii = ''
}
}
if (hex) hexBuf.value.unshift({ ascii, hex: hex.trimEnd(), ts: Date.now() })
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
}
watch(() => store.buffers, (b) => {
if (!myClientSession.value) return
const sid = myClientSession.value.sessionId
const buf = b[sid]
if (!buf || buf.length === 0) return
const all = buf.join('')
b[sid] = []
pushHex(all)
}, { deep: true })
function clearHex() { hexBuf.value = [] }
function clearUsbLog() { usbLog.value = [] }
async function refresh() { await store.refresh() }
onMounted(refresh)
function directionLabel(d: UsbDirection) {
return d === 'self-out' ? '我用对方的' : '对方用我的'
}
function modeLabel(m: Mode) {
return m === 'serial' ? '串口' : m === 'usb' ? 'USB 字节桥' : 'USB/IP (Linux)'
}
const isSerial = computed(() => mode.value === 'serial')
const isUsb = computed(() => mode.value === 'usb')
const isUsbip = computed(() => mode.value === 'usbip')
const endpoints = computed<UsbEndpointInfo[]>(() => myClientSession.value?.info?.endpoints || [])
const vcomPath = computed<string | undefined>(() => myClientSession.value?.info?.userVirtualPath)
</script>
<template>
<div class="usb-panel">
<!-- 方向 + 模式 -->
<div class="dir-row">
<span class="dir-label">方向</span>
<div class="dir-toggle">
<button :class="{ active: direction === 'self-out' }" @click="direction = 'self-out'">我用对方的</button>
<button :class="{ active: direction === 'self-in' }" @click="direction = 'self-in'">对方用我的</button>
</div>
<span class="dir-label" style="margin-left: 8px">类型</span>
<div class="dir-toggle">
<button :class="{ active: mode === 'serial' }" @click="mode = 'serial'">串口</button>
<button :class="{ active: mode === 'usb' }" @click="mode = 'usb'">USB</button>
<button :class="{ active: mode === 'usbip' }" @click="mode = 'usbip'">USB/IP</button>
</div>
<el-button size="small" :loading="scanning" @click="scan" :disabled="!peer">
{{ direction === 'self-out' ? '拉取对端' : '刷新本机' }}
</el-button>
</div>
<div v-if="isUsbip" class="hint">
USB/IP Linux 内核自带的真透明 USB 透传 (设备出现在本机 lsusb).
macOS/Windows 默认不支持. Linux 用户需手动装 <code>usbip</code> + 加载 <code>vhci_hcd</code> 模块.
设备端需先 <code>usbip bind -b &lt;busid&gt;</code>.
</div>
<div v-if="!visibleDevices.length" class="empty-tip">
<p v-if="!peer">请先在 sidebar 选择一台设备</p>
<p v-else>暂无可用的 {{ modeLabel(mode) }} 设备</p>
<small v-if="direction === 'self-in' && mode === 'serial'">Linux 用户如未看到 /dev/ttyUSB*, 需要在 dialout 组.</small>
</div>
<el-table v-else :data="visibleDevices" size="small" highlight-current-row @row-click="(row: UsbDeviceInfo) => selectedBus = row.busId">
<el-table-column prop="busId" label="设备" width="220" />
<el-table-column label="VID:PID" width="120">
<template #default="{ row }">
<code>{{ row.vid.toString(16).padStart(4,'0') }}:{{ row.pid.toString(16).padStart(4,'0') }}</code>
</template>
</el-table-column>
<el-table-column prop="product" label="产品" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" type="primary" :disabled="!!myClientSession" @click.stop="() => { selectedBus = row.busId; baudRate = row.baudRate || 9600; attach() }">
附加
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="isSerial && visibleDevices.length" class="baud-row">
<span>默认波特率</span>
<el-select v-model.number="baudRate" size="small" style="width: 120px">
<el-option label="9600" :value="9600" />
<el-option label="19200" :value="19200" />
<el-option label="38400" :value="38400" />
<el-option label="57600" :value="57600" />
<el-option label="74880" :value="74880" />
<el-option label="115200" :value="115200" />
<el-option label="230400" :value="230400" />
<el-option label="460800" :value="460800" />
<el-option label="921600" :value="921600" />
</el-select>
<small style="color:var(--el-text-color-secondary)">点击附加后弹窗确认</small>
</div>
<div v-if="isUsb && visibleDevices.length" class="baud-row">
<span>Interface #</span>
<el-input-number v-model="usbIfaceNum" :min="0" :max="15" size="small" controls-position="right" style="width: 90px" />
<el-checkbox v-model="detachKern" style="margin-left: 8px">Linux 自动 detach 内核驱动</el-checkbox>
</div>
<!-- 已有 session: 显示状态 + 操作 -->
<div v-if="myClientSession" class="attach-box">
<div class="attach-row">
<span>已附加: <code>{{ myClientSession.busId }}</code> · {{ directionLabel(myClientSession.direction) }} · <code>{{ modeLabel(myClientSession.kind) }}</code></span>
<span>流量: in {{ myClientSession.bytesIn }}B / out {{ myClientSession.bytesOut }}B</span>
<div class="spacer" />
<el-button v-if="isSerial && !vcomPath" size="small" @click="sendText">发送文本</el-button>
<el-button v-if="isSerial && !vcomPath" size="small" @click="clearHex">清屏</el-button>
<el-button v-if="isUsb" size="small" @click="clearUsbLog">清日志</el-button>
<el-button size="small" type="danger" @click="detach(myClientSession)">关闭</el-button>
</div>
<!-- 虚拟串口: 醒目的路径展示 -->
<div v-if="isSerial && vcomPath" class="vcom-banner">
<div class="vcom-banner-row">
<span class="vcom-banner-label">本机虚拟串口 (PuTTY / Arduino IDE / screen 直接打开):</span>
</div>
<div class="vcom-banner-row">
<code class="vcom-banner-path">{{ vcomPath }}</code>
<small class="vcom-hint">波特率 {{ baudRate }}, 8N1</small>
</div>
</div>
<!-- 串口: hex 流 -->
<div v-if="isSerial" ref="hexEl" class="hex-view">
<div v-if="!hexBuf.length" class="hex-empty">等待串口数据…</div>
<div v-for="(line, i) in hexBuf" :key="i" class="hex-line">
<span class="hex-bytes">{{ line.hex }}</span>
<span class="hex-ascii">{{ line.ascii }}</span>
</div>
</div>
<!-- USB 字节桥: endpoint 列表 + 控制/批量表单 -->
<div v-if="isUsb" class="usb-bridge">
<div v-if="endpoints.length" class="ep-list">
<strong>Endpoints:</strong>
<code v-for="ep in endpoints" :key="ep.endpointNumber" class="ep">
EP{{ ep.endpointNumber.toString(16).toUpperCase() }} ({{ ep.direction === 'in' ? 'IN' : 'OUT' }}, {{ ep.transferType }}, {{ ep.packetSize }}B)
</code>
</div>
<div v-else class="ep-list"><em>无 endpoint (可能需要先选择 interface)</em></div>
<!-- 控制传输 -->
<div class="usb-section">
<h4>控制传输 (Control Transfer)</h4>
<div class="ctrl-row">
<el-select v-model="directionBit" size="small" style="width: 130px">
<el-option label="device-to-host (IN)" value="device-to-host" />
<el-option label="host-to-device (OUT)" value="host-to-device" />
</el-select>
<el-select v-model="reqType" size="small" style="width: 90px">
<el-option label="standard" value="standard" />
<el-option label="class" value="class" />
<el-option label="vendor" value="vendor" />
</el-select>
<el-select v-model="recipient" size="small" style="width: 90px">
<el-option label="device" value="device" />
<el-option label="interface" value="interface" />
<el-option label="endpoint" value="endpoint" />
<el-option label="other" value="other" />
</el-select>
</div>
<div class="ctrl-row">
<span>request</span>
<el-input-number v-model="bRequest" :min="0" :max="255" size="small" controls-position="right" style="width: 100px" />
<span>wValue</span>
<el-input-number v-model="wValue" :min="0" :max="65535" size="small" controls-position="right" style="width: 120px" />
<span>wIndex</span>
<el-input-number v-model="wIndex" :min="0" :max="65535" size="small" controls-position="right" style="width: 120px" />
</div>
<div v-if="directionBit === 'host-to-device'" class="ctrl-row">
<span>data (hex)</span>
<el-input v-model="ctrlData" size="small" placeholder="01 02 03 ..." style="flex: 1" />
</div>
<div v-else class="ctrl-row">
<span>length</span>
<el-input-number v-model="ctrlLength" :min="1" :max="4096" size="small" controls-position="right" style="width: 100px" />
</div>
<div class="ctrl-row">
<el-button size="small" type="primary" @click="directionBit === 'host-to-device' ? doCtrlOut() : doCtrlIn()">
发送
</el-button>
</div>
</div>
<!-- 批量传输 -->
<div class="usb-section">
<h4>批量 / 中断 传输 (Bulk / Interrupt)</h4>
<div class="ctrl-row">
<el-radio-group v-model="bulkDirection" size="small">
<el-radio-button label="in" value="in">IN (读)</el-radio-button>
<el-radio-button label="out" value="out">OUT (写)</el-radio-button>
</el-radio-group>
<span>EP</span>
<el-input-number v-model="bulkEndpoint" :min="0" :max="255" size="small" controls-position="right" style="width: 100px" />
<span v-if="bulkDirection === 'in'">length</span>
<el-input-number v-if="bulkDirection === 'in'" v-model="bulkLength" :min="1" :max="65536" size="small" controls-position="right" style="width: 100px" />
<span>timeout(ms)</span>
<el-input-number v-model="bulkTimeout" :min="100" :max="60000" :step="500" size="small" controls-position="right" style="width: 120px" />
</div>
<div v-if="bulkDirection === 'out'" class="ctrl-row">
<span>data (hex)</span>
<el-input v-model="bulkData" size="small" placeholder="01 02 03 ..." style="flex: 1" />
</div>
<div class="ctrl-row">
<el-button size="small" type="primary" @click="doBulkTransfer">发送</el-button>
</div>
</div>
<!-- 日志 -->
<div class="usb-log">
<h4>调用日志</h4>
<div class="log-list">
<div v-for="(l, i) in usbLog" :key="i" class="log-line" :class="{ ok: l.ok, fail: !l.ok }">
<span class="log-ts">{{ new Date(l.ts).toLocaleTimeString() }}</span>
<span class="log-tag">{{ l.tag }}</span>
<span class="log-text">{{ l.text }}</span>
<pre v-if="l.hex" class="log-hex">{{ l.hex }}</pre>
</div>
</div>
</div>
</div>
</div>
<!-- 对端发起的 session -->
<div v-if="serverSession" class="server-box">
<strong>对方正在使用我的设备:</strong>
<code>{{ serverSession.busId }}</code> · {{ directionLabel('self-in') }} · {{ modeLabel(serverSession.kind) }}
<span style="margin-left: 8px">流量: in {{ serverSession.bytesIn }}B / out {{ serverSession.bytesOut }}B</span>
<div class="spacer" />
<el-button size="small" type="danger" @click="detach(serverSession)">停止</el-button>
</div>
</div>
</template>
<style scoped>
.usb-panel { padding: 16px; overflow-y: auto; height: 100%; }
.dir-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
.dir-label { font-size: 12px; color: var(--el-text-color-secondary); }
.dir-toggle { display: flex; background: var(--el-fill-color-light); border-radius: 6px; padding: 2px; }
.dir-toggle button { background: transparent; border: none; padding: 5px 12px; font-size: 13px; border-radius: 4px; cursor: pointer; color: var(--el-text-color-regular); }
.dir-toggle button.active { background: var(--el-color-primary); color: #fff; font-weight: 500; }
.hint { background: var(--el-fill-color-light); padding: 8px 12px; border-radius: 6px; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 12px; }
.hint code { background: #1e1e1e10; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
.empty-tip { padding: 24px; text-align: center; color: var(--el-text-color-secondary); }
.empty-tip small { display: block; margin-top: 8px; font-size: 11px; }
.baud-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; font-size: 12px; color: var(--el-text-color-secondary); flex-wrap: wrap; }
.vcom-row { margin: 8px 0; padding: 8px 12px; background: var(--el-color-primary-light-9); border: 1px solid var(--el-color-primary-light-5); border-radius: 6px; }
.vcom-row .vcom-options { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--el-text-color-secondary); }
.vcom-banner { background: var(--el-color-success-light-9); border-top: 1px solid var(--el-color-success-light-5); padding: 12px 16px; }
.vcom-banner-row { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
.vcom-banner-row:last-child { margin-bottom: 0; }
.vcom-banner-label { font-size: 12px; color: var(--el-text-color-secondary); font-weight: 500; }
.vcom-banner-path { font-family: ui-monospace, Consolas, monospace; font-size: 16px; font-weight: 600; color: var(--el-color-success); background: #fff; padding: 4px 10px; border-radius: 4px; border: 1px solid var(--el-color-success-light-5); user-select: all; }
.vcom-hint { color: var(--el-text-color-secondary); font-size: 11px; }
.attach-box { margin-top: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; overflow: hidden; }
.attach-row { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--el-fill-color-light); font-size: 12px; flex-wrap: wrap; }
.attach-row .spacer { flex: 1; }
.server-box { margin-top: 12px; padding: 8px 12px; background: var(--el-color-warning-light-9); border: 1px solid var(--el-color-warning-light-5); border-radius: 6px; display: flex; align-items: center; font-size: 12px; }
.server-box .spacer { flex: 1; }
.hex-view {
font-family: ui-monospace, Consolas, monospace;
font-size: 12px;
background: #0d0d0d;
color: #d4d4d4;
height: 280px;
overflow-y: auto;
padding: 8px;
}
.hex-empty { color: #888; text-align: center; padding: 24px; }
.hex-line { display: flex; gap: 12px; line-height: 1.4; }
.hex-bytes { color: #79c0ff; min-width: 360px; word-break: break-all; }
.hex-ascii { color: #ffa657; }
.usb-bridge { padding: 12px; background: var(--el-bg-color); }
.ep-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; font-size: 12px; }
.ep { background: var(--el-fill-color-light); padding: 2px 6px; border-radius: 3px; font-size: 11px; }
.usb-section { border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; margin-top: 12px; }
.usb-section h4 { margin: 0 0 8px 0; font-size: 13px; font-weight: 600; }
.ctrl-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; font-size: 12px; }
.usb-log { margin-top: 16px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
.usb-log h4 { margin: 0 0 8px 0; font-size: 13px; font-weight: 600; }
.log-list { font-family: ui-monospace, Consolas, monospace; font-size: 11px; max-height: 200px; overflow-y: auto; background: #0d0d0d; padding: 6px; border-radius: 4px; }
.log-line { display: flex; gap: 8px; padding: 2px 0; line-height: 1.4; }
.log-line.fail { color: #f97583; }
.log-line.ok { color: #85e89d; }
.log-ts { color: #888; }
.log-tag { color: #79c0ff; min-width: 70px; }
.log-text { color: #d4d4d4; flex: 1; word-break: break-word; }
.log-hex { margin: 2px 0 2px 80px; color: #79c0ff; white-space: pre-wrap; word-break: break-all; font-size: 11px; }
</style>
+274
View File
@@ -0,0 +1,274 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { TerminalSessionInfo, ForwardSessionInfo, UsbDeviceInfo, UsbSessionInfo, UsbKind, UsbAttachConfig, ApprovalRequestView, AuditEntry } from '@/api'
interface TerminalChannelState {
buffer: string[] // 字节 base64 数组, UI 端自行解码
lastWrite: number
openedAt: number
shell: string
rows: number
cols: number
readOnly: boolean
closed: boolean
}
export const useTerminalStore = defineStore('terminal', () => {
// 客户端: sessionId -> peerId / 状态
const sessions = ref<Record<string, { peerId: string; shell: string; rows: number; cols: number; readOnly: boolean; openedAt: number; closed: boolean }>>({})
// 字节缓冲: sessionId -> base64 字符串数组 (UI 渲染时一次性 concat -> utf8 -> xterm.write)
const buffers = ref<Record<string, string[]>>({})
async function open(peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) {
const r = await window.api.terminalOpen(peerId, opts)
if (r.ok && r.sessionId) {
sessions.value[r.sessionId] = {
peerId,
shell: '',
rows: opts?.rows ?? 24,
cols: opts?.cols ?? 80,
readOnly: !!opts?.readOnly,
openedAt: Date.now(),
closed: false,
}
buffers.value[r.sessionId] = []
}
return r
}
function onOpened(p: { sessionId: string; peerId: string; shell: string; rows: number; cols: number }) {
const s = sessions.value[p.sessionId]
if (!s) return
s.shell = p.shell; s.rows = p.rows; s.cols = p.cols
}
function onOutput(p: { sessionId: string; peerId: string; data: string }) {
const buf = buffers.value[p.sessionId]
if (!buf) buffers.value[p.sessionId] = [p.data]
else buf.push(p.data)
}
function onClosed(p: { sessionId: string; peerId: string; reason?: string }) {
const s = sessions.value[p.sessionId]
if (s) s.closed = true
}
function takeBuffer(sessionId: string): string {
const buf = buffers.value[sessionId]
if (!buf) return ''
const out = buf.join('')
buffers.value[sessionId] = []
return out
}
function appendBuffer(sessionId: string, chunk: string) {
const buf = buffers.value[sessionId] || (buffers.value[sessionId] = [])
buf.push(chunk)
}
async function input(sessionId: string, dataBase64: string) {
return window.api.terminalInput(sessionId, dataBase64)
}
async function resize(sessionId: string, rows: number, cols: number) {
return window.api.terminalResize(sessionId, rows, cols)
}
async function close(sessionId: string, reason?: string) {
return window.api.terminalClose(sessionId, reason)
}
async function refresh() {
const r = await window.api.terminalListSessions()
// 仅补充 server 端已知 (本机被控) 状态, 不覆盖 client 端 (UI 已建)
return r
}
const list = computed(() => Object.entries(sessions.value).map(([id, s]) => ({ sessionId: id, ...s })))
return {
sessions, buffers,
open, onOpened, onOutput, onClosed,
takeBuffer, appendBuffer,
input, resize, close, refresh,
list,
}
})
export const useForwardStore = defineStore('forward', () => {
// 我发起的 (clientSessions): 我方是 TCP server (self-out) 或 TCP client (self-in)
const client = ref<ForwardSessionInfo[]>([])
// 对方发起的 (serverSessions): 我方是 TCP client (self-out) 或 TCP server (self-in)
const server = ref<ForwardSessionInfo[]>([])
async function refresh() {
const r = await window.api.forwardListSessions()
client.value = r.client || []
server.value = r.server || []
}
async function open(peerId: string, args: { direction?: 'self-out' | 'self-in'; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) {
const r = await window.api.forwardOpen(peerId, args)
if (r.ok) await refresh()
return r
}
async function close(sessionId: string) {
const r = await window.api.forwardClose(sessionId)
await refresh()
return r
}
const sessions = computed(() => [...client.value, ...server.value])
function onOpened(_info: any) { /* no-op */ }
function onClosed(_info: any) {
// 局部更新; 完整刷新由 forward:opened/closed 事件触发, 这里 best-effort
const idx1 = client.value.findIndex(s => s.sessionId === _info.sessionId)
if (idx1 >= 0) {
const cur = client.value[idx1]
client.value[idx1] = { ...cur, bytesIn: _info.bytesIn, bytesOut: _info.bytesOut }
}
const idx2 = server.value.findIndex(s => s.sessionId === _info.sessionId)
if (idx2 >= 0) {
const cur = server.value[idx2]
server.value[idx2] = { ...cur, bytesIn: _info.bytesIn, bytesOut: _info.bytesOut }
}
}
return { client, server, sessions, refresh, open, close, onOpened, onClosed }
})
export const useUsbStore = defineStore('usb', () => {
const devicesByPeer = ref<Record<string, UsbDeviceInfo[]>>({})
const myLocalDevices = ref<UsbDeviceInfo[]>([])
const client = ref<UsbSessionInfo[]>([]) // 我发起的
const server = ref<UsbSessionInfo[]>([]) // 对方发起的
const sessions = computed(() => [...client.value, ...server.value])
// sessionId -> base64 字符串缓冲 (串口 hex 显示用)
const buffers = ref<Record<string, string[]>>({})
async function list(peerId: string) {
const r = await window.api.usbList(peerId)
if (r.ok && r.devices) devicesByPeer.value[peerId] = r.devices
return r
}
async function listLocal() {
const r = await window.api.usbListLocal()
if (r.ok && r.devices) myLocalDevices.value = r.devices
return r
}
async function refresh() {
const r = await window.api.usbListSessions()
if (Array.isArray(r)) {
client.value = r
server.value = []
} else {
client.value = r.client || []
server.value = r.server || []
}
}
async function attach(peerId: string, args: { busId: string; direction?: 'self-out' | 'self-in'; config: UsbAttachConfig }) {
const r = await window.api.usbAttach(peerId, args.busId, { direction: args.direction, config: args.config })
if (r.ok) await refresh()
return r
}
// 串口: 发送字节
async function serialSend(sessionId: string, dataBase64: string) {
return window.api.usbSerialSend(sessionId, dataBase64)
}
// USB: 控制传输
async function ctrlOut(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, dataBase64?: string) {
return window.api.usbCtrlOut(sessionId, setup, dataBase64)
}
async function ctrlIn(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number) {
const r = await window.api.usbCtrlIn(sessionId, setup, length)
return { ok: r.ok, dataBase64: r.dataBase64, status: r.status }
}
// USB: 批量传输
async function bulkOut(sessionId: string, endpoint: number, dataBase64: string) {
return window.api.usbBulkOut(sessionId, endpoint, dataBase64)
}
async function bulkIn(sessionId: string, endpoint: number, length: number, timeoutMs?: number) {
const r = await window.api.usbBulkIn(sessionId, endpoint, length, timeoutMs)
return { ok: r.ok, dataBase64: r.dataBase64, status: r.status }
}
async function detach(sessionId: string) {
const r = await window.api.usbDetach(sessionId)
await refresh()
return r
}
function onOutput(p: { sessionId: string; peerId: string; data: string }) {
const buf = buffers.value[p.sessionId] || (buffers.value[p.sessionId] = [])
buf.push(p.data)
}
function takeBuffer(sessionId: string): string {
const buf = buffers.value[sessionId]
if (!buf) return ''
const out = buf.join('')
buffers.value[sessionId] = []
return out
}
function onClosed(_info: any) {
const upd = (arr: typeof client.value) => {
const idx = arr.findIndex(s => s.sessionId === _info.sessionId)
if (idx >= 0) {
const cur = arr[idx]
arr[idx] = { ...cur, bytesIn: _info.bytesIn, bytesOut: _info.bytesOut }
}
}
upd(client.value)
upd(server.value)
}
return {
devicesByPeer, myLocalDevices, client, server, sessions, buffers,
list, listLocal, refresh, attach,
serialSend, ctrlOut, ctrlIn, bulkOut, bulkIn,
detach, onOutput, takeBuffer, onClosed,
}
})
export const useApprovalStore = defineStore('approval', () => {
const queue = ref<ApprovalRequestView[]>([])
async function refresh() {
queue.value = await window.api.remoteApprovalList()
}
function add(req: ApprovalRequestView) {
queue.value = [...queue.value, req]
}
async function reply(requestId: string, ok: boolean, remember = false) {
await window.api.remoteApprovalReply(requestId, ok, remember)
queue.value = queue.value.filter(r => r.requestId !== requestId)
}
return { queue, refresh, add, reply }
})
export const useAuditStore = defineStore('audit', () => {
const entries = ref<AuditEntry[]>([])
async function refresh(limit = 200, since?: number) {
entries.value = await window.api.auditList({ limit, since })
}
async function prune(days?: number) {
return window.api.auditPrune(days ? days * 24 * 3600 * 1000 : undefined)
}
return { entries, refresh, prune }
})