Files
LocalNetMsg/scripts/selftest/packaging-check.cjs
T
xiaoshuai 2a641b1eff feat(serial): USB 页面下线, 改成串口映射 + 串口助手
## USB 整体下线

把 USB 功能真删掉, 不是只藏 UI:

- 删 `src/main/remote/usb.ts` + `src/renderer/src/components/remote/UsbPanel.vue`
- 删 `usb` (node-usb) 和 `node-hid` 两个依赖 —— 查过, 本来就没被真正用起来
- `asarUnpack` 去掉 node-usb/usb/node-hid 的规则
- ChatView 的 tab 由 "USB" 改成 "串口"

## 串口映射 + 串口助手 (新)

`src/main/remote/serial.ts` (取代 usb.ts) 只做串口, 一个文件两件事:

- **串口映射**: 把一端的真实串口映射成另一端可用的虚拟串口
  (Linux/macOS 用 socat 建 PTY 配对, Windows 用 com0com 建 COM 对)。
  方向做成 "我用对方的" / "对方用我的" 两种, 两边都能发起。
- **串口助手**: 不建虚拟口时, 应用自己当助手 —— 字节流直接显示,
  能发文本/HEX, 能追加 LF, 能循环发送, 能清屏/存日志。

`src/renderer/src/components/remote/SerialPanel.vue` (新) 把两者做在一个面板里:
设备表 (串口 / VID:PID / 产品 / 制造商)、串口参数 (波特率/数据位/停止位/校验)、
TX/RX 时间戳 + hex + ascii 视图、虚拟口路径横幅 (可一键复制)。

## 串口助手要能调 Linux

Linux 上调串口最常见的两个坑, 都做成产品功能:

- **没权限**: 诊断 dialout 组 + socat + 串口列表, 一键提权修复
  (`sudo usermod -aG dialout $USER`, 提示要重新登录才生效)。
  "设备没插" 和 "没权限" 分开报错, 不再混成一句 "打开失败"。
- **缺 socat**: 建虚拟口前先探 socat, 没有就弹安装指引 (apt/dnf),
  免得 attach 做到一半才失败。

平台化文案也分开了: Linux 提示 dialout, Windows 提示设备管理器, macOS 提示隐私授权。

## 线上格式一个字节没动 (兼容性关键)

局域网里常有一台机器跑旧版本, 所以:

- `PROTOCOL_VERSION` 仍然 = 1
- WS 帧类型仍然叫 `'usb'`
- 只把内部类型改名 (`SerialDeviceInfo` / `SerialAttachConfig` / ...)

