Files
LocalNetMsg/docs/relation-and-sync.md
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

30 KiB
Raw Permalink Blame History

关系模型 + 同步区设计

目标: 把"发现"和"信任"彻底分开。发现只是知道对方存在; 关系必须由用户显式建立; 同步区在关系之上, 靠持久化的文件关系表 (manifest) 正确处理增删改, 尤其删除。

这份文档描述的是已经落地的实现。早期设计稿里的"邀请码"和"网盘子目录" 两套方案没有采用, 原因分别在 §3.1 和 §4.1。代码位置见 §13。


1. 为什么要改 (现状的问题)

改造前是隐式全自动的:

设备启动 → UDP 广播 announce{deviceId, name, address, chatPort, filePort, driveToken}
        → 局域网所有机器收到 → 自动进"最近设备" → 自动建 WS → 消息双向开放
        → 网盘 token 在广播包里明文传 → 局域网任何人拿它就能浏览你的整个共享目录

问题:

  1. 发现 = 信任。同网段任何进程发个广播包就成了"联系人", 无法选择"我只跟谁建立关系"。
  2. 网盘凭据全局广播, 而远程终端/串口 却有授权闸门 —— 两套标准。网盘等于对整个局域网敞开。
  3. 没有确认环节, 无法表达"我认识这个人, 我要跟他同步文件夹"。
  4. 授权粒度不统一: 远程操作有 per-peer 白名单, 网盘和消息完全没有。
  5. 删除无法传播。没有 manifest 就没有"这个文件之前存在过、现在被谁删了"的记录。

对应的解法就是下面的三层模型 + 关系级授权 + 文件关系表。


2. 三层模型

层 含义 生命周期 产生信任吗
发现 (Discovery) 知道对方存在 临时 (15s 心跳) 否
关系 (Relation) 用户显式建立的一次绑定 持久 是
授权 (Grant) 关系上的具体能力 随关系 —

关系层目前只有两项能力, 双方各自独立授权 (A 允许 B 看网盘 ≠ B 允许 A 看网盘):

能力 含义
drive 允许对方浏览我的网盘 (共享目录)
sync 允许与对方同步文件 (走该关系专属的同步区)

远程终端 / 端口转发 / 串口 没有并进关系层, 它们仍然走原来的 settings.remoteAllowPeers 白名单 + 弹窗闸门 (src/main/remote/approval.ts)。 理由: 那三个是"一次性操作请求", 关系是"长期绑定", 生命周期和撤销语义都不一样, 硬合并会让远程操作每次都要查关系表, 反而更绕。

关键改动: 全局 driveToken 不再出现在广播包里。index.ts 的 announce 包 故意不带这个字段, 网盘接口改成校验关系令牌 (per-peer, 对方签发给我的那个)。


3. 建立关系: 对方直接弹窗 (没有邀请码)

3.1 为什么不用邀请码

早期设计稿走的是"6 位数字码 + 聊天系统消息卡片 + 一次性邀请表"那一套, 实现时全部砍掉了:

  1. 远程终端已经有一套成熟的授权闸门 —— 白名单 + 24h 记忆 + 弹窗 (approval.ts + ApprovalDialog.vue)。关系层直接复用同一个队列、同一个弹窗组件, 用户的心智模型是一致的: "对方弹窗问我要不要给权限"。
  2. 邀请码要额外维护一整套状态机: 码的生成/分发/过期/一次性/防爆破/聊天消息卡片/ 待确认表。而这套状态机解决的问题, 弹窗一步就解决了 —— 授权范围本来就是当场选的事。
  3. 同步本来就需要一条 WS 通道。请求走已有的通道比走"码"更直接, 而且天然知道 对方在不在线: 帧发不出去就直接失败回收关系, 不用等邀请码超时。
  4. 用户还得想办法把码告诉对方 (念给对方听?), 反而比"对方弹个窗"麻烦。

