feat: 网盘浏览下载 + 远程功能修复 + 连接状态同步 + Linux 图标打包
新增网盘功能 - FileServer 增加 GET /drive/meta|list|download, 每机长期 token 校验 + 目录穿越防护 - 新增 drive.ts 客户端: listDrive / getDriveRoot / downloadDriveFile / collectDriveFiles - ipc 暴露 drive:list|root|download|openFolder|pickDir, DB 增 drive_token 列 + ALTER 迁移 - 新增 DrivePanel.vue, 聊天框加"网盘" tab, 设置页加网盘分区 - 支持整目录递归下载并保持层级 修复远程功能 - 终端: 控制端收到的 output 帧被直接丢弃 (case 'output': return), 导致 xterm 全空白 - ChatServer: ws 构造即 listen, 'listening' 可能早于 start() 挂监听, 导致永久挂起 - USB: serverAttach 自行生成 sessionId 与控制端不一致 (数据帧查表落空); libusb 枚举误用 getDevices() (WebUSB 语义, 只返回已授权设备) 改 loadDevices(); control transfer 传数字 bmRequestType (node-usb v3 要字符串枚举); detachKernelDriver 挪到 selectConfiguration 之前 (否则 EBUSY) - 转发 UI: store 的 onOpened 是 no-op 致对方发起的转发不显示、无轮询致流量/TTL 不刷新、 对方发起的会话关不掉 (新增 closeSession, server 侧关闭需通知对端否则端口泄漏)、 入口/目标补归属标注、方向标签语义统一、监听端口输入框加宽 修复"在线却显示等待重连" - 根因: 启动时主进程先连上对端 (connectTo 早于 createMainWindow), device:wsState 在渲染层订阅前发出去被丢弃; 之后连接一直保持, 再无 open 事件 -> UI 永久停在等待重连 - 改为推全量快照 + 渲染层 mount 时主动拉取 (app:state); 渲染层先注册监听再 await - 通道语义改为 outgoing 或 inbound 任一 WS 活着即可用 (原来只看 outgoing) 修复 Linux 安装后没图标 - linux.icon 指向单张 PNG 时 app-builder 解析不出尺寸 (size=0), 图标被装进 hicolor/0x0/ 非法目录, 桌面环境找不到; 改为指向 resources/icons 目录 (8 个标准尺寸) - StartupWMClass 填的是 productName, 与窗口真实 WM_CLASS (可执行文件名) 对不上, 任务栏找不到启动器; 改为 local-net-msg 其他 - 新增 scripts/selftest 自测框架 (两个虚拟节点 + 真实 ChatServer/ChatClient), 30 条用例全过 - 重写 scripts/make-icon.js 生成多尺寸图标集 (原脚本引用的源图已不存在, sharp 依赖也缺) - 新增 docs/drive.md, docs/connection-status.md
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
// LocalNetMsg 远程功能自测 (terminal / forward / usb)
|
||||
// 在一个进程里跑两个虚拟节点 (A = 控制端, B = 被控端), 走真实的
|
||||
// ChatServer + ChatClient + 三个 remote manager, 接线方式与 src/main/ipc.ts 完全一致.
|
||||
//
|
||||
// 用法:
|
||||
// npm run selftest # 全部
|
||||
// npm run selftest -- terminal # 只跑某些用例
|
||||
import { spawn, ChildProcess } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, rmSync, openSync, closeSync, readSync, writeSync, writeFileSync, readFileSync } from 'node:fs'
|
||||
import * as fs from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { connect } from 'node:net'
|
||||
import { createServer, Server } from 'node:http'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { ChatServer } from '../../src/main/chat-server'
|
||||
import { ChatClient } from '../../src/main/chat-client'
|
||||
import { FileServer } from '../../src/main/file-server'
|
||||
import { listDrive, downloadDriveFile, collectDriveFiles } from '../../src/main/drive'
|
||||
import { computeWsState } from '../../src/main/ws-state'
|
||||
import { TerminalManager } from '../../src/main/remote/terminal'
|
||||
import { ForwardManager } from '../../src/main/remote/forward'
|
||||
import { UsbBridgeManager, toUsbControlSetup } from '../../src/main/remote/usb'
|
||||
import { updateSettings } from '../../src/main/settings'
|
||||
import { listAudit } from '../../src/main/db'
|
||||
import type { DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, UsbFrame } from '../../src/main/protocol'
|
||||
|
||||
// ============ 小型测试框架 ============
|
||||
interface CaseResult { name: string; ok: boolean; detail: string; ms: number }
|
||||
const results: CaseResult[] = []
|
||||
let current = ''
|
||||
|
||||
function assert(cond: any, msg: string) {
|
||||
if (!cond) throw new Error(msg)
|
||||
}
|
||||
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
async function runCase(name: string, fn: () => Promise<void>) {
|
||||
current = name
|
||||
const t0 = Date.now()
|
||||
try {
|
||||
await fn()
|
||||
results.push({ name, ok: true, detail: '', ms: Date.now() - t0 })
|
||||
console.log(` \x1b[32mPASS\x1b[0m ${name} (${Date.now() - t0}ms)`)
|
||||
} catch (e: any) {
|
||||
const detail = String(e?.message || e)
|
||||
results.push({ name, ok: false, detail, ms: Date.now() - t0 })
|
||||
console.log(` \x1b[31mFAIL\x1b[0m ${name} (${Date.now() - t0}ms): ${detail}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 虚拟节点 ============
|
||||
interface UiEvent { kind: string; sessionId?: string; peerId?: string; data?: string; text?: string; reason?: string }
|
||||
|
||||
class Node {
|
||||
info: DeviceInfo
|
||||
server: ChatServer
|
||||
client: ChatClient
|
||||
fileServer: FileServer
|
||||
terminal: TerminalManager
|
||||
forward: ForwardManager
|
||||
usb: UsbBridgeManager
|
||||
events: UiEvent[] = []
|
||||
driveDir = ''
|
||||
driveToken = ''
|
||||
|
||||
constructor(id: string, name: string) {
|
||||
this.info = {
|
||||
deviceId: id, name, hostname: name, platform: 'linux', version: 1,
|
||||
appVersion: '0.1.0', address: '127.0.0.1', chatPort: 0, filePort: 0, ts: Date.now(),
|
||||
}
|
||||
this.server = new ChatServer(0)
|
||||
this.client = new ChatClient(this.info)
|
||||
this.fileServer = new FileServer(0, join(tmpdir(), `lnm-selftest-dl-${id.slice(-4)}`), {
|
||||
getDriveRoot: () => this.driveDir,
|
||||
getDriveToken: () => this.driveToken,
|
||||
isDriveEnabled: () => true,
|
||||
})
|
||||
this.terminal = new TerminalManager(this.client, id)
|
||||
this.forward = new ForwardManager(this.client)
|
||||
this.usb = new UsbBridgeManager(this.client)
|
||||
}
|
||||
|
||||
async start() {
|
||||
const port = await this.server.start()
|
||||
this.info.chatPort = port
|
||||
const filePort = await this.fileServer.start()
|
||||
this.info.filePort = filePort
|
||||
// 网盘目录: 放几个测试文件
|
||||
this.driveDir = join(tmpdir(), `lnm-selftest-drive-${this.info.deviceId.slice(-4)}`)
|
||||
rmSync(this.driveDir, { recursive: true, force: true })
|
||||
mkdirSync(join(this.driveDir, 'sub', 'deep'), { recursive: true })
|
||||
writeFileSync(join(this.driveDir, 'hello.txt'), 'HELLO-FROM-DRIVE')
|
||||
writeFileSync(join(this.driveDir, 'sub', 'nested.txt'), 'NESTED-FROM-DRIVE')
|
||||
writeFileSync(join(this.driveDir, 'sub', 'deep', 'deeper.txt'), 'DEEPER-FROM-DRIVE')
|
||||
this.driveToken = `tok-${this.info.deviceId.slice(-4)}`
|
||||
this.info.driveToken = this.driveToken
|
||||
|
||||
// ---- 与 src/main/ipc.ts 完全一致的帧路由 ----
|
||||
const TRACE = !!process.env.LNM_TRACE
|
||||
const route = (peer: DeviceInfo, frame: WsFrame) => {
|
||||
if (TRACE) {
|
||||
const p: any = (frame as any).payload
|
||||
console.log(`[trace ${this.info.name}] <- ${frame.type}${p ? `/${p.type}` : ''} ${p?.dir || ''} ${p?.sessionId?.slice(0, 8) || ''} ${p?.data ? `(${Buffer.from(p.data, 'base64').length}B)` : ''}`)
|
||||
}
|
||||
if (frame.type === 'terminal') {
|
||||
this.terminal.handleIncoming(peer, frame.payload as TerminalFrame)
|
||||
.catch((e) => console.warn(`[${this.info.name}] terminal handle error:`, e?.message))
|
||||
} else if (frame.type === 'forward') {
|
||||
this.forward.handleIncoming(peer, frame.payload as ForwardFrame)
|
||||
} else if (frame.type === 'usb') {
|
||||
this.usb.handleIncoming(peer, frame.payload as UsbFrame)
|
||||
}
|
||||
}
|
||||
this.server.on('frame', route)
|
||||
this.client.on('message', route)
|
||||
|
||||
// ---- 渲染层能看到的事件 (对应 ipc.ts 里 broadcastToRenderer) ----
|
||||
this.terminal.on('output', (sessionId, bytes, peerId) =>
|
||||
this.events.push({ kind: 'terminal:output', sessionId, peerId, data: bytes.toString('base64') }))
|
||||
this.terminal.on('sessionOpened', (sessionId, peerId) =>
|
||||
this.events.push({ kind: 'terminal:opened', sessionId, peerId }))
|
||||
this.terminal.on('sessionClosed', (sessionId, peerId, reason) =>
|
||||
this.events.push({ kind: 'terminal:closed', sessionId, peerId, reason }))
|
||||
this.terminal.on('approvalRequested', (req) =>
|
||||
this.events.push({ kind: 'approval', peerId: req.peerId, text: req.detail }))
|
||||
this.forward.on('sessionOpened', (i: any) =>
|
||||
this.events.push({ kind: 'forward:opened', sessionId: i.sessionId, peerId: i.peerId }))
|
||||
this.forward.on('sessionClosed', (i: any) =>
|
||||
this.events.push({ kind: 'forward:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason }))
|
||||
this.usb.on('output', (sessionId, bytes, peerId) =>
|
||||
this.events.push({ kind: 'usb:output', sessionId, peerId, data: bytes.toString('base64') }))
|
||||
this.usb.on('sessionOpened', (i: any) =>
|
||||
this.events.push({ kind: 'usb:opened', sessionId: i.sessionId, peerId: i.peerId }))
|
||||
this.usb.on('sessionClosed', (i: any) =>
|
||||
this.events.push({ kind: 'usb:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason }))
|
||||
this.usb.on('sessionError', (i: any) =>
|
||||
this.events.push({ kind: 'usb:error', sessionId: i.sessionId, peerId: i.peerId, text: i.error }))
|
||||
|
||||
this.client.on('error', (_peer, err) => console.warn(`[${this.info.name}] ws error: ${err.message}`))
|
||||
|
||||
// 追踪本端发出的帧
|
||||
if (TRACE) {
|
||||
const origSend = this.client.send.bind(this.client)
|
||||
;(this.client as any).send = (frame: WsFrame, deviceId?: string) => {
|
||||
const p: any = (frame as any).payload
|
||||
console.log(`[trace ${this.info.name}] -> ${frame.type}${p ? `/${p.type}` : ''} ${p?.dir || ''} ${p?.sessionId?.slice(0, 8) || ''} ${p?.data ? `(${Buffer.from(p.data, 'base64').length}B)` : ''}`)
|
||||
return origSend(frame, deviceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectTo(other: Node) {
|
||||
this.client.connectTo({ ...other.info })
|
||||
}
|
||||
|
||||
// 该节点收到的某个 kind 事件 (最近 n 秒内)
|
||||
hasEvent(kind: string, pred?: (e: UiEvent) => boolean) {
|
||||
return this.events.some(e => e.kind === kind && (!pred || pred(e)))
|
||||
}
|
||||
findEvents(kind: string) { return this.events.filter(e => e.kind === kind) }
|
||||
clearEvents() { this.events = [] }
|
||||
|
||||
async stop() {
|
||||
try { await this.server.stop() } catch {}
|
||||
try { this.client.stop() } catch {}
|
||||
try { this.terminal.stop() } catch {}
|
||||
try { this.forward.stop() } catch {}
|
||||
try { this.usb.stop() } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function decode(b64: string) { return Buffer.from(b64, 'base64').toString('utf8') }
|
||||
|
||||
// ============ 小工具 ============
|
||||
function startHttpServer(port: number, body: string): Promise<Server> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
||||
res.end(body)
|
||||
})
|
||||
s.on('error', reject)
|
||||
s.listen(port, '127.0.0.1', () => resolve(s))
|
||||
})
|
||||
}
|
||||
|
||||
// 发起 HTTP 请求, 收到完整响应后**保持连接** (隧道是单连接设计, 连接断了整个转发就结束)
|
||||
function httpGet(port: number, path = '/', timeoutMs = 5000): Promise<{ body: string; sock: Socket }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = connect({ host: '127.0.0.1', port }, () => {
|
||||
req.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: keep-alive\r\n\r\n`)
|
||||
})
|
||||
let buf = ''
|
||||
let settled = false
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve({ body: buf, sock: req })
|
||||
}
|
||||
// 目标服务是 keep-alive, 连接不会自己关 —— 按 Content-Length 判完整, 别等 'end'
|
||||
const tryComplete = () => {
|
||||
const idx = buf.indexOf('\r\n\r\n')
|
||||
if (idx < 0) return
|
||||
const m = /content-length:\s*(\d+)/i.exec(buf.slice(0, idx))
|
||||
const need = m ? Number(m[1]) : 0
|
||||
if (buf.length >= idx + 4 + need) finish()
|
||||
}
|
||||
const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error(`http 超时 (收到 ${buf.length}B): ${buf.slice(0, 120)}`)) } }, timeoutMs)
|
||||
req.on('data', (d) => { buf += d.toString('utf8'); tryComplete() })
|
||||
req.on('end', () => finish())
|
||||
req.on('error', (e) => { if (!settled) { settled = true; clearTimeout(timer); reject(e) } })
|
||||
})
|
||||
}
|
||||
|
||||
function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 50): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t0 = Date.now()
|
||||
const tick = () => {
|
||||
if (pred()) return resolve()
|
||||
if (Date.now() - t0 > timeoutMs) return reject(new Error('等待超时'))
|
||||
setTimeout(tick, stepMs)
|
||||
}
|
||||
tick()
|
||||
})
|
||||
}
|
||||
|
||||
// 造一个 socat 串口对: 一头是 PTY (给被控端当真实串口), 另一头我们直接读写
|
||||
async function startSocatSerial(linkDir: string): Promise<{ proc: ChildProcess; userPath: string; internalPath: string }> {
|
||||
const userPath = join(linkDir, 'user')
|
||||
const internalPath = join(linkDir, 'internal')
|
||||
const proc = spawn('socat', [
|
||||
'-d', '-d',
|
||||
`pty,raw,echo=0,link=${userPath}`,
|
||||
`pty,raw,echo=0,link=${internalPath}`,
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let err = ''
|
||||
proc.stderr?.on('data', (b) => { err += b.toString() })
|
||||
const t0 = Date.now()
|
||||
while (!(existsSync(userPath) && existsSync(internalPath))) {
|
||||
if (Date.now() - t0 > 5000) { proc.kill(); throw new Error('socat 起不来: ' + err) }
|
||||
await sleep(100)
|
||||
}
|
||||
return { proc, userPath, internalPath }
|
||||
}
|
||||
|
||||
// 打开 PTY 的一端做非阻塞读写
|
||||
function openPty(path: string) {
|
||||
const fd = openSync(path, fs.constants.O_RDWR | fs.constants.O_NONBLOCK)
|
||||
return {
|
||||
fd,
|
||||
write: (s: string | Buffer) => writeSync(fd, s),
|
||||
read: (): string => {
|
||||
const buf = Buffer.alloc(4096)
|
||||
try {
|
||||
const n = readSync(fd, buf, 0, buf.length, null)
|
||||
return n > 0 ? buf.slice(0, n).toString('utf8') : ''
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'EAGAIN' || e?.code === 'EWOULDBLOCK') return ''
|
||||
throw e
|
||||
}
|
||||
},
|
||||
close: () => { try { closeSync(fd) } catch {} },
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 主流程 ============
|
||||
async function main() {
|
||||
const filter = process.argv.slice(2).filter(a => !a.startsWith('-'))
|
||||
const want = (name: string) => !filter.length || filter.some(f => name.toLowerCase().includes(f.toLowerCase()))
|
||||
const step = (m: string) => console.log(`[step] ${m}`)
|
||||
|
||||
step('建节点 A')
|
||||
const A = new Node('peer-aaaaaaaa-0000-4000-8000-00000000000a', '控制端A')
|
||||
step('建节点 B')
|
||||
const B = new Node('peer-bbbbbbbb-0000-4000-8000-00000000000b', '被控端B')
|
||||
step('启动 A')
|
||||
await A.start()
|
||||
step('启动 B')
|
||||
await B.start()
|
||||
step(`节点就绪 A(:${A.info.chatPort}) B(:${B.info.chatPort})`)
|
||||
|
||||
// 双向连通 (模拟 discovery 找到对方后双方都连上)
|
||||
step('建立 WS 连接')
|
||||
A.connectTo(B)
|
||||
B.connectTo(A)
|
||||
await waitFor(() => A.client.isOpen(B.info.deviceId) && B.client.isOpen(A.info.deviceId), 8000)
|
||||
console.log(`\n两个节点已连通: A(:${A.info.chatPort}) <-> B(:${B.info.chatPort})\n`)
|
||||
|
||||
// 授权: 双方互相放行, 避免弹窗 (等价于设置里勾了"允许")
|
||||
updateSettings({
|
||||
remoteEnabled: true,
|
||||
remoteAllowPeers: {
|
||||
[A.info.deviceId]: { terminal: true, forward: true, usb: true },
|
||||
[B.info.deviceId]: { terminal: true, forward: true, usb: true },
|
||||
},
|
||||
})
|
||||
|
||||
// ============ 终端 ============
|
||||
console.log('\x1b[1m[终端]\x1b[0m')
|
||||
let termSessionId = ''
|
||||
const TERM_MARK = 'LNM_TERM_SELFTEST'
|
||||
|
||||
if (want('terminal') || want('term')) {
|
||||
await runCase('T1 打开终端: 控制端收到 ack ok', async () => {
|
||||
const r = await A.terminal.openOnPeer(B.info.deviceId, { rows: 24, cols: 80 })
|
||||
assert(r.ok, `open 被拒: ${r.reason}`)
|
||||
assert(!!r.sessionId, '没有 sessionId')
|
||||
termSessionId = r.sessionId!
|
||||
})
|
||||
|
||||
await runCase('T2 控制端能收到终端输出 (提示符/回显)', async () => {
|
||||
const got = await waitFor(() => A.hasEvent('terminal:output'), 6000).then(() => true).catch(() => false)
|
||||
assert(got, '控制端 6s 内没有收到任何 terminal:output 事件 —— 终端画面是空的')
|
||||
const text = A.findEvents('terminal:output').map(e => decode(e.data!)).join('')
|
||||
assert(text.length > 0, '收到的输出为空')
|
||||
})
|
||||
|
||||
await runCase('T3 输入命令, 输出能回显到控制端', async () => {
|
||||
A.clearEvents()
|
||||
const ok = A.terminal.sendInput(termSessionId, `echo ${TERM_MARK}\n`)
|
||||
assert(ok, 'sendInput 失败 (会话不存在?)')
|
||||
await waitFor(() => A.findEvents('terminal:output').map(e => decode(e.data!)).join('').includes(TERM_MARK), 8000)
|
||||
.catch(() => { throw new Error('8s 内没有看到命令回显 ' + TERM_MARK) })
|
||||
})
|
||||
|
||||
await runCase('T4 窗口尺寸 resize 能传到被控端', async () => {
|
||||
A.terminal.resize(termSessionId, 40, 120)
|
||||
await sleep(300)
|
||||
const sess = B.terminal.listServerSessions().find(s => s.sessionId === termSessionId)
|
||||
assert(!!sess, '被控端没有这个 session')
|
||||
assert(sess!.rows === 40 && sess!.cols === 120, `被控端尺寸不对: ${sess!.rows}x${sess!.cols}`)
|
||||
})
|
||||
|
||||
await runCase('T5 关闭终端: 被控端 session 被回收', async () => {
|
||||
A.terminal.close(termSessionId, 'selftest')
|
||||
await waitFor(() => B.terminal.listServerSessions().every(s => s.sessionId !== termSessionId), 5000)
|
||||
.catch(() => { throw new Error('被控端 session 没有被回收') })
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 端口转发 ============
|
||||
console.log('\n\x1b[1m[端口转发]\x1b[0m')
|
||||
if (want('forward') || want('for')) {
|
||||
const targetPort = 47881 // 被控端的目标服务
|
||||
const listenPort = 47882 // 控制端的本地监听
|
||||
const peerListenPort = 47883 // 被控端的监听 (self-in)
|
||||
const ctrlTargetPort = 47884 // 控制端的目标服务 (self-in)
|
||||
const peerTarget = await startHttpServer(targetPort, 'HELLO-FROM-PEER')
|
||||
const ctrlTarget = await startHttpServer(ctrlTargetPort, 'HELLO-FROM-CONTROL')
|
||||
const openSocks: Socket[] = []
|
||||
try {
|
||||
await runCase('F1 self-out: 控制端 127.0.0.1:47882 拿到被控端服务内容', async () => {
|
||||
const r = await A.forward.openOnPeer(B.info.deviceId, {
|
||||
direction: 'self-out', listenPort, targetHost: '127.0.0.1', targetPort, ttlSec: 120,
|
||||
})
|
||||
assert(r.ok, `open 被拒: ${r.reason}`)
|
||||
const { body, sock } = await httpGet(listenPort)
|
||||
openSocks.push(sock)
|
||||
assert(body.includes('HELLO-FROM-PEER'), `拿到的是: ${body.slice(0, 120)}`)
|
||||
})
|
||||
|
||||
await runCase('F2 self-in: 被控端 127.0.0.1:47883 拿到控制端服务内容', async () => {
|
||||
const r = await A.forward.openOnPeer(B.info.deviceId, {
|
||||
direction: 'self-in', listenPort: peerListenPort, targetHost: '127.0.0.1', targetPort: ctrlTargetPort, ttlSec: 120,
|
||||
})
|
||||
assert(r.ok, `open 被拒: ${r.reason}`)
|
||||
const { body, sock } = await httpGet(peerListenPort)
|
||||
openSocks.push(sock)
|
||||
assert(body.includes('HELLO-FROM-CONTROL'), `拿到的是: ${body.slice(0, 120)}`)
|
||||
})
|
||||
|
||||
await runCase('F3 拒绝把本机自身端口 (47900) 作为转发目标', async () => {
|
||||
const r = await A.forward.openOnPeer(B.info.deviceId, {
|
||||
direction: 'self-out', listenPort: 47890, targetHost: '127.0.0.1', targetPort: 47900, ttlSec: 60,
|
||||
})
|
||||
assert(!r.ok, '自身端口竟然被放行了')
|
||||
})
|
||||
|
||||
await runCase('F4 转发字节计数在两端都有记录', async () => {
|
||||
const cs = A.forward.listClientSessions()
|
||||
assert(cs.length > 0, '控制端没有转发 session')
|
||||
const s = cs[0]
|
||||
assert(s.bytesIn > 0 && s.bytesOut > 0, `字节计数不对: in=${s.bytesIn} out=${s.bytesOut}`)
|
||||
})
|
||||
|
||||
await runCase('F5 关掉"对方发起的"转发: 对端 session 和监听端口都要释放', async () => {
|
||||
// 由 B 发起: B bind 47885, A 作为 server 侧连 B 填的目标
|
||||
const r = await B.forward.openOnPeer(A.info.deviceId, {
|
||||
direction: 'self-out', listenPort: 47885, targetHost: '127.0.0.1', targetPort: targetPort, ttlSec: 300,
|
||||
})
|
||||
assert(r.ok, `B 发起转发失败: ${r.reason}`)
|
||||
const serverSess = A.forward.listServerSessions().find(s => s.sessionId === r.sessionId)
|
||||
assert(!!serverSess, 'A 侧没有对应的 server session (对方发起的)')
|
||||
assert(B.forward.listClientSessions().some(s => s.sessionId === r.sessionId), 'B 侧 client session 不存在')
|
||||
|
||||
// A 在 UI 上点"关闭" (对方发起的) —— 走 closeSession, 要能关掉 server 侧
|
||||
const closed = A.forward.closeSession(r.sessionId!, 'user')
|
||||
assert(closed, 'closeSession 没关掉任何东西')
|
||||
assert(A.forward.listServerSessions().every(s => s.sessionId !== r.sessionId), 'A 侧 server session 还在')
|
||||
|
||||
// 关键: close 帧要发到对端, B 的 client session 及其监听端口要一起回收
|
||||
await waitFor(() => B.forward.listClientSessions().every(s => s.sessionId !== r.sessionId), 5000)
|
||||
.catch(() => { throw new Error('对端的 client session 没被回收 (close 帧没发出去?)') })
|
||||
let portStillOpen = true
|
||||
try {
|
||||
await new Promise<void>((res, rej) => {
|
||||
const sock = connect({ host: '127.0.0.1', port: 47885 }, () => { sock.destroy(); res() })
|
||||
sock.on('error', () => rej(new Error('refused')))
|
||||
setTimeout(() => { sock.destroy(); rej(new Error('timeout')) }, 2000)
|
||||
})
|
||||
} catch { portStillOpen = false }
|
||||
assert(!portStillOpen, '对端的监听端口 47885 还占着, 没释放')
|
||||
})
|
||||
} finally {
|
||||
for (const s of openSocks) { try { s.destroy() } catch {} }
|
||||
peerTarget.close(); ctrlTarget.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ============ USB / 串口 ============
|
||||
console.log('\n\x1b[1m[USB / 串口]\x1b[0m')
|
||||
if (want('usb')) {
|
||||
await runCase('U1 列对方设备: 请求-响应链路通', async () => {
|
||||
const r = await A.usb.listOnPeer(B.info.deviceId)
|
||||
assert(r.ok, `list 失败: ${r.reason}`)
|
||||
assert(Array.isArray(r.devices), 'devices 不是数组')
|
||||
})
|
||||
|
||||
await runCase('U2 USB 模式设备枚举 (libusb) 能列出真机设备', async () => {
|
||||
const devs = await B.usb.listLocal()
|
||||
const usbDevs = devs.filter(d => d.kind === 'usb')
|
||||
console.log(` (枚举到 ${devs.length} 个设备, 其中 usb ${usbDevs.length} 个)`)
|
||||
assert(usbDevs.length > 0, '一个 libusb 设备都没枚举到 —— USB 模式不可用')
|
||||
})
|
||||
|
||||
await runCase('U2b 内核占用的设备: attach 失败原因能正确回传到控制端', async () => {
|
||||
const devs = await B.usb.listLocal()
|
||||
const usbDevs = devs.filter(d => d.kind === 'usb')
|
||||
assert(usbDevs.length > 0, '没有可用的 usb 设备')
|
||||
// 这台机器上的设备都被内核驱动占着 (键鼠/摄像头/蓝牙), 测试里不让它抢驱动
|
||||
const pick = usbDevs.find(d => /hub/i.test(d.product || '')) || usbDevs[0]
|
||||
console.log(` (选用设备 ${pick.busId} ${pick.product || ''})`)
|
||||
const r = await A.usb.attach(B.info.deviceId, {
|
||||
busId: pick.busId, direction: 'self-out',
|
||||
config: { kind: 'usb', configurationValue: 1, interfaceNumber: 0, detachKernelDriver: false },
|
||||
})
|
||||
// 期望: 要么成功, 要么失败原因能传回来 (而不是超时/静默)
|
||||
if (!r.ok) {
|
||||
console.log(` (按预期被内核占用挡住: ${r.reason})`)
|
||||
assert(!!r.reason && r.reason.length > 0, '失败时没有给出原因')
|
||||
}
|
||||
})
|
||||
|
||||
await runCase('U2c 控制传输 setup 翻译正确, 真机 GET_DESCRIPTOR 能拿到数据', async () => {
|
||||
const usbMod: any = require('usb')
|
||||
const devs = await usbMod.webusb.loadDevices()
|
||||
assert(devs.length > 0, 'libusb 枚举不到设备')
|
||||
const dev = devs.find((d: any) => d.vendorId === 0x0c45 && d.productId === 0x6366) || devs[0]
|
||||
await dev.open()
|
||||
try {
|
||||
// 用应用里同一套翻译函数 (bmRequestType 数值 -> node-usb v3 字符串枚举)
|
||||
const setup = toUsbControlSetup({ requestType: 0x80, request: 0x06, value: 0x0100, index: 0x0000 })
|
||||
const buf = await dev.nativeControlTransferIn(setup, 3000, 18)
|
||||
assert(!!buf && buf.length === 18, `描述符长度不对: ${buf?.length}`)
|
||||
const hexd = Buffer.from(buf).toString('hex')
|
||||
console.log(` (descriptor: ${hexd})`)
|
||||
assert(hexd.startsWith('1201'), '不是合法的设备描述符')
|
||||
} finally {
|
||||
await dev.close().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
// 串口双向字节流: 用 socat 造一个 PTY 对冒充"真串口"
|
||||
const vcomDir = join(tmpdir(), `lnm-selftest-vcom-${Date.now()}`)
|
||||
mkdirSync(vcomDir, { recursive: true })
|
||||
let socatProc: ChildProcess | null = null
|
||||
let devEnd: ReturnType<typeof openPty> | null = null
|
||||
try {
|
||||
const pair = await startSocatSerial(vcomDir)
|
||||
socatProc = pair.proc
|
||||
// 被控端持有 "user" 端 (当真实串口), 测试这边拿 "internal" 端当外部设备
|
||||
B.usb.listLocal = async () => ([{
|
||||
busId: `serial:${pair.userPath}`, vid: 0, pid: 0, deviceClass: 0x02, deviceSubclass: 0x02,
|
||||
product: 'selftest-pty', manufacturer: 'socat', kind: 'serial', serialPath: pair.userPath, baudRate: 115200,
|
||||
}] as any)
|
||||
|
||||
await runCase('U3 attach 串口: 控制端能拿到 attached ok', async () => {
|
||||
const r = await A.usb.attach(B.info.deviceId, {
|
||||
busId: `serial:${pair.userPath}`, direction: 'self-out',
|
||||
config: { kind: 'serial', baudRate: 115200 },
|
||||
})
|
||||
assert(r.ok, `attach 被拒: ${r.reason}`)
|
||||
})
|
||||
|
||||
await runCase('U4 串口双向字节流: 控制端 -> 设备', async () => {
|
||||
const sess = A.usb.listClientSessions()[0]
|
||||
assert(!!sess, '控制端没有 usb session')
|
||||
devEnd = openPty(pair.internalPath)
|
||||
const ok = await A.usb.sendSerialBytes(sess.sessionId, Buffer.from('HELLO-FROM-CONTROL\n'))
|
||||
assert(ok, 'sendSerialBytes 失败')
|
||||
// 设备端应该收到
|
||||
let seen = ''
|
||||
for (let i = 0; i < 50 && !seen.includes('HELLO-FROM-CONTROL'); i++) {
|
||||
await sleep(100)
|
||||
seen += devEnd.read()
|
||||
}
|
||||
assert(seen.includes('HELLO-FROM-CONTROL'), `设备端没收到: ${JSON.stringify(seen)}`)
|
||||
})
|
||||
|
||||
await runCase('U5 串口双向字节流: 设备 -> 控制端', async () => {
|
||||
assert(!!devEnd, '设备端没准备好')
|
||||
A.clearEvents()
|
||||
devEnd.write('HELLO-FROM-DEVICE\n')
|
||||
await waitFor(() => A.findEvents('usb:output').map(e => decode(e.data!)).join('').includes('HELLO-FROM-DEVICE'), 6000)
|
||||
.catch(() => { throw new Error('控制端没收到设备发来的字节') })
|
||||
})
|
||||
} finally {
|
||||
try { devEnd?.close() } catch {}
|
||||
try { socatProc?.kill() } catch {}
|
||||
try { rmSync(vcomDir, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 网盘 ============
|
||||
console.log('\n\x1b[1m[网盘]\x1b[0m')
|
||||
if (want('drive') || want('网盘')) {
|
||||
await runCase('D1 列对方网盘根目录', async () => {
|
||||
const r = await listDrive(B.info, '')
|
||||
assert(r.ok, `列目录失败: ${r.reason}`)
|
||||
const names = (r.entries || []).map(e => e.name)
|
||||
assert(names.includes('hello.txt'), `没看到 hello.txt, 实际: ${names.join(',')}`)
|
||||
assert(names.includes('sub'), `没看到子目录 sub, 实际: ${names.join(',')}`)
|
||||
const f = (r.entries || []).find(e => e.name === 'hello.txt')!
|
||||
assert(f.size === 'HELLO-FROM-DRIVE'.length, `大小不对: ${f.size}`)
|
||||
assert(!f.isDir, '文件被标成目录了')
|
||||
})
|
||||
|
||||
await runCase('D2 进子目录再列一次', async () => {
|
||||
const r = await listDrive(B.info, 'sub')
|
||||
assert(r.ok, `列子目录失败: ${r.reason}`)
|
||||
const names = (r.entries || []).map(e => e.name)
|
||||
assert(names.includes('nested.txt'), `实际: ${names.join(',')}`)
|
||||
})
|
||||
|
||||
await runCase('D3 下载文件: 内容一致', async () => {
|
||||
const dest = join(tmpdir(), `lnm-selftest-dl-${Date.now()}`)
|
||||
mkdirSync(dest, { recursive: true })
|
||||
const r = await downloadDriveFile({ peer: B.info, relPath: 'hello.txt', destDir: dest })
|
||||
assert(r.ok, `下载失败: ${r.reason}`)
|
||||
const content = readFileSync(r.path!, 'utf8')
|
||||
assert(content === 'HELLO-FROM-DRIVE', `内容不对: ${content}`)
|
||||
assert(r.size === 'HELLO-FROM-DRIVE'.length, `大小不对: ${r.size}`)
|
||||
})
|
||||
|
||||
await runCase('D4 下载子目录里的文件', async () => {
|
||||
const dest = join(tmpdir(), `lnm-selftest-dl2-${Date.now()}`)
|
||||
mkdirSync(dest, { recursive: true })
|
||||
const r = await downloadDriveFile({ peer: B.info, relPath: 'sub/nested.txt', destDir: dest })
|
||||
assert(r.ok, `下载失败: ${r.reason}`)
|
||||
assert(readFileSync(r.path!, 'utf8') === 'NESTED-FROM-DRIVE', '内容不对')
|
||||
})
|
||||
|
||||
await runCase('D5 目录穿越被挡住 (.. 逃不出网盘根)', async () => {
|
||||
const r = await listDrive(B.info, '../../../../etc')
|
||||
assert(!r.ok, '竟然列出了网盘之外的目录!')
|
||||
const r2 = await listDrive(B.info, '..')
|
||||
assert(!r2.ok, '竟然能列出上级目录!')
|
||||
})
|
||||
|
||||
await runCase('D6 token 错误 -> 拒绝访问', async () => {
|
||||
const bad = { ...B.info, driveToken: 'wrong-token' }
|
||||
const r = await listDrive(bad, '')
|
||||
assert(!r.ok, '错误 token 竟然能访问!')
|
||||
assert(/令牌/.test(r.reason || ''), `错误信息不对: ${r.reason}`)
|
||||
})
|
||||
|
||||
await runCase('D7 对方没开网盘 (无 token) -> 明确提示不支持', async () => {
|
||||
const r = await listDrive({ ...B.info, driveToken: undefined }, '')
|
||||
assert(!r.ok, '无 token 竟然能访问!')
|
||||
assert(/不支持/.test(r.reason || ''), `错误信息不对: ${r.reason}`)
|
||||
})
|
||||
|
||||
await runCase('D8 递归收集目录下的全部文件', async () => {
|
||||
const r = await collectDriveFiles(B.info, 'sub')
|
||||
assert(r.ok, `收集失败: ${r.reason}`)
|
||||
const names = r.files.map(f => f.relPath).sort()
|
||||
assert(names.includes('sub/nested.txt'), `缺 nested.txt: ${names.join(',')}`)
|
||||
assert(names.includes('sub/deep/deeper.txt'), `缺深层文件: ${names.join(',')}`)
|
||||
})
|
||||
|
||||
await runCase('D9 整目录下载: 保持层级', async () => {
|
||||
const dest = join(tmpdir(), `lnm-selftest-dl3-${Date.now()}`)
|
||||
mkdirSync(dest, { recursive: true })
|
||||
const collected = await collectDriveFiles(B.info, 'sub')
|
||||
assert(collected.ok, '收集失败')
|
||||
for (const f of collected.files) {
|
||||
const relInside = f.relPath.slice('sub'.length).replace(/^[/\\]+/, '')
|
||||
const dirInside = relInside ? dirname(relInside) : ''
|
||||
const destSubPath = dirInside && dirInside !== '.' ? join('sub', dirInside) : 'sub'
|
||||
const r = await downloadDriveFile({ peer: B.info, relPath: f.relPath, destDir: dest, destSubPath })
|
||||
assert(r.ok, `下载 ${f.relPath} 失败: ${r.reason}`)
|
||||
}
|
||||
assert(existsSync(join(dest, 'sub', 'nested.txt')), '层级不对: 少了 sub/nested.txt')
|
||||
assert(existsSync(join(dest, 'sub', 'deep', 'deeper.txt')), '层级不对: 少了 sub/deep/deeper.txt')
|
||||
assert(readFileSync(join(dest, 'sub', 'deep', 'deeper.txt'), 'utf8') === 'DEEPER-FROM-DRIVE', '内容不对')
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 消息通道状态 ============
|
||||
// 回归: 启动时主进程先连上对端, 渲染层后订阅 -> open 事件被丢掉, UI 永久显示"等待重连"
|
||||
// 所以状态必须是"拉取式"的: 任何时刻去查都能查到当前真实状态
|
||||
if (want('ws') || want('state')) {
|
||||
console.log('\n\x1b[1m[通道状态]\x1b[0m')
|
||||
|
||||
await runCase('W1 通道状态可随时拉取 (连接早已建好, 现在才查也查得到)', async () => {
|
||||
assert(A.client.isOpen(B.info.deviceId), '前提不成立: A->B 的 outgoing 没开')
|
||||
// 模拟"渲染层晚订阅": 此刻才去查状态
|
||||
const st = computeWsState([B.info.deviceId], A.client.getStatus(), A.server)
|
||||
assert(st[B.info.deviceId] === true, `状态不对: ${JSON.stringify(st)}`)
|
||||
})
|
||||
|
||||
await runCase('W2 只有 inbound 通 (对方连我, 我没主动连) 也算通道可用', async () => {
|
||||
const C = new Node('peer-cccccccc-0000-4000-8000-00000000000c', '只入站C')
|
||||
await C.start()
|
||||
try {
|
||||
B.connectTo(C) // 只有 B -> C 这一条腿
|
||||
await waitFor(() => C.server.isConnected(B.info.deviceId), 8000)
|
||||
assert(!C.client.isOpen(B.info.deviceId), '前提不成立: C 不该有 outgoing 连接')
|
||||
const st = computeWsState([B.info.deviceId], C.client.getStatus(), C.server)
|
||||
assert(st[B.info.deviceId] === true, `只有 inbound 通时不该判死: ${JSON.stringify(st)}`)
|
||||
} finally {
|
||||
await C.stop()
|
||||
}
|
||||
})
|
||||
|
||||
await runCase('W3 两条腿都断了 -> 状态回落 false', async () => {
|
||||
A.client.remove(B.info.deviceId)
|
||||
B.client.remove(A.info.deviceId)
|
||||
await waitFor(() => !A.server.isConnected(B.info.deviceId), 5000)
|
||||
await waitFor(() => !B.server.isConnected(A.info.deviceId), 5000)
|
||||
const st = computeWsState([B.info.deviceId], A.client.getStatus(), A.server)
|
||||
assert(st[B.info.deviceId] === false, `断了还报可用: ${JSON.stringify(st)}`)
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 审计 ============
|
||||
if (want('audit')) {
|
||||
console.log('\n\x1b[1m[审计]\x1b[0m')
|
||||
await runCase('A1 关键操作都写进了 audit_log', async () => {
|
||||
const rows = listAudit({ limit: 200 })
|
||||
const actions = new Set(rows.map(r => r.action))
|
||||
const need = ['terminal.open', 'forward.open']
|
||||
for (const n of need) assert(actions.has(n), `audit 里没有 ${n} (现有: ${[...actions].join(', ')})`)
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 汇总 ============
|
||||
await A.stop()
|
||||
await B.stop()
|
||||
|
||||
const pass = results.filter(r => r.ok).length
|
||||
const fail = results.length - pass
|
||||
console.log(`\n\x1b[1m结果: ${pass} 通过 / ${fail} 失败\x1b[0m`)
|
||||
if (fail) {
|
||||
console.log('\n失败用例:')
|
||||
for (const r of results.filter(x => !x.ok)) console.log(` - ${r.name}\n ${r.detail}`)
|
||||
}
|
||||
process.exit(fail ? 1 : 0)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('自测脚本异常:', e)
|
||||
process.exit(2)
|
||||
})
|
||||
Reference in New Issue
Block a user