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:
2026-10-20 16:43:56 +08:00
parent ec9f854136
commit a79086e4e8
42 changed files with 2258 additions and 144 deletions
+72 -23
View File
@@ -1,28 +1,77 @@
// 把候选 logo 处理成 app icon:
// - 转 PNG (白色背景填充为透明)
// - 缩放到 1024x1024 (electron-builder 推荐)
// - 顺便输出 256x256 给 renderer 用
const sharp = require('sharp')
// 生成 Linux 多尺寸图标集 -> resources/icons/<size>x<size>.png
//
// 为什么需要多尺寸: electron-builder 的 Linux 打包会把图标装进
// /usr/share/icons/hicolor/<size>x<size>/apps/<executable>.png。
// 如果 linux.icon 指向"单张 PNG", app-builder 解析不出尺寸 (返回 size=0),
// 图标就被装到 hicolor/0x0/ 这种非法目录里, 桌面环境根本找不到 -> 安装后没图标。
// 指向一个"目录 + <size>x<size>.png 命名"才能被正确识别。
//
// 用法:
// node scripts/make-icon.js # 用 resources/icon.png 作为基准图
// node scripts/make-icon.js <源图路径> # 用指定图片重新生成基准图 + 全套尺寸
//
// 依赖: ImageMagick 的 convert (生成结果已提交, 平时不需要跑这个脚本)
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const SRC = path.resolve(__dirname, '..', 'resources', 'logo-candidate_004.jpg')
const RES = path.resolve(__dirname, '..', 'resources')
const OUT_DIR = path.join(RES, 'icons')
const BASE = path.join(RES, 'icon.png')
// hicolor 标准尺寸: 覆盖所有实际显示场景, 全部从 512 基准图缩小而来 (清晰不糊)
const SIZES = [16, 24, 32, 48, 64, 128, 256, 512]
async function main() {
const out1024 = path.resolve(__dirname, '..', 'resources', 'icon.png')
const out256 = path.resolve(__dirname, '..', 'resources', 'icon-256.png')
// 缩放到 1024x1024, JPEG 白色背景保持 (Windows 图标习惯)
await sharp(SRC)
.resize(1024, 1024, { fit: 'cover', position: 'center' })
.png({ quality: 95 })
.toFile(out1024)
await sharp(SRC)
.resize(256, 256, { fit: 'cover', position: 'center' })
.png()
.toFile(out256)
console.log('Generated:', out1024, out256)
function haveConvert() {
try {
execFileSync('convert', ['-version'], { stdio: 'ignore' })
return true
} catch {
return false
}
}
main().catch(e => { console.error(e); process.exit(1) })
function convert(args) {
execFileSync('convert', args, { stdio: 'inherit' })
}
function main() {
if (!haveConvert()) {
console.error('需要 ImageMagick 的 convert 命令。')
console.error('Debian/Ubuntu: sudo apt install imagemagick')
process.exit(1)
}
const src = process.argv[2]
if (src) {
// 指定了源图: 先把基准图做成 512x512 (最高精度, 往下缩都清晰)
if (!fs.existsSync(src)) {
console.error('源图不存在:', src)
process.exit(1)
}
convert([src, '-resize', '512x512', '-background', 'none', '-gravity', 'center', '-extent', '512x512', BASE])
console.log('基准图已更新:', BASE)
}
if (!fs.existsSync(BASE)) {
console.error('基准图不存在:', BASE)
process.exit(1)
}
fs.mkdirSync(OUT_DIR, { recursive: true })
for (const s of SIZES) {
const out = path.join(OUT_DIR, `${s}x${s}.png`)
// <= 基准尺寸的直接从基准图降采样 (只重采样一次, 小图标才清晰);
// 超过基准尺寸的才放大, 放到最后单独处理
if (s <= 256) {
convert([BASE, '-resize', `${s}x${s}`, '-strip', out])
} else {
convert([BASE, '-resize', '512x512', '-background', 'none', '-gravity', 'center', '-extent', '512x512', out])
}
console.log('生成', path.relative(process.cwd(), out))
}
console.log('\n完成。打包时用 linux.icon = "resources/icons" 指向这个目录。')
}
main()
+48
View File
@@ -0,0 +1,48 @@
// 无头测试用的 electron 桩: 只提供 src/main 里真正用到的 API
// (app.getPath / app.getVersion / BrowserWindow / ipcMain / shell ...)
const os = require('node:os')
const path = require('node:path')
const fs = require('node:fs')
const root = process.env.LNM_SELFTEST_HOME || path.join(os.tmpdir(), 'lnm-selftest')
fs.mkdirSync(root, { recursive: true })
function ensure(p) {
fs.mkdirSync(p, { recursive: true })
return p
}
module.exports = {
app: {
isPackaged: false,
getPath(name) {
if (name === 'userData') return ensure(path.join(root, 'userData'))
if (name === 'documents') return ensure(path.join(root, 'documents'))
if (name === 'downloads') return ensure(path.join(root, 'downloads'))
if (name === 'home') return os.homedir()
return ensure(path.join(root, name))
},
getVersion: () => '0.1.0',
getName: () => 'local-net-msg',
getAppPath: () => process.cwd(),
on: () => {},
once: () => {},
whenReady: async () => {},
quit: () => {},
},
BrowserWindow: class BrowserWindow {
static getAllWindows() { return [] }
static getFocusedWindow() { return null }
isMinimized() { return false }
restore() {}
show() {}
focus() {}
webContents = { send: () => {} }
},
// 注意: 故意不提供 ipcRenderer —— electron-store 见到它会以为是渲染进程
ipcMain: { on: () => {}, once: () => {}, handle: () => {}, removeHandler: () => {} },
contextBridge: { exposeInMainWorld: () => {} },
dialog: { showErrorBox: () => {}, showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
shell: { openPath: async () => '', showItemInFolder: () => {} },
Notification: class { static isSupported() { return false } constructor() {} show() {} },
}
+676
View File
@@ -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)
})
+15
View File
@@ -0,0 +1,15 @@
// @lydell/node-pty 的 module.exports 带 __esModule 标记 (非枚举),
// esbuild 的 __toESM interop 会因此认为它"没有 default 导出",
// 导致 terminal.ts 里的 `(nodePty as any).default ?? nodePty` 变成 undefined.default.
// 这里包一层, 重新导出一个"干净"的 CJS 对象.
//
// 注意: 必须用变量拼包名, 否则 esbuild 的 --alias 会把这里的 require 也重写成指向自己.
const PKG = '@lydell/node-pty'
const m = require(PKG)
module.exports = {
spawn: m.spawn,
fork: m.fork,
createTerminal: m.createTerminal,
open: m.open,
native: m.native,
}