2a641b1eff
## 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 区在, 波特率/数据位/停止位/校验/循环发送/清屏/保存日志 按钮齐全, 方向切换会换设备列表 (我用对方的 = 对方设备, 对方用我的 = 本机设备)。
146 lines
8.3 KiB
TypeScript
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),
|
|
})
|