Files
LocalNetMsg/AGENTS.md
T
xiaoshuai 4fc86edd4e feat(sync): 同步盘 + 关系层 (先建关系, 再做文件同步)
## 关系层 (src/main/relations.ts, relation-service.ts)

原来"发现即信任": UDP 广播包里明文带着 driveToken, 同网段任何进程拿到就能浏览
你的整个网盘, 而且没有"授权"这个概念。现在拆成三层:

  发现 (临时) -> 关系 (持久, 用户显式建立) -> 授权 (分级, 双方各自独立)

建立关系的方式跟远程终端的授权闸门**完全一致** —— 对方直接弹窗, 不用邀请码:
A 点"发起同步关系" -> WS 帧 relation:request -> B 侧弹窗 (复用 ApprovalDialog,
kind='relation') -> 同意 -> relation:accept -> 双方落库并交换 per-peer 令牌。

- 令牌: myToken=我签发给对方的, peerToken=对方签发给我的
- **driveToken 不再进广播包**; file-server 在关系令牌匹配不上时回退到旧的全局
  token, 但只允许"只读浏览网盘" —— 不给同步区, 也不给写/删
- 关系存 JSON 文件 (原子写), 不做 schema 迁移
- 授权能力: drive(浏览我的网盘) / sync(与我同步文件), 双方各自独立勾选
- 远程终端/端口转发/USB 仍走原来的 remoteAllowPeers 白名单, 没有并进关系层
- 同一对端 60s 内只弹一次窗 (WS 是"发现即建立"的, 不限流就能被刷屏)

## 同步区: 独立于网盘的第二套空间

用户的同步目录可以是任意文件夹 (~/文档/工作), 不必待在网盘目录里。

  /drive/*  网盘   -> 共享目录, 要 drive 授权
  /sync/*   同步区 -> 某关系专属的同步目录, 要 sync 授权, 路径严格限制在该目录内

顶层目录可以同时和多个设备同步: 每个关系自动分配一个子目录 (~/备份/MECHREVO/,
~/备份/lenovo/), 物理隔开, 同名文件不会互相覆盖 —— "统一目录多个聊天备份"成立。

## 文件关系表 (src/main/sync-manifest.ts) —— 同步盘地基

核心是**区分"文件被删了"和"文件本来就不存在"**:

- **墓碑**: 删除不抹记录, 只标 state='deleted'。直接抹掉的话, 对方离线回来会把
  已删文件当成"新增"重新下载 -> 删除永远删不掉; 对方从备份恢复也会让删除被
  静默撤销。有墓碑 + seq 比较就都能挡住。
- **seq = Lamport 时钟**: hash 只能答"一样不一样", seq 才能答"谁更晚" —— 判定
  删除方向全靠它。扫描顺序必须是 advanceClock -> reconcileLocal -> mergePeer
  (先收消息再产生事件), 否则跨机器的 seq 不可比, 删除方向会判反。
- **三方对比**: 基线(上次两边一致的 hash) + 本地 + 远端。缺基线才退回 mtime 兜底。
- 墓碑留 90 天 (比大品牌默认 30 天保守), 且要"对方已越过该 seq"才清理。
- **删除传播**: 对方推关系表过来时, 比对基线确认我没改过才删本地那份; 改过则
  报冲突 —— 绝不静默丢数据。
- 冲突: 手动同步弹窗对比让用户选方向; 自动同步跳过并记录, 不替用户决定。

## 其余

- 自动同步 (src/main/auto-sync.ts): 60s 轮询 + 指数退避, 对方上线/开关打开时
  立刻跑一轮。
- 同步面板 (SyncPanel.vue): 关系区 + 变更列表 (含"将删除对方/本机") + 冲突对比
  弹窗 + 自动同步开关。
- 关系授权弹窗复用远程终端那套 ApprovalDialog。
- 目录穿越防护: 上传/下载/删除的路径都严格限制在各自根目录内, 且**软链归一后
  再比一次** (同步目录是用户任意文件夹, 里面出现软链很常见)。
- 跨关系校验: 令牌与 relationId 绑定, A 关系的对方不能读写 B 关系的同步目录。
- 关系令牌在 IPC 返回给渲染层时剥掉, 界面拿不到凭据。
- 建关系/授权/同步/删除传播都写 audit_log。

## 测试

- npm run selftest       64 通过 / 0 失败 (R关系 / M墓碑删除传播 / Y同步盘)
- npm run selftest:sync  26 条全通过 (双节点端到端: 建立关系->同步->删->传播->防复活)

## 文档

docs/relation-and-sync.md —— 三层模型 / 关系建立 / 墓碑与 seq / 三方对比判定表 /
安全要点 / 已知不一致。
2026-10-20 19:30:25 +08:00

11 KiB
Raw Blame History

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 / USB) 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:*)
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 / USB) 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 建立关系 → 双向同步 → 改文件 → 删文件 → 删除传播 → 墓碑防复活. 改同步引擎后跑它 —— 它守住的是"删了能删掉、删了不会复活"这两条命门。

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
    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, ...)
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. postinstall runs electron-builder install-app-deps to rebuild better-sqlite3 against the bundled Electron ABI. After npm i or upgrading Electron, this must run. If you forget, sqlite open will throw on launch.

  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().

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 是合法文件名,不该动)。