所以最终形态是: A 发起 → B 弹窗 → B 同意 → 双方各自落库。

3.2 完整交互流程

A 侧 (发起方)                                B 侧 (被请求方)
────────────────────────────────────────────────────────────────────────
同步面板 → "建立同步关系"
  选本机同步目录
  勾选"我开放给对方的能力"
    [x] 允许浏览我的网盘 (drive)
    [x] 允许与我同步文件 (sync)
  点"发起同步关系"
      │
      │  WS 帧 relation:request
      │  (含 relationId / 我签发的 token / 我开放的能力 /
      │   我的同步目录 / 我提议的目录名)
      ▼
                                     主进程收到 → 直接弹窗
                                     (ApprovalDialog, kind='relation')
                                       - 显示对方要什么
                                       - 我开放什么 (可改勾选)
                                       - 我的本机同步目录 (可填)
                                     点"同意"
      │  WS 帧 relation:accept
      │  (含 B 签发的 token / B 开放的能力 / B 的同步目录)
      ▼
A: 关系落库 state='active'
   记 peerToken = B 签发的那个
                                     B: 关系落库 state='active'
                                        记 myToken = 自己签发的那个

拒绝时 B 回 relation:reject, A 侧把 pending 关系删掉 (不留残条)。 发起方在等待期间的关系状态是 pending; 一旦 accept 到达就转 active。

已建立过关系的对端再发 relation:request 会被直接回绝 (不发弹窗), 避免重复弹窗刷屏。

3.3 WS 帧

帧 方向 载荷要点
relation:request 发起方 → 对方 relationId, token(我签发的), grants, syncLocalDir, syncRemotePath, peer(我的设备信息)
relation:accept 对方 → 发起方 relationId, token(我签发的), grants, syncLocalDir, syncRemotePath, peer
relation:reject 对方 → 发起方 relationId, reason

三个帧都在 src/main/protocol.ts 定义, 由 ipc.ts 的 routeRelationFrame() 分发。

3.4 令牌模型

令牌 谁持有 作用
myToken 对方 (我签发给它的) 它用来访问我的网盘/同步区
peerToken 我 (对方签发给我的) 我用来访问对方的网盘/同步区
driveToken (旧) 全局广播 废弃, 不再出现在发现包里
  • 每个关系两个令牌, 各自独立签发 (32 位 hex)。令牌比较用 timingSafeEqual (relations.ts 的 safeEqual)。
  • 吊销是单边的: 我吊销关系 → 我的 myToken 被换掉 + state='revoked', 对方手里的旧令牌立刻访问不了我; 但我持有的对方的 peerToken 仍然可用, 要等对方也吊销才失效。
  • 过渡期: 老版本对端没有"关系"概念, 但本机缓存的旧 drive.token 仍然有效 (file-server.ts 的 authDrive 里先试关系令牌, 失败再退回旧的全局 token)。 这样可以渐进式升级, 不用一刀切。

3.5 授权粒度

关系上只有 drive 和 sync 两项, 而且双方各自独立:

  • myGrants = 我开放给对方的能力
  • peerGrants = 对方开放给我的能力

