Files
LocalNetMsg/src/preload/index.ts
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

146 lines
8.3 KiB
TypeScript

// preload: 把主进程能力通过 contextBridge 暴露给渲染进程
import { contextBridge, ipcRenderer, webUtils } from 'electron'
contextBridge.exposeInMainWorld('api', {
platform: process.platform,
versions: process.versions,
// self / 设备
self: () => ipcRenderer.invoke('self:info'),
listDevices: () => ipcRenderer.invoke('device:list'),
// 启动时拉一次主进程当前状态 (WS 通道等) — 补上订阅前丢失的事件
appState: () => ipcRenderer.invoke('app:state'),
triggerScan: () => ipcRenderer.invoke('device:triggerScan'),
deleteDevice: (deviceId: string) => ipcRenderer.invoke('device:delete', deviceId),
// 消息
listMessages: (peerId: string) => ipcRenderer.invoke('message:list', peerId),
sendText: (toDeviceId: string, content: string, opts?: { replyTo?: any }) =>
ipcRenderer.invoke('message:sendText', { toDeviceId, content, replyTo: opts?.replyTo }),
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean; replyTo?: any }) =>
ipcRenderer.invoke('message:sendFile', { toDeviceId, localPath, ...(opts || {}) }),
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string, opts?: { replyTo?: any }) =>
ipcRenderer.invoke('message:sendBuffer', { toDeviceId, dataBase64, name, mime, replyTo: opts?.replyTo }),
recall: (messageId: string) => ipcRenderer.invoke('message:recall', messageId),
deleteMessage: (messageId: string, opts?: { deleteFile?: boolean }) => ipcRenderer.invoke('message:delete', messageId, opts),
markRead: (peerId: string, upToMessageId: string) => ipcRenderer.invoke('message:markRead', { peerId, upToMessageId }),
retry: (messageId: string) => ipcRenderer.invoke('message:retry', messageId),
typing: (toDeviceId: string) => ipcRenderer.invoke('message:typing', toDeviceId),
// 未读
unreadList: () => ipcRenderer.invoke('unread:list'),
clearUnread: (deviceId: string) => ipcRenderer.invoke('unread:clear', deviceId),
// 设置
getSettings: () => ipcRenderer.invoke('settings:get'),
setSettings: (patch: any) => ipcRenderer.invoke('settings:set', patch),
chooseDownloadDir: () => ipcRenderer.invoke('settings:chooseDownloadDir'),
// 文件
openInFolder: (p: string) => ipcRenderer.invoke('file:openInFolder', p),
open: (p: string) => ipcRenderer.invoke('file:open', p),
readBase64: (p: string) => ipcRenderer.invoke('file:readBase64', p),
pickFile: (opts?: { image?: boolean }) => ipcRenderer.invoke('file:pick', opts),
// 图片: 缩略图 / 复制到剪贴板 / 看原图
imageThumb: (args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) =>
ipcRenderer.invoke('image:thumb', args),
imageCopy: (args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) =>
ipcRenderer.invoke('image:copy', args),
imageDataUrl: (path: string) => ipcRenderer.invoke('image:dataUrl', { path }),
// 拖拽文件 -> 真实磁盘路径 (Electron 32+ 必须用 webUtils.getPathForFile)
getPathForFile: (file: File): string => {
try { return webUtils.getPathForFile(file) || '' } catch { return '' }
},
// 应用
hide: () => ipcRenderer.invoke('app:hide'),
appVersion: () => ipcRenderer.invoke('app:version'),
// 开机自启
getAutoStart: () => ipcRenderer.invoke('autostart:get'),
setAutoStart: (enabled: boolean) => ipcRenderer.invoke('autostart:set', enabled),
// 网络接口 (只读)
listInterfaces: () => ipcRenderer.invoke('network:listInterfaces'),
// 事件订阅: 主进程 webContents.send('xxx', payload) 会在此处派发
on(channel: string, cb: (payload: any) => void) {
const listener = (_e: any, payload: any) => cb(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
},
// 离线消息: 启动后让主进程把 pending 重新尝试一遍
flushPending: () => ipcRenderer.invoke('message:flushPending'),
// 远程: 终端
terminalOpen: (peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) =>
ipcRenderer.invoke('terminal:open', { peerId, ...(opts || {}) }),
terminalInput: (sessionId: string, dataBase64: string) =>
ipcRenderer.invoke('terminal:input', { sessionId, dataBase64 }),
terminalResize: (sessionId: string, rows: number, cols: number) =>
ipcRenderer.invoke('terminal:resize', { sessionId, rows, cols }),
terminalClose: (sessionId: string, reason?: string) =>
ipcRenderer.invoke('terminal:close', { sessionId, reason }),
terminalListSessions: () => ipcRenderer.invoke('terminal:listSessions'),
// 远程: 端口转发
forwardOpen: (peerId: string, args: { direction?: 'self-out' | 'self-in'; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) =>
ipcRenderer.invoke('forward:open', { peerId, ...args }),
forwardClose: (sessionId: string) => ipcRenderer.invoke('forward:close', { sessionId }),
forwardListSessions: () => ipcRenderer.invoke('forward:listSessions'),
// 远程: 串口 (映射 + 串口助手)
// 串口: 列表 / 映射 / 助手
serialList: (peerId: string) => ipcRenderer.invoke('serial:list', { peerId }),
serialListLocal: () => ipcRenderer.invoke('serial:listLocal'),
serialAttach: (peerId: string, busId: string, opts: { direction?: 'self-out' | 'self-in'; config: any }) =>
ipcRenderer.invoke('serial:attach', { peerId, busId, ...(opts || {}) }),
serialSend: (sessionId: string, dataBase64: string) =>
ipcRenderer.invoke('serial:send', { sessionId, dataBase64 }),
serialDetach: (sessionId: string) => ipcRenderer.invoke('serial:detach', { sessionId }),
serialListSessions: () => ipcRenderer.invoke('serial:listSessions'),
serialDiagnose: () => ipcRenderer.invoke('serial:diagnose'),
serialFixPerms: () => ipcRenderer.invoke('serial:fixPerms'),
serialEnvInfo: () => ipcRenderer.invoke('serial:envInfo'),
serialSaveLog: (content: string, suggestedName?: string) =>
ipcRenderer.invoke('serial:saveLog', { content, suggestedName }),
// 网盘: 浏览对方共享目录 + 选文件下载
driveList: (peerId: string, path?: string) => ipcRenderer.invoke('drive:list', { peerId, path: path || '' }),
driveRoot: (peerId: string) => ipcRenderer.invoke('drive:root', { peerId }),
driveDownload: (peerId: string, paths: string[]) => ipcRenderer.invoke('drive:download', { peerId, paths }),
driveOpenFolder: () => ipcRenderer.invoke('drive:openFolder'),
drivePickDir: () => ipcRenderer.invoke('drive:pickDir'),
// 文件夹同步: 本地目录 <-> 对方网盘目录
syncPickLocalDir: () => ipcRenderer.invoke('sync:pickLocalDir'),
syncScan: (args: { peerId: string; localDir?: string; remotePath?: string; relationId?: string }) =>
ipcRenderer.invoke('sync:scan', args),
syncApply: (args: {
peerId: string; localDir?: string; remotePath?: string; relationId?: string;
items: { relPath: string; action: 'upload' | 'download' | 'delete-local' | 'delete-remote' | 'skip' }[]
}) => ipcRenderer.invoke('sync:apply', args),
// 关系 (同步盘): 建立/授权/吊销 —— 对方那边直接弹窗, 不用邀请码
relationList: () => ipcRenderer.invoke('relation:list'),
relationRequest: (args: { peerId: string; syncLocalDir: string; syncRemotePath?: string; grants: string[] }) =>
ipcRenderer.invoke('relation:request', args),
relationSetGrants: (args: { relationId: string; grants: string[] }) =>
ipcRenderer.invoke('relation:setGrants', args),
relationSetSync: (args: { relationId: string; syncLocalDir?: string; syncRemotePath?: string; syncEnabled?: boolean }) =>
ipcRenderer.invoke('relation:setSync', args),
relationRevoke: (relationId: string) => ipcRenderer.invoke('relation:revoke', { relationId }),
relationDelete: (relationId: string) => ipcRenderer.invoke('relation:delete', { relationId }),
// 授权 / 审计
remoteApprovalList: () => ipcRenderer.invoke('remote:approval:list'),
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean, extra?: { grants?: string[]; syncLocalDir?: string; syncRemotePath?: string }) =>
ipcRenderer.invoke('remote:approval:reply', { requestId, ok, remember, ...(extra || {}) }),
auditList: (args?: { limit?: number; since?: number; sessionId?: string }) =>
ipcRenderer.invoke('audit:list', args || {}),
auditPrune: (olderThanMs?: number) => ipcRenderer.invoke('audit:prune', olderThanMs),
})