4fc86edd4e
## 关系层 (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 / 三方对比判定表 / 安全要点 / 已知不一致。
141 lines
11 KiB
Markdown
141 lines
11 KiB
Markdown
# 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` 是合法文件名,不该动)。
|