网盘浏览和文件同步是两套独立授权:

  • 只给 sync → 对方完全不能浏览我的网盘 (scopeRoot='none', 直接 403 "未授权浏览网盘"), 但可以通过 /sync/* 访问该关系专属的同步目录。
  • 只给 drive → 对方能浏览我的整个网盘共享目录, 但访问不了同步区。

授权可以在同步面板里改 (relation:setGrants), 改完立即生效。


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

4.1 为什么改成独立同步区

早期设计稿是"在对方的网盘里开一个子目录当同步区"。实现时改成了独立的同步区, 原因:

  1. 用户的同步目录不必待在网盘里。网盘共享目录是"我愿意公开给人看的东西", 而同步目录是"我自己的工作目录" (比如 ~/文档/工作)。把工作目录塞进公开共享区, 既不合逻辑也很别扭。
  2. 浏览和同步是两回事, 授权应该拆开。让对方浏览我的网盘 ≠ 让对方把我的目录同步走。 合成一个能力就没法表达"只同步不浏览"。
  3. 路径边界更清楚。网盘的边界是"网盘根", 同步区的边界是"这个关系专属的同步目录", 两个边界互不重叠, 穿越防护也只需要各自校验一次。

4.2 两套空间的对照

网盘 同步区
路由前缀 /drive/* /sync/*
需要的授权 drive sync
路径根 网盘共享目录 (全局一个) 该关系专属的同步目录 (syncLocalDir, 位置任意)
谁能访问 拿到 drive 的关系对端 拿到 sync 的关系对端
用途 浏览 / 挑选下载 双向同步

同步区的服务端路径解析 (file-server.ts 的 resolveSyncPath) 严格限制在 getSyncScope(relationId) 返回的目录内 —— 这个目录来自关系记录的 syncLocalDir, 所以同步目录可以是磁盘上任意一个文件夹。

4.3 目录约定

关系记录里保留两个目录字段 (对称, 不需要协商):

字段 含义 (我这侧视角)
syncLocalDir 我的同步目录 (绝对路径, 我这边的)
syncRemotePath 我在对方网盘里用的子目录 (提议值, 默认 同步/<我的名字>)
peerSyncRoot 对方在我网盘里用的子目录 (对方提议的)

发起方提议自己的 syncRemotePath, 接受方可以改成自己的并给出自己的, 于是双方各自算出:

发起方: syncRemotePath = 自己提的     peerSyncRoot = 对方给的
接受方: syncRemotePath = 自己给的     peerSyncRoot = 对方提的

注意: 独立同步区方案落地后, 文件实际落在对方的 syncLocalDir 里 (/sync/file 的路径是相对该目录的), syncRemotePath / peerSyncRoot 目前只作为双方协商与展示用的约定保留在关系记录和帧里, 不再决定数据落点。 见 §12 的"已知不一致"。

4.3.1 一个顶层目录可以管多个聊天备份 (按关系分子目录)

顶层目录可以同时和多个设备同步 —— 每个关系在自己的子目录里, 物理上隔开:

~/备份/MECHREVO/   <- 和 MECHREVO 同步的数据
~/备份/lenovo/     <- 和 lenovo 同步的数据

这样"统一目录多个聊天备份"就成立了, 而且两条数据不会互相覆盖:

  • 同步区接口的路径相对这个子目录, 不是相对顶层目录。
  • 关系记录里的 syncSubdir 就是这个子目录名 (默认对方设备名, 重名加 id 前缀)。
  • 实际同步根 = join(syncLocalDir, syncSubdir), 由 RelationService.localRootOf() 统一算, 服务端和客户端都用它 —— 两边必须算成同一个, 否则路径会错位。

踩过的坑: 登记本地文件时如果用顶层目录去扫, relPath 会带上子目录名 (比如 MECHREVO/a.txt), 污染关系表, 对方会看到一个"凭空多出来的路径"。扫描一律要用实际同步根。

4.4 服务端接口

同步区走的是已有的文件服务器 (HTTP, 默认 47901), 请求都要带 token + peerId + relationId:

接口 说明
GET /sync/scan?token=&peerId=&relationId=&hash=1 递归列出该关系的同步目录 (相对路径 / 大小 / mtime / 可选 sha256)
GET /sync/file?token=&peerId=&relationId=&path= 下载一个文件 (path 相对同步目录根)
POST /sync/file?token=&peerId=&relationId= 上传。目标相对路径在 header x-file-rel-path; 带 x-file-action: delete 时执行删除
GET /sync/manifest?token=&peerId=&relationId= 拉取对方的文件关系表 (含墓碑和序号)
POST /sync/manifest?token=&peerId=&relationId= 把我的文件关系表推给对方 (删除就是这样传播的)

鉴权: authSync() = 有效关系 + 关系里有 sync 能力, 否则 403。 路径: resolveSyncPath() 把相对路径 resolve 到同步目录根, 越界 (含 ..) 直接 400。

4.5 老对端降级

  • 对方是旧版本 (没有 /sync/*): 扫描失败, 界面提示"对方需要升级到带同步盘的版本"。
  • 对方能扫但没有 /sync/manifest (404): 远端视图退化成"只有实际扫描结果", 没有墓碑也没有序号 —— 此时删除无法可靠传播, 首次同步也没有基线, 退回 mtime 兜底判定。
  • 网盘浏览另有老通道: 老版本对端仍可用本机缓存的旧 driveToken 访问 /drive/*。

5. 文件关系表 (manifest): 三方对比 + 墓碑 + seq

这是整个同步功能的地基。没有它就没法正确处理删除。

5.1 存储

src/main/sync-manifest.ts, 按 relationId 分开存:

interface Manifest {
  relationId: string
  clock: number        // 我的 Lamport 时钟
  remoteSeq: number    // 水位线: 我已处理到对方的哪个序号
  entries: Record<string, ManifestEntry>   // key = relPath
}

按 relationId 分开是关键: 一个本地目录同时跟多个关系同步时, 各自的基线 / 墓碑 / 序号互不干扰, 不会串。

5.2 单条记录

interface ManifestEntry {
  fileId: string       // 稳定 id: 首次见到该路径时生成, 重命名后不变
  relPath: string      // 相对同步目录的路径 (同时是 entries 的 key)

  baseHash: string     // 基线: 上次双方内容一致时的 hash (空 = 从来没同步过)
  baseSize: number
  baseMtime: number

  myHash: string       // 我这边最后一次观察到的内容
  mySize: number
  myMtime: number
  myState: 'present' | 'deleted'   // 我这边的当前状态
  mySeq: number        // 我这边最后一次变更的序号

  peerHash: string     // 对方最后一次广播过来的内容
  peerSize: number
  peerMtime: number
  peerState: 'present' | 'deleted'  // 对方最后一次广播的状态
  peerSeq: number      // 对方最后一次变更的序号

  deletedAt: number    // 墓碑时间 (任一侧删除的时间), 0 = 没有墓碑
}

一句话概括: 基线 (base) + 我 (my) + 对方 (peer), 三份信息各自带 hash 和序号, 判定时三方对比。

5.3 为什么要有 seq (变更序号)

  • 内容 hash 只能回答"一样不一样", 回答不了"谁更晚"。
  • 判定删除方向恰恰需要"谁更晚": 对方 seq 5 的删除 vs 我 seq 3 的修改 → 删除更晚, 删除赢。

实现用 Lamport 时钟:

本地每变一次 (增/改/删/移动)  →  clock++
收到对方的 manifest            →  clock = max(clock, 对方 seq)
                                 remoteSeq = max(remoteSeq, 对方 seq)

于是本地时钟永远 ≥ 见过的对方序号, 两边不会因为时钟漂移而"分叉"。 删除也是一个 seq 事件, 所以"删除"和"修改"可以用同一套规则比新旧。

水位线 (remoteSeq + 每条的 peerSeq) 同时防重复和乱序: 重复同步同一批事件不会出错。

5.3.1 顺序有讲究: 先收消息, 再产生事件

Lamport 时钟要求"收到消息之后才能产生事件", 否则跨机器的 seq 不可比。代码里体现为固定的三步:

1) advanceClock(manifest, 对方的条目)   // 先把本地时钟推到 >= 对方当前 seq
2) reconcileLocal(manifest, 本地文件)   // 再产生本地事件 (增/改/删), 分配的 mySeq 一定更大
3) mergePeer(manifest, 对方的条目)      // 最后合并对方字段

两个顺序陷阱 (都踩过):

  • 不能把 mergePeer 提到 reconcileLocal 前面。mergePeer 会为"对方有、我没见过"的路径建条目, 新条目的 myState 只能是 deleted (意思是"我这边没有这个文件")。如果它先跑, reconcileLocal 就看不到"本地本来有这个文件"的事实 → 本地的删除事件被吞掉 → 墓碑建不出来 → 删除传播不出去。
  • 不能把 reconcileLocal 提到 advanceClock 前面。那样本地事件分到的 seq 可能小于对方已知的 seq, 比较时"对方刚下载过这个文件"的 seq 反而比"我刚删掉它"的大 → 我的删除被误判成"对方复活了" → 删除被撤销。

5.3.2 本地文件必须先登记进关系表

关系表不能只记录"同步过程中见过的文件"。如果对方知道某个文件、我本地也有, 但我的关系表里还没有它, 那么我本地把它删掉时, 关系表看不出这是"删除"还是"从来没过" → 墓碑建不出来 → 删除传播不出去。

所以对方把关系表推过来时 (applyPeerManifest), 要先用 reconcileLocal 把本地实际有的文件登记进去, 再合并对方字段。

5.4 墓碑 (tombstone): 删除为什么不直接抹掉

删除时不删记录, 只把状态标成 deleted 并记 deletedAt。直接抹掉会有两个致命问题:

问题 A: 删除传不出去

B 离线时 A 删了 x.txt。如果 A 直接抹掉记录, B 回来同步时:

  • B 的 manifest 里有 x.txt (B 认为它存在)
  • A 的 manifest 里没有 → A 理解成"这是 B 新增的" → 把已删除的文件重新下载回来
  • 删除被撤销了, 用户永远删不掉一个文件

有墓碑时: A 保留 x.txt 的记录并标 state='deleted', B 同步拉到墓碑 → 删本地文件 → 自己也记墓碑 → 删除成功传播。

问题 B: "删除"会被"复活"

A 删了 x.txt (有墓碑)。B 离线, 不知道这件事。B 的本地因为某种原因又出现了 x.txt (从备份恢复 / 手动放回), B 上传它 → A 收到:

  • 如果 A 只看"对方有这个文件, 我没有" → A 当成新增接受 → 删除被静默撤销
  • 有墓碑 + seq 比较: A 的墓碑 seq 更高 → A 拒绝接受, 保持删除状态 → 正确

5.5 墓碑优先规则

我这边有墓碑、对方声称文件还在时, 比 seq:

情况 判定 动作
对方 seq > 我的墓碑 seq 对方真的恢复了文件 download (接受)
对方 seq ≤ 我的墓碑 seq, 且对方内容也变了 双方都动过 conflict (不静默丢数据)
对方 seq ≤ 我的墓碑 seq, 对方内容没变 删除更晚, 删除赢 delete-remote (删掉对方那份)

5.6 墓碑保留多久

  • 大品牌: Google Drive 回收站 30 天, OneDrive 30 天, Dropbox 30 天, Syncthing tombstone 30 天。
  • 本项目取 90 天 (更保守, 覆盖长时间离线的设备)。常量 TOMBSTONE_TTL_MS。
  • 清理条件 (pruneTombstones): 超过 90 天 且 对方已经越过这个序号 (对方也知道这件事了, peerState==='deleted' 且 peerMaxSeq > mySeq)。
  • 对方一直没上线的, 墓碑一直留着 —— 这正是我们要的。

5.7 三方对比 (不是两方对比)

普通同步只比"本地 vs 远端"是不够的 —— 两边都改过的时候分不清谁赢。 三方对比 = 基线 (上次同步时两边一致的状态) + 本地现状 + 远端现状。

本地 远端 基线 判定 动作
有 有 一致 相同 无
有 有 只有本地改 本地新 upload
有 有 只有远端改 远端新 download
有 有 都改过 冲突 conflict
无 有 有, 且我没改 我删了 delete-remote (传播删除)
无 有 有, 且对方改过 冲突 conflict
无 有 无 对方新增 download
有 无 有, 且我没改 对方删了 delete-local (传播删除)
有 无 有, 且我改过 冲突 conflict
有 无 无 本地新增 upload
无 无 有 两边都删了 无 (墓碑到期可清理)

没有基线时 (首次同步): 退回 mtime 兜底 —— 差得远 (≥2s) 当单向, 接近当冲突。 方向是"宁可多弹窗", 静默覆盖丢数据更糟。

5.8 冲突怎么办

两边都改过且内容不同:

  • 绝不静默丢数据。
  • 手动同步时: 弹窗对比 (大小 / 修改时间 / hash), 用户选方向 (本地覆盖对方 / 对方覆盖本地 / 跳过)。 这就是同步面板里的"冲突对比弹窗"。
  • 自动同步时: 保留两份 —— 本地那份留 x.txt, 远端那份存成 x (冲突 2026-10-20 17-30).txt。

6. 同步一轮的完整流程

[本地] 扫描磁盘 → 实际文件集 (scanLocalDir)
   ↓
[本地] 对齐关系表 (reconcileLocal) —— 只改关系表, 不动磁盘:
   manifest 有 + 磁盘没了   → 删除事件 (seq++, 记墓碑)  ← 关键: 区分"被删"和"从来没有"
   磁盘有 + manifest 没有   → 新增事件 (seq++)
   磁盘有 + hash 变了       → 修改事件 (seq++)
   manifest 是墓碑 + 磁盘有了 → 复活事件 (seq++)      ← 用户手动放回来了
   消失的路径 + hash 相同的新路径 → 重命名 (保留 fileId, 不产生假删除)
   ↓
[远端] 组装对方视图 (buildPeerView):
   先取对方 manifest (带墓碑/序号) → 再用 /sync/scan 的实际扫描覆盖内容信息
   (实际存在但对方说删了 → 按"存在"处理, 序号沿用对方的, 避免误判)
   ↓
[合并] mergePeer: 只接受 seq > peerSeq 的事件; clock/remoteSeq 推到 max
   ↓
[判定] planSync: 三方对比 → 一份可执行的计划 (§5.7 的表)
   ↓
[执行] 用户勾选后 applySyncItems:
   upload        → POST /sync/file (x-file-rel-path)
   download      → GET  /sync/file?path=
   delete-local  → 删本地文件 + 记墓碑
   delete-remote → POST /sync/file (x-file-action: delete)
   成功后更新基线 (baseHash = 同步后的内容 hash) —— 下次就能精确判断"谁改了"
   ↓
[清理] pruneTombstones (90 天 且 对方已越过该序号)
   ↓
[推送] POST /sync/manifest 把我的关系表推给对方
   → 对方那边 applyPeerManifest() 应用 (删除就是这样跨机器传播的)

幂等性: 重复同步同一批事件不会出错 (水位线过滤)。 乱序容忍: seq 单调, 小的先到先忽略。


7. 删除传播的落点

RelationService.applyPeerManifest() (src/main/relation-service.ts) 是对方推来关系表时的处理点:

对方说"这个文件我删了" (peerState='deleted'):
  我这边没有            → 什么都不做 (幂等)
  我这边有 + 从没同步过   → 不动 (那本来就是两个各自独立的文件, 不是同一个)
  我这边有 + 同步过 + 我改过 → 冲突, 上报给界面, 不静默丢数据
  我这边有 + 同步过 + 我没改过 → 删本地文件 + 记墓碑

删除动作会写审计 (sync.delete.propagated), 并给渲染层发 sync:applied 事件。


8. 自动同步

关系上的 syncEnabled 开关就是入口 (同步面板里一个开关, 勾上即生效)。 实现是 src/main/auto-sync.ts 的 AutoSync 类, 在 ipc.ts 的 bindNetworkContext() 里创建并 start()。

8.1 触发

触发 行为
定时轮询 默认 60s 一轮, 只跑 state='active' + syncEnabled + 配好了同步目录的关系
对方上线 WS 状态转在线时立刻 trigger() 试一次, 不用等下一个轮询周期
用户刚打开开关 relation:setSync 里立刻 trigger(relationId) 跑一次
失败重试 指数退避 1min → 2min → 4min … 上限 5min

8.2 一轮自动同步做什么

scanSync() → 拿到变更列表
  ├─ conflict                              → **跳过**, 只记数并提示
  ├─ upload / download / delete-local / delete-remote → 直接执行
  └─ same                                  → 跳过
执行完 → 写回关系的 lastSyncAt / lastError, 并给界面发 sync:auto 事件
  • 冲突永远留给用户手动选方向 (手动同步时弹窗对比)。自动同步不替用户决定丢哪一份, 绝不静默覆盖任何一方。
  • 同一关系有防重入 (正在跑就直接跳过这一轮)。

8.3 不做的

  • 不做多目录对多目录的一对多同步。
  • 没有本地目录变化的事件监听 (fs.watch): 目前只有 60s 轮询 + 对方上线触发。

9. 数据落地

关系和关系表都存 JSON 文件 (原子写), 不是数据库:

文件 位置 内容
relations.json paths.appData/relations.json 全部关系记录
sync-manifest.json paths.appData/sync-manifest.json 全部关系的文件关系表 (key = relationId)
drive.token paths.appData/drive.token 旧的全局网盘 token (只作老对端兼容)

paths.appData = app.getPath('userData')/data。

为什么用 JSON 文件而不是 DB:

  1. 关系是"少量、需要原子整体替换"的状态, 不是需要 SQL 聚合 / 索引 / 事务的数据。 几十条关系, 一次性读写完全够用。
  2. 原子写很简单: 先写 xxx.json.tmp-<pid> 再 rename 覆盖, 崩溃不会写坏文件。 文件损坏时还会自动备份成 xxx.json.broken-<时间戳> 并从空状态启动 —— 应用不能因为一个坏文件起不来。
  3. 不用做 schema 迁移。关系结构以后还会演进, JSON 加个字段就行; DB 一旦改了表结构, 老用户升级就得写迁移脚本。
  4. 单测可以直接指定临时文件路径, 不碰用户真实数据。
  5. 有先例: 远程操作白名单 remoteAllowPeers 也存在 settings.json 里, 不是 DB。

devices 表仍然保留 (设备缓存, 含历史 drive_token), 但新关系不再用它。


10. 安全要点

要点 做法
网盘 / 同步区鉴权 token + peerId → 查关系 → 必须有 drive / sync 授权 → 否则 403。令牌用 timingSafeEqual 比较
网盘 token 不再广播 发现包不带 driveToken; 同网段任何进程拿不到网盘凭据
目录穿越 上传 / 下载 / 删除的路径都 resolve 后校验前缀, 必须严格落在各自的根目录内 (网盘根 / 该关系的同步目录), .. 一律拒绝
令牌不进渲染层 IPC 返回关系时用 publicRelation() 剥掉 myToken / peerToken, 界面拿不到凭据
吊销立即生效 吊销时换掉 myToken 并置 state='revoked', 对方手里旧令牌立刻失效; 同时清掉该关系的关系表
墓碑不泄密 墓碑只记路径和 hash, 不含内容
审计 建关系 / 接受 / 拒绝 / 吊销 (relation.*)、手动同步 (sync.apply)、删除传播 (sync.delete.propagated) 都写 audit_log

11. 当前进度

阶段 内容 状态
P0 关系层: 关系模型 + per-peer 令牌 + 网盘按关系授权 (relations.ts, relation-service.ts) ✅ 已完成
P0 同步层: manifest + 墓碑 + Lamport seq + 三方对比 (sync-manifest.ts, sync.ts) ✅ 已完成
P0 同步区接口: /sync/scan /sync/file /sync/manifest + 路径防护 (file-server.ts) ✅ 已完成
P1 弹窗 UI: ApprovalDialog.vue 的 relation 分支 + SyncPanel.vue (发起/接受/扫描/同步/冲突弹窗) ✅ 已完成
P1 自动同步: AutoSync (60s 轮询 + 上线触发 + 冲突跳过) (auto-sync.ts) ✅ 已完成
P1 自测用例 (关系建立、删除传播、墓碑、冲突) ⬜ 待做
P2 关系管理页 (查看 / 吊销 / 改权限) —— 目前只在同步面板里 ⬜ 待做

12. 已知不一致 (实现与设计有出入, 未改代码)

写这份文档时对出来的几处出入, 只记录, 没有改代码:

  1. scripts/selftest/main.ts 里新加的 Y1–Y10 用例 import 了 compareSync, 但 src/main/sync.ts 没有这个导出 (两方对比已被 sync-manifest.ts 的 planSync 三方对比取代)。跑 npm run selftest -- sync 会直接失败。 用例本身也还是按旧的"网盘子目录 + driveToken"写的, 需要按新方案重写。
  2. relations.ts 的 Relation 上有 localSeq / remoteSeq 两个序号字段, 但实际没有任何地方更新它们 —— 真正的时钟在 manifest 的 clock / remoteSeq 里 (lastSyncAt / lastError 由自动同步在写)。这两个字段目前是死字段。
  3. syncRemotePath / peerSyncRoot 这套"网盘子目录"约定还留在关系记录和帧里, 但独立同步区落地后 sync.ts 的 remoteTarget() 直接返回 relPath, 文件实际落在对方的 syncLocalDir 里 —— 这两个字段目前只是协商/展示用的残留。
  4. 接受方在弹窗里不填同步目录时, syncLocalDir 会留空, 之后对方的 /sync/scan 会返回 400 "同步目录未设置" —— 需要在同步面板里补填才能同步。
  5. src/main/sync-state.ts (FileSyncStateStore) 是一套旧的基线存储 (localHash / remoteHash), 新的 manifest 已经自带基线, 这个文件看起来没被接进新链路。
  6. 自动同步跑的那一轮不写审计。sync.apply 是在 ipc.ts 的 sync:apply IPC 里记的, 而 auto-sync.ts 直接调 applySyncItems(), 绕过了那层 —— 自动同步的每一次自动上传 / 下载 / 删除传播都没有审计记录 (删除传播那条 sync.delete.propagated 是对端记的)。

13. 相关文件

文件 作用
src/main/relations.ts 关系模型 + RelationStore (JSON 原子写) + 令牌生成 / 比较 + 网盘授权判定
src/main/relation-service.ts 建 / 接受 / 拒绝 / 吊销关系, applyPeerManifest() (删除传播落点)
src/main/sync-manifest.ts 文件关系表: reconcileLocal / mergePeer / planSync / updateBaseline / pruneTombstones
src/main/sync.ts 同步引擎: 本地扫描、远端视图组装、上传 / 下载 / 删除、manifest 推送
src/main/auto-sync.ts 自动同步: 轮询 / 上线触发 / 退避重试 / 冲突跳过
src/main/file-server.ts /drive/* 与 /sync/* 路由 + 鉴权 + 目录穿越防护
src/main/remote/approval.ts 授权闸门队列 (关系弹窗复用它, kind='relation')
src/main/ipc.ts 关系 / 同步的 IPC 通道, publicRelation() (剥令牌)
src/renderer/src/components/remote/SyncPanel.vue 同步面板: 发起关系 / 扫描变更 / 勾选同步 / 冲突弹窗
src/renderer/src/components/remote/ApprovalDialog.vue 授权弹窗 (含 relation 分支)