Files
LocalNetMsg/AGENTS.md
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

202 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
1. **`chat-client` MUST have an `on('error')` listener.** Node EventEmitter throws `ERR_UNHANDLED_ERROR` and 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; just `console.warn` and move on.
2. **Drag-drop file path.** Electron 32 removed `File.path`. Use `webUtils.getPathForFile(file)` from `electron` — exposed in `src/preload/index.ts` as `getPathForFile(file)`. Callers must read it via `window.api.getPathForFile(f)` BEFORE the await boundary.
3. **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 真的开始拦截。
4. **`mkdir` for Windows reserved names.** Output dir is `dist-Electron\` (escaped; `dist\electron` chokes on Windows). Don't rename without verifying the build still produces a valid path.
5. **`nsis` is `oneClick: false`.** Installer asks for install path + creates Desktop/Start-Menu shortcuts. Permissions are user-scoped.
6. **Renderer global drag highlight.** Track `dragenter`/`dragleave` with a depth counter (not a boolean) — child elements fire leave/enter pairs. See `MessageInput.vue`'s `dragDepth`.
7. **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`。
8. **File conflict naming.** Append `_N` before the first `.`: `archive.tar.gz` → `archive_1.tar.gz`, `.bashrc` → `.bashrc_1`. Logic in `file-server.ts` `targetPath()`.
9. **图片消息: 缩略图随消息体走, 右键复制写的是原图.**
气泡里显示的是 `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:copy` IPC -> `clipboard.writeImage`, 写的是**原图**
(本地文件优先, 退回 `dataBase64`), 不是气泡里那个缩略图。
缩略图缺失时 (老消息 / 老版本对端) 渲染层退到 `image:thumb` 现场生成, 失败才显示文件卡片。
10. **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'` 帧名和实际内容对不上, 改的时候别手贱"顺手改对" —— 会把
所有还在跑旧版本的对端一起踢下线。
11. **未读/角标: 正在看的会话必须立刻清未读, 且不能被 `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 in `file-server.ts`. Useful for testing the limit path.
- `deviceName`, `downloadDir`, `notifications`, `autoStart` live in `~/AppData/Roaming/local-net-msg/config.json` via `electron-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 装完"没图标"是个坑,有两个独立原因:
1. **`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`;生成结果已提交,平时不用跑)。
2. **`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`。最容易踩的两个:
1. **`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 守着这条规则。
2. **Windows 保留设备名**:`CON` `PRN` `AUX` `NUL` `COM1-9` `LPT1-9`(带扩展名也算)不能直接当文件名——对它们 `open()` 打开的是设备不是文件。落盘前统一走 `src/main/safe-name.ts` 的 `safeFileName()`(按**本机**平台决定要不要改写,Linux 上 `CON` 是合法文件名,不该动)。