代价: 混版本时已下线的 USB 字节桥不可用。换来: 新旧版本聊天照常,
串口映射也照常, 不会因为升协议被踢下线。**别手贱顺手改对** (AGENTS.md 坑 #10)。

## 其它

- 主进程 `output` 事件带方向 (rx/tx), 助手才能分清"我发的"和"设备回的";
  客户端自己发出的字节不回显成 rx (无回环, S5 用例守住)
- 虚拟串口 (vcom) 模式下 app 不读内部端, 避免和 socat 抢数据
- `src/main/permissions.ts` 重写成串口专用, 删掉 USB/HID 相关诊断与修复
- 保留 `src/main/remote/vcom.ts` (socat/com0com 配对) 不动, 与 serial.ts 分离

## 验证

- npm run typecheck        ✅
- npm run selftest         ✅ 79 通过 / 0 失败 (USB 用例整体换成 S1-S9 串口用例)
- npm run build            ✅
- npm run package:linux    ✅ 新 deb (84MB)
- npm run selftest:package ✅ 4/4 (改成校验 serialport 原生绑定已解包)

真机 (CDP 驱动真实窗口) 验证过: 串口 tab 能打开, 设备表列到 32 个 /dev/ttyS*,
助手区 + hex 区在, 波特率/数据位/停止位/校验/循环发送/清屏/保存日志 按钮齐全,
方向切换会换设备列表 (我用对方的 = 对方设备, 对方用我的 = 本机设备)。
2026-10-21 18:04:55 +08:00

122 lines
5.4 KiB
JavaScript

// 打包产物冒烟检查: 验证原生模块真的被解包 + 能从 app.asar 里加载并 spawn
//
// 为什么需要单独一个脚本:
// 自测 (scripts/selftest/main.ts) 是从**源码**跑的, 用的是 pty 桩, 根本不碰 app.asar。
// 而 asarUnpack 配错这类问题 (比如 node-pty 平台包没递归解包) **只在打包产物上才暴露**,
// 而且在 Linux 上看不出来 (只需要一个 .node, Electron 能 dlopen 归档里的),
// 到 Windows 才炸 (conpty.dll / OpenConsole.exe / worker 脚本都必须是真实文件)。
//
// 用法:
// node scripts/selftest/packaging-check.cjs [打包输出目录]
// 默认 dist-Electron/linux-unpacked (Windows 上传 dist-Electron/win-unpacked)
//
// 必须用 Electron 的 node 跑 (app.asar 只有 Electron 认):
// cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/packaging-check.cjs
const path = require('node:path')
const fs = require('node:fs')
const results = []
async function check(name, fn) {
try {
const detail = await fn()
results.push({ name, ok: true, detail: detail || '' })
console.log(` \x1b[32mPASS\x1b[0m ${name}${detail ? ' — ' + detail : ''}`)
} catch (e) {
results.push({ name, ok: false, detail: String(e?.message || e) })
console.log(` \x1b[31mFAIL\x1b[0m ${name} — ${e?.message || e}`)
}
}
function assert(cond, msg) { if (!cond) throw new Error(msg) }
async function main() {
const root = path.resolve(process.argv[2] || path.join(process.cwd(), 'dist-Electron', `${process.platform}-unpacked`))
const resources = path.join(root, 'resources')
const asar = path.join(resources, 'app.asar')
const unpacked = path.join(resources, 'app.asar.unpacked')
console.log(`\n\x1b[1m[打包产物检查]\x1b[0m ${root}\n`)
await check('打包产物存在', () => {
assert(fs.existsSync(asar), `找不到 ${asar}`)
return path.basename(asar)
})
// 1) node-pty 平台包必须完整解包 (含深层 prebuilds/<plat>/)
await check('node-pty 平台包已完整解包 (含 prebuilds 下的 .node/.dll/.exe)', () => {
const pkgDir = path.join(unpacked, 'node_modules', '@lydell')
assert(fs.existsSync(pkgDir), `unpacked 里没有 @lydell: ${pkgDir}`)
const platformPkgs = fs.readdirSync(pkgDir).filter(n => n.startsWith('node-pty-'))
assert(platformPkgs.length > 0, `没有 node-pty 平台包: ${fs.readdirSync(pkgDir).join(',')}`)
const found = []
for (const p of platformPkgs) {
const pre = path.join(pkgDir, p, 'prebuilds')
if (!fs.existsSync(pre)) continue
const walk = (d) => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const full = path.join(d, e.name)
if (e.isDirectory()) walk(full)
else if (/\.(node|dll|exe)$/i.test(e.name)) found.push(path.relative(pkgDir, full))
}
}
walk(pre)
}
assert(found.length > 0, '平台包的 prebuilds 下没有任何原生文件 —— 规则可能没递归')
return found.join(', ')
})
// 2) 其它原生模块也要解包 (Electron 的 smart unpack 不一定覆盖到)
await check('其它原生模块已解包 (serialport)', () => {
const nm = path.join(unpacked, 'node_modules')
assert(fs.existsSync(nm), 'unpacked 里没有 node_modules')
const found = []
const walk = (d, depth) => {
if (depth > 4) return
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const full = path.join(d, e.name)
if (e.isDirectory()) walk(full, depth + 1)
else if (e.name.endsWith('.node')) found.push(path.relative(nm, full))
}
}
walk(nm, 0)
assert(found.length > 0, '没有任何 .node 被解包')
// 串口功能依赖 serialport 的原生绑定, 必须在 (USB 字节桥已下线, 不再需要 node-hid / node-usb)
assert(
found.some(f => f.includes('@serialport')),
`serialport 的原生绑定没被解包, 串口功能会起不来: ${found.join(', ')}`,
)
return `${found.length} 个原生文件`
})
// 3) 真正从 app.asar 里加载 node-pty 并 spawn —— 这才是终端能不能用的判据
// 注意: 必须异步等, 用同步忙等会把事件循环堵死, pty 的 data 回调永远不触发
await check('从 app.asar 加载 node-pty 并真实 spawn 成功', async () => {
const pty = require(path.join(asar, 'node_modules', '@lydell', 'node-pty'))
const isWin = process.platform === 'win32'
const shell = isWin ? 'cmd.exe' : '/bin/sh'
const args = isWin ? ['/c', 'echo LNM_PKG_OK'] : ['-c', 'echo LNM_PKG_OK']
let out = ''
const p = pty.spawn(shell, args, {
cols: 80, rows: 24, name: 'xterm-256color', encoding: null,
cwd: isWin ? process.env.USERPROFILE : process.env.HOME || process.cwd(),
})
p.onData(d => { out += Buffer.isBuffer(d) ? d.toString('utf8') : String(d) })
const t0 = Date.now()
while (!out.includes('LNM_PKG_OK') && Date.now() - t0 < 8000) {
await new Promise(r => setTimeout(r, 50))
}
assert(out.includes('LNM_PKG_OK'), `8s 内没拿到输出: ${JSON.stringify(out.slice(0, 200))}`)
return out.trim().split('\n')[0]
})
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) })