# 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--` 里, 主包没有 `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'` 帧名和实际内容对不上, 改的时候别手贱"顺手改对" —— 会把 所有还在跑旧版本的对端一起踢下线。 另注: **拉对端的串口列表要对方用户批准** (新旧版本都这样), 对方没点批准就是 15 秒超时 -> 列表空 + 报"超时"。这是审批机制不是 bug, 别去加"自动跳过审批"的逻辑。 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=` 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. ## 提交与推送 远端是自建 Gitea (`https://git.xsinfo.vip/xiaoshuai/LocalNetMsg.git`)。**HTTPS 没有配 credential helper**,非交互式 `git push` 会卡在 `could not read Username` 上 —— 所以 push URL 已经指到 SSH: ``` origin https://git.xsinfo.vip/xiaoshuai/LocalNetMsg.git (fetch) origin git@git.xsinfo.vip:xiaoshuai/LocalNetMsg.git (push) ``` fetch 走 HTTPS、push 走 SSH,直接 `git push` 就能用。SSH key 失效时退回: `git push git@git.xsinfo.vip:xiaoshuai/LocalNetMsg.git main:main`(显式指定 URL 不改 remote 配置)。 拉远端最新提交用 `git fetch git@git.xsinfo.vip:xiaoshuai/LocalNetMsg.git main:refs/remotes/origin/main` —— 否则 `origin/main` 是陈的,`git status` 里的 "领先 N" 是假的。 ## 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/x/apps/.png`,而单张 PNG 交给 `app-builder` 时它解析不出尺寸(返回 `size: 0`),于是装进 `hicolor/0x0/` 这种非法目录,桌面环境根本找不到 → 启动器没图标。正确做法是给一个**目录**,里面按 `x.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 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--` 里除了 `.node` 还有 `conpty.dll` / `OpenConsole.exe`,而 node-pty 的 ConPTY worker 用 `worker_threads.Worker` 起脚本——都必须是磁盘上的真实文件。规则写成 `.../node-pty-*/*`(单层)会把 `prebuilds//` 下的东西留在 asar 里,Windows 终端直接起不来(Linux 上看不出来,因为只需要一个 `.node`)。自测 S7 守着这条规则。 2. **Windows 保留设备名**:`CON` `PRN` `AUX` `NUL` `COM1-9` `LPT1-9`(带扩展名也算)不能直接当文件名——对它们 `open()` 打开的是设备不是文件。落盘前统一走 `src/main/safe-name.ts` 的 `safeFileName()`(按**本机**平台决定要不要改写,Linux 上 `CON` 是合法文件名,不该动)。