## 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 区在, 波特率/数据位/停止位/校验/循环发送/清屏/保存日志 按钮齐全, 方向切换会换设备列表 (我用对方的 = 对方设备, 对方用我的 = 本机设备)。
17 KiB
LocalNetMsg Agent Guide
LAN instant-messaging desktop app. Stack: Electron 32 + Vue 3 + TypeScript + Vite + Pinia + Element Plus + SQLite (better-sqlite3). Three independent processes built by electron-vite.
Commands
| Task | Command |
|---|---|
| Dev (Electron + HMR) | npm run dev |
| Build (no installer) | npm run build |
| Typecheck main+preload | npm run typecheck:node |
| Typecheck renderer | npm run typecheck:web |
| Typecheck both | npm run typecheck |
| Remote feature self-test (terminal / forward / serial) | npm run selftest |
| Sync-drive end-to-end test (relation -> sync -> delete propagation) | npm run selftest:sync |
| Packaged-artifact smoke check (asar unpack + real pty spawn) | npm run selftest:package (run after npm run package:*) |
| Real-Electron nativeImage check (thumbnail encode / transparency / clipboard) | npm run selftest:image (needs a display) |
| Windows installer | npm run package:win → dist-Electron\LocalNetMsg-0.1.0-Setup.exe |
| macOS / Linux | npm run package:mac / npm run package:linux |
| Unpacked dir only | npm run package:dir |
All package:* scripts bake in ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR (npmmirror.com). Do not remove unless on the open internet.
npm run selftest runs the remote (terminal / port-forward / serial) self-test: one process, two virtual
nodes, real ChatServer + ChatClient + remote managers. See scripts/selftest/.
npm run selftest:sync runs the sync-drive end-to-end test (scripts/selftest/sync-e2e.ts): two virtual
nodes, real WS + HTTP, covering 建立关系 → 双向同步 → 改文件 → 删文件 → 删除传播 → 墓碑防复活.
改同步引擎后跑它 —— 它守住的是"删了能删掉、删了不会复活"这两条命门。
npm run selftest:image 在真 Electron(需要显示器)下验 nativeImage 的实际行为: 缩略图编码阶梯、
PNG 透明通道、转 JPEG 丢透明、剪贴板写读、坏文件降级。它打的是生产代码
(src/main/image.ts, esbuild 打包且 --external:electron), 不是桩 —— 自测里的 electron 是桩, 那套只能验逻辑。
视觉验证 (UI 改动后用): scripts/selftest/inject-image-msg.cjs 往 App 的 SQLite 注入演示消息,
scripts/selftest/cdp-shot.cjs 用 CDP 驱动真实窗口点按钮/右键并截图 (环境里没有 xdotool)。
用完记得把注入的数据清掉。
Verify changes via npm run typecheck + npm run selftest + npm run selftest:sync + manual npm run dev.
Layout
src/
main/ Electron main process (Node) — entry: index.ts
discovery.ts UDP broadcast beacon (multi-NIC, per-interface subnet bcast)
chat-server.ts WebSocketServer on 0.0.0.0:47900
chat-client.ts Outgoing WS with backoff reconnect
file-server.ts HTTP /upload + /file, default cap 100GB
db.ts SQLite via better-sqlite3 (data/app.db)
settings.ts electron-store wrapper (~/AppData/Roaming/local-net-msg/config.json)
notify.ts System Notification + Windows overlay badge
image.ts nativeImage 封装: 缩略图生成 + 复制到剪贴板 + 读原图
image-core.ts 图片纯逻辑 (阈值/编码选择/选图源) —— 不 import electron, 可单测
ipc.ts All ipcMain.handle() wiring
protocol.ts MessageEnvelope, WsFrame, DeviceInfo types
relations.ts 关系层: 持久绑定 + per-peer 令牌 + 分级授权 (JSON 存储)
relation-service.ts 关系服务(类): 建立/接受/拒绝/吊销 + 应用对方关系表
sync-manifest.ts 文件关系表: 墓碑 + Lamport seq + 三方对比 (同步盘地基)
sync.ts 同步引擎: 扫描 -> 三方对比 -> 上传/下载/删除传播
auto-sync.ts 自动同步定时器 (60s 轮询 + 指数退避)
preload/ contextBridge exposing typed API to renderer
renderer/ Vue 3 SPA (Vite root)
src/
main.ts createApp + Pinia + ElementPlus (full import) + zhCn
App.vue Global event listeners (device:*, message:*, settings:*)
components/ Sidebar, ChatView, MessageInput, MessageItem, SettingsView, MarkdownView
stores/ Pinia: device.ts, message.ts, session.ts
api.ts IPC typings (mirrors preload)
utils/ format.ts (colorFor, initialsOf, formatSize, ...)
unread.ts (未读处理策略: unreadActionForIncoming)
electron.vite.config.ts Three builds → out/{main,preload,renderer}/
Renderer alias: @ → src/renderer/src.
同步盘 (sync drive) — 三层模型
发现 ≠ 信任。拆成三层: 发现(临时) → 关系(持久, 用户显式建立) → 授权(分级, 双方各自独立)。
- 关系怎么建立: 跟远程终端的授权闸门完全一致 —— 对方直接弹窗, 不用邀请码/6位码/系统消息卡片。
A 点"发起同步关系"→ WS 帧relation:request→ B 侧enqueueApproval()(kind='relation') → 弹窗同意 → 回relation:accept。 帧定义在protocol.ts, 路由在ipc.ts的routeRelationFrame()。 - 令牌:
myToken=我签发给对方的,peerToken=对方签发给我的。旧的全局driveToken不再进 UDP 广播包 (同网段任何进程拿到广播包就能浏览你整个网盘)。file-server 在关系令牌 匹配不上时回退到旧的全局 token, 给还没升级的对端留过渡通道。 - 两套独立空间:
/drive/*网盘 —— 要drive授权, 路径限制在网盘根内/sync/*同步区 —— 要sync授权, 路径限制在该关系的同步目录内 (任意位置, 不必在网盘里) 只给sync时完全不能浏览网盘。
- 文件关系表 (manifest): 每个关系一份 (key=relationId, 所以同一个本地目录跟多个关系同步也不串)。
- 墓碑: 删除不抹记录, 只标
state='deleted'—— 直接抹掉的话, 对方离线回来会把已删文件 当成"新增"重新下载, 删除永远删不掉; 对方从备份恢复也会让删除被静默撤销。 - seq = Lamport 时钟: hash 只能答"一样不一样", seq 才能答"谁更晚" —— 判定删除方向的关键。
- 三方对比: 基线(上次两边一致的 hash) + 本地 + 远端。缺基线时退回 mtime 兜底 (宁可多弹窗)。
- 墓碑保留 90 天 (比大品牌默认 30 天保守), 且要"对方已越过该 seq"才清理。
- 删除传播落点:
RelationService.applyPeerManifest()—— 对方说删了, 我比对基线确认没改过才删本地; 改过则报冲突, 绝不静默丢数据。
- 墓碑: 删除不抹记录, 只标
详细设计见 docs/relation-and-sync.md。
Network protocol
UDP discovery on 47800, chat WS on 47900, file HTTP on 47901. All three bind 0.0.0.0 and auto-shift on conflict.
DeviceInfo carries address (chosen from the first non-internal IPv4 of the responding NIC) plus chatPort/filePort. The chat server sends no broadcast of its own — discovery comes purely from UDP. Receiver uses rinfo.address (real source IP), never the payload's self.address.
Hard-won pitfalls (don't repeat these)
-
chat-clientMUST have anon('error')listener. Node EventEmitter throwsERR_UNHANDLED_ERRORand kills the main process if'error'fires with no listener. The WS'error'event is followed by'close'which triggers reconnect. Don't try to "fix" by emitting on the client; justconsole.warnand move on. -
Drag-drop file path. Electron 32 removed
File.path. UsewebUtils.getPathForFile(file)fromelectron— exposed insrc/preload/index.tsasgetPathForFile(file). Callers must read it viawindow.api.getPathForFile(f)BEFORE the await boundary. -
Native rebuild —— 只重建
better-sqlite3, 别用electron-builder install-app-deps.better-sqlite3不是 N-API, 绑死 V8/ABI (Electron 32 → node ABI 128), 必须对着 Electron 头文件编译;npm i或升级 Electron 后必须重编, 否则 sqlite 打开即崩。postinstall现在是node scripts/postinstall.js: 只重建 better-sqlite3, 装完在 Electron 下 真跑一次读写校验 (失败就 exit 1, 把"启动即崩"提前到 install 阶段暴露)。 别改回electron-builder install-app-deps—— 它重建所有原生模块, 且给子进程塞npm_config_build_from_source=false; npm 11 对未知 npm_config_* 保留原始字符串, 于是子进程里 它是字符串"false"—— JS 里是真值 ——pkg-prebuilds-verify直接 exit 1 (node_modules/pkg-prebuilds/bin/verify.mjs:14), 强制源码编译。node-hid 于是去编译它自带的 node-addon-api 3.2.1, 和现代 Node 头文件不兼容, 编译失败, 整个npm install挂掉 —— 而它本来根本不需要编译 (自带 N-API 预编译产物, 任何 ABI 都能加载)。 同理, node-pty 1.2 的二进制在平台包@lydell/node-pty-<plat>-<arch>里, 主包没有prebuilds/目录 —— 按主包去找只会误报。 npm 11 还有个allowScripts机制 (根 package.json 的allowScripts字段): 没被显式列入的 安装脚本只会被"提醒"而非拦截, 但建议把信任的包列进白名单, 免得将来 npm 真的开始拦截。 -
mkdirfor Windows reserved names. Output dir isdist-Electron\(escaped;dist\electronchokes on Windows). Don't rename without verifying the build still produces a valid path. -
nsisisoneClick: false. Installer asks for install path + creates Desktop/Start-Menu shortcuts. Permissions are user-scoped. -
Renderer global drag highlight. Track
dragenter/dragleavewith a depth counter (not a boolean) — child elements fire leave/enter pairs. SeeMessageInput.vue'sdragDepth. -
WS 通道状态要"拉取式"同步, 不能只靠事件推送. 聊天框顶部显示的是"通道可用"(outgoing 或 inbound 任一 WS 活着),跟侧边栏的"设备在线"(UDP 发现)是两回事。启动时主进程往往先连上对端(
index.ts里connectTo早于createMainWindow),那条device:wsState事件会在渲染层订阅之前就发出去被丢掉 —— 早期实现只有事件推送,UI 因此永久停在"等待重连…",而连接其实一直是好的。规矩:(1)device:wsState推全量快照而不是增量;(2) 渲染层 mount 时主动invoke('app:state')拉一次;(3) 渲染层先注册监听再 await,否则 await 期间的事件也会丢。详见docs/connection-status.md。 -
File conflict naming. Append
_Nbefore the first.:archive.tar.gz→archive_1.tar.gz,.bashrc→.bashrc_1. Logic infile-server.tstargetPath(). -
图片消息: 缩略图随消息体走, 右键复制写的是原图. 气泡里显示的是
body.thumbDataUrl(data URL), 发送方生成一次, 对方直接拿来显示 —— 所以对端不用再解码一遍, 老版本对端也看得到图。 缩略图用 Electron 的nativeImage生成 (src/main/image.ts), 不引 sharp/jimp: 宽度上限 480, PNG 优先 (保透明 —— 截图转 JPEG 会变黑底), PNG 超 60KB 才退 JPEG, 然后按"降质量 → 缩尺寸"的阶梯找一档够小的 (src/main/image-core.ts里的纯逻辑, 可单测)。 源文件超过 20MB 不生成缩略图 —— nativeImage 解码会把整张图铺成 RGBA 内存。 拖拽/选中的图片必须走sendFile(asImage: true)(有真实路径, 流式上传), 只有粘贴的图片才走sendBuffer(整图 base64 进 WS 帧, 大图会撑爆带宽)。 右键"复制图片"走image:copyIPC ->clipboard.writeImage, 写的是原图 (本地文件优先, 退回dataBase64), 不是气泡里那个缩略图。 缩略图缺失时 (老消息 / 老版本对端) 渲染层退到image:thumb现场生成, 失败才显示文件卡片。 -
USB 字节桥和 USB/IP 已下线, 只剩串口. v0.2.0 把
src/main/remote/usb.ts换成了src/main/remote/serial.ts—— 只做串口映射 (Linux/macOS 用 socat 建 PTY 配对, Windows 用 com0com) + 串口助手 (应用自己收发字节: 文本/HEX、循环发送、保存日志)。 删掉的: node-usb 的 control/bulk 字节桥、USB/IP 真透传, 以及node-usb/node-hid两个原生依赖 (它们其实压根没被真正用到)。 线上格式一个字节都没动 —— WS 帧类型仍然叫'usb',PROTOCOL_VERSION仍是 1, 内部类型全改名成Serial*。原因: 局域网里常有一台机器还跑着旧版本, 升协议版本会让 两边连不上 (连文字消息都发不了), 代价太大。现在新旧版本之间: 聊天照常, 串口映射也照常 (老版本本来就支持kind:'serial'), 只有 USB 字节桥在混版本时用不了 —— 而那功能已下线。 代价: 协议里那个'usb'帧名和实际内容对不上, 改的时候别手贱"顺手改对" —— 会把 所有还在跑旧版本的对端一起踢下线。 -
未读/角标: 正在看的会话必须立刻清未读, 且不能被
savedPath拦下来. 踩过的坑:App.vue的message:received里先判断if (!savedPath) return(等文件落地再算未读), 结果文字消息没有savedPath—— 正好在"清未读"之前就 return 了, 于是用户明明在看这个会话, 角标数字却一直挂着。文件/图片消息因为第二帧带savedPath才碰巧能清掉, 所以只有文字消息暴露这个 bug。 规矩: 先判isActive-> 清未读 + 回已读回执, 再判savedPath。 决策抽成了纯函数src/renderer/src/utils/unread.ts的unreadActionForIncoming()(clear/wait/count), 自测 U1/U2 守着它 —— 别再把顺序写回 if 里。 另外两处也要清: 侧边栏重复点同一个设备时activePeerId不变, watch 不触发, 得显式清一次; 主进程device:delete清了 unread 但忘了updateBadge(), 角标数字会残留。
Configuration knobs
LNM_MAX_FILE_SIZE=<bytes>env var overrides the 100 GB default infile-server.ts. Useful for testing the limit path.deviceName,downloadDir,notifications,autoStartlive in~/AppData/Roaming/local-net-msg/config.jsonviaelectron-store. Edit the file directly while dev is stopped.data/app.db— SQLite DB. DELETE to reset all devices/messages. Auto-migrates on next launch.
Packaging gotcha
release/ contains an app.asar Windows cannot delete while the previous app instance holds a file handle. Build now writes to dist-Electron\ to dodge this. If you ever switch back, kill the running app first or you'll get EPERM.
Linux packaging (icon + taskbar)
Ubuntu 装完"没图标"是个坑,有两个独立原因:
-
linux.icon必须指向目录,不能是单张 PNG。 electron-builder 把图标装进/usr/share/icons/hicolor/<size>x<size>/apps/<exe>.png,而单张 PNG 交给app-builder时它解析不出尺寸(返回size: 0),于是装进hicolor/0x0/这种非法目录,桌面环境根本找不到 → 启动器没图标。正确做法是给一个目录,里面按<size>x<size>.png命名:resources/icons/{16,24,32,48,64,128,256,512}x{...}.png,然后linux.icon = "resources/icons"。图标集用node scripts/make-icon.js生成(需要 ImageMagick 的convert;生成结果已提交,平时不用跑)。 -
StartupWMClass要跟窗口真实的 WM_CLASS 一致。 electron-builder 默认填productName(LocalNetMsg),但 Electron 实际设的是可执行文件名(local-net-msg)—— 对不上,任务栏就找不到对应启动器,显示成通用图标。用xprop -id <wid> WM_CLASS确认真实值,然后在build.linux.desktop.StartupWMClass里写它。
Windows / macOS 仍用单张 resources/icon.png(那里不按尺寸分目录)。
Windows 兼容性
完整的分析、已修问题和待验证清单见 docs/windows-compat.md。最容易踩的两个:
-
build.asarUnpack里的原生模块规则必须递归(**/*)。 node-pty 1.2 是"主包 + 平台包"拆包架构,平台包@lydell/node-pty-<platform>-<arch>里除了.node还有conpty.dll/OpenConsole.exe,而 node-pty 的 ConPTY worker 用worker_threads.Worker起脚本——都必须是磁盘上的真实文件。规则写成.../node-pty-*/*(单层)会把prebuilds/<plat>/下的东西留在 asar 里,Windows 终端直接起不来(Linux 上看不出来,因为只需要一个.node)。自测 S7 守着这条规则。 -
Windows 保留设备名:
CONPRNAUXNULCOM1-9LPT1-9(带扩展名也算)不能直接当文件名——对它们open()打开的是设备不是文件。落盘前统一走src/main/safe-name.ts的safeFileName()(按本机平台决定要不要改写,Linux 上CON是合法文件名,不该动)。