Files
LocalNetMsg/scripts/selftest/cdp-shot.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

176 lines
6.2 KiB
JavaScript

// 用 CDP (Chrome DevTools Protocol) 驱动真实 Electron 窗口:
// 点开设备 -> 截图 -> 右键图片 -> 再截图。
//
// 用途: 肉眼验证"图片直接显示 + 右键能复制图片"。环境里没有 xdotool,
// 所以走 DevTools 协议自己点。
//
// 用法: node scripts/selftest/cdp-shot.cjs <outDir> [port]
const fs = require('node:fs')
const path = require('node:path')
const WebSocket = require('ws')
const outDir = process.argv[2] || '/tmp/lnm-shots'
const port = process.argv[3] || '9222'
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
async function findPage() {
for (let i = 0; i < 40; i++) {
try {
const res = await fetch(`http://127.0.0.1:${port}/json/list`)
const list = await res.json()
const page = list.find(t => t.type === 'page' && t.webSocketDebuggerUrl)
if (page) return page
} catch { /* 还没起来 */ }
await sleep(500)
}
throw new Error('连不上 CDP (应用没起来?)')
}
function connect(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 })
ws.once('open', () => resolve(ws))
ws.once('error', reject)
})
}
async function main() {
fs.mkdirSync(outDir, { recursive: true })
const page = await findPage()
const ws = await connect(page.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString())
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id)
pending.delete(msg.id)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result)
}
})
const send = (method, params) => new Promise((resolve, reject) => {
const mid = ++id
pending.set(mid, { resolve, reject })
ws.send(JSON.stringify({ id: mid, method, params: params || {} }))
})
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ' ' + JSON.stringify(r.exceptionDetails.exception?.description || ''))
return r.result?.value
}
const shot = async (name) => {
const r = await send('Page.captureScreenshot', { format: 'png' })
const p = path.join(outDir, name)
fs.writeFileSync(p, Buffer.from(r.data, 'base64'))
console.log('截图: ' + p)
return p
}
await send('Page.enable')
await send('Runtime.enable')
// 等 Vue 挂载
for (let i = 0; i < 30; i++) {
const ok = await evaluate(`!!document.querySelector('.device-item')`)
if (ok) break
await sleep(500)
}
// 1) 点开设备 (侧边栏第一项)
const clicked = await evaluate(`(() => {
const el = document.querySelector('.device-item')
if (!el) return false
el.click()
return true
})()`)
console.log('点击设备项: ' + clicked)
await sleep(1500)
// 2) 看看聊天区里有没有图片气泡
const info = await evaluate(`(() => {
const imgs = [...document.querySelectorAll('.bubble.image img')]
return {
imageCount: imgs.length,
srcPrefix: imgs[0] ? imgs[0].src.slice(0, 30) : '',
naturalW: imgs[0] ? imgs[0].naturalWidth : 0,
naturalH: imgs[0] ? imgs[0].naturalHeight : 0,
fallbackCount: document.querySelectorAll('.image-fallback').length,
bubbleText: [...document.querySelectorAll('.bubble')].map(b => b.innerText.trim().slice(0, 40)),
}
})()`)
console.log('页面状态: ' + JSON.stringify(info, null, 2))
await shot('01-chat-image.png')
// 3) 右键点图片 -> 看右键菜单
const ctx = await evaluate(`(() => {
const img = document.querySelector('.bubble.image img')
if (!img) return null
const r = img.getBoundingClientRect()
const x = Math.round(r.left + r.width / 2)
const y = Math.round(r.top + r.height / 2)
img.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y }))
return { x, y }
})()`)
console.log('右键坐标: ' + JSON.stringify(ctx))
await sleep(800)
const menu = await evaluate(`(() => {
const items = [...document.querySelectorAll('.ctx-menu .ctx-item')].map(e => e.innerText.trim())
return { items, hasCopyImage: items.includes('复制图片') }
})()`)
console.log('右键菜单: ' + JSON.stringify(menu, null, 2))
await shot('02-context-menu.png')
// 4) 点"复制图片" -> 看提示条 (提示只在 imageCopy 返回 ok 时才会出现)
const clickedCopy = await evaluate(`(() => {
const items = [...document.querySelectorAll('.ctx-menu .ctx-item')]
const it = items.find(e => e.innerText.trim() === '复制图片')
if (!it) return false
it.click()
return true
})()`)
console.log('点击"复制图片": ' + clickedCopy)
await sleep(1500)
const toast = await evaluate(`(() => {
const t = document.body.innerText
return { copied: t.includes('图片已复制到剪贴板'), failed: t.includes('复制失败') || t.includes('还没有可复制') }
})()`)
console.log('提示条: ' + JSON.stringify(toast))
await shot('03-after-copy.png')
// 5) 切到串口 tab -> 看看串口面板
const tab = await evaluate(`(() => {
const btns = [...document.querySelectorAll('.header-tabs button')]
const b = btns.find(x => x.innerText.trim() === '串口')
if (!b) return { found: false, tabs: btns.map(x => x.innerText.trim()) }
b.click()
return { found: true }
})()`)
console.log('串口 tab: ' + JSON.stringify(tab))
await sleep(1500)
const panel = await evaluate(`(() => {
const p = document.querySelector('.serial-panel')
if (!p) return { found: false }
return {
found: true,
hasDeviceTable: !!p.querySelector('.el-table'),
rows: p.querySelectorAll('.el-table tbody tr').length,
hasAssistant: !!p.querySelector('.assistant'),
hasHexView: !!p.querySelector('.hex-view'),
buttons: [...p.querySelectorAll('button')].map(b => b.innerText.trim()).filter(Boolean),
text: p.innerText.slice(0, 300),
}
})()`)
console.log('串口面板: ' + JSON.stringify(panel, null, 2))
await shot('04-serial-panel.png')
ws.close()
console.log('done')
}
main().catch(e => { console.error('CDP 失败:', e.message); process.exit(1) })