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 / 三方对比判定表 /
安全要点 / 已知不一致。
This commit is contained in:
2026-10-20 19:30:25 +08:00
parent 090bc3593f
commit 4fc86edd4e
22 changed files with 5905 additions and 74 deletions
+36 -1
View File
@@ -12,6 +12,7 @@ LAN instant-messaging desktop app. Stack: **Electron 32 + Vue 3 + TypeScript + V
| 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` |
@@ -22,7 +23,11 @@ All `package:*` scripts bake in `ELECTRON_MIRROR` + `ELECTRON_BUILDER_BINARIES_M
`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/`.
Verify changes via `npm run typecheck` + `npm run selftest` + manual `npm run dev`.
`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
@@ -38,6 +43,11 @@ src/
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/
@@ -52,6 +62,31 @@ 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.
+601
View File
@@ -0,0 +1,601 @@
# 关系模型 + 同步区设计
> 目标: 把"发现"和"信任"彻底分开。发现只是知道对方存在; **关系**必须由用户显式建立;
> **同步区**在关系之上, 靠持久化的文件关系表 (manifest) 正确处理增删改, 尤其**删除**。
>
> 这份文档描述的是**已经落地的实现**。早期设计稿里的"邀请码"和"网盘子目录"
> 两套方案**没有采用**, 原因分别在 §3.1 和 §4.1。代码位置见 §13。
---
## 1. 为什么要改 (现状的问题)
改造前是隐式全自动的:
```
设备启动 → UDP 广播 announce{deviceId, name, address, chatPort, filePort, driveToken}
→ 局域网所有机器收到 → 自动进"最近设备" → 自动建 WS → 消息双向开放
→ 网盘 token 在广播包里明文传 → 局域网任何人拿它就能浏览你的整个共享目录
```
问题:
1. **发现 = 信任**。同网段任何进程发个广播包就成了"联系人", 无法选择"我只跟谁建立关系"。
2. **网盘凭据全局广播**, 而远程终端/USB 却有授权闸门 —— **两套标准**。网盘等于对整个局域网敞开。
3. **没有确认环节**, 无法表达"我认识这个人, 我要跟他同步文件夹"。
4. **授权粒度不统一**: 远程操作有 per-peer 白名单, 网盘和消息完全没有。
5. **删除无法传播**。没有 manifest 就没有"这个文件之前存在过、现在被谁删了"的记录。
对应的解法就是下面的三层模型 + 关系级授权 + 文件关系表。
---
## 2. 三层模型
| 层 | 含义 | 生命周期 | 产生信任吗 |
|---|---|---|---|
| **发现 (Discovery)** | 知道对方存在 | 临时 (15s 心跳) | 否 |
| **关系 (Relation)** | 用户显式建立的一次绑定 | 持久 | 是 |
| **授权 (Grant)** | 关系上的具体能力 | 随关系 | — |
关系层目前只有两项能力, **双方各自独立**授权 (A 允许 B 看网盘 ≠ B 允许 A 看网盘):
| 能力 | 含义 |
|---|---|
| `drive` | 允许对方浏览我的网盘 (共享目录) |
| `sync` | 允许与对方同步文件 (走该关系专属的同步区) |
远程终端 / 端口转发 / USB **没有**并进关系层, 它们仍然走原来的
`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` 分开存:
```ts
interface Manifest {
relationId: string
clock: number // 我的 Lamport 时钟
remoteSeq: number // 水位线: 我已处理到对方的哪个序号
entries: Record<string, ManifestEntry> // key = relPath
}
```
**按 relationId 分开是关键**: 一个本地目录同时跟多个关系同步时, 各自的基线 / 墓碑 /
序号互不干扰, 不会串。
### 5.2 单条记录
```ts
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 分支) |
+2
View File
@@ -16,6 +16,8 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"selftest:build": "esbuild scripts/selftest/main.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/selftest.cjs --alias:electron=./scripts/selftest/electron-stub.cjs --alias:@lydell/node-pty=./scripts/selftest/pty-shim.cjs --external:better-sqlite3 --external:serialport --external:usb --external:node-hid --external:sudo-prompt --external:ws",
"selftest": "npm run selftest:build && cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/.out/selftest.cjs",
"selftest:sync:build": "esbuild scripts/selftest/sync-e2e.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/sync-e2e.cjs --alias:electron=./scripts/selftest/electron-stub.cjs --external:better-sqlite3 --external:serialport --external:usb --external:node-hid --external:sudo-prompt --external:ws",
"selftest:sync": "npm run selftest:sync:build && cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/.out/sync-e2e.cjs",
"selftest:package": "cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/packaging-check.cjs",
"package:win": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --win --x64",
"package:mac": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --mac",
+742 -3
View File
@@ -6,7 +6,7 @@
// npm run selftest # 全部
// npm run selftest -- terminal # 只跑某些用例
import { spawn, ChildProcess } from 'node:child_process'
import { existsSync, mkdirSync, rmSync, openSync, closeSync, readSync, writeSync, writeFileSync, readFileSync } from 'node:fs'
import { existsSync, mkdirSync, rmSync, openSync, closeSync, readSync, writeSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
import * as fs from 'node:fs'
import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os'
@@ -20,13 +20,24 @@ import { FileServer } from '../../src/main/file-server'
import { listDrive, downloadDriveFile, collectDriveFiles } from '../../src/main/drive'
import { computeWsState } from '../../src/main/ws-state'
import { safeFileName } from '../../src/main/safe-name'
import { scanLocalDir, scanSync, applySyncItems, type SyncFileInfo, type SyncContext } from '../../src/main/sync'
import {
ManifestStore, planSync, mergePeer, markLocalDeleted, reconcileLocal,
type Manifest, type ManifestWireEntry,
} from '../../src/main/sync-manifest'
import { RelationStore, type Grant, type Relation } from '../../src/main/relations'
import { RelationService } from '../../src/main/relation-service'
import { listPendingApprovals, replyApproval } from '../../src/main/remote/approval'
import { defaultCwd, defaultShell } from '../../src/main/remote/shell'
import { TerminalManager } from '../../src/main/remote/terminal'
import { ForwardManager } from '../../src/main/remote/forward'
import { UsbBridgeManager, toUsbControlSetup } from '../../src/main/remote/usb'
import { updateSettings } from '../../src/main/settings'
import { listAudit } from '../../src/main/db'
import type { DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, UsbFrame } from '../../src/main/protocol'
import type {
DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, UsbFrame,
RelationRequestFrame, RelationAcceptFrame, RelationRejectFrame,
} from '../../src/main/protocol'
// ============ 小型测试框架 ============
interface CaseResult { name: string; ok: boolean; detail: string; ms: number }
@@ -66,6 +77,21 @@ class Node {
events: UiEvent[] = []
driveDir = ''
driveToken = ''
// ---- 关系层 (同步盘) ----
/** 关系库 (JSON 文件) */
relations!: RelationStore
/** 文件关系表仓库 (墓碑 + 序号) */
manifestStore!: ManifestStore
/** 关系服务 (建关系/授权/应用对方的关系表) */
relationService!: RelationService
/** 关系库文件路径 (自测里要重新打开验证持久化) */
relationsFile = ''
/** 文件关系表文件路径 */
manifestFile = ''
/** 本节点的同步目录 (自己选的任意本地目录, 不必在网盘里) */
syncLocalDir = ''
/** 实际同步根 = 顶层目录 + 关系子目录 (关系建立后才知道) */
syncRoot = ''
constructor(id: string, name: string) {
this.info = {
@@ -78,6 +104,15 @@ class Node {
getDriveRoot: () => this.driveDir,
getDriveToken: () => this.driveToken,
isDriveEnabled: () => true,
// 新版: 按关系里的 per-peer 令牌鉴权 (网盘 / 同步区各要各的授权)
authorize: (token, peerId) => this.relationService
? this.relationService.authorizeDrive(token, peerId)
: { ok: false, reason: '未就绪' },
getSyncScope: (relationId) => this.relationService?.getSyncScope(relationId) ?? null,
getSyncManifest: ({ relationId }) => this.relationService?.getSyncManifest(relationId) ?? null,
onSyncManifest: ({ relationId, wire }) => this.relationService
? this.relationService.applyPeerManifest(relationId, wire)
: { applied: [], conflicts: [] },
})
this.terminal = new TerminalManager(this.client, id)
this.forward = new ForwardManager(this.client)
@@ -99,6 +134,27 @@ class Node {
this.driveToken = `tok-${this.info.deviceId.slice(-4)}`
this.info.driveToken = this.driveToken
// ---- 关系层: 关系库 + 文件关系表 + 关系服务 ----
const tmpBase = join(tmpdir(), `lnm-selftest-rel-${this.info.deviceId.slice(-4)}-${Date.now()}`)
rmSync(tmpBase, { recursive: true, force: true })
mkdirSync(tmpBase, { recursive: true })
this.relationsFile = join(tmpBase, 'relations.json')
this.manifestFile = join(tmpBase, 'manifest.json')
this.relations = new RelationStore(this.relationsFile)
this.manifestStore = new ManifestStore(this.manifestFile)
this.relationService = new RelationService({
store: this.relations,
manifestStore: this.manifestStore,
self: this.info,
send: (peerId, frame) => this.client.send(frame, peerId),
notify: (event, payload) => this.events.push({ kind: event, peerId: payload?.peerId }),
})
// 本节点的同步目录: 自己选的任意本地目录 (独立于网盘)
this.syncLocalDir = join(tmpBase, 'sync')
rmSync(this.syncLocalDir, { recursive: true, force: true })
mkdirSync(this.syncLocalDir, { recursive: true })
this.syncRoot = this.syncLocalDir
// ---- 与 src/main/ipc.ts 完全一致的帧路由 ----
const TRACE = !!process.env.LNM_TRACE
const route = (peer: DeviceInfo, frame: WsFrame) => {
@@ -113,6 +169,13 @@ class Node {
this.forward.handleIncoming(peer, frame.payload as ForwardFrame)
} else if (frame.type === 'usb') {
this.usb.handleIncoming(peer, frame.payload as UsbFrame)
} else if (frame.type === 'relation:request') {
this.relationService.handleRelationRequest(peer, frame.payload as RelationRequestFrame)
.catch((e) => console.warn(`[${this.info.name}] relation request error:`, e?.message))
} else if (frame.type === 'relation:accept') {
this.relationService.handleRelationAccept(peer, frame.payload as RelationAcceptFrame)
} else if (frame.type === 'relation:reject') {
this.relationService.handleRelationReject(peer, frame.payload as RelationRejectFrame)
}
}
this.server.on('frame', route)
@@ -175,6 +238,11 @@ class Node {
function decode(b64: string) { return Buffer.from(b64, 'base64').toString('utf8') }
// 读文本文件, 读不到就当空串 (断言里好写)
function readText(p: string): string {
try { return readFileSync(p, 'utf8') } catch { return '' }
}
// ============ 小工具 ============
function startHttpServer(port: number, body: string): Promise<Server> {
return new Promise((resolve, reject) => {
@@ -228,6 +296,123 @@ function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 50): Promise<vo
})
}
// 等待某个条件成立并**返回找到的东西** (关系授权弹窗之类的异步产物)
async function waitForItem<T>(fn: () => T | undefined | null | false, timeoutMs = 5000, stepMs = 30): Promise<T> {
const t0 = Date.now()
for (;;) {
const v = fn()
if (v) return v
if (Date.now() - t0 > timeoutMs) throw new Error('等待超时')
await sleep(stepMs)
}
}
// 两个节点双向连上 (模拟 discovery 找到对方后双方都连)
async function link(a: Node, b: Node) {
a.connectTo(b)
b.connectTo(a)
await waitFor(() => a.client.isOpen(b.info.deviceId) && b.client.isOpen(a.info.deviceId), 8000)
}
/**
* 建立一条关系: 发起方发请求 -> 对方弹窗 -> 同意 -> 双方关系落库且 active。
* 授权队列 (approval.ts) 是模块级单例, 用 peerId 区分是哪条请求。
*/
async function establishRelation(
from: Node, to: Node,
opts?: { grants?: Grant[]; peerGrants?: Grant[]; fromPath?: string; toPath?: string },
): Promise<{ relationId: string; fromRel: Relation; toRel: Relation }> {
const grants = opts?.grants ?? ['drive', 'sync']
const peerGrants = opts?.peerGrants ?? grants
const r = from.relationService.requestRelation({
peerId: to.info.deviceId,
peerName: to.info.name,
peerAddress: to.info.address,
peerChatPort: to.info.chatPort,
peerFilePort: to.info.filePort,
syncLocalDir: from.syncLocalDir,
syncRemotePath: opts?.fromPath ?? `同步/${from.info.name}`,
grants,
})
assert(r.ok, `发起关系失败: ${r.reason}`)
assert(!!r.relationId, '发起关系没有返回 relationId')
const relationId = r.relationId!
// 对方那边应该弹出授权窗口
const req = await waitForItem(
() => listPendingApprovals().find(x => x.kind === 'relation' && x.peerId === from.info.deviceId),
5000,
).catch(() => { throw new Error('对方没有弹出关系授权窗口') })
const replied = replyApproval(req.requestId, true, {
grants: peerGrants,
syncLocalDir: to.syncLocalDir,
syncRemotePath: opts?.toPath ?? `同步/${to.info.name}`,
})
assert(replied, '回复关系授权失败 (请求已超时?)')
// 双方关系都要变成 active
await waitFor(() => from.relations.getRelation(relationId)?.state === 'active', 5000)
.catch(() => { throw new Error('发起方的关系没有变成 active') })
await waitFor(() => to.relations.getRelation(relationId)?.state === 'active', 5000)
.catch(() => { throw new Error('对方的关系没有落库') })
const fromRel = from.relations.getRelation(relationId)!
const toRel = to.relations.getRelation(relationId)!
// 实际同步根 = 顶层目录 + 按关系隔离的子目录 (测试要按它来放文件)
from.syncRoot = join(from.syncLocalDir, fromRel.syncSubdir || '')
to.syncRoot = join(to.syncLocalDir, toRel.syncSubdir || '')
mkdirSync(from.syncRoot, { recursive: true })
mkdirSync(to.syncRoot, { recursive: true })
return { relationId, fromRel, toRel }
}
/**
* 同步上下文: 本地目录 + 对方同步区 + 关系里的对方令牌 + 关系表仓库。
* token 用关系里的 peerToken (对方签发给我的那个), peerId 传我的 deviceId。
*/
function syncCtx(node: Node, peer: Node, relationId: string): SyncContext {
const rel = node.relations.getRelation(relationId)
assert(!!rel, `关系不存在: ${relationId}`)
const token = rel!.peerToken
assert(!!token, '关系里没有对方的令牌')
return {
peer: { ...peer.info, driveToken: token },
localDir: node.syncRoot,
remotePath: '',
token,
relationId,
store: node.manifestStore,
peerSyncRoot: rel!.peerSyncRoot,
// 请求里要带**我方**的 deviceId (服务端靠它确认令牌确实是签给对方的)
selfDeviceId: node.info.deviceId,
}
}
// 发一个 HTTP 请求, 拿状态码 + 原始文本 (用来精确断言 403 之类的)
function httpJson(port: number, path: string, opts?: {
method?: string; headers?: Record<string, string>; body?: Buffer | string; timeoutMs?: number
}): Promise<{ status: number; text: string; data: any }> {
return new Promise((resolve) => {
let done = false
const finish = (status: number, text: string) => {
if (done) return
done = true
let data: any = null
try { data = JSON.parse(text) } catch { /* 非 JSON 也行 */ }
resolve({ status, text, data })
}
const req = httpRequest({
host: '127.0.0.1', port, method: opts?.method || 'GET', path,
headers: opts?.headers || {}, timeout: opts?.timeoutMs ?? 15000,
}, (res) => {
const chunks: Buffer[] = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => finish(res.statusCode || 0, Buffer.concat(chunks).toString('utf8')))
})
req.on('timeout', () => { req.destroy(); finish(0, '') })
req.on('error', () => finish(0, ''))
if (opts?.body) req.end(opts.body)
else req.end()
})
}
// 造一个 socat 串口对: 一头是 PTY (给被控端当真实串口), 另一头我们直接读写
async function startSocatSerial(linkDir: string): Promise<{ proc: ChildProcess; userPath: string; internalPath: string }> {
const userPath = join(linkDir, 'user')
@@ -277,11 +462,23 @@ async function main() {
const A = new Node('peer-aaaaaaaa-0000-4000-8000-00000000000a', '控制端A')
step('建节点 B')
const B = new Node('peer-bbbbbbbb-0000-4000-8000-00000000000b', '被控端B')
step('建节点 E (只给 sync 授权的对端, 测授权分级/多关系隔离)')
const E = new Node('peer-eeeeeeee-0000-4000-8000-00000000000e', '同步授权E')
step('建节点 F (测拒绝)')
const F = new Node('peer-ffffffff-0000-4000-8000-00000000000f', '关系测试F')
step('建节点 G (测吊销)')
const G = new Node('peer-99999999-0000-4000-8000-000000000009', '关系测试G')
step('启动 A')
await A.start()
step('启动 B')
await B.start()
step(`节点就绪 A(:${A.info.chatPort}) B(:${B.info.chatPort})`)
step('启动 E')
await E.start()
step('启动 F')
await F.start()
step('启动 G')
await G.start()
step(`节点就绪 A(:${A.info.chatPort}) B(:${B.info.chatPort}) E(:${E.info.chatPort}) F(:${F.info.chatPort}) G(:${G.info.chatPort})`)
// 双向连通 (模拟 discovery 找到对方后双方都连上)
step('建立 WS 连接')
@@ -768,6 +965,545 @@ async function main() {
})
}
// ============ 关系 (同步盘授权) ============
console.log('\n\x1b[1m[关系]\x1b[0m')
// A-B: 主关系 (R1 建的, 后面 M 组要用); A-E: 只给 sync 授权的关系 (R4 建的, M8 多关系隔离要用)
let relAB = ''
let relAE = ''
if (want('relation') || want('rel')) {
// W3 那组把 A-B 的连接掐了, 这里重新连上 (真实客户端断了也会重连)
await link(A, B)
await link(A, E)
await link(A, F)
await link(A, G)
await runCase('R1 建立关系: 发起 -> 对方弹窗 -> 同意 -> 双方关系落库且 active, 令牌互换', async () => {
const { relationId, fromRel, toRel } = await establishRelation(A, B)
relAB = relationId
assert(fromRel.state === 'active', `发起方关系状态不对: ${fromRel.state}`)
assert(toRel.state === 'active', `对方关系状态不对: ${toRel.state}`)
assert(!!fromRel.myToken && !!fromRel.peerToken, '令牌没互换 (有一边是空的)')
assert(fromRel.myToken !== fromRel.peerToken, '两个令牌是同一个 (不该是)')
assert(toRel.myToken === fromRel.peerToken, '对方签发的令牌对不上')
assert(toRel.peerToken === fromRel.myToken, '对方拿到的令牌对不上')
assert(toRel.syncLocalDir === B.syncLocalDir, '对方的同步目录没落库')
assert(fromRel.peerGrants.includes('sync') && toRel.peerGrants.includes('sync'), '授权能力没互换')
})
await runCase('R2 对方拒绝: 发起方的 pending 关系被清理', async () => {
const r = A.relationService.requestRelation({
peerId: F.info.deviceId, peerName: F.info.name, peerAddress: F.info.address,
peerChatPort: F.info.chatPort, peerFilePort: F.info.filePort,
syncLocalDir: A.syncLocalDir, syncRemotePath: '同步/F', grants: ['drive', 'sync'],
})
assert(r.ok, `发起失败: ${r.reason}`)
assert(A.relations.getRelation(r.relationId!)?.state === 'pending', '发起方没有落 pending 关系')
const req = await waitForItem(
() => listPendingApprovals().find(x => x.kind === 'relation' && x.peerId === A.info.deviceId),
).catch(() => { throw new Error('对方没有弹出关系授权窗口') })
assert(replyApproval(req.requestId, false), '回复拒绝失败')
await waitFor(() => !A.relations.getRelation(r.relationId!), 5000)
.catch(() => { throw new Error('对方拒绝后, 发起方的 pending 关系还在') })
})
await runCase('R3 per-peer 令牌: 关系令牌能访问对方网盘; 错令牌 403; 令牌与 peerId 不匹配 403', async () => {
const rel = A.relations.getRelation(relAB)
assert(!!rel, '前置条件不成立: A-B 关系不存在')
const token = rel!.peerToken
assert(!!token, '关系里没有对方的令牌')
// 1) 关系里的对方令牌 -> 能访问对方网盘
const ok = await listDrive({ ...B.info, driveToken: token }, '')
assert(ok.ok, `用关系令牌访问对方网盘失败: ${ok.reason}`)
assert((ok.entries || []).some(e => e.name === 'hello.txt'), '列出来的不是对方的网盘内容')
// 2) 错令牌 -> 403
const bad = await httpJson(B.info.filePort, `/drive/scan?token=wrong-token&peerId=${encodeURIComponent(A.info.deviceId)}`)
assert(bad.status === 403, `错令牌应该 403, 实际 ${bad.status}`)
// 3) 令牌跟 peerId 对不上 -> 403 (这枚令牌是发给 A 的, 不能拿给别的设备用)
const mismatch = await httpJson(B.info.filePort,
`/drive/scan?token=${encodeURIComponent(token)}&peerId=${encodeURIComponent(F.info.deviceId)}`)
assert(mismatch.status === 403, `令牌与设备不匹配应该 403, 实际 ${mismatch.status}`)
})
await runCase('R4 授权分级: 只给 sync 时网盘 403 (/drive/scan), 但同步区能访问 (/sync/scan 200)', async () => {
const { relationId } = await establishRelation(A, E, { grants: ['drive', 'sync'], peerGrants: ['sync'] })
relAE = relationId
const rel = A.relations.getRelation(relationId)!
const q = `token=${encodeURIComponent(rel.peerToken)}&peerId=${encodeURIComponent(A.info.deviceId)}`
// 网盘: 没给 drive -> 403
const drive = await httpJson(E.info.filePort, `/drive/scan?${q}`)
assert(drive.status === 403, `只给 sync 时网盘应该 403, 实际 ${drive.status}`)
// 同步区: 给了 sync -> 200
const sync = await httpJson(E.info.filePort, `/sync/scan?${q}&relationId=${encodeURIComponent(relationId)}&hash=1`)
assert(sync.status === 200, `只给 sync 时同步区应该 200, 实际 ${sync.status}`)
assert(sync.data?.ok === true, `同步区返回不对: ${sync.text.slice(0, 120)}`)
})
await runCase('R5 吊销关系后: 旧令牌立刻失效 (403)', async () => {
// 注意: 用独立的节点 G —— 对方对同一对端 60s 内只弹一次窗 (防骚扰),
// R2 刚用过 F, 再用 F 会被直接回绝 (请求过于频繁)
const { relationId } = await establishRelation(A, G)
const gRel = G.relations.getRelation(relationId)!
const oldToken = gRel.peerToken // G 手里能访问 A 网盘的那枚 (= A 的 myToken)
assert(!!oldToken, '关系里没有令牌')
const before = await httpJson(A.info.filePort,
`/drive/scan?token=${encodeURIComponent(oldToken)}&peerId=${encodeURIComponent(G.info.deviceId)}`)
assert(before.status === 200, `吊销前应该能访问对方网盘, 实际 ${before.status}`)
const rev = A.relationService.revokeRelation(relationId)
assert(rev.ok, '吊销失败')
const after = await httpJson(A.info.filePort,
`/drive/scan?token=${encodeURIComponent(oldToken)}&peerId=${encodeURIComponent(G.info.deviceId)}`)
assert(after.status === 403, `吊销后旧令牌应该 403, 实际 ${after.status}`)
})
await runCase('R6 关系是持久化的: 重新 new 一个 RelationStore 读同一个文件, 关系还在', async () => {
const rel = A.relations.getRelation(relAB)
assert(!!rel, '前置条件不成立: A-B 关系不存在')
const store2 = new RelationStore(A.relationsFile)
const again = store2.getRelation(relAB!)
assert(!!again, '重新打开关系库文件后, 关系不见了')
assert(again!.state === 'active', `状态不对: ${again!.state}`)
assert(again!.peerId === B.info.deviceId, '对方的 deviceId 不对')
assert(!!again!.myToken && !!again!.peerToken, '令牌丢了')
assert(!!store2.getActiveRelationByPeer(B.info.deviceId), '按 peerId 查不到关系')
})
}
// ============ 文件关系表 / 删除传播 ============
console.log('\n\x1b[1m[文件关系表 / 删除传播]\x1b[0m')
if (want('manifest') || want('sync-manifest')) {
// 关系是前置条件: 单独跑这一组时自己建一条
let mSetupErr = ''
try {
await link(A, B)
await link(A, E)
if (!A.relations.getActiveRelationByPeer(B.info.deviceId)) {
relAB = (await establishRelation(A, B)).relationId
}
if (!A.relations.getActiveRelationByPeer(E.info.deviceId)) {
relAE = (await establishRelation(A, E, { grants: ['drive', 'sync'], peerGrants: ['sync'] })).relationId
}
} catch (e: any) {
mSetupErr = String(e?.message || e)
}
const needSetup = () => assert(!mSetupErr && !!relAB, `前置条件不成立: ${mSetupErr || 'A-B 关系没建起来'}`)
// 准备文件: A 有 a.txt / shared.txt, B 有 b.txt
writeFileSync(join(A.syncRoot, 'a.txt'), 'A-CONTENT')
writeFileSync(join(A.syncRoot, 'shared.txt'), 'FROM-A')
writeFileSync(join(B.syncRoot, 'b.txt'), 'B-CONTENT')
await runCase('M1 首次同步建立基线: A 的文件上传到 B, B 的文件下载到 A, 两边内容一致', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const items = scan.items
.filter(i => i.action === 'upload' || i.action === 'download')
.map(i => ({ relPath: i.relPath, action: i.action as 'upload' | 'download' }))
const r = await applySyncItems({ ...ctx, items })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
// A 的文件到了 B
assert(existsSync(join(B.syncRoot, 'a.txt')), 'a.txt 没传到对方')
assert(readText(join(B.syncRoot, 'a.txt')) === 'A-CONTENT', 'a.txt 内容不对')
assert(existsSync(join(B.syncRoot, 'shared.txt')), 'shared.txt 没传到对方')
assert(readText(join(B.syncRoot, 'shared.txt')) === 'FROM-A', 'shared.txt 内容不对')
// B 的文件到了 A
assert(existsSync(join(A.syncRoot, 'b.txt')), 'b.txt 没下载到本地')
assert(readText(join(A.syncRoot, 'b.txt')) === 'B-CONTENT', 'b.txt 内容不对')
// 基线建起来了 (后面判"谁改了"全靠它)
const man = A.manifestStore.loadManifest(relAB)
assert(!!man.entries['a.txt']?.baseHash, 'a.txt 没有基线')
assert(!!man.entries['b.txt']?.baseHash, 'b.txt 没有基线')
})
await runCase('M2 本地改了文件: 判定为 upload (不是 conflict) -> 同步后对方内容更新', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
writeFileSync(join(A.syncRoot, 'a.txt'), 'A-EDITED')
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'a.txt')
assert(!!it, 'a.txt 没出现在扫描结果里')
assert(it!.action === 'upload', `本地改过应判 upload, 实际 ${it!.action}`)
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'a.txt', action: 'upload' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(readText(join(B.syncRoot, 'a.txt')) === 'A-EDITED', '对方内容没更新')
})
await runCase('M3 本地删了文件: 判定为 delete-remote -> 同步后对方那份也没了 (删除传播)', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
rmSync(join(A.syncRoot, 'a.txt'), { force: true })
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'a.txt')
assert(!!it, 'a.txt 没出现在扫描结果里')
assert(it!.action === 'delete-remote', `本地删了应判 delete-remote, 实际 ${it!.action}`)
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'a.txt', action: 'delete-remote' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(!existsSync(join(B.syncRoot, 'a.txt')), '对方那份还在 (删除没传播)')
})
await runCase('M4 对方删了文件: 判定为 delete-local -> 同步后本地那份也没了', async () => {
needSetup()
const ctxA = syncCtx(A, B, relAB)
const ctxB = syncCtx(B, A, relAB)
// 对方先删掉, 再扫描一次记墓碑 (这一步不 apply —— 删除是通过关系表传播的)
rmSync(join(B.syncRoot, 'b.txt'), { force: true })
const scanB = await scanSync(ctxB)
assert(scanB.ok, `对方扫描失败: ${scanB.reason}`)
// 我这边扫描 -> 从对方的关系表里看到墓碑 -> 判 delete-local
const scanA = await scanSync(ctxA)
assert(scanA.ok, `扫描失败: ${scanA.reason}`)
const it = scanA.items.find(i => i.relPath === 'b.txt')
assert(!!it, 'b.txt 没出现在扫描结果里')
assert(it!.action === 'delete-local', `对方删了应判 delete-local, 实际 ${it!.action}`)
const r = await applySyncItems({ ...ctxA, items: [{ relPath: 'b.txt', action: 'delete-local' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(!existsSync(join(A.syncRoot, 'b.txt')), '本地那份还在 (删除没传播)')
})
await runCase('M5 删除不会被撤销: 删掉并同步后再扫一次, 不会被当成"对方新增"重新下载 (墓碑生效)', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'b.txt')
assert(!!it, 'b.txt 没出现在扫描结果里')
assert(it!.action !== 'download', `墓碑失效, 被当成对方新增要重新下载: ${it!.action}`)
assert(!existsSync(join(A.syncRoot, 'b.txt')), '本地文件复活了')
// 墓碑还在关系表里
const e = A.manifestStore.loadManifest(relAB).entries['b.txt']
assert(!!e, '关系表里没有 b.txt 的记录')
assert(e!.myState === 'deleted', `墓碑状态不对: ${e!.myState}`)
assert(e!.mySeq > 0, '墓碑没有序号')
})
await runCase('M6 复活防护: 对方拿一个更早的 seq 声称文件还在 -> 墓碑仍然赢 (保持删除), 不会被复活', async () => {
// 单元级: 构造一个"我删了"的墓碑, 对方拿更早的序号声称文件还在
const store = new ManifestStore(join(tmpdir(), `lnm-selftest-m6-${Date.now()}.json`))
const man: Manifest = store.loadManifest('rel-m6')
const H = 'a'.repeat(64)
man.clock = 10
man.entries['z.txt'] = {
fileId: 'file-m6', relPath: 'z.txt',
baseHash: H, baseSize: 1, baseMtime: 0,
myHash: H, mySize: 1, myMtime: 0, myState: 'present', mySeq: 1,
peerHash: H, peerSize: 1, peerMtime: 0, peerState: 'present', peerSeq: 9,
deletedAt: 0,
}
markLocalDeleted(man, 'z.txt') // 我这边删了 -> 记墓碑 (mySeq = 11)
const mySeq = man.entries['z.txt'].mySeq
assert(mySeq > 10, `墓碑序号不对: ${mySeq}`)
// 对方拿一个**更早**的序号 (5) 声称文件还在
const older = new Map<string, ManifestWireEntry>([['z.txt', {
fileId: 'file-m6', relPath: 'z.txt', hash: H, size: 1, mtime: 0,
state: 'present', seq: 5, deletedAt: 0,
}]])
mergePeer(man, older)
const it = planSync(man, [], older).items.find(i => i.relPath === 'z.txt')!
assert(it.action !== 'download', `墓碑失效, 文件被当成新增要下载: ${it.action}`)
assert(it.action === 'delete-remote', `墓碑应该赢 (删除继续传播), 实际 ${it.action}`)
// 对方用**更新**的序号声称恢复 -> 才允许复活
const newer = new Map<string, ManifestWireEntry>([['z.txt', { ...older.get('z.txt')!, seq: mySeq + 1 }]])
const it2 = planSync(man, [], newer).items.find(i => i.relPath === 'z.txt')!
assert(it2.action === 'download', `对方序号更新应该允许恢复, 实际 ${it2.action}`)
})
await runCase('M7 冲突: 两边都改了同一个文件 -> 判 conflict, apply 不会静默覆盖任一边', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
writeFileSync(join(A.syncRoot, 'shared.txt'), 'FROM-A-V2')
writeFileSync(join(B.syncRoot, 'shared.txt'), 'FROM-B-V2')
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'shared.txt')
assert(!!it, 'shared.txt 没出现在扫描结果里')
assert(it!.action === 'conflict', `两边都改过应判 conflict, 实际 ${it!.action}`)
// apply 里没有 conflict 这个动作 (只能 skip) -> 两边都不该被覆盖
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'shared.txt', action: 'skip' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(readText(join(A.syncRoot, 'shared.txt')) === 'FROM-A-V2', '本地被覆盖了')
assert(readText(join(B.syncRoot, 'shared.txt')) === 'FROM-B-V2', '对方被覆盖了')
})
await runCase('M8 多关系隔离: 同一个本地目录跟两个关系同步, 基线/墓碑互不串 (key 是 relationId)', async () => {
needSetup()
assert(!!relAE, '前置条件不成立: A-E 关系没建起来')
const ctxB = syncCtx(A, B, relAB)
const ctxE = syncCtx(A, E, relAE)
// 专门用一个干净的文件测隔离 (iso.txt)
writeFileSync(join(A.syncRoot, 'iso.txt'), 'ISO-CONTENT')
// 先跟 E 同步一轮 -> E 这条关系也有 iso.txt 的基线
const scanE = await scanSync(ctxE)
assert(scanE.ok, `扫描失败: ${scanE.reason}`)
const upE = scanE.items
.filter(i => i.action === 'upload')
.map(i => ({ relPath: i.relPath, action: 'upload' as const }))
assert(upE.some(i => i.relPath === 'iso.txt'), 'E 关系里没有 iso.txt 的上传项')
const rE = await applySyncItems({ ...ctxE, items: upE })
assert(rE.ok, `同步失败: ${JSON.stringify(rE.results)}`)
// 再跟 B 同步一轮 -> B 这条关系也有 iso.txt 的基线
const scanB1 = await scanSync(ctxB)
assert(scanB1.ok, `扫描失败: ${scanB1.reason}`)
const upB = scanB1.items
.filter(i => i.action === 'upload')
.map(i => ({ relPath: i.relPath, action: 'upload' as const }))
assert(upB.some(i => i.relPath === 'iso.txt'), 'B 关系里没有 iso.txt 的上传项')
const rB = await applySyncItems({ ...ctxB, items: upB })
assert(rB.ok, `同步失败: ${JSON.stringify(rB.results)}`)
assert(!!A.manifestStore.loadManifest(relAE).entries['iso.txt']?.baseHash, 'E 关系里没有 iso.txt 的基线')
assert(!!A.manifestStore.loadManifest(relAB).entries['iso.txt']?.baseHash, 'B 关系里没有 iso.txt 的基线')
// 现在本地删掉 iso.txt, 只跟 B 这条关系同步
rmSync(join(A.syncRoot, 'iso.txt'), { force: true })
const scanB2 = await scanSync(ctxB)
assert(scanB2.ok, `扫描失败: ${scanB2.reason}`)
const it = scanB2.items.find(i => i.relPath === 'iso.txt')
assert(!!it, 'iso.txt 没出现在扫描结果里')
assert(it!.action === 'delete-remote', `本地删了应判 delete-remote, 实际 ${it!.action}`)
const r = await applySyncItems({ ...ctxB, items: [{ relPath: 'iso.txt', action: 'delete-remote' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(!existsSync(join(B.syncRoot, 'iso.txt')), '对方那份还在')
// B 关系的墓碑不能串到 E 关系上
const manB = A.manifestStore.loadManifest(relAB)
const manE = A.manifestStore.loadManifest(relAE)
assert(manB.entries['iso.txt']?.myState === 'deleted', 'B 关系里没有墓碑')
assert(manE.entries['iso.txt']?.myState !== 'deleted', 'E 关系被 B 的墓碑污染了')
assert(manB.relationId !== manE.relationId, '两个关系用了同一个 key')
})
await runCase('M9 目录穿越: 同步路径里带 ../ 必须被拒绝 (本地和远端都不许写到目录外)', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
// 本地侧: relPath 带 ../ -> 直接判非法路径, 一步都不该走
const r = await applySyncItems({ ...ctx, items: [{ relPath: '../evil-escape.txt', action: 'upload' }] })
assert(!r.ok, '本地穿越竟然成功了!')
assert(!existsSync(join(dirname(A.syncRoot), 'evil-escape.txt')), '文件写到同步目录外面了!')
// 远端侧: 绕过客户端, 直接打对方的 /sync/file
const q = `token=${encodeURIComponent(ctx.token!)}&peerId=${encodeURIComponent(A.info.deviceId)}&relationId=${encodeURIComponent(relAB)}`
const body = Buffer.from('EVIL')
const up = await httpJson(B.info.filePort, `/sync/file?${q}`, {
method: 'POST',
headers: {
'x-file-rel-path': encodeURIComponent('../evil-escape.txt'),
'x-file-size': String(body.length),
'x-file-overwrite': '1',
'Content-Length': String(body.length),
},
body,
})
assert(up.status !== 200, `远端上传穿越竟然成功了 (HTTP ${up.status})`)
assert(!existsSync(join(dirname(B.syncRoot), 'evil-escape.txt')), '远端文件写到同步目录外面了!')
// 下载方向也要挡住
const dl = await httpJson(B.info.filePort, `/sync/file?${q}&path=${encodeURIComponent('../evil-escape.txt')}`)
assert(dl.status !== 200, `远端下载穿越竟然成功了 (HTTP ${dl.status})`)
})
await runCase('M10 幂等: 同一批变更连续 apply 两次, 结果不变, 不产生重复副作用', async () => {
needSetup()
const ctx = syncCtx(A, B, relAB)
writeFileSync(join(A.syncRoot, 'idem.txt'), 'IDEMPOTENT')
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'idem.txt')
assert(!!it, 'idem.txt 没出现在扫描结果里')
assert(it!.action === 'upload', `新文件应判 upload, 实际 ${it!.action}`)
const clockBefore = A.manifestStore.loadManifest(relAB).clock
const r1 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'upload' }] })
assert(r1.ok, `第一次同步失败: ${JSON.stringify(r1.results)}`)
const r2 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'upload' }] })
assert(r2.ok, `第二次同步失败: ${JSON.stringify(r2.results)}`)
// 结果不变: 内容一样, 没有多出副本, 关系表时钟也没动
assert(readText(join(B.syncRoot, 'idem.txt')) === 'IDEMPOTENT', '对方内容不对')
const copies = readdirSync(B.syncRoot).filter(n => n.includes('idem'))
assert(copies.length === 1, `对方多出了副本: ${copies.join(',')}`)
const clockAfter = A.manifestStore.loadManifest(relAB).clock
assert(clockAfter === clockBefore, `重复 apply 让关系表时钟前进了: ${clockBefore} -> ${clockAfter}`)
// 再扫一次: 应该是 same, 不该再上传
const it2 = (await scanSync(ctx)).items.find(i => i.relPath === 'idem.txt')
assert(!!it2 && it2.action === 'same', `重复 apply 后还在上传: ${it2?.action}`)
// 删除也一样幂等
rmSync(join(A.syncRoot, 'idem.txt'), { force: true })
const it3 = (await scanSync(ctx)).items.find(i => i.relPath === 'idem.txt')
assert(!!it3 && it3.action === 'delete-remote', `本地删了应判 delete-remote, 实际 ${it3?.action}`)
const d1 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'delete-remote' }] })
assert(d1.ok, `第一次删除失败: ${JSON.stringify(d1.results)}`)
const d2 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'delete-remote' }] })
assert(d2.ok, `重复删除失败: ${JSON.stringify(d2.results)}`)
assert(!existsSync(join(B.syncRoot, 'idem.txt')), '对方那份还在')
})
}
// ============ 文件夹同步 ============
if (want('sync')) {
console.log('\n\x1b[1m[文件夹同步]\x1b[0m')
// 同步区是**独立于网盘**的一套空间 (/sync/*): 要有关系里的 sync 授权
const Y1 = new Node('peer-y1111111-0000-4000-8000-0000000000y1', '同步端Y1')
const Y2 = new Node('peer-y2222222-0000-4000-8000-0000000000y2', '同步端Y2')
await Y1.start()
await Y2.start()
await link(Y1, Y2)
// 前置: 建一条同步关系 (同步区走 /sync/*, 没关系连不上)
// 注意顺序: **先建关系再放文件** —— 实际同步根 = 顶层目录 + 关系子目录,
// 关系没建好时子目录还没分配, 文件放错地方就"凭空消失"了。
let yRelId = ''
let ySetupErr = ''
try {
yRelId = (await establishRelation(Y1, Y2)).relationId
} catch (e: any) {
ySetupErr = String(e?.message || e)
}
mkdirSync(join(Y1.syncRoot, 'sub'), { recursive: true })
writeFileSync(join(Y1.syncRoot, 'same.txt'), 'SAME-CONTENT')
writeFileSync(join(Y1.syncRoot, 'sub', 'deep.txt'), 'DEEP-CONTENT')
writeFileSync(join(Y2.syncRoot, 'only-remote.txt'), 'REMOTE-ONLY')
const yCtx = (): SyncContext => {
assert(!!yRelId, `前置条件不成立: 同步关系没建起来 (${ySetupErr})`)
return syncCtx(Y1, Y2, yRelId)
}
await runCase('Y1 本地扫描: 相对路径 / 大小 / hash 正确', async () => {
const r = scanLocalDir(Y1.syncRoot)
assert(r.ok, `扫描失败: ${r.reason}`)
const names = r.files.map(f => f.relPath)
assert(names.includes('same.txt'), `缺 same.txt: ${names.join(',')}`)
assert(names.includes('sub/deep.txt'), `缺深层文件: ${names.join(',')}`)
const f = r.files.find(x => x.relPath === 'same.txt')!
assert(f.size === 'SAME-CONTENT'.length, `大小不对: ${f.size}`)
assert(!!f.hash && f.hash.length === 64, `hash 不对: ${f.hash}`)
})
await runCase('Y2 三方对比: 只有一边有 / 内容一致 / 都不对', async () => {
const local = scanLocalDir(Y1.syncRoot).files
const same = local.find(f => f.relPath === 'same.txt')!
const man: Manifest = { relationId: 'y-unit', clock: 0, remoteSeq: 0, entries: {} }
reconcileLocal(man, local) // 先把本地现状对齐进关系表 (跟 scanSync 里做的一样)
const peer = new Map<string, ManifestWireEntry>([
['same.txt', { fileId: 'p1', relPath: 'same.txt', hash: same.hash!, size: same.size, mtime: same.mtime, state: 'present', seq: 1, deletedAt: 0 }],
['only-remote.txt', { fileId: 'p2', relPath: 'only-remote.txt', hash: 'b'.repeat(64), size: 10, mtime: Date.now(), state: 'present', seq: 1, deletedAt: 0 }],
])
const { items, stats } = planSync(man, local, peer)
const get = (p: string) => items.find(i => i.relPath === p)!
assert(get('same.txt').action === 'same', `same.txt 判成了 ${get('same.txt').action}`)
assert(get('only-remote.txt').action === 'download', `only-remote 判成了 ${get('only-remote.txt').action}`)
assert(get('sub/deep.txt').action === 'upload', `deep.txt 判成了 ${get('sub/deep.txt').action}`)
assert(stats.same === 1 && stats.download === 1 && stats.upload === 1,
`统计不对: ${JSON.stringify(stats)}`)
})
await runCase('Y3 首次同步没有基线: 内容不同且修改时间接近 -> 判 conflict (不静默覆盖)', async () => {
const now = Date.now()
const local: SyncFileInfo[] = [{ relPath: 'c.txt', size: 3, mtime: now, hash: 'a'.repeat(64) }]
const man: Manifest = { relationId: 'y-unit3', clock: 0, remoteSeq: 0, entries: {} }
reconcileLocal(man, local)
const peer = new Map<string, ManifestWireEntry>([['c.txt', {
fileId: 'p', relPath: 'c.txt', hash: 'b'.repeat(64), size: 4, mtime: now + 500,
state: 'present', seq: 1, deletedAt: 0,
}]])
const { items, stats } = planSync(man, local, peer)
assert(items[0].action === 'conflict', `判成了 ${items[0].action}`)
assert(stats.conflict === 1, `冲突数不对: ${stats.conflict}`)
})
await runCase('Y4 首次同步没有基线: 谁更新就往谁的方向同步 (mtime 兜底)', async () => {
const now = Date.now()
const localNew: SyncFileInfo[] = [{ relPath: 'x.txt', size: 3, mtime: now + 60_000, hash: 'a'.repeat(64) }]
const remoteOld: SyncFileInfo[] = [{ relPath: 'x.txt', size: 4, mtime: now, hash: 'b'.repeat(64) }]
const mk = (files: SyncFileInfo[]): Manifest => {
const man: Manifest = { relationId: 'y-unit4', clock: 0, remoteSeq: 0, entries: {} }
reconcileLocal(man, files)
return man
}
const peerOld = new Map<string, ManifestWireEntry>([['x.txt', {
fileId: 'p', relPath: 'x.txt', hash: remoteOld[0].hash!, size: 4, mtime: remoteOld[0].mtime,
state: 'present', seq: 1, deletedAt: 0,
}]])
const peerNew = new Map<string, ManifestWireEntry>([['x.txt', {
fileId: 'p', relPath: 'x.txt', hash: localNew[0].hash!, size: 3, mtime: localNew[0].mtime,
state: 'present', seq: 1, deletedAt: 0,
}]])
assert(planSync(mk(localNew), localNew, peerOld).items[0].action === 'upload', '本地新应判 upload')
assert(planSync(mk(remoteOld), remoteOld, peerNew).items[0].action === 'download', '对方新应判 download')
})
await runCase('Y5 端到端: 上传的文件真的落到对方的同步区, 内容一致', async () => {
const ctx = yCtx()
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'sub/deep.txt', action: 'upload' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
const landed = join(Y2.syncRoot, 'sub', 'deep.txt')
assert(existsSync(landed), `文件没落到对方的同步区: ${landed}`)
assert(readText(landed) === 'DEEP-CONTENT', '内容不对')
})
await runCase('Y6 端到端: 下载对方的文件到本地', async () => {
const ctx = yCtx()
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'only-remote.txt', action: 'download' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
const landed = join(Y1.syncRoot, 'only-remote.txt')
assert(existsSync(landed), `文件没下载到本地: ${landed}`)
assert(readText(landed) === 'REMOTE-ONLY', '内容不对')
})
await runCase('Y7 建立基线后本地改了文件: 判 upload (不是 conflict) -> 同步后对方也更新', async () => {
const ctx = yCtx()
// 先建立基线: 扫一次 + 同步一次 (双方都认下这个文件)
const base = await scanSync(ctx)
assert(base.ok, `扫描失败: ${base.reason}`)
const baseItems = base.items
.filter(i => i.action === 'upload' || i.action === 'download')
.map(i => ({ relPath: i.relPath, action: i.action as 'upload' | 'download' }))
const rb = await applySyncItems({ ...ctx, items: baseItems })
assert(rb.ok, `建立基线失败: ${JSON.stringify(rb.results)}`)
// 基线建立后再扫一次应该是 same
const again = await scanSync(ctx)
const sameIt = again.items.find(i => i.relPath === 'same.txt')
assert(!!sameIt && sameIt.action === 'same', `建立基线后应该判 same, 实际 ${sameIt?.action}`)
// 现在改本地文件 -> 只有本地改过 -> upload
writeFileSync(join(Y1.syncRoot, 'same.txt'), 'LOCAL-EDITED-V2')
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const it = scan.items.find(i => i.relPath === 'same.txt')
assert(!!it, 'same.txt 没出现在扫描结果里')
assert(it!.action === 'upload', `本地改过应判 upload, 实际 ${it!.action}`)
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'same.txt', action: 'upload' }] })
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
assert(readText(join(Y2.syncRoot, 'same.txt')) === 'LOCAL-EDITED-V2', '对方同步区没被更新')
})
await runCase('Y8 目录穿越: 上传时 rel-path 带 ../ 必须被拒绝', async () => {
const ctx = yCtx()
assert(!existsSync(join(dirname(Y2.syncRoot), 'evil-escape.txt')), '测试前不该有同名文件')
const r = await applySyncItems({ ...ctx, items: [{ relPath: '../evil-escape.txt', action: 'upload' }] })
assert(!r.ok, '穿越竟然成功了!')
assert(!existsSync(join(dirname(Y2.syncRoot), 'evil-escape.txt')), '文件写到同步目录外面了!')
})
await runCase('Y9 token 不对 -> 对方拒绝 (403)', async () => {
const bad = { ...Y2.info, driveToken: 'wrong-token-sync' }
const scan = await scanSync({
peer: bad, localDir: Y1.syncLocalDir, remotePath: '',
relationId: yRelId, store: Y1.manifestStore,
})
assert(!scan.ok, '错 token 竟然扫成功了!')
assert(/令牌/.test(scan.reason || ''), `错误信息不对: ${scan.reason}`)
})
await runCase('Y10 扫描对方目录: 拿到递归清单和 hash', async () => {
const ctx = yCtx()
const scan = await scanSync(ctx)
assert(scan.ok, `扫描失败: ${scan.reason}`)
const names = scan.items.map(i => i.relPath)
assert(names.includes('same.txt'), `缺 same.txt: ${names.join(',')}`)
assert(names.includes('sub/deep.txt'), `缺深层文件: ${names.join(',')}`)
const same = scan.items.find(i => i.relPath === 'same.txt')!
assert(same.remote && !!same.remote.hash, '远端没有 hash')
assert(same.local && !!same.local.hash, '本地没有 hash')
})
await Y1.stop()
await Y2.stop()
}
// ============ 审计 ============
if (want('audit')) {
console.log('\n\x1b[1m[审计]\x1b[0m')
@@ -782,6 +1518,9 @@ async function main() {
// ============ 汇总 ============
await A.stop()
await B.stop()
await E.stop()
await F.stop()
await G.stop()
const pass = results.filter(r => r.ok).length
const fail = results.length - pass
+270
View File
@@ -0,0 +1,270 @@
// 同步盘端到端测试 (两个虚拟节点 A/B, 走真实的 WS + HTTP 通道)
//
// 覆盖的是"用户真正会遇到的路径":
// 对方弹窗同意 -> 建立关系 -> 双向同步 -> 改文件 -> 删文件 -> 删除传播 -> 墓碑防复活
// 和 scripts/selftest/main.ts 的定位不同: 那边是逐条断言, 这边是**完整链路**,
// 用来守住"删了能删掉、删了不会复活"这两条命门。
//
// 运行: npm run selftest:sync
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { ChatServer } from '../../src/main/chat-server'
import { ChatClient } from '../../src/main/chat-client'
import { FileServer } from '../../src/main/file-server'
import { RelationStore } from '../../src/main/relations'
import { ManifestStore } from '../../src/main/sync-manifest'
import { RelationService } from '../../src/main/relation-service'
import { scanSync, applySyncItems } from '../../src/main/sync'
import { listPendingApprovals, replyApproval } from '../../src/main/remote/approval'
import type { DeviceInfo, WsFrame } from '../../src/main/protocol'
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
let failures = 0
function check(name: string, cond: any, extra?: string) {
if (cond) { console.log(` PASS ${name}`) }
else { failures++; console.log(` FAIL ${name}${extra ? ' :: ' + extra : ''}`) }
}
class Node {
info: DeviceInfo
server: ChatServer
client: ChatClient
fileServer: FileServer
relations: RelationStore
manifestStore: ManifestStore
relationService!: RelationService
syncDir = ''
base = ''
constructor(id: string, name: string) {
this.info = {
deviceId: id, name, hostname: name, platform: 'linux', version: 1,
appVersion: '0.1.0', address: '127.0.0.1', chatPort: 0, filePort: 0, ts: Date.now(),
}
this.base = join(tmpdir(), `lnm-smoke-${id.slice(-4)}-${Date.now()}`)
rmSync(this.base, { recursive: true, force: true })
mkdirSync(this.base, { recursive: true })
this.relations = new RelationStore(join(this.base, 'relations.json'))
this.manifestStore = new ManifestStore(join(this.base, 'manifest.json'))
this.server = new ChatServer(0)
this.client = new ChatClient(this.info)
this.fileServer = new FileServer(0, join(this.base, 'dl'), {
getDriveRoot: () => join(this.base, 'drive'),
getDriveToken: () => `tok-${id}`,
isDriveEnabled: () => true,
authorize: (token, peerId) => this.relationService
? this.relationService.authorizeDrive(token, peerId)
: { ok: false, reason: '未就绪' },
getSyncScope: (relationId, peerId) => this.relationService?.getSyncScope(relationId, peerId) ?? null,
getSyncManifest: ({ relationId }) => this.relationService?.getSyncManifest(relationId) ?? null,
onSyncManifest: ({ relationId, wire }) => this.relationService
? this.relationService.applyPeerManifest(relationId, wire)
: { applied: [], conflicts: [] },
})
}
async start() {
const cp = await this.server.start()
const fp = await this.fileServer.start()
this.info.chatPort = cp
this.info.filePort = fp
mkdirSync(join(this.base, 'drive'), { recursive: true })
this.syncDir = join(this.base, 'sync')
mkdirSync(this.syncDir, { recursive: true })
this.relationService = new RelationService({
store: this.relations,
manifestStore: this.manifestStore,
self: this.info,
send: (peerId, frame) => this.client.send(frame, peerId),
notify: () => {},
})
const route = (peer: DeviceInfo, frame: WsFrame) => {
if (frame.type === 'relation:request') {
this.relationService.handleRelationRequest(peer, frame.payload).catch(() => {})
} else if (frame.type === 'relation:accept') {
this.relationService.handleRelationAccept(peer, frame.payload)
} else if (frame.type === 'relation:reject') {
this.relationService.handleRelationReject(peer, frame.payload)
}
}
this.server.on('frame', route)
this.client.on('message', route)
this.client.on('error', () => {})
}
connectTo(other: Node) { this.client.connectTo({ ...other.info }) }
async stop() { try { await this.server.stop() } catch {} ; try { this.client.stop() } catch {} }
}
async function main() {
const A = new Node('aaaaaaaa-1111-2222-3333-444444444444', 'A机')
const B = new Node('bbbbbbbb-1111-2222-3333-444444444444', 'B机')
await A.start()
await B.start()
A.connectTo(B)
B.connectTo(A)
await sleep(700)
// ---- 1) 建立关系 (模拟对方在弹窗里点同意) ----
const req = A.relationService.requestRelation({
peerId: B.info.deviceId, peerName: B.info.name,
peerAddress: '127.0.0.1', peerChatPort: B.info.chatPort, peerFilePort: B.info.filePort,
syncLocalDir: A.syncDir, syncRemotePath: '', grants: ['drive', 'sync'],
})
check('发起关系请求成功', req.ok, JSON.stringify(req))
await sleep(500)
const ask = listPendingApprovals().find(x => x.kind === 'relation' && x.peerId === A.info.deviceId)
check('对方弹出了关系授权请求', !!ask)
if (ask) {
replyApproval(ask.requestId, true, { grants: ['drive', 'sync'], syncLocalDir: B.syncDir, syncRemotePath: '' })
}
await sleep(700)
const relA = A.relations.getRelationByPeer(B.info.deviceId)
const relB = B.relations.getRelationByPeer(A.info.deviceId)
// 实际同步根 = 顶层目录 + 按关系隔离的子目录 (一个顶层目录管多个聊天备份时各自隔开)
const aRoot = join(A.syncDir, relA?.syncSubdir || '')
const bRoot = join(B.syncDir, relB?.syncSubdir || '')
mkdirSync(aRoot, { recursive: true })
mkdirSync(bRoot, { recursive: true })
check('A 侧关系 active', relA?.state === 'active', relA?.state)
check('B 侧关系 active', relB?.state === 'active', relB?.state)
check('双方令牌已交换', !!relA?.peerToken && !!relB?.peerToken)
if (!relA || !relB) { await A.stop(); await B.stop(); process.exit(1) }
// ---- 2) 同步: A 的文件传到 B ----
writeFileSync(join(aRoot, 'hello.txt'), 'HELLO-FROM-A')
writeFileSync(join(bRoot, 'only-b.txt'), 'ONLY-FROM-B')
const ctxA = {
peer: B.info, localDir: aRoot, token: relA.peerToken,
relationId: relA.relationId, store: A.manifestStore,
selfDeviceId: A.info.deviceId,
}
const scan1 = await scanSync(ctxA)
check('扫描成功', scan1.ok, scan1.reason)
check('hello.txt 判为待上传', scan1.items.find(i => i.relPath === 'hello.txt')?.action === 'upload',
JSON.stringify(scan1.items.map(i => [i.relPath, i.action])))
check('only-b.txt 判为待下载', scan1.items.find(i => i.relPath === 'only-b.txt')?.action === 'download',
JSON.stringify(scan1.items.map(i => [i.relPath, i.action])))
const items1 = scan1.items
.filter(i => i.action === 'upload' || i.action === 'download' || i.action === 'delete-local' || i.action === 'delete-remote')
.map(i => ({ relPath: i.relPath, action: i.action as any }))
const ap1 = await applySyncItems({ ...ctxA, items: items1 })
check('同步执行成功', ap1.ok, JSON.stringify(ap1.results))
check('B 收到了 hello.txt', existsSync(join(bRoot, 'hello.txt')))
check('A 收到了 only-b.txt', existsSync(join(aRoot, 'only-b.txt')))
check('内容正确', readFileSync(join(bRoot, 'hello.txt'), 'utf8') === 'HELLO-FROM-A')
// ---- 3) 本地改了 -> 应判 upload (有基线了) ----
writeFileSync(join(aRoot, 'hello.txt'), 'HELLO-FROM-A-V2')
const scan2 = await scanSync(ctxA)
check('改过的文件判为待上传(不是冲突)',
scan2.items.find(i => i.relPath === 'hello.txt')?.action === 'upload',
JSON.stringify(scan2.items.map(i => [i.relPath, i.action])))
// ---- 4) 删除传播: A 删了 -> B 那份也要没 ----
rmSync(join(aRoot, 'hello.txt'))
const scan3 = await scanSync(ctxA)
check('删掉的文件判为"将删除对方"',
scan3.items.find(i => i.relPath === 'hello.txt')?.action === 'delete-remote',
JSON.stringify(scan3.items.map(i => [i.relPath, i.action])))
const ap3 = await applySyncItems({
...ctxA,
items: scan3.items.filter(i => i.action === 'delete-remote' || i.action === 'delete-local' || i.action === 'upload' || i.action === 'download')
.map(i => ({ relPath: i.relPath, action: i.action as any })),
})
check('删除传播执行成功', ap3.ok, JSON.stringify(ap3.results))
check('B 那份也被删了', !existsSync(join(bRoot, 'hello.txt')))
// ---- 5) 墓碑: 再扫一次不该把它当成"对方新增"重新下载 ----
const scan4 = await scanSync(ctxA)
const again = scan4.items.find(i => i.relPath === 'hello.txt')
check('墓碑生效: 不会被重新下载', !again || again.action === 'same',
JSON.stringify(scan4.items.map(i => [i.relPath, i.action])))
// ---- 6) 授权边界: 错令牌被拒 ----
const bad = await scanSync({ ...ctxA, token: 'wrong-token' })
check('错令牌被拒', !bad.ok, bad.reason)
// ---- 7) 反向删除传播: B 删了 -> A 那份也要没 ----
rmSync(join(bRoot, 'only-b.txt'))
const ctxB = {
peer: A.info, localDir: bRoot, token: relB.peerToken,
relationId: relB.relationId, store: B.manifestStore,
selfDeviceId: B.info.deviceId,
}
const scanB = await scanSync(ctxB)
check('B 侧识别出"我删了 only-b.txt, 要传播给对方"',
scanB.items.find(i => i.relPath === 'only-b.txt')?.action === 'delete-remote',
JSON.stringify(scanB.items.map(i => [i.relPath, i.action])))
await applySyncItems({
...ctxB,
items: scanB.items.filter(i => i.action !== 'same').map(i => ({ relPath: i.relPath, action: i.action as any })),
})
check('A 那份 only-b.txt 也被删了', !existsSync(join(aRoot, 'only-b.txt')))
// ---- 7b) 完整删除传播链路: A 删 -> A 推关系表 -> B 那边自动删掉本地那份 ----
writeFileSync(join(aRoot, 'both.txt'), 'SHARED')
await applySyncItems({ ...ctxA, items: [{ relPath: 'both.txt', action: 'upload' }] })
const scanB2 = await scanSync(ctxB) // B 拉一次, 建立基线
await applySyncItems({
...ctxB,
items: scanB2.items.filter(i => i.action === 'download')
.map(i => ({ relPath: i.relPath, action: i.action as any })),
})
check('both.txt 同步到了 B', existsSync(join(bRoot, 'both.txt')))
rmSync(join(aRoot, 'both.txt'))
const scanDel = await scanSync(ctxA)
check('A 侧判为"将删除对方"',
scanDel.items.find(i => i.relPath === 'both.txt')?.action === 'delete-remote',
JSON.stringify(scanDel.items.map(i => [i.relPath, i.action])))
await applySyncItems({ ...ctxA, items: [{ relPath: 'both.txt', action: 'delete-remote' }] })
await sleep(400) // 等对方收到并应用
check('删除传播到 B: B 本地那份也没了', !existsSync(join(bRoot, 'both.txt')))
// ---- 8) 冲突: 两边都改同一个文件 -> 判 conflict, 不静默覆盖 ----
writeFileSync(join(aRoot, 'conf.txt'), 'A-VERSION')
await applySyncItems({ ...ctxA, items: [{ relPath: 'conf.txt', action: 'upload' }] })
writeFileSync(join(aRoot, 'conf.txt'), 'A-VERSION-2')
writeFileSync(join(bRoot, 'conf.txt'), 'B-VERSION-2')
const scanC = await scanSync(ctxA)
check('两边都改过 -> 判冲突',
scanC.items.find(i => i.relPath === 'conf.txt')?.action === 'conflict',
JSON.stringify(scanC.items.map(i => [i.relPath, i.action])))
// ---- 9) 复活防护: 对方拿更早的 seq 声称文件还在 -> 墓碑赢 ----
const M = require('../../src/main/sync-manifest')
const man = { relationId: 'r1', clock: 0, remoteSeq: 0, entries: {} as any }
const snap = (relPath: string, hash: string) => ({ relPath, size: 10, mtime: Date.now(), hash })
// 先建立基线: 两边一致
M.reconcileLocal(man, [snap('z.txt', 'H1')])
const peerMap = new Map([['z.txt', { fileId: 'f1', relPath: 'z.txt', hash: 'H1', size: 10, mtime: 1, state: 'present', seq: 1, deletedAt: 0 }]])
M.mergePeer(man, peerMap)
M.updateBaseline(man, 'z.txt', 'H1', 10, 1)
// 本地删除 -> 墓碑
M.reconcileLocal(man, [])
const tombSeq = man.entries['z.txt'].mySeq
check('本地删除记成了墓碑', man.entries['z.txt'].myState === 'deleted' && tombSeq > 0)
// 对方用一个更早的 seq 声称文件还在 -> 墓碑应该赢
const stalePeer = new Map([['z.txt', { fileId: 'f1', relPath: 'z.txt', hash: 'H1', size: 10, mtime: 1, state: 'present', seq: 1, deletedAt: 0 }]])
M.mergePeer(man, stalePeer)
const planStale = M.planSync(man, [], stalePeer)
check('墓碑赢: 不会让已删文件复活',
planStale.items.find(i => i.relPath === 'z.txt')?.action === 'delete-remote',
JSON.stringify(planStale.items.map(i => [i.relPath, i.action])))
// 对方用更新的 seq 声称文件还在 -> 接受复活
const freshPeer = new Map([['z.txt', { fileId: 'f1', relPath: 'z.txt', hash: 'H2', size: 10, mtime: 2, state: 'present', seq: tombSeq + 5, deletedAt: 0 }]])
M.mergePeer(man, freshPeer)
const planFresh = M.planSync(man, [], freshPeer)
check('对方 seq 更新 -> 接受复活(下载)',
planFresh.items.find(i => i.relPath === 'z.txt')?.action === 'download',
JSON.stringify(planFresh.items.map(i => [i.relPath, i.action])))
await A.stop()
await B.stop()
console.log(failures === 0 ? '\n全部通过' : `\n${failures} 条失败`)
process.exit(failures === 0 ? 0 : 1)
}
main().catch((e) => { console.error('崩溃:', e); process.exit(1) })
+177
View File
@@ -0,0 +1,177 @@
// 自动同步: 关系开了"自动同步"就定时跑一轮
//
// 触发:
// - 定时轮询 (默认 60s)
// - 对方上线时 (ipc.ts 调 trigger)
// - 用户刚开启自动同步时
//
// 失败处理: 指数退避 (1min -> 2min -> 4min ... 上限 5min), 不刷屏。
// 冲突处理: **跳过并记录**, 不静默覆盖任何一方 —— 自动同步绝不能替用户决定丢哪份。
// 用户在同步面板里手动选方向即可。
import { join, resolve } from 'node:path'
import { RelationStore, type Relation } from './relations'
import { ManifestStore } from './sync-manifest'
import { scanSync, applySyncItems } from './sync'
import { recordAudit } from './db'
import type { DeviceInfo } from './protocol'
export interface AutoSyncOptions {
relations: RelationStore
manifestStore: ManifestStore
/** 找到对方 (含 address / port / 令牌等) */
findPeer: (peerId: string) => DeviceInfo | null
/** 通知渲染层 */
notify: (event: string, payload?: any) => void
/** 轮询间隔 (毫秒), 默认 60s */
intervalMs?: number
}
/** 退避上限 5 分钟 */
const BACKOFF_MAX_MS = 5 * 60_000
const BACKOFF_BASE_MS = 60_000
export class AutoSync {
private relations: RelationStore
private manifestStore: ManifestStore
private findPeer: (peerId: string) => DeviceInfo | null
private notify: (event: string, payload?: any) => void
private intervalMs: number
private timer: NodeJS.Timeout | null = null
/** relationId -> 下次允许重试的时间戳 */
private nextTryAt = new Map<string, number>()
/** relationId -> 当前连续失败次数 */
private failures = new Map<string, number>()
/** 正在跑的关系 (防重入) */
private running = new Set<string>()
constructor(opts: AutoSyncOptions) {
this.relations = opts.relations
this.manifestStore = opts.manifestStore
this.findPeer = opts.findPeer
this.notify = opts.notify
this.intervalMs = opts.intervalMs ?? 60_000
}
start(): void {
if (this.timer) return
this.timer = setInterval(() => { this.tick().catch(() => {}) }, this.intervalMs)
// 别让定时器拖住进程退出
this.timer.unref?.()
}
stop(): void {
if (this.timer) { clearInterval(this.timer); this.timer = null }
}
/** 对方上线 / 用户刚开开关时: 立刻试一次 (不受退避限制) */
trigger(relationId?: string): void {
if (relationId) {
this.nextTryAt.delete(relationId)
this.runOne(relationId).catch(() => {})
return
}
this.tick(true).catch(() => {})
}
/** 跑一轮所有该跑的关系 */
async tick(force = false): Promise<void> {
const list = this.relations.listActiveRelations().filter(r => r.syncEnabled && r.syncLocalDir)
for (const rel of list) {
if (!force) {
const wait = this.nextTryAt.get(rel.relationId) || 0
if (Date.now() < wait) continue
}
await this.runOne(rel.relationId).catch(() => {})
}
}
/** 跑一个关系的同步 */
async runOne(relationId: string): Promise<void> {
if (this.running.has(relationId)) return
const rel = this.relations.getRelation(relationId)
if (!rel || rel.state !== 'active' || !rel.syncEnabled) return
if (!rel.syncLocalDir || !rel.peerToken) return
const peer = this.findPeer(rel.peerId)
if (!peer) return
this.running.add(relationId)
try {
const ctx = {
peer,
// 实际同步根 = 顶层目录 + 按关系隔离的子目录
localDir: this.localRootOf(rel),
remotePath: rel.syncRemotePath,
token: rel.peerToken,
relationId: rel.relationId,
store: this.manifestStore,
}
const scan = await scanSync(ctx)
if (!scan.ok) {
this.fail(rel, scan.reason || '扫描失败')
return
}
// 冲突跳过: 自动同步不替用户决定丢哪一份
const conflicts = scan.items.filter(i => i.action === 'conflict')
const actions = scan.items
.filter(i => i.action === 'upload' || i.action === 'download' || i.action === 'delete-local' || i.action === 'delete-remote')
.map(i => ({ relPath: i.relPath, action: i.action as 'upload' | 'download' | 'delete-local' | 'delete-remote' }))
let failed = 0
if (actions.length) {
const r = await applySyncItems({ ...ctx, items: actions })
failed = r.failed
}
const errText = conflicts.length
? `有 ${conflicts.length} 个文件两边都改过, 需在同步面板里手动选择方向`
: (failed ? `同步失败 ${failed} 项` : '')
this.relations.updateRelation(relationId, {
lastSyncAt: Date.now(),
lastError: errText,
})
if (!errText) {
this.failures.delete(relationId)
this.nextTryAt.delete(relationId)
} else {
this.scheduleRetry(relationId)
}
this.notify('sync:auto', {
relationId, peerId: rel.peerId,
changed: actions.length, failed, conflicts: conflicts.map(c => c.relPath),
})
// 自动同步那一轮也要留审计 (否则界面上看不到"自动同步干了啥")
recordAudit({
action: 'sync.auto', source: this.relations.getRelation(relationId)?.peerId || rel.peerId,
target: rel.peerId,
result: failed === 0 ? 'ok' : 'error',
payload: {
relationId, changed: actions.length, failed,
conflicts: conflicts.map(c => c.relPath).slice(0, 20),
},
})
} finally {
this.running.delete(relationId)
}
}
/** 实际同步根 = 顶层目录 + 按关系隔离的子目录 */
private localRootOf(rel: Relation): string {
const base = resolve(rel.syncLocalDir || '')
const sub = (rel.syncSubdir || '').replace(/^[/\\]+/, '').replace(/[/\\]+$/, '')
return sub ? join(base, sub) : base
}
/** 记一次失败并安排退避重试 */
private fail(rel: Relation, reason: string): void {
this.relations.updateRelation(rel.relationId, { lastError: reason })
this.scheduleRetry(rel.relationId)
}
private scheduleRetry(relationId: string): void {
const n = (this.failures.get(relationId) || 0) + 1
this.failures.set(relationId, n)
const delay = Math.min(BACKOFF_BASE_MS * Math.pow(2, n - 1), BACKOFF_MAX_MS)
this.nextTryAt.set(relationId, Date.now() + delay)
}
}
+601 -27
View File
@@ -10,10 +10,10 @@
import { createServer, IncomingMessage, ServerResponse } from 'node:http'
import { EventEmitter } from 'node:events'
import { createWriteStream, existsSync, statSync, mkdirSync, readdirSync } from 'node:fs'
import { createReadStream } from 'node:fs'
import { createWriteStream, existsSync, statSync, mkdirSync, readdirSync, openSync, readSync, closeSync } from 'node:fs'
import { createReadStream, lstatSync, realpathSync } from 'node:fs'
import { mkdir, stat, unlink, rename } from 'node:fs/promises'
import { join, extname, resolve, basename, sep } from 'node:path'
import { join, extname, resolve, basename, sep, dirname } from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { getSettings } from './settings'
import { safeFileName } from './safe-name'
@@ -23,16 +23,47 @@ export interface FileServerEvents {
progress: [info: { fileId: string; sent: number; total: number; fromAddr: string }]
/** 对端从我的网盘下载了一个文件 */
driveDownloaded: [info: { name: string; size: number; relPath: string; fromAddr: string }]
/** 对端往我的网盘上传了一个文件 (文件夹同步的上行方向) */
driveUploaded: [info: { relPath: string; size: number; fromAddr: string }]
/** 对端让我删掉了网盘里的一个文件 (删除传播的落地) */
driveDeleted: [info: { relPath: string; size: number; fromAddr: string }]
}
/** 网盘接口的回调 (设置项运行时可变, 所以用 getter 而不是固定值) */
export interface DriveOptions {
/** 网盘根目录 (对端只能看到这个目录里的内容) */
getDriveRoot: () => string
/** 访问令牌; 返回空 = 不提供网盘服务 */
/** 访问令牌; 返回空 = 不提供网盘服务 (旧版全局 token, 只作兼容) */
getDriveToken: () => string | undefined
/** 总开关 */
isDriveEnabled: () => boolean
/**
* 新版: 按对方签发的 per-peer 令牌鉴权。
* 返回 ok=false 就一律 403; scopeRoot 非空表示把访问限制在该子目录内
* (只给了 sync 授权时, 对方只能碰这个关系专属的目录)。
*/
authorize?: (token: string | null, peerId: string | null) => DriveAuthResult
/**
* 同步区: 某个关系在我这边的同步目录 (绝对路径)。
* /sync/file 的所有路径都限制在这个目录内 —— 这样"同步目录"可以是
* 用户任意一个文件夹 (比如 ~/文档/工作), 不必待在网盘目录里面。
*/
getSyncScope?: (relationId: string, peerId: string) => string | null
/** 同步: 对方来拉我的文件关系表 */
getSyncManifest?: (args: { peerId: string; relationId: string }) => unknown | null
/** 同步: 对方把它的文件关系表推给我 -> 我这边应用 (删本地文件等) */
onSyncManifest?: (args: { peerId: string; relationId: string; wire: any }) => { applied?: string[]; conflicts?: string[]; reason?: string }
}
export interface DriveAuthResult {
ok: boolean
reason?: string
/** 限定在这个子目录内 (相对网盘根); 空 = 不限制 */
scopeRoot?: string
relationId?: string
peerId?: string
/** 被授权的能力 (drive / sync) */
grants?: string[]
}
/** 网盘目录里的一个条目 */
@@ -45,6 +76,36 @@ export interface DriveEntry {
isDir: boolean
}
/** 目录同步: 扫描结果里的一个文件 */
export interface DriveScanFile {
/** 相对网盘根目录的路径 */
relPath: string
size: number
mtime: number
/** sha256; 文件太大时不算 (见 HASH_MAX_SIZE) */
hash?: string
}
/** 超过这个大小就不算 hash 了 (算起来慢, 同步时靠 size+mtime 兜底) */
const HASH_MAX_SIZE = 256 * 1024 * 1024
/** 算文件的 sha256 (同步) */
export function hashFileSync(p: string): string {
const h = createHash('sha256')
const buf = Buffer.alloc(1024 * 1024)
const fd = openSync(p, 'r')
try {
for (;;) {
const n = readSync(fd, buf, 0, buf.length, null)
if (n <= 0) break
h.update(buf.subarray(0, n))
}
} finally {
closeSync(fd)
}
return h.digest('hex')
}
// 单文件大小上限, 默认 100GB (LAN 局域网传大视频常见, 不限太死)
// 覆盖方式: 环境变量 LNM_MAX_FILE_SIZE=<bytes>
function resolveMaxSize(): number {
@@ -77,11 +138,57 @@ function safeDecode(s: string | undefined): string {
}
}
/**
* 软链安全的路径归一。
* 目标可能还不存在 (上传时), 这时逐级向上找到最近的存在父目录, 归一它再拼回去。
* 返回 null 表示归不出来 (路径不存在且父目录也不存在)。
*/
function realpathSafe(p: string): string | null {
try { return realpathSync(p) } catch { /* 继续往下 */ }
let cur = resolve(p)
const tail: string[] = []
for (;;) {
const parent = dirname(cur)
if (parent === cur) return null
tail.unshift(basename(cur))
cur = parent
try { return join(realpathSync(cur), ...tail) } catch { /* 再往上找 */ }
}
}
// 流式计算 SHA-256 hex (上传时, 一边传一边 hash, 不预读阻塞)
function sha256Buf(buf: Buffer): string {
return createHash('sha256').update(buf).digest('hex')
}
// 读 JSON 请求体 (同步关系表用); 超限或解析失败返回 null
function readJsonBody(req: IncomingMessage, limit = 8 * 1024 * 1024): Promise<any | null> {
return new Promise((resolve) => {
const chunks: Buffer[] = []
let size = 0
let done = false
const finish = (v: any | null) => {
if (done) return
done = true
resolve(v)
}
req.on('data', (c: Buffer) => {
size += c.length
if (size > limit) {
try { req.destroy() } catch {}
finish(null)
return
}
chunks.push(c)
})
req.on('end', () => {
try { finish(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch { finish(null) }
})
req.on('error', () => finish(null))
req.on('aborted', () => finish(null))
})
}
export class FileServer extends EventEmitter {
private server = createServer((req, res) => this.handle(req, res))
private port: number
@@ -160,6 +267,32 @@ export class FileServer extends EventEmitter {
await this.handleDrive(req, url, res)
return
}
if (url.startsWith('/drive/') && req.method === 'POST') {
await this.handleDrivePost(req, url, res)
return
}
// ---- 同步: 文件关系表 (manifest) ----
if (url.startsWith('/sync/manifest') && req.method === 'GET') {
await this.handleSyncManifestGet(url, res)
return
}
if (url.startsWith('/sync/manifest') && req.method === 'POST') {
await this.handleSyncManifestPost(req, url, res)
return
}
// ---- 同步区: 扫描 + 文件读写 (路径限制在关系的同步目录内, 与网盘无关) ----
if (url.startsWith('/sync/scan') && req.method === 'GET') {
await this.handleSyncScan(url, res)
return
}
if (url.startsWith('/sync/file') && req.method === 'GET') {
await this.handleSyncFileGet(req, url, res)
return
}
if (url.startsWith('/sync/file') && req.method === 'POST') {
await this.handleSyncFilePost(req, url, res)
return
}
this.json(res, 404, { ok: false, error: 'not found' })
} catch (e: any) {
this.json(res, 500, { ok: false, error: e?.message || 'server error' })
@@ -169,37 +302,123 @@ export class FileServer extends EventEmitter {
// ========== 网盘 ==========
// 把对端传来的相对路径解析成绝对路径, 并确保没有跳出网盘根目录
private resolveDrivePath(rel: string): { abs: string; root: string } | null {
// scopeRoot 非空时, 还要确保没有跳出这个关系的专属子目录
private resolveDrivePath(rel: string, scopeRoot?: string): { abs: string; root: string } | null {
if (!this.drive) return null
const root = resolve(this.drive.getDriveRoot())
const clean = (rel || '').replace(/^[/\\]+/, '')
const abs = resolve(join(root, clean))
// 必须严格在 root 之内 (相等或以 root + sep 开头)
if (abs !== root && !abs.startsWith(root + sep)) return null
if (scopeRoot) {
// 'none' 是哨兵值: 表示"完全不开放网盘浏览"(只给了 sync 授权)
if (scopeRoot === 'none') return null
const scopeAbs = resolve(join(root, scopeRoot.replace(/^[/\\]+/, '')))
if (abs !== scopeAbs && !abs.startsWith(scopeAbs + sep)) return null
}
// 软链逃逸防护: 路径前缀检查对软链无效, 归一后再比一次
const realRoot = realpathSafe(root)
const realAbs = realpathSafe(abs)
if (!realRoot || !realAbs) return null
if (realAbs !== realRoot && !realAbs.startsWith(realRoot + sep)) return null
return { abs, root }
}
private driveTokenOk(token: string | null): boolean {
if (!this.drive) return false
if (!this.drive.isDriveEnabled()) return false
/**
* 同步区的路径解析: 根是这个关系在我这边的同步目录 (绝对路径, 位置任意)。
* 与网盘完全独立 —— 用户的同步目录不必待在网盘目录里面。
* 必须有 sync 授权 (由 authorize 回调判定), 路径再限制在这个目录内。
*/
private resolveSyncPath(relationId: string, rel: string, peerId: string): { abs: string; root: string } | null {
if (!this.drive?.getSyncScope) return null
const scope = this.drive.getSyncScope(relationId, peerId)
if (!scope) return null
const root = resolve(scope)
const clean = (rel || '').replace(/^[/\\]+/, '')
const abs = resolve(join(root, clean))
if (abs !== root && !abs.startsWith(root + sep)) return null
// 软链逃逸防护: 同步目录是用户任意文件夹, 里面出现软链很常见。
// 路径前缀检查对软链无效 —— 归一后必须仍在 root 内。
const realRoot = realpathSafe(root)
const realAbs = realpathSafe(abs)
if (!realRoot || !realAbs) return null
if (realAbs !== realRoot && !realAbs.startsWith(realRoot + sep)) return null
return { abs, root }
}
/** 发一个文件给对端 */
private serveDownload(req: IncomingMessage, res: ServerResponse, r: { abs: string; root: string }, rel: string) {
let st: any
try { st = statSync(r.abs) } catch { this.json(res, 404, { ok: false, error: '文件不存在' }); return }
if (!st.isFile()) { this.json(res, 400, { ok: false, error: '不是文件' }); return }
res.statusCode = 200
res.setHeader('Content-Length', String(st.size))
res.setHeader('Content-Type', 'application/octet-stream')
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(basename(r.abs))}"`)
res.setHeader('Access-Control-Allow-Origin', '*')
this.emit('driveDownloaded', {
name: basename(r.abs), size: st.size, relPath: rel,
fromAddr: req.socket.remoteAddress || '',
})
createReadStream(r.abs).pipe(res)
}
/**
* 网盘访问鉴权。
* 新版走 per-peer 关系令牌 (谁签发给我的、给��授权了什么都查得到);
* 没接 authorize 回调时退回旧的全局 token (兼容还没升级的对端)。
*/
private authDrive(token: string | null, peerId: string | null, need?: 'drive' | 'drive-write' | 'sync'): {
ok: boolean; reason?: string; scopeRoot?: string; grants?: string[]; relationId?: string; legacy?: boolean
} {
if (!this.drive) return { ok: false, reason: '网盘未开启' }
if (!this.drive.isDriveEnabled()) return { ok: false, reason: '网盘未开启' }
if (this.drive.authorize) {
const r = this.drive.authorize(token, peerId)
if (r?.ok) {
// 只给了 sync 的话, 网盘浏览完全不给 (scopeRoot='none' 表示任何路径都越界)
if (need === 'drive' && r.grants && !r.grants.includes('drive')) {
return { ok: false, reason: '未授权浏览网盘' }
}
if (need === 'drive-write' && r.grants && !r.grants.includes('drive')) {
return { ok: false, reason: '未授权写入网盘' }
}
if (need === 'sync' && r.grants && !r.grants.includes('sync')) {
return { ok: false, reason: '未授权同步' }
}
return { ok: true, reason: r.reason, scopeRoot: r.scopeRoot, grants: r.grants, relationId: r.relationId }
}
// 过渡期兼容: 对方还没建立关系 (比如还没升级的老版本对端) -> 退回旧的全局 token。
// **只允许只读浏览网盘**: 这个 token 曾经在 UDP 广播里明文传过, 不能让它碰到
// 同步区 (用户任意文件夹) 或写/删这类破坏性接口。
const legacy = this.drive.getDriveToken()
if (legacy && token && token === legacy && need === 'drive') {
return { ok: true, scopeRoot: '', grants: undefined, legacy: true }
}
return { ok: false, reason: r?.reason }
}
const expect = this.drive.getDriveToken()
if (!expect) return false
return !!token && token === expect
if (!expect) return { ok: false, reason: '网盘令牌无效' }
if (!token || token !== expect) return { ok: false, reason: '网盘令牌无效' }
return { ok: true }
}
private async handleDrive(req: IncomingMessage, url: string, res: ServerResponse) {
const qIdx = url.indexOf('?')
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
const token = params.get('token')
const peerId = params.get('peerId')
if (!this.drive || !this.drive.isDriveEnabled()) {
this.json(res, 404, { ok: false, error: '网盘未开启' })
return
}
if (!this.driveTokenOk(token)) {
this.json(res, 403, { ok: false, error: '网盘令牌无效' })
const auth = this.authDrive(token, peerId, 'drive')
if (!auth.ok) {
this.json(res, 403, { ok: false, error: auth.reason || '网盘令牌无效' })
return
}
const scope = auth.scopeRoot
const route = url.slice('/drive/'.length, qIdx < 0 ? undefined : qIdx)
if (route === 'meta') {
@@ -208,7 +427,7 @@ export class FileServer extends EventEmitter {
}
if (route === 'list') {
const rel = params.get('path') || ''
const r = this.resolveDrivePath(rel)
const r = this.resolveDrivePath(rel, scope)
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); return }
let st: any
try { st = statSync(r.abs) } catch { this.json(res, 404, { ok: false, error: '目录不存在' }); return }
@@ -231,28 +450,52 @@ export class FileServer extends EventEmitter {
this.json(res, 200, { ok: true, path: rel, entries })
return
}
if (route === 'scan') {
await this.handleDriveScan(url, res, scope)
return
}
if (route === 'download') {
const rel = params.get('path') || ''
const r = this.resolveDrivePath(rel)
const r = this.resolveDrivePath(rel, scope)
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); return }
let st: any
try { st = statSync(r.abs) } catch { this.json(res, 404, { ok: false, error: '文件不存在' }); return }
if (!st.isFile()) { this.json(res, 400, { ok: false, error: '不是文件' }); return }
res.statusCode = 200
res.setHeader('Content-Length', String(st.size))
res.setHeader('Content-Type', 'application/octet-stream')
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(basename(r.abs))}"`)
res.setHeader('Access-Control-Allow-Origin', '*')
this.emit('driveDownloaded', {
name: basename(r.abs), size: st.size, relPath: rel,
fromAddr: req.socket.remoteAddress || '',
})
createReadStream(r.abs).pipe(res)
this.serveDownload(req, res, r, rel)
return
}
this.json(res, 404, { ok: false, error: 'not found' })
}
private async handleDrivePost(req: IncomingMessage, url: string, res: ServerResponse) {
const qIdx = url.indexOf('?')
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
const token = params.get('token')
const peerId = params.get('peerId')
if (!this.drive || !this.drive.isDriveEnabled()) {
this.json(res, 404, { ok: false, error: '网盘未开启' })
req.resume()
return
}
const auth = this.authDrive(token, peerId, 'drive-write')
if (!auth.ok) {
this.json(res, 403, { ok: false, error: auth.reason || '网盘令牌无效' })
req.resume()
return
}
const scope = auth.scopeRoot
const route = url.slice('/drive/'.length, qIdx < 0 ? undefined : qIdx)
if (route === 'upload') {
await this.handleDriveUpload(req, res, scope)
return
}
if (route === 'delete') {
await this.handleDriveDelete(req, url, res, scope)
return
}
this.json(res, 404, { ok: false, error: 'not found' })
req.resume()
}
private json(res: ServerResponse, code: number, data: any) {
res.statusCode = code
res.setHeader('Content-Type', 'application/json; charset=utf-8')
@@ -260,6 +503,337 @@ export class FileServer extends EventEmitter {
res.end(JSON.stringify(data))
}
// ========== 网盘: 目录同步用的扩展接口 ==========
/**
* GET /drive/scan?token=&path=&hash=1
* 递归列出目录下的所有文件 (相对路径 / 大小 / 修改时间 / 可选 sha256)。
* 同步靠它拿到"对方那侧"的完整快照, 才能跟本地做对比。
*/
private async handleDriveScan(url: string, res: ServerResponse, scope?: string) {
const qIdx = url.indexOf('?')
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
const rel = params.get('path') || ''
const wantHash = params.get('hash') !== '0'
const r = this.resolveDrivePath(rel, scope)
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); return }
let st: any
try { st = statSync(r.abs) } catch { this.json(res, 404, { ok: false, error: '目录不存在' }); return }
if (!st.isDirectory()) { this.json(res, 400, { ok: false, error: '不是目录' }); return }
this.scanInto(res, r.abs, rel.replace(/[/\\]+$/, ''), wantHash)
}
/** 递归列目录 (网盘和同步区共用) */
private scanInto(res: ServerResponse, rootAbs: string, relBase: string, wantHash: boolean) {
const files: DriveScanFile[] = []
const dirs: string[] = []
let skipped = 0
const MAX_ENTRIES = 20000
// 目录深度上限, 防止软链接环 / 超深目录把服务拖死
const walk = (abs: string, relBase2: string, depth: number) => {
if (depth > 16 || files.length >= MAX_ENTRIES) return
let names: string[] = []
try { names = readdirSync(abs) } catch { skipped++; return }
for (const name of names) {
if (files.length >= MAX_ENTRIES) return
// 跳过自己写的临时文件
if (name.startsWith('.')) continue
const childAbs = join(abs, name)
const childRel = relBase2 ? `${relBase2}/${name}` : name
let s: any
// 用 lstat: 软链不跟随 (软链能指向同步目录外面 -> 会漏出去)
try { s = lstatSync(childAbs) } catch { skipped++; continue }
if (s.isSymbolicLink()) { skipped++; continue }
if (s.isDirectory()) {
dirs.push(childRel)
walk(childAbs, childRel, depth + 1)
} else if (s.isFile()) {
const entry: DriveScanFile = {
relPath: childRel, size: s.size, mtime: s.mtimeMs,
}
// 大文件不算 hash (太慢), 只靠 size+mtime 判定
if (wantHash && s.size <= HASH_MAX_SIZE) {
try { entry.hash = hashFileSync(childAbs) } catch { skipped++ }
}
files.push(entry)
}
}
}
walk(rootAbs, relBase, 0)
files.sort((a, b) => a.relPath.localeCompare(b.relPath))
dirs.sort()
this.json(res, 200, { ok: true, path: relBase, files, dirs, skipped, root: rootAbs })
}
// ========== 同步区 (/sync/*) ==========
//
// 同步区和网盘是**两套独立的空间**:
// - 网盘 (/drive/*): 共享目录, 要 drive 授权才能浏览
// - 同步区 (/sync/*): 某个关系专属的同步目录, 要 sync 授权, 且路径严格限制在该目录内
// 这样用户的同步目录可以是任意文件夹 (~/文档/工作), 不必待在网盘里。
/**
* 同步区鉴权: 必须是有效关系 + 有 sync 能力 + **relationId 与令牌绑定的关系一致**。
*
* 最后一条最关键: 令牌本身只证明"这个令牌有效", 不证明"这个令牌属于哪个关系"。
* 不校验的话, A 关系的对方拿 A 的令牌就能读写 B 关系的同步目录 (跨关系越权)。
*/
private authSync(params: URLSearchParams): { ok: boolean; reason?: string; relationId?: string } {
if (!this.drive?.getSyncScope) return { ok: false, reason: '不支持同步' }
const auth = this.authDrive(params.get('token'), params.get('peerId'), 'sync')
if (!auth.ok) return { ok: false, reason: auth.reason }
if (auth.grants && !auth.grants.includes('sync')) return { ok: false, reason: '未授权同步' }
// 令牌绑定了唯一的关系, 请求里的 relationId 必须就是它
const want = params.get('relationId') || ''
if (!auth.relationId) return { ok: false, reason: '令牌未绑定关系' }
if (!want || want !== auth.relationId) return { ok: false, reason: '关系不匹配' }
return { ok: true, relationId: auth.relationId }
}
/** GET /sync/scan?token=&peerId=&relationId=&hash=1 */
private async handleSyncScan(url: string, res: ServerResponse) {
const qIdx = url.indexOf('?')
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
const auth = this.authSync(params)
if (!auth.ok) { this.json(res, 403, { ok: false, error: auth.reason || '未授权' }); return }
const relationId = params.get('relationId') || ''
const r = this.resolveSyncPath(relationId, '', params.get('peerId') || '')
if (!r) { this.json(res, 400, { ok: false, error: '同步目录未设置' }); return }
let st: any
try { st = statSync(r.abs) } catch { this.json(res, 404, { ok: false, error: '同步目录不存在' }); return }
if (!st.isDirectory()) { this.json(res, 400, { ok: false, error: '同步目录不是目录' }); return }
this.scanInto(res, r.abs, '', params.get('hash') !== '0')
}
/** GET /sync/file?token=&peerId=&relationId=&path= */
private async handleSyncFileGet(req: IncomingMessage, url: string, res: ServerResponse) {
const qIdx = url.indexOf('?')
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
const auth = this.authSync(params)
if (!auth.ok) { this.json(res, 403, { ok: false, error: auth.reason || '未授权' }); return }
const rel = params.get('path') || ''
const r = this.resolveSyncPath(params.get('relationId') || '', rel, params.get('peerId') || '')
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); return }
this.serveDownload(req, res, r, rel)
}
/**
* POST /sync/file?token=&peerId=&relationId=
* Header: x-file-rel-path 目标相对路径
* x-file-action=delete 时执行删除 (删除传播)
*/
private async handleSyncFilePost(req: IncomingMessage, url: string, res: ServerResponse) {
const qIdx = url.indexOf('?')
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
const auth = this.authSync(params)
if (!auth.ok) { this.json(res, 403, { ok: false, error: auth.reason || '未授权' }); req.resume(); return }
const relationId = params.get('relationId') || ''
const rel = safeDecode(req.headers['x-file-rel-path']?.toString() || '')
if (!rel) { this.json(res, 400, { ok: false, error: '缺少目标路径' }); req.resume(); return }
const r = this.resolveSyncPath(relationId, rel, params.get('peerId') || '')
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); req.resume(); return }
const action = req.headers['x-file-action']?.toString() || 'upload'
if (action === 'delete') {
req.resume()
await this.removeFile(res, r, rel, req.socket.remoteAddress || '')
return
}
await this.writeUpload(req, res, r, rel)
}
/**
* POST /drive/upload
* 把文件写进对方网盘里的**指定相对路径** (文件夹同步的上行方向)。
* Header:
* x-file-rel-path 目标相对路径 (相对网盘根, 含目录), 必填
* x-file-size 文件大小
* x-file-sha256 可选, 传了就校验内容
* x-file-overwrite 1 = 允许覆盖已存在文件
*/
private async handleDriveUpload(req: IncomingMessage, res: ServerResponse, scope?: string) {
const rel = safeDecode(req.headers['x-file-rel-path']?.toString() || '')
// 目录穿越防护: 目标必须严格在网盘根内 (有 scope 时还要在 scope 内)
const r = this.resolveDrivePath(rel, scope)
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); req.resume(); return }
await this.writeUpload(req, res, r, rel)
}
/** 真正把请求体写成文件 (网盘和同步区共用) */
private async writeUpload(
req: IncomingMessage, res: ServerResponse, r: { abs: string; root: string }, rel: string,
) {
const size = Number(req.headers['x-file-size'] || '0') || 0
const expectHash = safeDecode(req.headers['x-file-sha256']?.toString() || '')
const overwrite = req.headers['x-file-overwrite']?.toString() === '1'
if (!rel) { this.json(res, 400, { ok: false, error: '缺少目标路径' }); req.resume(); return }
if (size > MAX_SIZE) {
this.json(res, 413, { ok: false, error: `文件超过限制 ${fmtSize(MAX_SIZE)}` })
req.resume()
return
}
if (r.abs === r.root) { this.json(res, 400, { ok: false, error: '非法路径' }); req.resume(); return }
const exists = existsSync(r.abs)
if (exists && !overwrite) {
this.json(res, 409, { ok: false, error: '目标已存在' })
req.resume()
return
}
// 先写 .tmp 再 rename, 中途崩了不会留半拉文件
const tmpDir = join(r.root, '.tmp')
await mkdir(tmpDir, { recursive: true })
const tmpTarget = join(tmpDir, `sync-${randomUUID()}.part`)
const written = await this.writeBodyToFile(req, tmpTarget, size)
if (!written.ok) {
try { await unlink(tmpTarget) } catch {}
this.json(res, written.status, { ok: false, error: written.error })
return
}
if (expectHash) {
const actual = hashFileSync(tmpTarget)
if (actual !== expectHash) {
try { await unlink(tmpTarget) } catch {}
this.json(res, 400, { ok: false, error: '内容校验失败 (hash 不一致)' })
return
}
}
try {
await mkdir(resolve(r.abs, '..'), { recursive: true })
await rename(tmpTarget, r.abs)
} catch (e: any) {
try { await unlink(tmpTarget) } catch {}
this.json(res, 500, { ok: false, error: `保存失败: ${e.message}` })
return
}
let finalSize = 0
let finalMtime = Date.now()
try { const s = statSync(r.abs); finalSize = s.size; finalMtime = s.mtimeMs } catch {}
this.emit('driveUploaded', {
relPath: rel, size: finalSize, fromAddr: req.socket.remoteAddress || '',
})
this.json(res, 200, { ok: true, relPath: rel, size: finalSize, mtime: finalMtime })
}
/**
* POST /drive/delete?token=&path=
* 删掉网盘里的一个文件 —— 这是**删除传播**的落点:
* 我这边删了文件 -> 同步时通知对方把它那份也删掉。
* 路径同样受 scope 限制 (只许删关系专属目录里的东西)。
* 文件本来就不存在 -> 幂等返回成功 (删除传播要能重复执行)。
*/
private async handleDriveDelete(req: IncomingMessage, url: string, res: ServerResponse, scope?: string) {
req.resume()
const qIdx = url.indexOf('?')
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
const rel = params.get('path') || ''
if (!rel) { this.json(res, 400, { ok: false, error: '缺少目标路径' }); return }
const r = this.resolveDrivePath(rel, scope)
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); return }
await this.removeFile(res, r, rel, req.socket.remoteAddress || '')
}
/** 真正删文件 (网盘和同步区共用)。文件本来就不存在 -> 幂等返回成功 */
private async removeFile(
res: ServerResponse, r: { abs: string; root: string }, rel: string, fromAddr: string,
) {
if (r.abs === r.root) { this.json(res, 400, { ok: false, error: '非法路径' }); return }
let st: any
try { st = statSync(r.abs) } catch {
this.json(res, 200, { ok: true, relPath: rel, deleted: false, reason: '文件不存在' })
return
}
if (st.isDirectory()) { this.json(res, 400, { ok: false, error: '不能删除目录' }); return }
try {
await unlink(r.abs)
} catch (e: any) {
this.json(res, 500, { ok: false, error: `删除失败: ${e?.message || e}` })
return
}
this.emit('driveDeleted', { relPath: rel, size: st.size, fromAddr })
this.json(res, 200, { ok: true, relPath: rel, deleted: true })
}
/**
* GET /sync/manifest?token=&peerId=&relationId=
* 对方来拉我的文件关系表 (含墓碑 + 序号)。
* 同步的两边各维护一份自己的视角, 靠这个接口互换。
*/
private async handleSyncManifestGet(url: string, res: ServerResponse) {
const qIdx = url.indexOf('?')
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
const token = params.get('token')
const peerId = params.get('peerId')
const relationId = params.get('relationId') || ''
if (!this.drive || !this.drive.isDriveEnabled()) {
this.json(res, 404, { ok: false, error: '网盘未开启' })
return
}
const auth = this.authSync(params)
if (!auth.ok) { this.json(res, 403, { ok: false, error: auth.reason || '令牌无效' }); return }
if (!this.drive.getSyncManifest) { this.json(res, 404, { ok: false, error: '不支持关系表' }); return }
const manifest = this.drive.getSyncManifest({ peerId: peerId || '', relationId })
if (!manifest) { this.json(res, 404, { ok: false, error: '关系不存在' }); return }
this.json(res, 200, { ok: true, manifest })
}
/**
* POST /sync/manifest?token=&peerId=&relationId=
* 对方把它的文件关系表推给我 -> 我这边应用 (按墓碑删本地文件等)。
* 这是删除能跨机器传播的关键一步。
*/
private async handleSyncManifestPost(req: IncomingMessage, url: string, res: ServerResponse) {
const qIdx = url.indexOf('?')
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
const token = params.get('token')
const peerId = params.get('peerId')
const relationId = params.get('relationId') || ''
if (!this.drive || !this.drive.isDriveEnabled()) {
this.json(res, 404, { ok: false, error: '网盘未开启' })
req.resume()
return
}
const auth = this.authSync(params)
if (!auth.ok) { this.json(res, 403, { ok: false, error: auth.reason || '令牌无效' }); req.resume(); return }
if (!this.drive.onSyncManifest) { this.json(res, 404, { ok: false, error: '不支持关系表' }); req.resume(); return }
const body = await readJsonBody(req)
req.resume()
if (!body || !body.relationId) { this.json(res, 400, { ok: false, error: '关系表为空' }); return }
const r = this.drive.onSyncManifest({ peerId: peerId || '', relationId, wire: body })
this.json(res, 200, { ok: true, applied: r.applied || [], conflicts: r.conflicts || [] })
}
// 把请求体写进文件, 返回 { ok, status, error }
private writeBodyToFile(
req: IncomingMessage, target: string, declaredSize: number,
): Promise<{ ok: boolean; status: number; error?: string }> {
return new Promise((resolve) => {
const ws = createWriteStream(target)
const hash = createHash('sha256')
const limit = Math.max(MAX_SIZE, declaredSize || 0)
let written = 0
let aborted = false
const fail = (status: number, error: string) => {
if (aborted) return
aborted = true
try { ws.destroy() } catch {}
try { req.destroy() } catch {}
resolve({ ok: false, status, error })
}
req.on('data', (chunk: Buffer) => {
written += chunk.length
hash.update(chunk)
if (written > limit) { fail(413, `文件过大, 已超过 ${fmtSize(limit)}`); return }
if (!ws.write(chunk)) { req.pause(); ws.once('drain', () => req.resume()) }
})
req.on('aborted', () => fail(400, '传输中断'))
ws.on('error', (e) => fail(500, `写入失败: ${e?.message || e}`))
ws.on('finish', () => resolve({ ok: true, status: 200 }))
req.on('end', () => ws.end())
})
}
private async handleUpload(req: IncomingMessage, res: ServerResponse) {
const fileId = req.headers['x-file-id']?.toString() || randomUUID()
// HTTP header 是 ASCII, 非 ASCII 字符 (中文文件名等) 发送前 encodeURIComponent
+28 -2
View File
@@ -19,6 +19,9 @@ import { ForwardManager } from './remote/forward'
import { UsbBridgeManager } from './remote/usb'
import { registerIpc, bindNetworkContext, collectWsState } from './ipc'
import { listDevices, totalUnread, getUnread, pruneAudit } from './db'
import { RelationStore } from './relations'
import { ManifestStore } from './sync-manifest'
import { RelationService } from './relation-service'
import { setBadgeCount } from './notify'
import { DEFAULT_PORTS, PROTOCOL_VERSION } from './protocol'
import type { DeviceInfo } from './protocol'
@@ -60,10 +63,27 @@ async function main() {
const discovery = new Discovery(self)
const chatServer = new ChatServer(DEFAULT_PORTS.chat)
const chatClient = new ChatClient(self)
// 关系层: 设备之间的持久绑定 + 分级授权 (网盘/同步都按关系授权)
const relations = new RelationStore(join(paths.appData, 'relations.json'))
const manifestStore = new ManifestStore(join(paths.appData, 'sync-manifest.json'))
const relationService = new RelationService({
store: relations,
manifestStore,
self,
send: (peerId, frame) => chatClient.send(frame, peerId),
notify: (event, payload) => broadcastToRenderer(event, payload),
})
const fileServer = new FileServer(DEFAULT_PORTS.file, ensureDownloadDir(), {
getDriveRoot: () => ensureDriveDir(),
getDriveToken: () => getDriveToken(),
isDriveEnabled: () => getSettings().driveEnabled !== false,
// 新版: 按对方签发的 per-peer 令牌鉴权 (关系表 + 授权范围)
authorize: (token, peerId) => relationService.authorizeDrive(token, peerId),
getSyncScope: (relationId, peerId) => relationService.getSyncScope(relationId, peerId),
getSyncManifest: ({ relationId, peerId }) => relationService.getSyncManifest(relationId, peerId),
onSyncManifest: ({ relationId, peerId, wire }) => relationService.applyPeerManifest(relationId, wire, peerId),
})
const terminal = new TerminalManager(chatClient, self.deviceId)
const forward = new ForwardManager(chatClient)
@@ -86,7 +106,10 @@ async function main() {
)
await discovery.start()
bindNetworkContext({ self, discovery, chatServer, chatClient, fileServer, terminal, forward, usb })
bindNetworkContext({
self, discovery, chatServer, chatClient, fileServer, terminal, forward, usb,
relations, manifestStore, relationService,
})
// 已入库的设备: 启动后主动尝试连接
for (const d of listDevices()) {
@@ -134,7 +157,10 @@ function buildSelf(): DeviceInfo {
chatPort: 0,
filePort: 0,
ts: Date.now(),
driveToken: getDriveToken(),
// 注意: 这里**故意不带 driveToken**。
// 网盘访问改成"按对方签发的 per-peer 令牌"(关系层), 全局 token 不再明文广播,
// 否则同网段任何进程拿到广播包就能浏览你的整个网盘。
// 老版本对端仍可以用本机已缓存的旧 token 访问 (渐进式升级)。
}
}
+236 -15
View File
@@ -18,6 +18,11 @@ import { ChatServer } from './chat-server'
import { ChatClient } from './chat-client'
import { FileServer, uploadFile, uploadBuffer } from './file-server'
import { listDrive, getDriveRoot, downloadDriveFile, collectDriveFiles } from './drive'
import { scanSync, applySyncItems, type SyncApplyItem } from './sync'
import { RelationStore, type Grant } from './relations'
import { ManifestStore } from './sync-manifest'
import { AutoSync } from './auto-sync'
import { RelationService } from './relation-service'
import { broadcastToRenderer, focusMainWindow } from './window'
import { rebuildMenu } from './tray'
import { notify, setBadgeCount } from './notify'
@@ -26,7 +31,7 @@ import { ForwardManager } from './remote/forward'
import { UsbBridgeManager } from './remote/usb'
import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval'
import { computeWsState } from './ws-state'
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig } from './protocol'
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig, WsFrame } from './protocol'
import { DEFAULT_PORTS } from './protocol'
interface NetCtx {
@@ -38,6 +43,11 @@ interface NetCtx {
terminal: TerminalManager
forward: ForwardManager
usb: UsbBridgeManager
relations: RelationStore
manifestStore: ManifestStore
relationService: RelationService
/** 自动同步 (在 bindNetworkContext 里创建) */
autoSync?: AutoSync
}
let ctx: NetCtx | null = null
@@ -93,8 +103,60 @@ function disarmPendingMetaTimer(fileId: string) {
}
}
/**
* 找一个对端 (优先用在线的 discovery 信息, 离线时退回设备表)。
* 有关系时把 driveToken 换成关系签发的 per-peer 令牌。
*/
function findPeer(peerId: string): DeviceInfo | null {
const p = ctx?.discovery.getPeers().find(x => x.deviceId === peerId)
if (p) {
// 有关系时用关系签发的 per-peer 令牌 (广播包里已经不再带全局 driveToken 了)
const rel = ctx?.relations.getActiveRelationByPeer(peerId)
if (rel?.peerToken) return { ...(p as DeviceInfo), driveToken: rel.peerToken }
return p as DeviceInfo
}
const d = listDevices().find(x => x.device_id === peerId) as any
if (!d) return null
return {
deviceId: d.device_id, name: d.name, hostname: d.hostname, platform: d.platform,
version: d.version, appVersion: d.app_version, address: d.last_ip || '127.0.0.1',
chatPort: DEFAULT_PORTS.chat, filePort: DEFAULT_PORTS.file, ts: d.last_seen,
driveToken: d.drive_token || undefined,
} as DeviceInfo
}
// 关系对渲染层的视图: **剥掉令牌** (界面不需要, 也不该拿到凭据)
function publicRelation(r: any) {
if (!r) return r
const { myToken, peerToken, ...rest } = r
return rest
}
// 关系帧路由: 建立 / 接受 / 拒绝 (两个方向都走这里)
// 返回 true = 这个帧已经处理完了, 调用方不用再管
function routeRelationFrame(peer: DeviceInfo, frame: WsFrame): boolean { if (frame.type === 'relation:request') {
ctx?.relationService.handleRelationRequest(peer, frame.payload)
.catch((e) => console.warn('[relation] handle request error:', e?.message))
return true
}
if (frame.type === 'relation:accept') { ctx?.relationService.handleRelationAccept(peer, frame.payload); return true }
if (frame.type === 'relation:reject') { ctx?.relationService.handleRelationReject(peer, frame.payload); return true }
if (frame.type === 'relation:revoked') { ctx?.relationService.handleRelationRevoked(peer, frame.payload.relationId); return true }
return false
}
export function bindNetworkContext(c: NetCtx) {
ctx = c
// 自动同步: 关系开了开关就定时跑一轮 (冲突跳过, 不静默覆盖)
c.autoSync = new AutoSync({
relations: c.relations,
manifestStore: c.manifestStore,
findPeer: (peerId) => findPeer(peerId),
notify: (event, payload) => broadcastToRenderer(event, payload),
})
c.autoSync.start()
// 网络事件 -> 渲染进程
c.discovery.on('found', (d) => {
// 用户已删除的设备 (ignored=1) — 静默忽略, 不进 sidebar, 不重连
@@ -213,6 +275,7 @@ export function bindNetworkContext(c: NetCtx) {
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
return
}
if (routeRelationFrame(peer, frame)) return
})
// chat-server 收到的所有 ws 帧 (来自其他设备的入站)
@@ -233,6 +296,7 @@ export function bindNetworkContext(c: NetCtx) {
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
return
}
if (routeRelationFrame(peer, frame)) return
})
c.chatServer.on('connect', (_ws, peer) => {
@@ -242,6 +306,8 @@ export function bindNetworkContext(c: NetCtx) {
broadcastToRenderer('device:online', { deviceId: peer.deviceId, address: peer.address })
// 对端上线 -> 触发离线消息 flush
flushPendingFor(peer.deviceId)
// 对端上线 -> 立刻试一次同步 (不用等下一个轮询周期)
c.autoSync?.trigger()
})
// chat-client WS 状态不广播给渲染层, 渲染层只看 discovery 的 online/offline
@@ -298,6 +364,18 @@ export function bindNetworkContext(c: NetCtx) {
broadcastToRenderer('drive:inbound', { name: info.name, size: info.size, peerId: peer?.deviceId, peerName: who })
})
// 对端往我的网盘上传了文件 (文件夹同步的上行) -> 本机提示 + 审计
c.fileServer.on('driveUploaded', (info: { relPath: string; size: number; fromAddr: string }) => {
const peer = ctx?.discovery.getPeers().find(p => p.address === info.fromAddr)
const who = peer ? peer.name : info.fromAddr || '某台设备'
recordAudit({
action: 'drive.upload', source: peer?.deviceId || info.fromAddr,
result: 'ok',
payload: { size: info.size, path: info.relPath },
})
broadcastToRenderer('drive:inbound', { name: basename(info.relPath), size: info.size, peerId: peer?.deviceId, peerName: who })
})
// 对端发起撤回 — 本地默默删 (含磁盘文件), 不弹框不提示
c.chatServer.on('recall', (from, messageId) => {
const row = getMessage(messageId)
@@ -1034,23 +1112,13 @@ export function registerIpc() {
// ===== 网盘 (浏览对方共享目录 + 选文件下载) =====
// 找 peer: 优先用 discovery 的在线列表, 退回 DB 里的历史记录
function findPeer(peerId: string): DeviceInfo | null {
const p = ctx?.discovery.getPeers().find(x => x.deviceId === peerId)
if (p) return p as DeviceInfo
const d = listDevices().find(x => x.device_id === peerId) as any
if (!d) return null
return {
deviceId: d.device_id, name: d.name, hostname: d.hostname, platform: d.platform,
version: d.version, appVersion: d.app_version, address: d.last_ip || '127.0.0.1',
chatPort: DEFAULT_PORTS.chat, filePort: DEFAULT_PORTS.file, ts: d.last_seen,
driveToken: d.drive_token || undefined,
} as DeviceInfo
}
ipcMain.handle('drive:list', async (_e, args: { peerId: string; path?: string }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备' }
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
return listDrive(peer, args.path || '')
})
@@ -1058,6 +1126,7 @@ export function registerIpc() {
if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备' }
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
return getDriveRoot(peer)
})
@@ -1127,6 +1196,150 @@ export function registerIpc() {
return r.filePaths[0]
})
// ===== 文件夹同步 (本地目录 <-> 对方网盘目录) =====
ipcMain.handle('sync:pickLocalDir', async () => {
const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
if (!w) return null
const cur = getSettings().syncLocalDir
const r = await dialog.showOpenDialog(w, {
properties: ['openDirectory', 'createDirectory'],
defaultPath: cur && existsSync(cur) ? cur : undefined,
})
if (r.canceled || !r.filePaths[0]) return null
return r.filePaths[0]
})
// 扫描两边, 生成变更列表 (只读磁盘; 关系表会被更新, 但那只是记录"我这边变了")
ipcMain.handle('sync:scan', async (_e, args: {
peerId: string; localDir?: string; remotePath?: string; relationId?: string
}) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备' }
const rel = args.relationId ? ctx.relations.getRelation(args.relationId) : undefined
// 有关系就用关系签发的 per-peer 令牌; 没有关系退回旧的全局 token (老版本对端)
let token = ''
if (rel && rel.state === 'active') {
if (!rel.peerToken) return { ok: false, reason: '关系令牌缺失, 请重新建立关系' }
token = rel.peerToken
} else {
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
token = peer.driveToken
}
// 有关系时用**实际同步根** (顶层目录 + 按关系隔离的子目录)
const localDir = args.localDir || (rel ? ctx.relationService.localRootOf(rel) : getSettings().syncLocalDir)
if (!localDir) return { ok: false, reason: '还没选本地目录' }
const remotePath = args.remotePath ?? (rel ? ctx.relationService.remotePathOf(rel) : (getSettings().syncRemotePath ?? ''))
const r = await scanSync({
peer, localDir, remotePath, token,
relationId: rel?.relationId,
store: ctx.manifestStore,
selfDeviceId: ctx.self.deviceId,
})
if (r.ok && !rel) {
updateSettings({ syncLocalDir: r.localDir, syncRemotePath: r.remotePath })
}
return r
})
// 执行用户勾选的同步项
ipcMain.handle('sync:apply', async (_e, args: {
peerId: string; localDir?: string; remotePath?: string; relationId?: string;
items: SyncApplyItem[]
}) => {
if (!ctx) return { ok: false, reason: 'no ctx', results: [], done: 0, failed: 0 }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备', results: [], done: 0, failed: 0 }
const rel = args.relationId ? ctx.relations.getRelation(args.relationId) : undefined
let token = ''
if (rel && rel.state === 'active') {
if (!rel.peerToken) return { ok: false, reason: '关系令牌缺失, 请重新建立关系', results: [], done: 0, failed: 0 }
token = rel.peerToken
} else {
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系', results: [], done: 0, failed: 0 }
token = peer.driveToken
}
// 有关系时用**实际同步根** (顶层目录 + 按关系隔离的子目录)
const localDir = args.localDir || (rel ? ctx.relationService.localRootOf(rel) : getSettings().syncLocalDir)
if (!localDir) return { ok: false, reason: '还没选本地目录', results: [], done: 0, failed: 0 }
const remotePath = args.remotePath ?? (rel ? ctx.relationService.remotePathOf(rel) : (getSettings().syncRemotePath ?? ''))
const items = (args.items || []).filter(i => i && i.relPath)
const r = await applySyncItems({
peer, localDir, remotePath, token,
relationId: rel?.relationId,
store: ctx.manifestStore,
selfDeviceId: ctx.self.deviceId,
items,
onProgress: (relPath, sent, total) => {
broadcastToRenderer('sync:progress', { peerId: args.peerId, relPath, sent, total })
},
})
recordAudit({
action: 'sync.apply', target: args.peerId,
result: r.failed === 0 ? 'ok' : 'error',
payload: { count: items.length, done: r.done, failed: r.failed, localDir, remotePath },
})
return r
})
// ===== 关系 (同步盘) =====
//
// 关系 = 两个设备之间的持久绑定 + 分级授权。
// 建立方式跟远程终端一样: **对方直接弹窗**, 不用邀请码。
ipcMain.handle('relation:list', () => (ctx?.relations.listRelations() || []).map(publicRelation))
// 发起建立关系 -> 对方那边弹窗
ipcMain.handle('relation:request', (_e, args: {
peerId: string; syncLocalDir: string; syncRemotePath?: string; grants: Grant[]
}) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备' }
return ctx.relationService.requestRelation({
peerId: peer.deviceId,
peerName: peer.name,
peerAddress: peer.address,
peerChatPort: peer.chatPort,
peerFilePort: peer.filePort,
syncLocalDir: args.syncLocalDir || '',
syncRemotePath: args.syncRemotePath || '',
grants: (args.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync'),
})
})
// 改我开放给对方的能力
ipcMain.handle('relation:setGrants', (_e, args: { relationId: string; grants: Grant[] }) => {
const grants = (args.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync')
const r = ctx?.relations.setMyGrants(args.relationId, grants)
return { ok: !!r, relation: publicRelation(r) }
})
// 改同步配置 (本地目录 / 对方网盘子目录 / 自动同步开关)
ipcMain.handle('relation:setSync', (_e, args: {
relationId: string; syncLocalDir?: string; syncRemotePath?: string; syncEnabled?: boolean
}) => {
const patch: any = {}
if (args.syncLocalDir !== undefined) patch.syncLocalDir = args.syncLocalDir
if (args.syncRemotePath !== undefined) patch.syncRemotePath = args.syncRemotePath
if (args.syncEnabled !== undefined) patch.syncEnabled = !!args.syncEnabled
const r = ctx?.relations.setSyncConfig(args.relationId, patch)
if (r?.syncEnabled) ctx?.autoSync?.trigger(r.relationId)
return { ok: !!r, relation: publicRelation(r) }
})
// 吊销关系 (令牌立刻失效)
ipcMain.handle('relation:revoke', (_e, args: { relationId: string }) =>
ctx?.relationService.revokeRelation(args.relationId) || { ok: false, reason: 'no ctx' })
// 彻底删除关系记录
ipcMain.handle('relation:delete', (_e, args: { relationId: string }) => {
const ok = ctx?.relations.deleteRelation(args.relationId) || false
try { ctx?.manifestStore.deleteManifest(args.relationId) } catch {}
return { ok }
})
// ---- 应用控制 ----
ipcMain.handle('app:hide', () => {
const w = BrowserWindow.getAllWindows()[0]
@@ -1284,8 +1497,16 @@ export function registerIpc() {
ipcMain.handle('remote:approval:list', () => listPendingApprovals())
ipcMain.handle('remote:approval:reply', (_e, args: { requestId: string; ok: boolean; remember?: boolean }) => {
return replyApproval(args.requestId, args.ok, args.remember)
ipcMain.handle('remote:approval:reply', (_e, args: {
requestId: string; ok: boolean; remember?: boolean
grants?: string[]; syncLocalDir?: string; syncRemotePath?: string
}) => {
return replyApproval(args.requestId, args.ok, {
remember: args.remember,
grants: args.grants,
syncLocalDir: args.syncLocalDir,
syncRemotePath: args.syncRemotePath,
})
})
ipcMain.handle('audit:list', (_e, args: { limit?: number; since?: number; sessionId?: string } = {}) => {
+60
View File
@@ -41,6 +41,66 @@ export type WsFrame =
| { type: 'terminal'; payload: TerminalFrame } // 远程终端 (双向多路复用)
| { type: 'forward'; payload: ForwardFrame } // 端口转发
| { type: 'usb'; payload: UsbFrame } // USB 设备透传
| { type: 'relation:request'; payload: RelationRequestFrame } // 我想跟你建立同步关系
| { type: 'relation:accept'; payload: RelationAcceptFrame } // 我同意了
| { type: 'relation:reject'; payload: RelationRejectFrame } // 我拒绝了
| { type: 'relation:revoked'; payload: RelationRevokedFrame } // 我把关系吊销了 (你那边也作废)
// ============ 关系 (同步盘) ============
//
// 关系 = 两个设备之间的持久绑定 + 分级授权。
// 建立方式跟远程终端的授权闸门一致: **对方直接弹窗**, 不用邀请码。
//
// A 点"建立同步关系" -> 选本地目录 -> 发 relation:request
// B 那边弹窗 (授权范围 + B 自己的本地目录) -> 同意 -> relation:accept
// A 收到 accept -> 双方各自落库, 交换 per-peer 令牌 -> 之后按关系授权访问网盘
//
// 令牌:
// token = 发起方签发的令牌, 对方用它访问发起方的网盘/同步区
// 两边各签一个, 各自保存对方的那个 (myToken / peerToken)
export interface RelationPeerInfo {
deviceId: string
name: string
address: string
chatPort: number
filePort: number
}
export interface RelationRequestFrame {
relationId: string
/** 我签发的令牌 (对方用它访问我的网盘) */
token: string
/** 我开放给对方的能力: 'drive' | 'sync' */
grants: string[]
/** 我的同步目录 (本机绝对路径) */
syncLocalDir: string
/** 我想用你网盘里的哪个子目录 */
syncRemotePath: string
peer: RelationPeerInfo
}
export interface RelationAcceptFrame {
relationId: string
/** 对方签发的令牌 (我用它访问对方网盘) */
token: string
/** 对方开放给我的能力 */
grants: string[]
/** 对方的同步目录 (本机绝对路径) */
syncLocalDir: string
/** 对方想用我网盘里的哪个子目录 */
syncRemotePath: string
peer: RelationPeerInfo
}
export interface RelationRejectFrame {
relationId: string
reason?: string
}
export interface RelationRevokedFrame {
relationId: string
}
// ============ 远程终端 ============
+501
View File
@@ -0,0 +1,501 @@
// 关系服务: 建立/接受/吊销关系 + 应用对方推来的文件关系表
//
// 交互流程 (跟远程终端的授权闸门一个路子 —— **对方直接弹窗**, 不用邀请码):
//
// A: 同步面板点"建立同步关系" -> 选本地目录 -> 勾选我要开放的能力
// -> requestRelation() -> 发 relation:request 帧
// B: 收到帧 -> 弹窗 (对方要什么 + B 自己的本地目录 + B 开放什么)
// -> 用户同意 -> 回 relation:accept 帧
// A: 收到 accept -> 关系落库 (active) -> 通知界面
// B: 拒绝 -> 回 relation:reject -> A 侧把 pending 关系撤掉
//
// 双方各自保存: 对方是谁 / 我开放了什么 / 对方开放了什么 / 两个同步目录 / 对方的令牌。
//
// 目录约定 (对称, 不用协商):
// syncRemotePath = "我在**对方**网盘里用的子目录" (我这侧视角)
// peerSyncRoot = "对方在我网盘里用的子目录" (我这侧视角)
// 发起方提议自己的 syncRemotePath; 接受方可以改, 并给出自己的 syncRemotePath。
// 于是发起方: syncRemotePath = 自己提的, peerSyncRoot = 对方给的
// 接受方: syncRemotePath = 自己给的, peerSyncRoot = 对方提的
//
// 删除传播的落点在 applyPeerManifest(): 对方说"这个文件我删了"(墓碑),
// 我这边比对基线确认自己没改过 -> 删本地文件; 改过 -> 报冲突, 不静默丢数据。
//
// 注意: 这是**类**不是单例 —— 一个进程里要能跑多个节点 (自测就是 A/B 两个虚拟设备)。
import { randomUUID } from 'node:crypto'
import { existsSync, mkdirSync, unlinkSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import {
RelationStore, newRelationToken, authorizeDriveAccess, normRelDir,
type Relation, type Grant,
} from './relations'
import {
ManifestStore, toWire, fromWire, mergePeer, advanceClock, reconcileLocal, markLocalDeleted,
type ManifestWire,
} from './sync-manifest'
import { scanLocalDir } from './sync'
import { enqueueApproval } from './remote/approval'
import { recordAudit } from './db'
import type { DeviceInfo, WsFrame, RelationRequestFrame, RelationAcceptFrame, RelationRejectFrame } from './protocol'
// ============ 工具 ============
function peerInfoOf(d: DeviceInfo) {
return {
deviceId: d.deviceId,
name: d.name,
address: d.address,
chatPort: d.chatPort,
filePort: d.filePort,
}
}
/**
* 我在**对方网盘**里用的默认子目录: 同步/<我的名字>。
* 这样在对方网盘里一眼能看出这个目录是谁的数据 ("同步/我的电脑" = 我放过去的)。
*/
function defaultRemotePath(selfName: string): string {
const safe = (selfName || '设备').replace(/[\\/:*?"<>|]/g, '_').trim() || '设备'
return `同步/${safe}`
}
/** 把相对路径解析到本地同步目录内, 挡住目录穿越 */
function localTarget(localDir: string, relPath: string): string | null {
const root = resolve(localDir)
const clean = relPath.replace(/^[/\\]+/, '')
const abs = resolve(join(root, clean))
if (abs !== root && !abs.startsWith(root + sep)) return null
return abs
}
// ============ 服务 ============
/** 同一个对端两次关系授权弹窗的最小间隔 (防重复弹窗骚扰) */
const ASK_COOLDOWN_MS = 60_000
export interface RelationServiceOptions {
store: RelationStore
manifestStore: ManifestStore
self: DeviceInfo
/** 发 WS 帧给某个设备 */
send: (peerId: string, frame: WsFrame) => boolean
/** 通知渲染层 */
notify: (event: string, payload?: any) => void
}
export class RelationService {
private store: RelationStore
private manifestStore: ManifestStore
private self: DeviceInfo
private send: (peerId: string, frame: WsFrame) => boolean
private notify: (event: string, payload?: any) => void
/** peerId -> 上次弹出关系授权请求的时间 (防重复弹窗骚扰) */
private lastAskAt = new Map<string, number>()
constructor(opts: RelationServiceOptions) {
this.store = opts.store
this.manifestStore = opts.manifestStore
this.self = opts.self
this.send = opts.send
this.notify = opts.notify
}
get relations(): RelationStore { return this.store }
get manifests(): ManifestStore { return this.manifestStore }
/**
* 某个关系在我这边的同步目录 (绝对路径)。
* 服务端用它把 /sync/file 的所有路径限制在这个目录内 ——
* 用户的同步目录可以是任意文件夹, 不必待在网盘目录里面。
*
* peerId 必须匹配: 否则 A 关系的对方能拿自己的令牌来读 B 关系的同步目录。
*/
getSyncScope(relationId: string, peerId?: string): string | null {
const rel = this.store.getRelation(relationId)
if (!rel || rel.state !== 'active') return null
if (peerId && rel.peerId !== peerId) return null
if (!rel.syncLocalDir) return null
return this.localRootOf(rel)
}
/** 对方网盘里给我预留的子目录 (用来算我请求的路径) */
remotePathOf(rel: Relation): string {
return normRelDir(rel.syncRemotePath)
}
/** 我的网盘里给对方预留的子目录 (授权范围) */
scopeRootOf(rel: Relation): string {
return normRelDir(rel.peerSyncRoot)
}
/**
* 我这边的**实际同步根** = 用户选的顶层目录 + 按关系隔离的子目录。
* (顶层目录管多个聊天备份时, 每个关系在自己的子目录里, 互不干扰)
*/
localRootOf(rel: Relation): string {
const base = resolve(rel.syncLocalDir || '')
const sub = normRelDir(rel.syncSubdir || '')
const root = sub ? join(base, sub) : base
// 顺手把目录建好: 关系子目录是自动分配的, 用户不会提前建
try { mkdirSync(root, { recursive: true }) } catch { /* 建不了就让它在扫描时报错 */ }
return root
}
/**
* 给某个 peer 挑一个不冲突的子目录名 (默认对方设备名, 重名加 id 前缀)。
* 顶层目录相同也不怕 —— 子目录不同, 物理上就隔开了。
*/
private pickSubdir(peerName: string, peerId: string, localDir: string): string {
const safe = (peerName || '设备').replace(/[\\/:*?"<>|]/g, '_').trim() || '设备'
const base = resolve(localDir || '')
const used = new Set(
this.store.listActiveRelations()
.filter(r => r.syncLocalDir && r.syncSubdir)
.map(r => `${resolve(r.syncLocalDir)}::${r.syncSubdir}`),
)
if (!used.has(`${base}::${safe}`)) return safe
const withId = `${safe}_${(peerId || '').slice(0, 4)}`
if (!used.has(`${base}::${withId}`)) return withId
return `${withId}_${randomUUID().slice(0, 4)}`
}
// ---- 发起 ----
requestRelation(args: {
peerId: string
peerName: string
peerAddress?: string
peerChatPort?: number
peerFilePort?: number
/** 我这边的同步目录 */
syncLocalDir: string
/** 我想在对方网盘里用的子目录 */
syncRemotePath: string
/** 我开放给对方的能力 */
grants: Grant[]
}): { ok: boolean; reason?: string; relationId?: string } {
if (!args.syncLocalDir) return { ok: false, reason: '请先选择本机同步目录' }
// 顶层目录**可以**和别的关系共用 —— 每个关系在自己的子目录里, 物理上隔开:
// ~/备份/MECHREVO/ <- 和 MECHREVO 同步的数据
// ~/备份/lenovo/ <- 和 lenovo 同步的数据
// 这样"一个目录管多个聊天备份"成立, 两条数据也不会互相覆盖。
const subdir = this.pickSubdir(args.peerName, args.peerId, args.syncLocalDir)
const relationId = randomUUID()
const token = newRelationToken()
const t = Date.now()
const rel: Relation = {
relationId,
peerId: args.peerId,
peerName: args.peerName || '',
peerAddress: args.peerAddress || '',
peerChatPort: args.peerChatPort || 0,
peerFilePort: args.peerFilePort || 0,
state: 'pending',
myGrants: args.grants,
peerGrants: [],
myToken: token,
peerToken: '',
syncLocalDir: args.syncLocalDir,
syncSubdir: subdir,
syncRemotePath: normRelDir(args.syncRemotePath) || defaultRemotePath(this.self.name),
peerSyncRoot: '',
syncEnabled: false,
localSeq: 0,
remoteSeq: 0,
createdAt: t,
updatedAt: t,
lastSyncAt: 0,
lastError: '',
}
this.store.upsertRelation(rel)
const payload: RelationRequestFrame = {
relationId,
token,
grants: args.grants,
syncLocalDir: args.syncLocalDir,
syncRemotePath: rel.syncRemotePath,
peer: peerInfoOf(this.self),
}
if (!this.send(args.peerId, { type: 'relation:request', payload })) {
this.store.deleteRelation(relationId)
return { ok: false, reason: '对方不在线, 请求没发出去' }
}
recordAudit({
action: 'relation.request', source: this.self.deviceId, target: args.peerId,
result: 'ok', payload: { relationId, grants: args.grants, syncLocalDir: args.syncLocalDir },
})
this.notify('relation:pending', { relationId, peerId: args.peerId, peerName: args.peerName })
return { ok: true, relationId }
}
// ---- 对方来请求 (我这边弹窗) ----
async handleRelationRequest(peer: DeviceInfo, frame: RelationRequestFrame): Promise<void> {
// 已经有关系了就别重复弹 (直接回绝)
const existing = this.store.getActiveRelationByPeer(peer.deviceId)
if (existing) {
this.send(peer.deviceId, { type: 'relation:reject', payload: { relationId: frame.relationId, reason: '已建立关系' } })
return
}
// relationId 由发起方指定 -> 不校验的话, 对方可以用一个已占用的 id
// 把**别的关系整个顶掉**(原 peer 立刻失去访问, 配置被清空)。
const occupied = this.store.getRelation(frame.relationId)
if (occupied) {
this.send(peer.deviceId, { type: 'relation:reject', payload: { relationId: frame.relationId, reason: '关系标识冲突' } })
return
}
// 防骚扰: WS 连接是"发现即建立"的, 同网段任何设备都能反复发请求刷弹窗。
// 同一个对端 60s 内只弹一次, 之后的直接回绝。
const last = this.lastAskAt.get(peer.deviceId) || 0
if (Date.now() - last < ASK_COOLDOWN_MS) {
this.send(peer.deviceId, { type: 'relation:reject', payload: { relationId: frame.relationId, reason: '请求过于频繁' } })
return
}
this.lastAskAt.set(peer.deviceId, Date.now())
const reqId = randomUUID()
const detail = `对方想和你建立同步关系 · 它的同步目录: ${frame.syncLocalDir || '(未设置)'}`
const p = enqueueApproval({
requestId: reqId,
peerId: peer.deviceId,
peerName: peer.name,
kind: 'relation',
detail,
ts: Date.now(),
grants: frame.grants,
syncLocalDir: frame.syncLocalDir,
syncRemotePath: frame.syncRemotePath,
relationId: frame.relationId,
})
// 用现有的审批事件名, 渲染层的 ApprovalDialog 直接就能显示 (跟远程终端同一个弹窗)
this.notify('remote:approval:requested', {
requestId: reqId,
peerId: peer.deviceId,
peerName: peer.name,
kind: 'relation',
detail,
grants: frame.grants,
syncLocalDir: frame.syncLocalDir,
syncRemotePath: frame.syncRemotePath,
relationId: frame.relationId,
ts: Date.now(),
})
const verdict = await p
if (!verdict.ok) {
this.send(peer.deviceId, { type: 'relation:reject', payload: { relationId: frame.relationId, reason: '对方拒绝' } })
recordAudit({
action: 'relation.reject', source: peer.deviceId, target: this.self.deviceId,
result: 'denied', payload: { relationId: frame.relationId },
})
this.notify('relation:rejected', { relationId: frame.relationId, peerId: peer.deviceId, byMe: true })
return
}
// 用户勾选的能力 (只认白名单里的)
const grants: Grant[] = (verdict.grants || frame.grants || []).filter(
(g): g is Grant => g === 'drive' || g === 'sync',
)
const myToken = newRelationToken()
// 注意: 这里**不能**回退到 frame.syncLocalDir —— 那是**对方机器上**的路径,
// 在我这边根本不存在。对方没填就留空, 之后在同步面板里补上。
const myLocalDir = verdict.syncLocalDir || ''
const myRemotePath = normRelDir(verdict.syncRemotePath ?? frame.syncRemotePath ?? '') || defaultRemotePath(this.self.name)
const rel: Relation = {
relationId: frame.relationId,
peerId: peer.deviceId,
peerName: peer.name,
peerAddress: peer.address,
peerChatPort: peer.chatPort,
peerFilePort: peer.filePort,
state: 'active',
myGrants: grants,
peerGrants: (frame.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync'),
myToken,
peerToken: frame.token || '',
syncLocalDir: myLocalDir,
syncSubdir: this.pickSubdir(peer.name, peer.deviceId, myLocalDir),
syncRemotePath: myRemotePath,
// 对方提议的目录 = 我网盘里给对方用的那个
peerSyncRoot: normRelDir(frame.syncRemotePath),
syncEnabled: false,
localSeq: 0,
remoteSeq: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
lastSyncAt: 0,
lastError: '',
}
this.store.upsertRelation(rel)
const payload: RelationAcceptFrame = {
relationId: frame.relationId,
token: myToken,
grants,
syncLocalDir: myLocalDir,
syncRemotePath: myRemotePath,
peer: peerInfoOf(this.self),
}
this.send(peer.deviceId, { type: 'relation:accept', payload })
recordAudit({
action: 'relation.accept', source: peer.deviceId, target: this.self.deviceId,
result: 'ok', payload: { relationId: frame.relationId, grants, syncLocalDir: myLocalDir },
})
this.notify('relation:changed', { relationId: frame.relationId, peerId: peer.deviceId, state: 'active' })
}
// ---- 对方接受了 ----
handleRelationAccept(peer: DeviceInfo, frame: RelationAcceptFrame): void {
const rel = this.store.getRelation(frame.relationId)
if (!rel) return
this.store.updateRelation(frame.relationId, {
state: 'active',
peerGrants: (frame.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync'),
peerToken: frame.token || '',
// 对方给的目录 = 我网盘里给对方用的那个
peerSyncRoot: normRelDir(frame.syncRemotePath),
peerName: frame.peer?.name || rel.peerName,
peerAddress: frame.peer?.address || rel.peerAddress,
peerChatPort: frame.peer?.chatPort || rel.peerChatPort,
peerFilePort: frame.peer?.filePort || rel.peerFilePort,
updatedAt: Date.now(),
})
recordAudit({
action: 'relation.established', source: peer.deviceId, target: this.self.deviceId,
result: 'ok', payload: { relationId: frame.relationId, grants: frame.grants },
})
this.notify('relation:changed', { relationId: frame.relationId, peerId: peer.deviceId, state: 'active' })
}
// ---- 对方拒绝了 ----
handleRelationReject(peer: DeviceInfo, frame: RelationRejectFrame): void {
const rel = this.store.getRelation(frame.relationId)
if (rel && rel.state === 'pending') this.store.deleteRelation(frame.relationId)
recordAudit({
action: 'relation.rejected', source: peer.deviceId, target: frame.relationId,
result: 'denied', payload: { reason: frame.reason },
})
this.notify('relation:rejected', { relationId: frame.relationId, peerId: peer.deviceId, byMe: false, reason: frame.reason })
}
// ---- 网盘授权 ----
authorizeDrive(token: string | null, peerId: string | null) {
return authorizeDriveAccess(this.store, token, peerId)
}
// ---- 关系表 (manifest) ----
/** 对方来拉我的关系表 (peerId 必须匹配, 防止跨关系拉表) */
getSyncManifest(relationId: string, peerId?: string): ManifestWire | null {
const rel = this.store.getRelation(relationId)
if (!rel || rel.state !== 'active') return null
if (peerId && rel.peerId !== peerId) return null
return toWire(this.manifestStore.loadManifest(relationId))
}
/**
* 应用对方推来的关系表 —— **删除传播的落点**。
*
* 对方说"这个文件我删了"(墓碑):
* 我这边有 + 从没同步过 (没有基线) -> 不动 (那是两个各自独立的文件)
* 我这边有 + 同步过 + 我改过 -> 冲突, 不静默丢数据
* 我这边有 + 同步过 + 我没改过 -> 删本地文件, 同步记墓碑
*/
applyPeerManifest(relationId: string, wire: ManifestWire, peerId?: string): { applied: string[]; conflicts: string[] } {
const rel = this.store.getRelation(relationId)
if (!rel || rel.state !== 'active') return { applied: [], conflicts: [] }
// peerId 必须匹配: 否则 A 关系的对方能拿自己的令牌来删 B 关系的文件
if (peerId && rel.peerId !== peerId) return { applied: [], conflicts: [] }
const manifest = this.manifestStore.loadManifest(relationId)
// 先收消息 (推高时钟), 再合并字段 —— 和扫描流程保持同一个因果顺序
const peerEntries = fromWire(wire)
advanceClock(manifest, peerEntries)
// **先把本地实际有的文件登记进来**。
// 不登记的话: 对方知道这个文件、我本地也有, 但关系表里没有它的记录 ->
// 我本地把它删了时, 关系表看不出"这是删除" -> 删除传播不出去 (墓碑建不出来)。
try {
// 注意: 必须扫**实际同步根** (顶层目录 + 关系子目录), 用顶层目录会把
// 子目录名写进 relPath (比如 "A机/hello.txt"), 污染关系表。
const local = scanLocalDir(this.localRootOf(rel), true)
if (local.ok) reconcileLocal(manifest, local.files)
} catch { /* 同步目录还没建好就跳过, 下一轮再登记 */ }
mergePeer(manifest, peerEntries)
const applied: string[] = []
const conflicts: string[] = []
for (const [p, e] of Object.entries(manifest.entries)) {
if (e.peerState !== 'deleted') continue
if (e.myState !== 'present') continue // 我这边本来就没有
if (!e.baseHash) continue // 没同步过 -> 不是同一个文件
if (e.myHash && e.myHash !== e.baseHash) {
conflicts.push(p) // 我改过 -> 冲突, 交给用户
continue
}
const target = localTarget(this.localRootOf(rel), p)
if (target) {
try {
if (existsSync(target)) unlinkSync(target)
} catch { /* 删不掉就当没删, 下次再说 */ }
}
markLocalDeleted(manifest, p)
applied.push(p)
}
this.manifestStore.saveManifest(manifest)
if (applied.length) {
recordAudit({
action: 'sync.delete.propagated', source: rel.peerId, target: rel.peerId,
result: 'ok', payload: { relationId, files: applied.slice(0, 50), count: applied.length },
})
this.notify('sync:applied', { relationId, peerId: rel.peerId, applied })
}
if (conflicts.length) {
this.notify('sync:conflict', { relationId, peerId: rel.peerId, conflicts })
}
return { applied, conflicts }
}
// ---- 吊销 ----
revokeRelation(relationId: string): { ok: boolean; reason?: string } {
const rel = this.store.getRelation(relationId)
if (!rel) return { ok: false, reason: '关系不存在' }
// 换掉我方签发的令牌 (对方手里那份立刻失效), 同时清掉我手里的对方令牌
// —— 不清的话, 我吊销了却还能读写对方的网盘/同步区, 不对称。
this.store.revokeRelation(relationId)
this.store.updateRelation(relationId, { peerToken: '' })
// 关系没了, 关系表也清掉 (下次重新建立时从干净状态开始)
try { this.manifestStore.deleteManifest(relationId) } catch {}
// 通知对端: 让对方也把这关系作废 (不然对方界面还显示"已建立", 还拿着旧令牌)
try { this.send(rel.peerId, { type: 'relation:revoked', payload: { relationId } }) } catch { /* 对方不在线就算了 */ }
recordAudit({
action: 'relation.revoke', source: this.self.deviceId, target: rel.peerId,
result: 'ok', payload: { relationId },
})
this.notify('relation:changed', { relationId, peerId: rel.peerId, state: 'revoked' })
return { ok: true }
}
/** 对端告诉我它把关系吊销了 -> 我这边也作废 */
handleRelationRevoked(peer: DeviceInfo, relationId: string): void {
const rel = this.store.getRelation(relationId)
if (!rel) return
this.store.updateRelation(relationId, { state: 'revoked', myToken: newRelationToken(), peerToken: '' })
try { this.manifestStore.deleteManifest(relationId) } catch {}
recordAudit({
action: 'relation.revoked.byPeer', source: peer.deviceId, target: this.self.deviceId,
result: 'ok', payload: { relationId },
})
this.notify('relation:changed', { relationId, peerId: peer.deviceId, state: 'revoked' })
}
}
+307
View File
@@ -0,0 +1,307 @@
// 关系层: 设备之间的**持久绑定** + **分级授权**
//
// 为什么要有这层 (原来最大的问题):
// 设备启动 -> UDP 广播 announce{deviceId, name, ..., driveToken}
// -> 局域网所有机器收到 -> 自动进"最近设备" -> 自动建 WS
// -> 消息双向开放; 网盘 token 在广播包里明文传
// 结果: 同网段任何进程拿到广播包就能浏览你的整个网盘, 而且没有"授权"这个概念。
//
// 现在拆成三层:
// 发现 (Discovery) 知道对方存在, 临时 (15s 心跳), **不产生信任**
// 关系 (Relation) 用户显式建立的一次绑定, 持久
// 授权 (Grant) 关系上的能力, 双方**各自独立**授权
//
// 建立关系的方式 —— 和远程终端的授权闸门完全一致 (不搞邀请码那一套):
// A 在同步面板点"建立同步关系" -> 选本地目录 -> 发请求
// -> B 那边**直接弹窗** (跟远程终端那个弹窗是同一套组件)
// -> B 勾选要开放的能力 + 选自己的本地目录 -> 同意/拒绝
// 双方各自落库, 交换 per-peer 令牌 -> 之后网盘和同步都按关系授权。
//
// 令牌模型:
// myToken 我签发给对方, 对方用它访问我的网盘/同步区
// peerToken 对方签发给我, 我用它访问对方的网盘/同步区
// 旧的全局 driveToken **不再广播**, 只作"对方还没升级"时的兼容通道。
//
// 存储: JSON 文件 (原子写)。选文件而不是 DB 的理由:
// - 关系是"少量、需要原子整体替换"的状态, 不是需要 SQL 聚合的数据
// - 不用做 schema 迁移, 老版本升级零风险
// - 单测可以直接指定临时文件路径, 不碰用户真实数据
// (先例: 远程操作白名单 remoteAllowPeers 也存在 settings.json 而不是 DB)
import { randomUUID, timingSafeEqual } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
import { dirname } from 'node:path'
// ============ 类型 ============
/** 关系上可以开放的能力 */
export type Grant = 'drive' | 'sync'
export const ALL_GRANTS: Grant[] = ['drive', 'sync']
export const GRANT_LABEL: Record<Grant, string> = {
drive: '允许浏览我的网盘',
sync: '允许与我同步文件',
}
export type RelationState = 'pending' | 'active' | 'revoked'
export interface Relation {
relationId: string
/** 对方 deviceId */
peerId: string
peerName: string
peerAddress: string
peerChatPort: number
peerFilePort: number
state: RelationState
/** 我开放给对方的能力 */
myGrants: Grant[]
/** 对方开放给我的能力 */
peerGrants: Grant[]
/** 我签发给对方的令牌 (对方用它访问我的网盘) */
myToken: string
/** 对方签发给我的令牌 (我用它访问对方的网盘) */
peerToken: string
/** 我这边的同步目录 (绝对路径) —— 同步区就是以它为根 */
syncLocalDir: string
/**
* 同步目录下的**按关系隔离的子目录** (通常是对方设备名)。
*
* 这样"一个顶层目录管多个聊天备份"才真正成立:
* ~/备份/MECHREVO/ <- 和 MECHREVO 同步的数据
* ~/备份/lenovo/ <- 和 lenovo 同步的数据
* 物理隔离, 两个 peer 的同名文件不会互相覆盖, 各关系的关系表也不会串。
*/
syncSubdir: string
/**
* 下面两个"网盘子目录"字段是**早期设计残留**, 现在**不再决定数据落点**:
* 同步走的是独立的同步区 (/sync/*), 路径相对对方的 syncLocalDir。
* 保留它们是为了老数据反序列化不报错, 不要拿它们算路径。
*/
syncRemotePath: string
peerSyncRoot: string
/** 是否开启自动同步 */
syncEnabled: boolean
/**
* 下面两个序号是早期设计留下的空壳 —— 真正的变更序号 (Lamport 时钟) 在
* 文件关系表 (sync-manifest.ts) 的 manifest.clock / manifest.remoteSeq 里。
* 保留字段是为了老数据反序列化不报错, 不要往里写东西。
*/
localSeq: number
remoteSeq: number
createdAt: number
updatedAt: number
lastSyncAt: number
lastError: string
}
export interface RelationsFile {
version: number
relations: Relation[]
}
// ============ 常量 ============
/** 关系令牌长度 (hex 字符数) */
const TOKEN_LEN = 32
// ============ 工具 ============
export function newRelationToken(): string {
return randomUUID().replace(/-/g, '').slice(0, TOKEN_LEN)
}
/** 定时安全比较 (长度不同直接 false, 不抛) */
export function safeEqual(a: string, b: string): boolean {
if (!a || !b) return false
const ba = Buffer.from(a, 'utf8')
const bb = Buffer.from(b, 'utf8')
if (ba.length !== bb.length) return false
try { return timingSafeEqual(ba, bb) } catch { return false }
}
function now(): number { return Date.now() }
/** 清掉路径里的前后空白和尾部斜杠 (子目录约定用) */
export function normRelDir(p: string): string {
return (p || '').trim().replace(/^[/\\]+/, '').replace(/[/\\]+$/, '')
}
// ============ 存储 ============
export class RelationStore {
private file: string
constructor(file: string) {
this.file = file
}
private load(): RelationsFile {
try {
if (!existsSync(this.file)) return { version: 1, relations: [] }
const raw = JSON.parse(readFileSync(this.file, 'utf8'))
if (!raw || typeof raw !== 'object') return { version: 1, relations: [] }
return {
version: Number(raw.version) || 1,
relations: Array.isArray(raw.relations) ? raw.relations : [],
}
} catch {
// 文件损坏不能让应用起不来: 备份一份坏的, 从空状态开始
try {
if (existsSync(this.file)) renameSync(this.file, `${this.file}.broken-${now()}`)
} catch { /* ignore */ }
return { version: 1, relations: [] }
}
}
private save(data: RelationsFile): void {
const dir = dirname(this.file)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
// 原子写: 先写临时文件再 rename, 避免中途崩溃写坏
const tmp = `${this.file}.tmp-${process.pid}`
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8')
renameSync(tmp, this.file)
}
private mutate(fn: (d: RelationsFile) => void): void {
const d = this.load()
fn(d)
this.save(d)
}
// ---- 关系 ----
listRelations(): Relation[] {
return this.load().relations.sort((a, b) => b.updatedAt - a.updatedAt)
}
/** 只列有效关系 */
listActiveRelations(): Relation[] {
return this.listRelations().filter(r => r.state === 'active')
}
getRelation(relationId: string): Relation | undefined {
return this.load().relations.find(r => r.relationId === relationId)
}
/** 按对方 deviceId 找关系 (任一状态, active 优先, 新的优先) */
getRelationByPeer(peerId: string): Relation | undefined {
const list = this.load().relations
.filter(r => r.peerId === peerId)
.sort((a, b) => (b.state === 'active' ? 1 : 0) - (a.state === 'active' ? 1 : 0) || b.updatedAt - a.updatedAt)
return list[0]
}
/** 按对方 deviceId 找**有效**关系 */
getActiveRelationByPeer(peerId: string): Relation | undefined {
const list = this.load().relations
.filter(r => r.peerId === peerId && r.state === 'active')
.sort((a, b) => b.updatedAt - a.updatedAt)
return list[0]
}
upsertRelation(r: Relation): Relation {
this.mutate(d => {
const i = d.relations.findIndex(x => x.relationId === r.relationId)
if (i >= 0) d.relations[i] = r
else d.relations.push(r)
})
return r
}
updateRelation(relationId: string, patch: Partial<Relation>): Relation | undefined {
this.mutate(d => {
const i = d.relations.findIndex(x => x.relationId === relationId)
if (i < 0) return
d.relations[i] = { ...d.relations[i], ...patch, updatedAt: now() }
})
return this.getRelation(relationId)
}
/** 吊销关系: 令牌立刻失效, 对方的请求直接 403 */
revokeRelation(relationId: string): boolean {
const r = this.getRelation(relationId)
if (!r) return false
// 换掉令牌: 即使对方还拿着旧的也访问不了
this.updateRelation(relationId, { state: 'revoked', myToken: newRelationToken(), updatedAt: now() })
return true
}
deleteRelation(relationId: string): boolean {
let ok = false
this.mutate(d => {
const n = d.relations.length
d.relations = d.relations.filter(r => r.relationId !== relationId)
ok = d.relations.length !== n
})
return ok
}
/** 改我开放给对方的能力 */
setMyGrants(relationId: string, grants: Grant[]): Relation | undefined {
return this.updateRelation(relationId, { myGrants: grants })
}
/** 改同步配置 (本地目录 / 对方网盘子目录 / 自动同步开关) */
setSyncConfig(relationId: string, patch: {
syncLocalDir?: string
syncRemotePath?: string
peerSyncRoot?: string
syncEnabled?: boolean
}): Relation | undefined {
return this.updateRelation(relationId, patch)
}
}
// ============ 授权判定 ============
export interface DriveAuth {
ok: boolean
reason?: string
peerId?: string
relationId?: string
/** 限定在这个子目录内 (相对网盘根); 空 = 不限制 */
scopeRoot?: string
grants?: Grant[]
}
/**
* 网盘/同步区访问授权判定。
*
* token 必须是"我签发给对方"的那个 per-peer 令牌, 且关系有效、且对方被授予了
* drive 或 sync 能力。只有 sync 能力时, 访问被限制在该关系的专用子目录内
* (peerSyncRoot), 对方碰不到我网盘里的其它东西。
*/
export function authorizeDriveAccess(store: RelationStore, token: string | null, peerId?: string | null): DriveAuth {
if (!token) return { ok: false, reason: '缺少令牌' }
const relations = store.listRelations().filter(r => r.state === 'active')
const rel = relations.find(r => safeEqual(r.myToken, token))
if (!rel) return { ok: false, reason: '令牌无效' }
if (peerId && rel.peerId !== peerId) return { ok: false, reason: '令牌与设备不匹配' }
if (!rel.myGrants.length) return { ok: false, reason: '未授权访问网盘' }
// 网盘浏览的权限范围: 给了 drive 就是整个网盘; 只给 sync 的话网盘浏览**完全不给**,
// 对方只能通过 /sync/* 访问本关系专属的同步目录。
const scopeRoot = rel.myGrants.includes('drive') ? '' : 'none'
return {
ok: true, peerId: rel.peerId, relationId: rel.relationId,
scopeRoot, grants: rel.myGrants,
}
}
/** 对方开放给我的能力 */
export function peerGrantsOf(store: RelationStore, peerId: string): Grant[] {
const rel = store.getActiveRelationByPeer(peerId)
return rel ? rel.peerGrants : []
}
/** 我开放给对方的能力 */
export function myGrantsOf(store: RelationStore, peerId: string): Grant[] {
const rel = store.getActiveRelationByPeer(peerId)
return rel ? rel.myGrants : []
}
/** 对方是否给了我某项能力 */
export function peerCan(store: RelationStore, peerId: string, grant: Grant): boolean {
return peerGrantsOf(store, peerId).includes(grant)
}
+43 -12
View File
@@ -8,30 +8,49 @@
import { BrowserWindow } from 'electron'
import { getSettings } from '../settings'
export type RemoteKind = 'terminal' | 'forward' | 'usb'
export type RemoteKind = 'terminal' | 'forward' | 'usb' | 'relation'
/** 需要走"远程操作白名单"的种类 (relation 走关系层自己的授权, 不在这张表里) */
export type RemoteGateKind = Exclude<RemoteKind, 'relation'>
interface Pending {
resolve: (ok: boolean, remember?: boolean) => void
resolve: (reply: ApprovalReply) => void
timer: NodeJS.Timeout
}
/**
* 用户对授权请求的回复。
* terminal/forward/usb 只用到 ok + remember;
* relation (建立同步关系) 还会带上用户勾选的授权范围和他自己的同步目录。
*/
export interface ApprovalReply {
ok: boolean
remember?: boolean
/** relation: 用户开放给对方的能力 */
grants?: string[]
/** relation: 用户这边的同步目录 */
syncLocalDir?: string
/** relation: 允许对方用我网盘里的哪个子目录 */
syncRemotePath?: string
}
const pending = new Map<string, Pending>()
const approvedAt = new Map<string, number>()
const APPROVE_TTL_MS = 24 * 3600_000
function cacheKey(peerId: string, kind: RemoteKind, nonce?: string) {
function cacheKey(peerId: string, kind: RemoteGateKind, nonce?: string) {
// nonce 用于 usb.attach 这种需要每次重新确认的场景
return nonce ? `${peerId}::${kind}::${nonce}` : `${peerId}::${kind}`
}
export function isAutoAllowed(peerId: string, kind: RemoteKind): boolean {
export function isAutoAllowed(peerId: string, kind: RemoteGateKind): boolean {
const s = getSettings()
if (!s.remoteEnabled) return false
const allow = s.remoteAllowPeers?.[peerId]?.[kind]
return !!allow
}
export function isRemembered(peerId: string, kind: RemoteKind): boolean {
export function isRemembered(peerId: string, kind: RemoteGateKind): boolean {
const k = cacheKey(peerId, kind)
const ts = approvedAt.get(k)
if (!ts) return false
@@ -42,11 +61,11 @@ export function isRemembered(peerId: string, kind: RemoteKind): boolean {
return true
}
export function markApproved(peerId: string, kind: RemoteKind) {
export function markApproved(peerId: string, kind: RemoteGateKind) {
approvedAt.set(cacheKey(peerId, kind), Date.now())
}
export function resetApproval(peerId: string, kind?: RemoteKind) {
export function resetApproval(peerId: string, kind?: RemoteGateKind) {
for (const k of approvedAt.keys()) {
if (k.startsWith(`${peerId}::`)) {
if (!kind || k.startsWith(`${peerId}::${kind}`)) approvedAt.delete(k)
@@ -55,7 +74,7 @@ export function resetApproval(peerId: string, kind?: RemoteKind) {
}
// 同步决策: 自动允许 / 缓存允许 -> 直接 true; 否则 false (UI 需要主动 ask)
export function shouldAsk(peerId: string, kind: RemoteKind): boolean {
export function shouldAsk(peerId: string, kind: RemoteGateKind): boolean {
if (isAutoAllowed(peerId, kind)) return false
if (isRemembered(peerId, kind)) {
markApproved(peerId, kind)
@@ -75,12 +94,20 @@ export interface ApprovalRequest {
kind: RemoteKind
detail?: string
ts: number
/** relation: 对方想要的能力 */
grants?: string[]
/** relation: 对方的同步目录 */
syncLocalDir?: string
/** relation: 对方想用我网盘里的哪个子目录 */
syncRemotePath?: string
/** relation: 关系 id */
relationId?: string
}
const waitingQueue: ApprovalRequest[] = []
const waitersByReq = new Map<string, Pending>()
export function enqueueApproval(req: ApprovalRequest): Promise<{ ok: boolean; remember?: boolean }> {
export function enqueueApproval(req: ApprovalRequest): Promise<ApprovalReply> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
pending.delete(req.requestId)
@@ -90,7 +117,7 @@ export function enqueueApproval(req: ApprovalRequest): Promise<{ ok: boolean; re
if (i >= 0) waitingQueue.splice(i, 1)
resolve({ ok: false })
}, ASK_TIMEOUT_MS)
pending.set(req.requestId, { resolve: (ok, remember) => resolve({ ok, remember }), timer })
pending.set(req.requestId, { resolve, timer })
waitersByReq.set(req.requestId, pending.get(req.requestId)!)
waitingQueue.push(req)
})
@@ -100,7 +127,11 @@ export function listPendingApprovals(): ApprovalRequest[] {
return [...waitingQueue]
}
export function replyApproval(requestId: string, ok: boolean, remember?: boolean): boolean {
export function replyApproval(
requestId: string,
ok: boolean,
extra?: { remember?: boolean; grants?: string[]; syncLocalDir?: string; syncRemotePath?: string },
): boolean {
const p = pending.get(requestId)
if (!p) return false
clearTimeout(p.timer)
@@ -108,7 +139,7 @@ export function replyApproval(requestId: string, ok: boolean, remember?: boolean
waitersByReq.delete(requestId)
const i = waitingQueue.findIndex(x => x.requestId === requestId)
if (i >= 0) waitingQueue.splice(i, 1)
p.resolve(ok, remember)
p.resolve({ ok, ...(extra || {}) })
return true
}
+6
View File
@@ -30,6 +30,10 @@ export interface Settings {
driveEnabled: boolean
/** 网盘根目录 (对外共享的目录, 对端只能看到这个目录里的内容) */
driveDir: string
/** 文件夹同步: 上次用的本地目录 */
syncLocalDir: string
/** 文件夹同步: 上次用的对方网盘子目录 (相对网盘根) */
syncRemotePath: string
}
export const store = new Store<Settings>({
@@ -52,6 +56,8 @@ export const store = new Store<Settings>({
auditRetentionDays: 30,
driveEnabled: true,
driveDir: paths.defaultDriveDir,
syncLocalDir: '',
syncRemotePath: '',
},
})
+640
View File
@@ -0,0 +1,640 @@
// 文件关系表 (manifest): 同步盘的地基
//
// 这个文件解决的核心问题: **怎么区分"文件被删除了"和"文件本来就不存在"**。
//
// 三方对比 (不是两方):
// 基线 (上次同步后两边一致的状态) + 本地现状 + 远端现状
//
// 墓碑 (tombstone): 删除不抹记录, 只标 state='deleted'。
// 直接抹掉会有两个致命问题:
// A. 删除传不出去 —— 对方离线时我删了 x.txt, 抹掉记录后对方回来说"我有 x.txt",
// 我会以为是新增 -> 重新下载 -> 删除被撤销, 用户永远删不掉这个文件。
// B. 删除会被复活 —— 对方从备份恢复了 x.txt 并上传, 我这边墓碑还在,
// 光看"对方有我没有"会当成新增接受 -> 删除被静默撤销。
// 有墓碑 + seq 比较就都能挡住 (见 planSync)。
//
// seq (变更序号): Lamport 时钟。
// hash 只能回答"一样不一样", seq 才能回答"谁更晚" —— 这正是判定删除方向的关键。
// 本地每变一次 seq++; 收到对方的 seq 时把本地时钟推到 max(local, remote)。
// 于是"对方 seq 5 的删除" vs "我 seq 3 的修改" -> 删除更晚 -> 删除赢。
//
// 存储: 每个关系一份 manifest, 放同一个 JSON 文件里 (原子写)。
// key = relationId, 所以**一个本地目录同时跟多个关系同步也不会串**:
// 各自有各自的基线/墓碑/序号, 互不干扰。
import { randomUUID } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
import { dirname } from 'node:path'
// ============ 类型 ============
/** 一个文件的快照 */
export interface SyncFileInfo {
relPath: string
size: number
mtime: number
/** sha256; 文件太大时可能没有 */
hash?: string
}
/** 单个文件的关系记录 (key = relPath) */
export interface ManifestEntry {
/** 稳定 id (首次见到这个路径时生成) */
fileId: string
relPath: string
/** 基线: 上次双方内容一致时的 hash (空 = 从来没同步过) */
baseHash: string
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
}
/** 一个关系的完整关系表 */
export interface Manifest {
relationId: string
/** 我的 Lamport 时钟 */
clock: number
/** 水位线: 我已处理到对方的哪个序号 */
remoteSeq: number
/** key = relPath */
entries: Record<string, ManifestEntry>
}
/** 发给对方的"我的当前状态" */
export interface ManifestWireEntry {
fileId: string
relPath: string
hash: string
size: number
mtime: number
state: 'present' | 'deleted'
seq: number
deletedAt: number
}
export interface ManifestWire {
relationId: string
/** 我的时钟当前值 (对端拿它推进自己的时钟) */
seq: number
entries: ManifestWireEntry[]
}
// ============ 常量 ============
/** 墓碑保留天数 (比大品牌默认的 30 天更保守, 覆盖长时间离线的设备) */
export const TOMBSTONE_TTL_MS = 90 * 24 * 3600 * 1000
/** 首次同步 (没有基线) 时, mtime 相差小于这个秒数算"同时改" -> 冲突 */
export const MTIME_TIE_SEC = 2
// ============ 存储 ============
interface ManifestFile {
version: number
manifests: Record<string, Manifest>
}
export class ManifestStore {
private file: string
constructor(file: string) {
this.file = file
}
private load(): ManifestFile {
try {
if (!existsSync(this.file)) return { version: 1, manifests: {} }
const raw = JSON.parse(readFileSync(this.file, 'utf8'))
if (!raw || typeof raw !== 'object') return { version: 1, manifests: {} }
const m = raw.manifests && typeof raw.manifests === 'object' ? raw.manifests : {}
return { version: Number(raw.version) || 1, manifests: m }
} catch {
try {
if (existsSync(this.file)) renameSync(this.file, `${this.file}.broken-${Date.now()}`)
} catch { /* ignore */ }
return { version: 1, manifests: {} }
}
}
private save(d: ManifestFile): void {
const dir = dirname(this.file)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
const tmp = `${this.file}.tmp-${process.pid}`
writeFileSync(tmp, JSON.stringify(d, null, 2), 'utf8')
renameSync(tmp, this.file)
}
loadManifest(relationId: string): Manifest {
const all = this.load().manifests
const m = all[relationId]
if (!m || typeof m !== 'object') return { relationId, clock: 0, remoteSeq: 0, entries: {} }
return {
relationId,
clock: Number(m.clock) || 0,
remoteSeq: Number(m.remoteSeq) || 0,
entries: m.entries && typeof m.entries === 'object' ? m.entries : {},
}
}
saveManifest(manifest: Manifest): void {
const d = this.load()
d.manifests[manifest.relationId] = manifest
this.save(d)
}
deleteManifest(relationId: string): void {
const d = this.load()
delete d.manifests[relationId]
this.save(d)
}
/** 列出所有有墓碑的关系 (清理用) */
listManifestIds(): string[] {
return Object.keys(this.load().manifests)
}
}
// ============ 关系表操作 ============
function emptyEntry(relPath: string): ManifestEntry {
return {
fileId: randomUUID(),
relPath,
baseHash: '', baseSize: 0, baseMtime: 0,
myHash: '', mySize: 0, myMtime: 0,
myState: 'deleted', mySeq: 0,
peerHash: '', peerSize: 0, peerMtime: 0,
peerState: 'deleted', peerSeq: 0,
deletedAt: 0,
}
}
/** 把我的当前状态导出成发给对方的格式 */
export function toWire(manifest: Manifest): ManifestWire {
const entries: ManifestWireEntry[] = []
for (const e of Object.values(manifest.entries)) {
entries.push({
fileId: e.fileId,
relPath: e.relPath,
hash: e.myState === 'present' ? e.myHash : '',
size: e.myState === 'present' ? e.mySize : 0,
mtime: e.myState === 'present' ? e.myMtime : 0,
state: e.myState,
seq: e.mySeq,
deletedAt: e.deletedAt,
})
}
return { relationId: manifest.relationId, seq: manifest.clock, entries }
}
/** 从对方发来的格式导入 (解析成对方的视图) */
export function fromWire(wire: ManifestWire): Map<string, ManifestWireEntry> {
const m = new Map<string, ManifestWireEntry>()
for (const e of wire?.entries || []) {
if (!e || typeof e.relPath !== 'string' || !e.relPath) continue
m.set(e.relPath, e)
}
return m
}
export interface ReconcileResult {
/** 本地这次的变化 (用于写审计/提示) */
added: string[]
modified: string[]
deleted: string[]
restored: string[]
/** 检测到的重命名 (旧路径 -> 新路径) */
renamed: { from: string; to: string }[]
}
/**
* 把本地磁盘现状和关系表对齐 (只改关系表, 不动磁盘)。
*
* 这一步是"区分被删 vs 从来没有"的地方:
* - 关系表里有 + 磁盘没了 -> **删除事件** (记墓碑, seq++)
* - 磁盘有 + 关系表里没有 -> **新增事件** (seq++)
* - 磁盘有 + 内容变了 -> **修改事件** (seq++)
* - 关系表是墓碑 + 磁盘有了 -> **复活事件** (seq++) ← 用户把文件放回来了
*/
export function reconcileLocal(
manifest: Manifest,
localFiles: SyncFileInfo[],
opts?: { detectRename?: boolean },
): ReconcileResult {
const res: ReconcileResult = { added: [], modified: [], deleted: [], restored: [], renamed: [] }
const localMap = new Map(localFiles.map(f => [f.relPath, f]))
let clock = manifest.clock
// ---- 先找重命名 (避免改名产生假删除) ----
const renames = new Map<string, string>() // oldPath -> newPath
if (opts?.detectRename !== false) {
const gonePaths: string[] = []
for (const [relPath, e] of Object.entries(manifest.entries)) {
if (e.myState === 'present' && !localMap.has(relPath) && e.baseHash) gonePaths.push(relPath)
}
for (const [relPath, l] of localMap) {
if (manifest.entries[relPath]) continue // 不是新增
// 新出现的路径: 看有没有"消失的路径"的基线 hash 和它一样
for (const gone of gonePaths) {
const ge = manifest.entries[gone]
if (!ge || !ge.baseHash) continue
if (l.hash && l.hash === ge.baseHash && !renames.has(gone)) {
renames.set(gone, relPath)
break
}
}
}
}
// ---- 已有的条目: 判定 删除 / 修改 / 复活 ----
for (const [relPath, e] of Object.entries(manifest.entries)) {
const l = localMap.get(relPath)
if (e.myState === 'present') {
if (!l) {
if (renames.has(relPath)) continue // 走重命名分支, 不算删除
// 磁盘上没了 -> **删除事件** (这是最关键的一条)
e.myState = 'deleted'
e.mySeq = ++clock
e.deletedAt = Date.now()
e.myHash = ''
e.mySize = 0
e.myMtime = 0
res.deleted.push(relPath)
} else if (l.hash && e.myHash && l.hash !== e.myHash) {
// 内容变了 -> 修改事件
e.myHash = l.hash
e.mySize = l.size
e.myMtime = l.mtime
e.mySeq = ++clock
res.modified.push(relPath)
} else if (!e.myHash && l.hash) {
// 补上之前没记到的 hash (老数据)
e.myHash = l.hash
e.mySize = l.size
e.myMtime = l.mtime
}
} else {
// 之前记的是"删除"状态
if (l) {
// 真的墓碑 (有过删除记录) -> 复活; 否则只是"第一次见到这个路径" -> 新增
const isTombstone = e.mySeq > 0 && e.deletedAt > 0
e.myState = 'present'
e.mySeq = ++clock
e.myHash = l.hash || ''
e.mySize = l.size
e.myMtime = l.mtime
if (isTombstone) {
e.deletedAt = 0
res.restored.push(relPath)
} else {
res.added.push(relPath)
}
}
}
}
// ---- 应用重命名 (换路径, 保留 fileId, **旧路径要留墓碑**) ----
for (const [from, to] of renames) {
const e = manifest.entries[from]
if (!e) continue
const l = localMap.get(to)
// 旧路径留一条墓碑: 不留的话对方永远看不到"这个文件没了" ->
// 对方网盘里的旧文件会永久残留, 删除传播不出去。
manifest.entries[from] = {
...e,
relPath: from,
myState: 'deleted',
mySeq: ++clock,
deletedAt: Date.now(),
myHash: '',
mySize: 0,
myMtime: 0,
}
e.relPath = to
e.myState = 'present'
e.mySeq = ++clock
e.myHash = l?.hash || e.myHash
e.mySize = l?.size || e.mySize
e.myMtime = l?.mtime || e.myMtime
manifest.entries[to] = e
res.renamed.push({ from, to })
}
// ---- 新增 ----
for (const l of localFiles) {
if (manifest.entries[l.relPath]) continue
const e = emptyEntry(l.relPath)
e.myState = 'present'
e.mySeq = ++clock
e.myHash = l.hash || ''
e.mySize = l.size
e.myMtime = l.mtime
manifest.entries[l.relPath] = e
res.added.push(l.relPath)
}
manifest.clock = clock
return res
}
/**
* 只做一件事: 把本地 Lamport 时钟推到"不小于对方当前的 seq"。
*
* Lamport 时钟的标准用法是"**先收消息, 再产生事件**":
* 本地每产生一个变更事件, 它的 seq 必须大于我已知的对方最大 seq,
* 否则跨机器的 seq 就不可比, 删除方向会判反。
* 所以扫描的顺序必须是: advanceClock -> reconcileLocal -> mergePeer。
*/
export function advanceClock(manifest: Manifest, peerEntries: Map<string, ManifestWireEntry>): void {
let maxPeerSeq = 0
for (const p of peerEntries.values()) if (p.seq > maxPeerSeq) maxPeerSeq = p.seq
if (maxPeerSeq > manifest.clock) manifest.clock = maxPeerSeq
if (maxPeerSeq > manifest.remoteSeq) manifest.remoteSeq = maxPeerSeq
}
/**
* 合并对方广播过来的状态。
* 只接受 seq 更大的事件 (水位线过滤) -> 重复同步同一批事件不会出错。
*
* 注意: 这里新建的条目 (对方有、我从没见过的路径) 的 myState 是 'deleted',
* 意思是"**我这边从来没有这个文件**", 不是"我把它删了" —— 真正的墓碑有 deletedAt。
*/
export function mergePeer(manifest: Manifest, peerEntries: Map<string, ManifestWireEntry>): void {
for (const p of peerEntries.values()) {
let e = manifest.entries[p.relPath]
if (!e) {
// 对方有、我从没见过的路径
e = emptyEntry(p.relPath)
e.fileId = p.fileId || e.fileId
manifest.entries[p.relPath] = e
}
if (p.seq <= e.peerSeq) continue // 老事件, 忽略
e.peerSeq = p.seq
e.peerState = p.state === 'present' ? 'present' : 'deleted'
e.peerHash = p.hash || ''
e.peerSize = p.size || 0
e.peerMtime = p.mtime || 0
if (p.state === 'deleted' && p.deletedAt && !e.deletedAt) e.deletedAt = p.deletedAt
}
}
// ============ 三方对比 ============
export type SyncAction =
| 'same' // 两边一致
| 'upload' // 本地 -> 对方 (覆盖)
| 'download' // 对方 -> 本地 (覆盖)
| 'delete-local' // 对方删了 -> 删我本地
| 'delete-remote' // 我删了 -> 删对方
| 'conflict' // 两边都动过 -> 用户选
export interface SyncPlanItem {
relPath: string
action: SyncAction
local?: SyncFileInfo
remote?: SyncFileInfo
/** 我这边状态 */
myState: 'present' | 'deleted'
/** 对方状态 */
peerState: 'present' | 'deleted'
/** 基线 hash (空 = 从来没同步过) */
baseHash: string
note: string
}
export interface SyncPlan {
items: SyncPlanItem[]
stats: {
same: number
upload: number
download: number
deleteLocal: number
deleteRemote: number
conflict: number
}
}
function sameContent(a: SyncFileInfo, b: SyncFileInfo): boolean {
if (a.hash && b.hash) return a.hash === b.hash
// 一边没 hash (超大文件) -> 退回 size + mtime
return a.size === b.size && Math.abs(a.mtime - b.mtime) < 1000
}
/** 没有基线时的兜底: 看 mtime 谁新 (差得远当单向, 接近当冲突) */
function decideByMtime(l: SyncFileInfo, r: SyncFileInfo): { action: SyncAction; note: string } {
const diff = l.mtime - r.mtime
if (Math.abs(diff) < MTIME_TIE_SEC * 1000) {
return { action: 'conflict', note: '内容不同且修改时间接近 (冲突)' }
}
if (diff < 0) return { action: 'download', note: '对方更新, 下载覆盖本地' }
return { action: 'upload', note: '本地更新, 上传覆盖对方' }
}
/**
* 三方对比: 基线 + 本地现状 + 远端现状 -> 一份可执行的同步计划。
*
* 判定表 (base = 上次两边一致的 hash):
*
* 本地 远端 基线 判定 动作
* 有 有 一致 相同 无
* 有 有 只有本地改 本地新 upload
* 有 有 只有远端改 远端新 download
* 有 有 都改过 冲突 conflict
* 无 有 有 我删了 delete-remote (对方没改) / conflict (对方改了)
* 无 有 无 远端新增 download
* 有 无 有 对方删了 delete-local (我没改) / conflict (我改了)
* 有 无 无 本地新增 upload
* 无 无 有 两边都删了 无 (墓碑到期可清理)
*
* 墓碑优先 (防止删除被复活):
* 我有墓碑 (myState='deleted') 且 对方 seq <= 我的墓碑 seq -> 删除赢, 删对方那份
* 对方 seq > 我的墓碑 seq -> 对方真的恢复了文件 -> 接受, 下载
*/
export function planSync(
manifest: Manifest,
localFiles: SyncFileInfo[],
peerEntries: Map<string, ManifestWireEntry>,
): SyncPlan {
const localMap = new Map(localFiles.map(f => [f.relPath, f]))
const allPaths = new Set<string>([
...Object.keys(manifest.entries),
...peerEntries.keys(),
])
const items: SyncPlanItem[] = []
const stats = { same: 0, upload: 0, download: 0, deleteLocal: 0, deleteRemote: 0, conflict: 0 }
for (const p of Array.from(allPaths).sort()) {
const e = manifest.entries[p]
const l = localMap.get(p)
const pw = peerEntries.get(p)
const myAlive = !!l
const peerAlive = !!pw && pw.state === 'present'
const base = e?.baseHash || ''
let action: SyncAction
let note: string
if (myAlive && peerAlive) {
const lh = l!.hash || ''
const ph = pw!.hash || ''
if (lh && ph ? lh === ph : sameContent(l!, { relPath: p, size: pw!.size, mtime: pw!.mtime, hash: ph })) {
action = 'same'
note = '内容一致'
} else {
const myChanged = base ? lh !== base : true
const peerChanged = base ? ph !== base : true
if (base && myChanged && peerChanged) {
action = 'conflict'
note = '两边都改过 (冲突)'
} else if (base && myChanged) {
action = 'upload'
note = '只有本地改过, 上传覆盖对方'
} else if (base && peerChanged) {
action = 'download'
note = '只有对方改过, 下载覆盖本地'
} else if (base) {
action = 'conflict'
note = '内容不一致 (冲突)'
} else {
const r = decideByMtime(l!, { relPath: p, size: pw!.size, mtime: pw!.mtime, hash: ph })
action = r.action
note = r.note + ' (首次同步, 无基线)'
}
}
} else if (!myAlive && peerAlive) {
// 对方有, 我没有
const peerChanged = base ? (pw!.hash || '') !== base : false
if (e && e.deletedAt > 0) {
// 我这边有**真墓碑** (是我删的, 有删除时间戳); 比 seq 决定删除还是复活
if (pw!.seq > e.mySeq) {
action = 'download'
note = '对方恢复了该文件 (对方序号更新), 下载'
} else if (peerChanged) {
// 墓碑更晚, 但对方改过内容 -> 不静默丢数据, 交给用户
action = 'conflict'
note = '我已删除, 对方又改过 (冲突)'
} else {
action = 'delete-remote'
note = '我已删除, 传播删除到对方'
}
} else if (base && peerChanged) {
action = 'conflict'
note = '对方改过, 但我这边删了 (冲突)'
} else if (base) {
action = 'delete-remote'
note = '我已删除, 传播删除到对方'
} else {
action = 'download'
note = '对方新增, 下载'
}
} else if (myAlive && !peerAlive) {
// 我有, 对方没有
const myChanged = base ? (l!.hash || '') !== base : false
if (base && myChanged) {
action = 'conflict'
note = '我改过, 对方删了 (冲突)'
} else if (base) {
action = 'delete-local'
note = '对方已删除, 传播删除到本地'
} else if (e && e.peerState === 'deleted' && e.peerSeq > 0) {
// 对方**明确说了删了**, 但我们俩从没同步过这个文件 -> 这是两个各自独立的
// 同名文件, 不是"同一个文件的两份"。不自动上传, 免得把对方的删除撤销掉。
action = 'conflict'
note = '对方删除了同名文件, 但我们从没同步过 (冲突)'
} else {
action = 'upload'
note = '本地新增, 上传'
}
} else {
// 两边都没有
action = 'same'
note = '两边都已删除'
}
items.push({
relPath: p,
action,
local: l,
remote: peerAlive ? { relPath: p, size: pw!.size, mtime: pw!.mtime, hash: pw!.hash } : undefined,
myState: myAlive ? 'present' : 'deleted',
peerState: peerAlive ? 'present' : 'deleted',
baseHash: base,
note,
})
if (action === 'same') stats.same++
else if (action === 'upload') stats.upload++
else if (action === 'download') stats.download++
else if (action === 'delete-local') stats.deleteLocal++
else if (action === 'delete-remote') stats.deleteRemote++
else if (action === 'conflict') stats.conflict++
}
return { items, stats }
}
/**
* 同步成功后更新基线 (两边内容一致了, 下次就能精确判断"谁改了")。
*/
export function updateBaseline(
manifest: Manifest,
relPath: string,
hash: string,
size = 0,
mtime = 0,
): void {
const e = manifest.entries[relPath]
if (!e) return
// 没有 hash (超大文件超了 hash 上限) 就**不要**把基线写成空 ——
// 基线空了, 三方对比就退化成 mtime 判断, 会静默覆盖, 违背"绝不静默丢数据"。
if (!hash) return
e.baseHash = hash
e.baseSize = size
e.baseMtime = mtime
}
/** 记录"我这边刚删了" (apply delete-local / delete-remote 成功后用) */
export function markLocalDeleted(manifest: Manifest, relPath: string): void {
const e = manifest.entries[relPath]
if (!e) return
if (e.myState !== 'deleted') {
e.myState = 'deleted'
e.mySeq = ++manifest.clock
}
e.deletedAt = e.deletedAt || Date.now()
e.myHash = ''
}
/**
* 清理到期墓碑。
* 条件: 墓碑超过保留期 **且** 对方已经越过这个序号 (对方也知道这件事了)。
* 对方没上线的, 墓碑一直留着 —— 这正是我们要的。
*/
export function pruneTombstones(manifest: Manifest, peerMaxSeq: number, now = Date.now()): string[] {
const removed: string[] = []
for (const [relPath, e] of Object.entries(manifest.entries)) {
if (e.myState !== 'deleted' || !e.deletedAt) continue
if (e.peerState !== 'deleted') continue // 对方还没删, 不能清
if (peerMaxSeq <= e.mySeq) continue // 对方还没越过这个序号
if (now - e.deletedAt < TOMBSTONE_TTL_MS) continue
delete manifest.entries[relPath]
removed.push(relPath)
}
return removed
}
+754
View File
@@ -0,0 +1,754 @@
// 文件夹同步引擎 (本地目录 <-> 对方网盘目录)
//
// 定位: 复用网盘已有的 HTTP 通道 (47901), 在其上扩展几个接口:
// GET /drive/scan?token=&path= -> 递归清单 (relPath/size/mtime/hash)
// POST /drive/upload?token= -> 写进对方网盘的指定相对路径
// POST /drive/delete?token= -> 删掉对方网盘里的指定文件 ← 删除传播
// GET /sync/manifest?token= -> 拿对方的文件关系表 (含墓碑 + 序号)
// POST /sync/manifest?token= -> 把我的文件关系表推给对方
//
// 同步判定是**三方对比** (基线 + 本地 + 远端), 全部逻辑在 sync-manifest.ts:
// - 基线 = 上次同步后两边内容一致的 hash
// - 墓碑 = 文件被删了但记录还在, 保证删除能传播、不会被复活
// - 序号 = Lamport 时钟, 用来判定"谁更晚" (尤其是删除 vs 修改)
//
// 同步是**手动触发**的: 先扫描出变更列表 (显示双方文件信息对比),
// 用户勾选/逐条确认后点"同步"才真的动数据。冲突 (两边都改过) 会弹窗让用户选方向。
//
// 没有关系 (老版本对端 / 还没建立关系) 时降级:
// - 拿不到对方的 manifest -> 用 /drive/scan 的结果当远端视图 (没有墓碑/序号)
// - 没有基线 -> 退回 mtime 兜底 (差得远当单向, 接近当冲突)
import { request as httpRequest, get as httpGet } from 'node:http'
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, closeSync, unlinkSync } from 'node:fs'
import { join, relative, resolve, sep, dirname, basename } from 'node:path'
import { createHash } from 'node:crypto'
import type { DeviceInfo } from './protocol'
import { safeFileName } from './safe-name'
import {
ManifestStore, toWire, fromWire, reconcileLocal, mergePeer, advanceClock, planSync,
updateBaseline, markLocalDeleted, pruneTombstones,
type Manifest, type ManifestWire, type ManifestWireEntry, type SyncFileInfo, type SyncAction,
} from './sync-manifest'
export type { SyncFileInfo } from './sync-manifest'
export { ManifestStore } from './sync-manifest'
// ============ 类型 ============
/** 一条变更 */
export interface SyncDiffItem {
relPath: string
/** 本地/远端都有, 还是只有一边有 */
presence: 'both' | 'local-only' | 'remote-only'
/** 建议动作 */
action: SyncAction
local?: SyncFileInfo
remote?: SyncFileInfo
/** 给用户看的一句话说明 */
note: string
}
export interface SyncStats {
localOnly: number
remoteOnly: number
same: number
upload: number
download: number
conflict: number
/** 对方删了 -> 我这边要删 */
deleteLocal: number
/** 我删了 -> 对方要删 */
deleteRemote: number
}
export interface SyncScanResult {
ok: boolean
reason?: string
localDir: string
remotePath: string
/** 对方网盘根目录 (提示用) */
remoteRoot?: string
items: SyncDiffItem[]
stats: SyncStats
}
/** 同步的一次上下文 (谁 + 哪两个目录 + 用什么令牌) */
export interface SyncContext {
peer: DeviceInfo
localDir: string
/** 对方网盘里, 我这份数据放在哪个子目录 */
remotePath?: string
/** 访问对方网盘的令牌 (关系的 peerToken; 老对端用 driveToken) */
token?: string
/** 关系 id (有的话才走 manifest 推送) */
relationId?: string
/**
* **我方**的 deviceId。请求要带上它, 服务端才能确认
* "这个令牌确实是签给对方的" (peerId 传错会被判成令牌与设备不匹配)。
*/
selfDeviceId?: string
/** 关系表仓库 */
store?: ManifestStore
/** 对方网盘里给我预留的子目录 (授权范围校验用) */
peerSyncRoot?: string
}
// ============ 常量 ============
/** 超过这个大小不算 hash (太慢) */
const HASH_MAX_SIZE = 256 * 1024 * 1024
/** 目录扫描深度上限 */
const MAX_DEPTH = 16
/** 扫描条目数上限 */
const MAX_ENTRIES = 20000
// ============ 本地扫描 ============
function hashFileSync(p: string): string | undefined {
try {
const st = statSync(p)
if (st.size > HASH_MAX_SIZE) return undefined
const h = createHash('sha256')
const buf = Buffer.alloc(1024 * 1024)
const fd = openSync(p, 'r')
try {
for (;;) {
const n = readSync(fd, buf, 0, buf.length, null)
if (n <= 0) break
h.update(buf.subarray(0, n))
}
} finally {
closeSync(fd)
}
return h.digest('hex')
} catch {
return undefined
}
}
/** 递归扫描本地目录, 返回相对路径清单 */
export function scanLocalDir(localDir: string, wantHash = true): { ok: boolean; reason?: string; files: SyncFileInfo[]; dirs: string[] } {
const root = resolve(localDir)
if (!existsSync(root)) return { ok: false, reason: '本地目录不存在', files: [], dirs: [] }
let st: any
try { st = statSync(root) } catch { return { ok: false, reason: '本地目录打不开', files: [], dirs: [] } }
if (!st.isDirectory()) return { ok: false, reason: '本地路径不是目录', files: [], dirs: [] }
const files: SyncFileInfo[] = []
const dirs: string[] = []
const walk = (abs: string, relBase: string, depth: number) => {
if (depth > MAX_DEPTH || files.length >= MAX_ENTRIES) return
let names: string[] = []
try { names = readdirSync(abs) } catch { return }
for (const name of names) {
if (files.length >= MAX_ENTRIES) return
if (name.startsWith('.')) continue // 跳过隐藏文件/临时文件
const childAbs = join(abs, name)
const childRel = relBase ? `${relBase}/${name}` : name
let s: any
try { s = statSync(childAbs) } catch { continue }
if (s.isDirectory()) {
dirs.push(childRel)
walk(childAbs, childRel, depth + 1)
} else if (s.isFile()) {
const info: SyncFileInfo = { relPath: childRel, size: s.size, mtime: s.mtimeMs }
if (wantHash) {
const h = hashFileSync(childAbs)
if (h) info.hash = h
}
files.push(info)
}
}
}
walk(root, '', 0)
files.sort((a, b) => a.relPath.localeCompare(b.relPath))
dirs.sort()
return { ok: true, files, dirs }
}
// ============ 远端 ============
function peerBase(ctx: SyncContext): { host: string; port: number; token: string } | null {
const token = ctx.token || ctx.peer.driveToken
if (!token || !ctx.peer.address || !ctx.peer.filePort) return null
return { host: ctx.peer.address, port: ctx.peer.filePort, token }
}
/** 请求参数: token + peerId (服务端要靠 peerId 确认"这个令牌确实是给对方签的") */
function peerQuery(ctx: SyncContext, extra?: string): string {
const base = peerBase(ctx)!
// peerId 是**请求方**(我) 的 deviceId, 不是对端的
const self = ctx.selfDeviceId
const q = self
? `token=${encodeURIComponent(base.token)}&peerId=${encodeURIComponent(self)}`
: `token=${encodeURIComponent(base.token)}`
return extra ? `${q}&${extra}` : q
}
function httpGetJson(url: string, timeoutMs = 60_000): Promise<{ ok: boolean; status: number; data: any; reason?: string }> {
return new Promise((resolve) => {
const req = httpGet(url, { timeout: timeoutMs }, (res) => {
const chunks: Buffer[] = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
let data: any = null
try { data = JSON.parse(text) } catch { /* 非 JSON */ }
resolve({ ok: res.statusCode === 200, status: res.statusCode || 0, data })
})
})
req.on('timeout', () => { req.destroy(); resolve({ ok: false, status: 0, data: null, reason: '请求超时' }) })
req.on('error', (e) => resolve({ ok: false, status: 0, data: null, reason: e?.message || '连接失败' }))
})
}
/** 递归扫描对方网盘目录 (实际内容) */
export async function scanRemoteDir(peer: DeviceInfo, remotePath = '', timeoutMs = 60_000): Promise<{ ok: boolean; reason?: string; files: SyncFileInfo[]; dirs: string[]; root?: string }> {
const token = peer.driveToken
if (!token || !peer.address || !peer.filePort) {
return { ok: false, reason: '对方版本不支持网盘', files: [], dirs: [] }
}
const q = `token=${encodeURIComponent(token)}&path=${encodeURIComponent(remotePath)}&hash=1`
const url = `http://${peer.address}:${peer.filePort}/drive/scan?${q}`
const r = await httpGetJson(url, timeoutMs)
if (!r.ok) {
const reason = r.data?.error || r.reason || `HTTP ${r.status}`
return { ok: false, reason: r.status === 403 ? (r.data?.error || '网盘令牌无效') : reason, files: [], dirs: [] }
}
const data = r.data
if (!data?.ok) return { ok: false, reason: data?.error || '读取失败', files: [], dirs: [] }
return {
ok: true,
files: (data.files || []).map((f: any) => ({ relPath: String(f.relPath), size: Number(f.size) || 0, mtime: Number(f.mtime) || 0, hash: f.hash })),
dirs: data.dirs || [],
root: data.root,
}
}
/** 拉取对方的文件关系表 (含墓碑 + 序号); 老对端没有这个接口 -> 返回 null (降级) */
export async function fetchRemoteManifest(ctx: SyncContext): Promise<{ ok: boolean; wire: ManifestWire | null; reason?: string }> {
const base = peerBase(ctx)
if (!base || !ctx.relationId) return { ok: true, wire: null }
const q = peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId)}`)
const url = `http://${base.host}:${base.port}/sync/manifest?${q}`
const r = await httpGetJson(url, 30_000)
if (!r.ok) {
// 404/403 = 对方不支持或没授权 -> 降级成"只有实际扫描"
if (r.status === 404) return { ok: true, wire: null }
return { ok: false, wire: null, reason: r.data?.error || r.reason || `HTTP ${r.status}` }
}
if (!r.data?.ok) return { ok: false, wire: null, reason: r.data?.error || '读取关系表失败' }
return { ok: true, wire: r.data.manifest || null }
}
/** 把我的文件关系表推给对方 (让对方知道我的删除) */
export async function pushRemoteManifest(ctx: SyncContext, manifest: Manifest): Promise<{ ok: boolean; reason?: string; applied?: string[]; conflicts?: string[] }> {
const base = peerBase(ctx)
if (!base || !ctx.relationId) return { ok: true }
const wire = toWire(manifest)
const body = Buffer.from(JSON.stringify(wire), 'utf8')
const q = peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId)}`)
const path = `/sync/manifest?${q}`
return new Promise((resolve) => {
const req = httpRequest({
host: base.host, port: base.port, method: 'POST', path, timeout: 60_000,
headers: { 'Content-Type': 'application/json', 'Content-Length': String(body.length) },
})
req.on('timeout', () => { req.destroy(); resolve({ ok: false, reason: '推送关系表超时' }) })
req.on('error', (e) => resolve({ ok: false, reason: e?.message || '推送失败' }))
req.on('response', (res) => {
const chunks: Buffer[] = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
let data: any = null
try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {}
if (res.statusCode !== 200) {
resolve({ ok: false, reason: data?.error || `HTTP ${res.statusCode}` })
return
}
resolve({ ok: true, applied: data?.applied || [], conflicts: data?.conflicts || [] })
})
})
req.end(body)
})
}
/**
* 组装"远端视图": 对方关系表 + 对方实际扫描。
*
* 为什么要两份:
* - 关系表里有**墓碑和序号**, 用来判定删除方向和防止复活
* - 实际扫描是**当下真实存在什么**, 首次同步时对方关系表是空的, 只能靠它
* 实际扫描的内容覆盖关系表的内容 (更准), 但保留关系表的序号/状态。
*/
export async function buildPeerView(ctx: SyncContext): Promise<{
ok: boolean
reason?: string
entries: Map<string, ManifestWireEntry>
root?: string
}> {
const base = peerBase(ctx)
if (!base) return { ok: false, reason: '对方版本不支持网盘', entries: new Map() }
// 同步区的扫描: 路径相对对方的同步目录根
const scanQ = peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId || '')}&hash=1`)
const scanUrl = `http://${base.host}:${base.port}/sync/scan?${scanQ}`
const scanRes = await httpGetJson(scanUrl, 60_000)
if (!scanRes.ok) {
const reason = scanRes.data?.error || scanRes.reason || `HTTP ${scanRes.status}`
return { ok: false, reason: scanRes.status === 403 ? (scanRes.data?.error || '网盘令牌无效') : reason, entries: new Map() }
}
if (!scanRes.data?.ok) return { ok: false, reason: scanRes.data?.error || '读取失败', entries: new Map() }
const entries = new Map<string, ManifestWireEntry>()
// 1) 先放对方关系表 (带墓碑/序号)
const man = await fetchRemoteManifest(ctx)
if (!man.ok) return { ok: false, reason: man.reason, entries: new Map() }
if (man.wire) {
for (const [p, e] of fromWire(man.wire)) entries.set(p, { ...e })
}
// 2) 实际扫描覆盖内容信息 (保留序号/状态)
for (const f of (scanRes.data.files || []) as SyncFileInfo[]) {
const old = entries.get(f.relPath)
if (old && old.state === 'deleted') {
// 对方说删了, 但实际还在 -> 对方改了还没更新关系表
// 按"实际存在"处理, 但序号沿用对方的 (后面比 seq 时不会误判)
entries.set(f.relPath, { ...old, state: 'present', hash: f.hash || old.hash, size: f.size, mtime: f.mtime })
continue
}
entries.set(f.relPath, {
fileId: old?.fileId || '',
relPath: f.relPath,
hash: f.hash || old?.hash || '',
size: f.size,
mtime: f.mtime,
state: 'present',
seq: old?.seq || 0,
deletedAt: old?.deletedAt || 0,
})
}
return { ok: true, entries, root: scanRes.data.root }
}
// ============ 扫描 + 计划 ============
function emptyStats(): SyncStats {
return { localOnly: 0, remoteOnly: 0, same: 0, upload: 0, download: 0, conflict: 0, deleteLocal: 0, deleteRemote: 0 }
}
function planToItems(plan: { items: any[] }): SyncDiffItem[] {
return plan.items.map((it: any) => {
const presence: SyncDiffItem['presence'] =
it.local && it.remote ? 'both' : it.local ? 'local-only' : 'remote-only'
return {
relPath: it.relPath,
presence,
action: it.action,
local: it.local,
remote: it.remote,
note: it.note,
}
})
}
/**
* 扫描并对比: 本地目录 <-> 对方网盘目录。
* 只读磁盘 (关系表会被更新, 但那只是记录"我这边变了"), 不动对方数据。
*/
export async function scanSync(args: SyncContext & { wantHash?: boolean }): Promise<SyncScanResult> {
const remotePath = args.remotePath || ''
const localRes = scanLocalDir(args.localDir, args.wantHash !== false)
if (!localRes.ok) {
return { ok: false, reason: localRes.reason, localDir: args.localDir, remotePath, items: [], stats: emptyStats() }
}
const peerView = await buildPeerView(args)
if (!peerView.ok) {
return { ok: false, reason: peerView.reason, localDir: args.localDir, remotePath, items: [], stats: emptyStats() }
}
const manifest = getManifest(args)
// 顺序很重要 (Lamport 时钟的标准用法: **先收消息, 再产生事件**):
// 1) advanceClock: 先把本地时钟推到 >= 对方当前的 seq
// 2) reconcileLocal: 再产生本地事件 (本地新增/修改/删除), 分配的 mySeq 一定更大
// 3) mergePeer: 最后合并对方的字段 (为"对方有我没有"的路径建条目)
// 顺序反了会有两个问题:
// - mergePeer 先建条目会把"我这边从来没有"标成 deleted, 本地删除事件就被吞了
// - reconcileLocal 先分配 seq 的话, 我的 seq 可能小于对方已知的 seq, 删除方向判反
advanceClock(manifest, peerView.entries)
reconcileLocal(manifest, localRes.files)
mergePeer(manifest, peerView.entries)
// 三方对比
const plan = planSync(manifest, localRes.files, peerView.entries)
// 存回
saveManifest(args, manifest)
const items = planToItems(plan)
const stats = emptyStats()
for (const it of items) {
if (it.action === 'same') stats.same++
else if (it.action === 'upload') stats.upload++
else if (it.action === 'download') stats.download++
else if (it.action === 'conflict') stats.conflict++
else if (it.action === 'delete-local') stats.deleteLocal++
else if (it.action === 'delete-remote') stats.deleteRemote++
if (it.presence === 'local-only') stats.localOnly++
else if (it.presence === 'remote-only') stats.remoteOnly++
}
return {
ok: true,
localDir: resolve(args.localDir),
remotePath,
remoteRoot: peerView.root,
items,
stats,
}
}
// ============ 关系表读写 ============
function manifestKey(args: SyncContext): string {
return args.relationId || `adhoc:${args.peer.deviceId}:${resolve(args.localDir)}:${args.remotePath || ''}`
}
function getManifest(args: SyncContext): Manifest {
if (!args.store) {
// 没有仓库 (临时对比): 用内存的, 至少单次调用内一致
const g = globalThis as any
if (!g.__lnmMemManifests) g.__lnmMemManifests = new Map<string, Manifest>()
const key = manifestKey(args)
if (!g.__lnmMemManifests.has(key)) {
g.__lnmMemManifests.set(key, { relationId: key, clock: 0, remoteSeq: 0, entries: {} })
}
return g.__lnmMemManifests.get(key)
}
return args.store.loadManifest(manifestKey(args))
}
function saveManifest(args: SyncContext, manifest: Manifest): void {
if (!args.store) {
const g = globalThis as any
if (g.__lnmMemManifests) g.__lnmMemManifests.set(manifestKey(args), manifest)
return
}
args.store.saveManifest(manifest)
}
// ============ 执行同步 ============
export interface SyncApplyItem {
relPath: string
/** 上传/下载/删除; 冲突项由用户选好方向后再传 */
action: 'upload' | 'download' | 'delete-local' | 'delete-remote' | 'skip'
}
export interface SyncApplyResult {
ok: boolean
reason?: string
results: { relPath: string; ok: boolean; action: string; reason?: string }[]
done: number
failed: number
/** 推给对方后, 对方那边发现的冲突 */
peerConflicts?: string[]
}
/** 把相对路径解析到本地目录内, 挡住目录穿越 */
function localTarget(localDir: string, relPath: string): string | null {
const root = resolve(localDir)
const clean = relPath.replace(/^[/\\]+/, '')
const abs = resolve(join(root, clean))
if (abs !== root && !abs.startsWith(root + sep)) return null
return abs
}
/** 拼出对方网盘里的目标相对路径 */
function remoteTarget(_remotePath: string, relPath: string): string {
return relPath
}
/** 上传一个本地文件到对方网盘 */
function uploadToDrive(
ctx: SyncContext, relPath: string, localFile: string,
onProgress?: (sent: number, total: number) => void,
): Promise<{ ok: boolean; reason?: string }> {
const base = peerBase(ctx)
if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' })
let size = 0
let hash = ''
try {
const st = statSync(localFile)
size = st.size
if (st.size <= HASH_MAX_SIZE) hash = hashFileSync(localFile) || ''
} catch (e: any) {
return Promise.resolve({ ok: false, reason: `读不了本地文件: ${e?.message || e}` })
}
const target = remoteTarget(ctx.remotePath || '', relPath)
const path = `/sync/file?${peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId || '')}`)}`
return new Promise((resolve) => {
let sent = 0
let failed = false
let lastEmit = 0
const fail = (reason: string) => {
if (failed) return
failed = true
try { req.destroy() } catch {}
resolve({ ok: false, reason })
}
const req = httpRequest({
host: base.host, port: base.port, method: 'POST', path, timeout: 300_000,
headers: {
'x-file-rel-path': encodeURIComponent(target),
'x-file-size': String(size),
...(hash ? { 'x-file-sha256': hash } : {}),
'x-file-overwrite': '1',
'Content-Length': String(size),
},
})
req.on('timeout', () => fail('上传超时'))
req.on('error', (e) => fail(e?.message || '上传失败'))
req.on('response', (res) => {
const chunks: Buffer[] = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
if (res.statusCode !== 200) {
let msg = `HTTP ${res.statusCode}`
try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8'))?.error || msg } catch {}
fail(msg)
return
}
resolve({ ok: true })
})
})
const rs = createReadStream(localFile)
rs.on('error', (e) => fail(e?.message || '读文件失败'))
rs.on('data', (chunk: Buffer | string) => {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
sent += buf.length
const now = Date.now()
if (now - lastEmit >= 100 || sent >= size) {
lastEmit = now
onProgress?.(sent, size || sent)
}
})
rs.pipe(req)
})
}
/** 从对方网盘下载一个文件到本地 */
function downloadFromDrive(
ctx: SyncContext, relPath: string,
onProgress?: (sent: number, total: number) => void,
): Promise<{ ok: boolean; reason?: string }> {
const base = peerBase(ctx)
if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' })
const target = localTarget(ctx.localDir, relPath)
if (!target) return Promise.resolve({ ok: false, reason: '非法路径' })
const remote = remoteTarget(ctx.remotePath || '', relPath)
const path = `/sync/file?${peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId || '')}&path=${encodeURIComponent(remote)}`)}`
return new Promise((resolve) => {
let sent = 0
let failed = false
let lastEmit = 0
const fail = (reason: string) => {
if (failed) return
failed = true
try { ws.destroy() } catch {}
try { unlinkSync(target) } catch {}
resolve({ ok: false, reason })
}
try {
mkdirSync(dirname(target), { recursive: true })
} catch (e: any) {
resolve({ ok: false, reason: `建目录失败: ${e?.message || e}` })
return
}
const ws = createWriteStream(target)
const req = httpRequest({ host: base.host, port: base.port, method: 'GET', path, timeout: 300_000 })
req.on('timeout', () => fail('下载超时'))
req.on('error', (e) => fail(e?.message || '下载失败'))
req.on('response', (res) => {
if (res.statusCode !== 200) {
const chunks: Buffer[] = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
let msg = `HTTP ${res.statusCode}`
try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8'))?.error || msg } catch {}
fail(msg)
})
return
}
const len = res.headers['content-length']
const total = len ? Number(len) : 0
res.on('data', (chunk: Buffer) => {
sent += chunk.length
const now = Date.now()
if (now - lastEmit >= 100 || (total && sent >= total)) {
lastEmit = now
onProgress?.(sent, total || sent)
}
})
res.pipe(ws)
ws.on('finish', () => { onProgress?.(sent, total || sent); resolve({ ok: true }) })
ws.on('error', (e) => fail(e?.message || '写入失败'))
})
req.end()
})
}
/** 让对方删掉它那边的那份 (删除传播) */
export function deleteFromDrive(ctx: SyncContext, relPath: string): Promise<{ ok: boolean; reason?: string }> {
const base = peerBase(ctx)
if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' })
const remote = remoteTarget(ctx.remotePath || '', relPath)
const path = `/sync/file?${peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId || '')}`)}`
const body = Buffer.alloc(0)
return new Promise((resolve) => {
const req = httpRequest({
host: base.host, port: base.port, method: 'POST', path, timeout: 60_000,
headers: {
'Content-Length': '0',
'x-file-rel-path': encodeURIComponent(remote),
'x-file-action': 'delete',
},
})
req.on('timeout', () => { req.destroy(); resolve({ ok: false, reason: '删除超时' }) })
req.on('error', (e) => resolve({ ok: false, reason: e?.message || '删除失败' }))
req.on('response', (res) => {
const chunks: Buffer[] = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
let data: any = null
try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {}
if (res.statusCode !== 200) {
resolve({ ok: false, reason: data?.error || `HTTP ${res.statusCode}` })
return
}
resolve({ ok: true })
})
})
req.end(body)
})
}
/**
* 执行一批同步项 (用户勾选的那些)。
* - upload: 本地 -> 对方网盘 (覆盖)
* - download: 对方网盘 -> 本地 (覆盖)
* - delete-local: 删本地文件 (对方删了, 传播到本地)
* - delete-remote: 让对方删掉它那份 (我删了, 传播到对方)
* - skip: 跳过
*/
export async function applySyncItems(args: SyncContext & {
items: SyncApplyItem[]
onProgress?: (relPath: string, sent: number, total: number) => void
/** 冲突时保留两份的命名 (自动同步用) */
conflictSuffix?: string
}): Promise<SyncApplyResult> {
const results: SyncApplyResult['results'] = []
let done = 0
let failed = 0
const manifest = getManifest(args)
for (const it of args.items) {
if (it.action === 'skip') {
results.push({ relPath: it.relPath, ok: true, action: 'skip', reason: '已跳过' })
continue
}
let r: { ok: boolean; reason?: string }
if (it.action === 'upload') {
const localFile = localTarget(args.localDir, it.relPath)
if (!localFile) {
r = { ok: false, reason: '非法路径' }
} else if (!existsSync(localFile)) {
r = { ok: false, reason: '本地文件已不存在' }
} else {
r = await uploadToDrive(args, it.relPath, localFile,
(sent, total) => args.onProgress?.(it.relPath, sent, total))
}
} else if (it.action === 'download') {
r = await downloadFromDrive(args, it.relPath,
(sent, total) => args.onProgress?.(it.relPath, sent, total))
} else if (it.action === 'delete-local') {
const localFile = localTarget(args.localDir, it.relPath)
if (!localFile) {
r = { ok: false, reason: '非法路径' }
} else {
try {
if (existsSync(localFile)) unlinkSync(localFile)
r = { ok: true }
} catch (e: any) {
r = { ok: false, reason: `删除失败: ${e?.message || e}` }
}
}
} else {
// delete-remote
r = await deleteFromDrive(args, it.relPath)
}
if (r.ok) done++
else failed++
results.push({ relPath: it.relPath, ok: r.ok, action: it.action, reason: r.reason })
// ---- 成功后更新关系表 ----
if (r.ok) {
const e = manifest.entries[it.relPath]
if (e) {
if (it.action === 'upload') {
e.myState = 'present'
// 基线 = 上传内容的 hash (超大文件可能没 hash -> 保持原基线, 别写成空)
if (e.myHash) {
updateBaseline(manifest, it.relPath, e.myHash, e.mySize, e.myMtime)
e.peerHash = e.myHash
e.peerState = 'present'
}
} else if (it.action === 'download') {
// 下载完本地多了一个文件 -> 本地状态变了, 要 bump seq (否则跨机器比不了)
// 只 hash 刚下载的这一个文件 —— 全目录重扫是 O(n^2), 几百个文件就能把同步卡死
const target = localTarget(args.localDir, it.relPath)
const h = target && existsSync(target) ? hashFileSync(target) : undefined
if (h) {
const st = statSync(target!)
e.myHash = h
e.mySize = st.size
e.myMtime = st.mtimeMs
e.myState = 'present'
if (e.mySeq === 0) e.mySeq = ++manifest.clock
updateBaseline(manifest, it.relPath, e.myHash, e.mySize, e.myMtime)
e.peerHash = e.myHash
e.peerState = 'present'
}
} else if (it.action === 'delete-local') {
// 对方删了 -> 我这边删, 记墓碑
markLocalDeleted(manifest, it.relPath)
} else if (it.action === 'delete-remote') {
// 我删了 -> 传播给对方, 我这边保持墓碑
markLocalDeleted(manifest, it.relPath)
}
}
}
}
// 清理到期墓碑 (对方已经越过这个序号 + 超过保留期)
let peerMaxSeq = manifest.remoteSeq
try {
const man = await fetchRemoteManifest(args)
if (man.wire && typeof man.wire.seq === 'number') peerMaxSeq = Math.max(peerMaxSeq, man.wire.seq)
} catch { /* 拿不到就按现有水位线来 */ }
pruneTombstones(manifest, peerMaxSeq)
saveManifest(args, manifest)
// 把我的关系表推给对方 (删除就是这样传播过去的)
let peerConflicts: string[] = []
try {
const p = await pushRemoteManifest(args, manifest)
if (p.ok && p.conflicts?.length) peerConflicts = p.conflicts
} catch { /* 推送失败不影响本次同步结果 */ }
return { ok: failed === 0, results, done, failed, peerConflicts }
}
+22 -2
View File
@@ -113,10 +113,30 @@ contextBridge.exposeInMainWorld('api', {
driveOpenFolder: () => ipcRenderer.invoke('drive:openFolder'),
drivePickDir: () => ipcRenderer.invoke('drive:pickDir'),
// 文件夹同步: 本地目录 <-> 对方网盘目录
syncPickLocalDir: () => ipcRenderer.invoke('sync:pickLocalDir'),
syncScan: (args: { peerId: string; localDir?: string; remotePath?: string; relationId?: string }) =>
ipcRenderer.invoke('sync:scan', args),
syncApply: (args: {
peerId: string; localDir?: string; remotePath?: string; relationId?: string;
items: { relPath: string; action: 'upload' | 'download' | 'delete-local' | 'delete-remote' | 'skip' }[]
}) => ipcRenderer.invoke('sync:apply', args),
// 关系 (同步盘): 建立/授权/吊销 —— 对方那边直接弹窗, 不用邀请码
relationList: () => ipcRenderer.invoke('relation:list'),
relationRequest: (args: { peerId: string; syncLocalDir: string; syncRemotePath?: string; grants: string[] }) =>
ipcRenderer.invoke('relation:request', args),
relationSetGrants: (args: { relationId: string; grants: string[] }) =>
ipcRenderer.invoke('relation:setGrants', args),
relationSetSync: (args: { relationId: string; syncLocalDir?: string; syncRemotePath?: string; syncEnabled?: boolean }) =>
ipcRenderer.invoke('relation:setSync', args),
relationRevoke: (relationId: string) => ipcRenderer.invoke('relation:revoke', { relationId }),
relationDelete: (relationId: string) => ipcRenderer.invoke('relation:delete', { relationId }),
// 授权 / 审计
remoteApprovalList: () => ipcRenderer.invoke('remote:approval:list'),
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) =>
ipcRenderer.invoke('remote:approval:reply', { requestId, ok, remember }),
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean, extra?: { grants?: string[]; syncLocalDir?: string; syncRemotePath?: string }) =>
ipcRenderer.invoke('remote:approval:reply', { requestId, ok, remember, ...(extra || {}) }),
auditList: (args?: { limit?: number; since?: number; sessionId?: string }) =>
ipcRenderer.invoke('audit:list', args || {}),
auditPrune: (olderThanMs?: number) => ipcRenderer.invoke('audit:prune', olderThanMs),
+117 -3
View File
@@ -246,9 +246,17 @@ export interface ApprovalRequestView {
requestId: string
peerId: string
peerName: string
kind: 'terminal' | 'forward' | 'usb'
kind: 'terminal' | 'forward' | 'usb' | 'relation'
detail: string
ts: number
/** relation: 对方想要的能力 */
grants?: string[]
/** relation: 对方的同步目录 */
syncLocalDir?: string
/** relation: 对方想用我网盘里的哪个子目录 */
syncRemotePath?: string
/** relation: 关系 id */
relationId?: string
}
// 远程: 审计
@@ -282,7 +290,94 @@ export interface DriveListResult {
root?: string
}
// 扩展 API
// 关系 (同步盘): 设备之间的持久绑定 + 分级授权
export type RelationGrant = 'drive' | 'sync'
export const RELATION_GRANT_LABEL: Record<RelationGrant, string> = {
drive: '允许浏览我的网盘',
sync: '允许与我同步文件',
}
export interface RelationView {
relationId: string
peerId: string
peerName: string
peerAddress: string
state: 'pending' | 'active' | 'revoked'
/** 我开放给对方的能力 */
myGrants: RelationGrant[]
/** 对方开放给我的能力 */
peerGrants: RelationGrant[]
/** 我的同步目录 (顶层目录) */
syncLocalDir: string
/** 同步目录下的按关系隔离的子目录 (通常是对方设备名) */
syncSubdir: string
/** 我在对方网盘里用的子目录 */
syncRemotePath: string
/** 对方在我网盘里用的子目录 */
peerSyncRoot: string
syncEnabled: boolean
createdAt: number
updatedAt: number
lastSyncAt: number
lastError: string
}
export interface SyncScanResult {
ok: boolean
reason?: string
localDir?: string
remotePath?: string
remoteRoot?: string
items: SyncDiffItem[]
stats: SyncStats
}
export interface SyncApplyResult {
ok: boolean
reason?: string
results: { relPath: string; ok: boolean; action: string; reason?: string }[]
done: number
failed: number
peerConflicts?: string[]
}
// 远程: 文件夹同步
export interface SyncFileInfo {
relPath: string
size: number
mtime: number
hash?: string
}
export type SyncAction =
| 'upload' // 本地 -> 对方
| 'download' // 对方 -> 本地
| 'delete-local' // 对方删了 -> 删我本地
| 'delete-remote' // 我删了 -> 删对方
| 'conflict'
| 'same'
export interface SyncDiffItem {
relPath: string
presence: 'both' | 'local-only' | 'remote-only'
action: SyncAction
local?: SyncFileInfo
remote?: SyncFileInfo
note: string
}
export interface SyncStats {
localOnly: number
remoteOnly: number
same: number
upload: number
download: number
conflict: number
deleteLocal: number
deleteRemote: number
}
export type LnmApiExtra = {
terminalOpen: (peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) =>
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
@@ -326,8 +421,27 @@ export type LnmApiExtra = {
driveOpenFolder: () => Promise<boolean>
drivePickDir: () => Promise<string | null>
// 文件夹同步
syncPickLocalDir: () => Promise<string | null>
syncScan: (args: { peerId: string; localDir?: string; remotePath?: string; relationId?: string }) => Promise<SyncScanResult>
syncApply: (args: {
peerId: string; localDir?: string; remotePath?: string; relationId?: string;
items: { relPath: string; action: 'upload' | 'download' | 'delete-local' | 'delete-remote' | 'skip' }[]
}) => Promise<SyncApplyResult>
// 关系 (同步盘): 对方那边直接弹窗, 不用邀请码
relationList: () => Promise<RelationView[]>
relationRequest: (args: { peerId: string; syncLocalDir: string; syncRemotePath?: string; grants: string[] }) =>
Promise<{ ok: boolean; reason?: string; relationId?: string }>
relationSetGrants: (args: { relationId: string; grants: string[] }) =>
Promise<{ ok: boolean; relation?: RelationView }>
relationSetSync: (args: { relationId: string; syncLocalDir?: string; syncRemotePath?: string; syncEnabled?: boolean }) =>
Promise<{ ok: boolean; relation?: RelationView }>
relationRevoke: (relationId: string) => Promise<{ ok: boolean; reason?: string }>
relationDelete: (relationId: string) => Promise<{ ok: boolean }>
remoteApprovalList: () => Promise<ApprovalRequestView[]>
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) => Promise<boolean>
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean, extra?: { grants?: string[]; syncLocalDir?: string; syncRemotePath?: string }) => Promise<boolean>
auditList: (args?: { limit?: number; since?: number; sessionId?: string }) => Promise<AuditEntry[]>
auditPrune: (olderThanMs?: number) => Promise<number>
}
+8 -2
View File
@@ -9,10 +9,11 @@ import TerminalPanel from './remote/TerminalPanel.vue'
import ForwardPanel from './remote/ForwardPanel.vue'
import UsbPanel from './remote/UsbPanel.vue'
import DrivePanel from './remote/DrivePanel.vue'
import SyncPanel from './remote/SyncPanel.vue'
import type { DeviceView } from '@/api'
import { initialsOf, colorFor, formatTime } from '@/utils/format'
import { ElAvatar, ElButton, ElEmpty, ElIcon, ElScrollbar, ElMessage } from 'element-plus'
import { ChatLineRound, Promotion, UploadFilled, Position, Connection, Cellphone, FolderOpened } from '@element-plus/icons-vue'
import { ChatLineRound, Promotion, UploadFilled, Position, Connection, Cellphone, FolderOpened, RefreshRight } from '@element-plus/icons-vue'
const props = defineProps<{ peer: DeviceView | null }>()
const emit = defineEmits<{ (e: 'open-image', url: string): void }>()
@@ -21,7 +22,7 @@ const device = useDeviceStore()
const message = useMessageStore()
const session = useSessionStore()
type Tab = 'chat' | 'terminal' | 'forward' | 'usb' | 'drive'
type Tab = 'chat' | 'terminal' | 'forward' | 'usb' | 'drive' | 'sync'
const activeTab = ref<Tab>('chat')
// 切到别的设备时回到 chat tab
watch(() => session.activePeerId, () => { activeTab.value = 'chat' })
@@ -169,6 +170,9 @@ async function onGlobalDrop(e: DragEvent) {
<button :class="{ active: activeTab === 'drive' }" @click="activeTab = 'drive'" :disabled="!peer.online && !channelAlive">
<el-icon><FolderOpened /></el-icon><span>网盘</span>
</button>
<button :class="{ active: activeTab === 'sync' }" @click="activeTab = 'sync'" :disabled="!peer.online && !channelAlive">
<el-icon><RefreshRight /></el-icon><span>同步</span>
</button>
</nav>
</header>
@@ -216,6 +220,8 @@ async function onGlobalDrop(e: DragEvent) {
<UsbPanel v-else-if="activeTab === 'usb'" :peer="peer" />
<!-- drive tab: 浏览对方网盘 + 选文件下载 -->
<DrivePanel v-else-if="activeTab === 'drive'" :peer="peer" />
<!-- sync tab: 本地目录 <-> 对方网盘目录 的手动同步 -->
<SyncPanel v-else-if="activeTab === 'sync'" :peer="peer" />
</main>
<main class="chat-main chat-main-empty" v-else>
<el-empty :image-size="120" description="选择一个设备开始聊天">
@@ -1,16 +1,80 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useApprovalStore } from '@/stores/remote'
import { RELATION_GRANT_LABEL, type RelationGrant, type Settings } from '@/api'
import { Folder, Connection } from '@element-plus/icons-vue'
const store = useApprovalStore()
const remember = ref(false)
const queue = computed(() => store.queue)
const current = computed(() => queue.value[0])
// relation 分支专用: 只有 kind==='relation' 才有值, 模板里就不用反复判空
const relationReq = computed(() => {
const r = current.value
return r && r.kind === 'relation' ? r : null
})
// ---- relation 分支的表单状态 ----
// 可勾选的能力全集
const ALL_GRANTS: RelationGrant[] = ['drive', 'sync']
// 我决定开放给对方的能力
const selectedGrants = ref<RelationGrant[]>([])
// 我这边的同步目录
const myLocalDir = ref('')
// 我想在**对方**网盘里用的子目录 (留空 = 主进程兜底成 同步/<我的设备名>)
// 一项能力都不开放的话关系没有意义, 直接禁用"允许"
const canAllow = computed(() => selectedGrants.value.length > 0)
// 队列里换了一条请求就重置表单, 避免上一条的选择串到下一条
watch(() => current.value?.requestId, () => {
remember.value = false
const r = relationReq.value
if (!r) return
// 默认按对方请求的能力预勾选, 用户可以改
selectedGrants.value = ALL_GRANTS.filter(g => (r.grants || []).includes(g))
myLocalDir.value = ''
// 把上次用过的同步目录带出来, 省得每次都重选
void loadMyLocalDir()
}, { immediate: true })
async function loadMyLocalDir() {
try {
// 渲染层的 Settings 类型还没跟上同步盘字段, 这里只取需要的那个
const st = await window.api.getSettings() as Settings & { syncLocalDir?: string }
if (st?.syncLocalDir && !myLocalDir.value) myLocalDir.value = st.syncLocalDir
} catch {
// 读不到设置就留空, 让用户自己选
}
}
// el-checkbox-group 回传的是 (string|number)[], 这里只留白名单里的能力
function onGrantsChange(val: (string | number)[]) {
selectedGrants.value = ALL_GRANTS.filter(g => val.includes(g))
}
async function pickLocalDir() {
const p = await window.api.syncPickLocalDir()
if (p) myLocalDir.value = p
}
async function reply(ok: boolean) {
if (!current.value) return
await store.reply(current.value.requestId, ok, remember.value)
const req = current.value
if (!req) return
if (req.kind === 'relation') {
if (ok) {
// 关系是长期授权: 不带 24h 记忆, 只回传我开放的范围和目录
await store.reply(req.requestId, true, false, {
grants: selectedGrants.value,
// 留空就传空串: 传 undefined 会被主进程当成"沿用对方的提议值"
syncLocalDir: myLocalDir.value.trim(),
})
} else {
await store.reply(req.requestId, false)
}
return
}
await store.reply(req.requestId, ok, remember.value)
remember.value = false
}
</script>
@@ -18,7 +82,47 @@ async function reply(ok: boolean) {
<template>
<Teleport to="body">
<div v-if="current" class="approval-mask">
<div class="approval-card">
<!-- 建立同步关系: 单独一套 UI, 因为用户要决定开放哪些能力 -->
<div v-if="relationReq" class="approval-card approval-card--relation">
<header>
<el-icon class="hi"><Connection /></el-icon>
<strong>{{ relationReq.peerName }}</strong> 想和你建立同步关系
</header>
<div v-if="relationReq.syncLocalDir" class="peer-dir">
<el-icon><Folder /></el-icon>
<span class="lbl">对方的同步目录</span>
<span class="val">{{ relationReq.syncLocalDir }}</span>
</div>
<section class="sec">
<div class="sec-title">开放给对方的能力</div>
<el-checkbox-group :model-value="selectedGrants" @update:model-value="onGrantsChange">
<el-checkbox v-for="g in ALL_GRANTS" :key="g" :value="g">{{ RELATION_GRANT_LABEL[g] }}</el-checkbox>
</el-checkbox-group>
<p class="tip">已按对方的请求预勾选, 可以取消你不想开放的能力。</p>
</section>
<section class="sec">
<div class="sec-title">我的同步目录</div>
<el-input v-model="myLocalDir" size="small" placeholder="选择要同步的本机文件夹">
<template #append>
<el-button @click="pickLocalDir"><el-icon><Folder /></el-icon>选择</el-button>
</template>
</el-input>
<p v-if="!myLocalDir" class="tip">留空 = 之后再在同步面板里设置</p>
<p class="tip">同步范围 = 对方为这个关系指定的目录, 你只看到对方愿意开放的那部分。</p>
</section>
<footer>
<div class="spacer" />
<button class="btn-reject" @click="reply(false)">拒绝</button>
<button class="btn-allow" :disabled="!canAllow" @click="reply(true)">允许</button>
</footer>
</div>
<!-- 远程操作 (terminal / forward / usb) -->
<div v-else class="approval-card">
<header>
<strong>{{ current.peerName }}</strong> 请求{{
current.kind === 'terminal' ? '打开终端' :
@@ -61,9 +165,17 @@ async function reply(ok: boolean) {
padding: 18px 20px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
}
/* 关系弹窗内容更多, 放宽一点 */
.approval-card--relation { width: 460px; }
header { font-size: 15px; margin-bottom: 8px; }
header .hi { vertical-align: -2px; margin-right: 4px; color: var(--el-color-primary); }
.detail { font-size: 13px; color: var(--el-text-color-regular); padding: 8px 0; }
.warn { font-size: 12px; color: var(--el-color-warning); background: rgba(230, 162, 60, 0.08); padding: 8px 10px; border-radius: 6px; }
.peer-dir { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--el-text-color-secondary); margin: 4px 0 2px; }
.peer-dir .val { color: var(--el-text-color-primary); word-break: break-all; }
.sec { margin-top: 12px; }
.sec-title { font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 6px; }
.tip { margin: 6px 0 0; font-size: 11px; color: var(--el-text-color-placeholder); line-height: 1.5; }
footer { display: flex; align-items: center; margin-top: 12px; gap: 8px; }
.remember { font-size: 12px; display: flex; align-items: center; gap: 4px; color: var(--el-text-color-secondary); }
.spacer { flex: 1; }
@@ -77,4 +189,5 @@ footer { display: flex; align-items: center; margin-top: 12px; gap: 8px; }
.btn-reject { background: var(--el-fill-color-light); color: var(--el-text-color-regular); }
.btn-allow { background: var(--el-color-primary); color: #fff; }
.btn-allow:hover { filter: brightness(1.05); }
</style>
.btn-allow[disabled] { opacity: 0.5; cursor: not-allowed; filter: none; }
</style>
@@ -0,0 +1,633 @@
<script setup lang="ts">
// 同步面板: **先建立关系, 再同步文件**
//
// 两段式:
// 1) 关系区: 跟这个设备的关系状态、我开放了什么、对方开放了什么、同步目录在哪
// 没有关系 -> 填本地目录 + 勾选开放的能力 -> 点"发起请求" -> 对方那边直接弹窗
// 2) 同步区: 扫描变更 (三方对比: 基线 + 本地 + 远端) -> 勾选 -> 同步
//
// 删除的处理 (和主流同步盘一致):
// - 本地删了、对方还在 -> "将删除对方" (把删除传播过去)
// - 对方删了、本地还在 -> "将删除本机"
// - 两边都改过 -> 冲突, 弹窗对比后由用户选方向, 绝不静默覆盖
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import type { DeviceView, SyncDiffItem, SyncScanResult, RelationView, RelationGrant } from '@/api'
import { RELATION_GRANT_LABEL } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Folder, Refresh, Upload, Download, Warning, Delete, Lock, Connection } from '@element-plus/icons-vue'
const props = defineProps<{ peer: DeviceView | null }>()
const ALL_GRANTS: RelationGrant[] = ['drive', 'sync']
// ============ 关系 ============
const relations = ref<RelationView[]>([])
/** 当前 peer 的有效关系 */
const relation = computed<RelationView | null>(() =>
relations.value.find(r => r.peerId === props.peer?.deviceId && r.state === 'active') || null)
/** 等待对方确认的请求 */
const pendingRelation = computed<RelationView | null>(() =>
relations.value.find(r => r.peerId === props.peer?.deviceId && r.state === 'pending') || null)
const newLocalDir = ref('')
const newGrants = ref<RelationGrant[]>(['drive', 'sync'])
const myGrants = ref<RelationGrant[]>([])
const sending = ref(false)
async function loadRelations() {
try {
relations.value = await window.api.relationList()
} catch {
relations.value = []
}
}
/** 发起建立关系 -> 对方那边弹窗 */
async function pickNewLocalDir() {
const p = await window.api.syncPickLocalDir()
if (p) newLocalDir.value = p
}
async function requestRelation() {
if (!props.peer) return
if (!newLocalDir.value) {
const p = await window.api.syncPickLocalDir()
if (!p) { ElMessage.warning('先选一个本机同步目录'); return }
newLocalDir.value = p
}
if (newGrants.value.length === 0) {
ElMessage.warning('至少开放一项能力, 否则对方没法用')
return
}
sending.value = true
const r = await window.api.relationRequest({
peerId: props.peer.deviceId,
syncLocalDir: newLocalDir.value,
grants: newGrants.value,
})
sending.value = false
if (!r.ok) {
ElMessage.error(r.reason || '发起失败')
return
}
ElMessage.success('请求已发出, 等对方确认')
await loadRelations()
}
async function saveGrants() {
if (!relation.value) return
await window.api.relationSetGrants({ relationId: relation.value.relationId, grants: myGrants.value })
ElMessage.success('已保存')
await loadRelations()
}
async function changeLocalDir() {
if (!relation.value) return
const p = await window.api.syncPickLocalDir()
if (!p) return
await window.api.relationSetSync({ relationId: relation.value.relationId, syncLocalDir: p })
await loadRelations()
}
/** 自动同步开关 */
const autoSyncOn = ref(false)
async function toggleAutoSync(on: boolean) {
if (!relation.value) return
const r = await window.api.relationSetSync({ relationId: relation.value.relationId, syncEnabled: on })
if (!r.ok) {
ElMessage.error('设置失败')
autoSyncOn.value = !on
return
}
await loadRelations()
ElMessage.success(on ? '已开启自动同步' : '已关闭自动同步')
}
async function revokeRelation() {
if (!relation.value) return
try {
await ElMessageBox.confirm(
'吊销后对方立刻不能再访问你的网盘和同步目录, 已同步的文件不会删除。要重新同步需要重新建立关系。',
'吊销关系',
{ type: 'warning', confirmButtonText: '吊销', cancelButtonText: '取消' },
)
} catch { return }
await window.api.relationRevoke(relation.value.relationId)
ElMessage.success('已吊销')
await loadRelations()
}
// ============ 同步 ============
const localDir = ref('')
const scanning = ref(false)
const syncing = ref(false)
const errorMsg = ref('')
const result = ref<SyncScanResult | null>(null)
type PickAction = 'upload' | 'download' | 'delete-local' | 'delete-remote'
/** 用户勾选要同步的项 */
const picks = ref<Record<string, PickAction>>({})
/** 冲突项的处理决定 */
const conflictChoice = ref<Record<string, 'upload' | 'download' | 'skip'>>({})
const conflictItem = ref<SyncDiffItem | null>(null)
const conflictVisible = ref(false)
const progress = ref<Record<string, { sent: number; total: number }>>({})
let unsubs: (() => void)[] = []
const items = computed<SyncDiffItem[]>(() => result.value?.items || [])
const changes = computed(() => items.value.filter(i => i.action !== 'same'))
const stats = computed(() => result.value?.stats)
const pickedCount = computed(() => Object.keys(picks.value).length)
const unresolvedConflicts = computed(() =>
items.value.filter(i => i.action === 'conflict' && !conflictChoice.value[i.relPath]))
const canSync = computed(() => {
if (!props.peer || pickedCount.value === 0) return false
return !Object.keys(picks.value).some(p => {
const it = items.value.find(x => x.relPath === p)
return it?.action === 'conflict' && !conflictChoice.value[p]
})
})
function fmtBytes(n?: number) {
if (n === undefined || n === null) return '-'
if (!n) return '0 B'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
}
function fmtTime(ts?: number) {
if (!ts) return '-'
const d = new Date(ts)
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
function shortHash(h?: string) {
if (!h) return '未计算'
return h.slice(0, 12)
}
function statusTag(item: SyncDiffItem) {
switch (item.action) {
case 'upload': return { text: '待上传', type: 'warning' as const }
case 'download': return { text: '待下载', type: 'primary' as const }
case 'delete-local': return { text: '删本机', type: 'danger' as const }
case 'delete-remote': return { text: '删对方', type: 'danger' as const }
case 'conflict': return { text: '冲突', type: 'danger' as const }
default: return { text: '一致', type: 'info' as const }
}
}
function isPicked(p: string) { return !!picks.value[p] }
function togglePick(item: SyncDiffItem, checked: boolean) {
const next = { ...picks.value }
if (checked) {
if (item.action === 'conflict') {
const c = conflictChoice.value[item.relPath]
if (!c || c === 'skip') { openConflict(item); return }
next[item.relPath] = c
} else if (
item.action === 'upload' || item.action === 'download' ||
item.action === 'delete-local' || item.action === 'delete-remote'
) {
next[item.relPath] = item.action
}
} else {
delete next[item.relPath]
}
picks.value = next
}
function pickAll(val: boolean) {
if (!val) { picks.value = {}; return }
const next: Record<string, PickAction> = {}
for (const it of changes.value) {
if (
it.action === 'upload' || it.action === 'download' ||
it.action === 'delete-local' || it.action === 'delete-remote'
) {
next[it.relPath] = it.action
} else if (it.action === 'conflict' && conflictChoice.value[it.relPath] && conflictChoice.value[it.relPath] !== 'skip') {
next[it.relPath] = conflictChoice.value[it.relPath] as 'upload' | 'download'
}
}
picks.value = next
}
function openConflict(item: SyncDiffItem) {
conflictItem.value = item
conflictVisible.value = true
}
function resolveConflict(choice: 'upload' | 'download' | 'skip') {
const item = conflictItem.value
if (!item) return
conflictChoice.value = { ...conflictChoice.value, [item.relPath]: choice }
const next = { ...picks.value }
if (choice === 'skip') delete next[item.relPath]
else next[item.relPath] = choice
picks.value = next
conflictVisible.value = false
}
async function scan() {
if (!props.peer) return
scanning.value = true
errorMsg.value = ''
const r = await window.api.syncScan({
peerId: props.peer.deviceId,
relationId: relation.value?.relationId,
localDir: relation.value?.syncLocalDir || localDir.value || undefined,
})
scanning.value = false
if (!r.ok) {
result.value = null
errorMsg.value = r.reason || '扫描失败'
return
}
result.value = r
if (r.localDir) localDir.value = r.localDir
picks.value = {}
conflictChoice.value = {}
progress.value = {}
const s = r.stats
const del = (s.deleteLocal || 0) + (s.deleteRemote || 0)
ElMessage.info(
`扫描完成: 待上传 ${s.upload} · 待下载 ${s.download} · 将删除 ${del} · 冲突 ${s.conflict} · 一致 ${s.same}`
)
}
async function syncSelected() {
if (!props.peer || pickedCount.value === 0) return
if (unresolvedConflicts.value.length > 0) {
ElMessage.warning(`还有 ${unresolvedConflicts.value.length} 个冲突没选方向`)
return
}
// 有删除动作时二次确认 (删除会真删文件)
const dels = Object.values(picks.value).filter(a => a === 'delete-local' || a === 'delete-remote').length
if (dels > 0) {
try {
await ElMessageBox.confirm(
`本次会删除 ${dels} 个文件 (删除会传播到对方, 对方那边也会删)。确定继续?`,
'确认删除',
{ type: 'warning', confirmButtonText: '确定删除', cancelButtonText: '取消' },
)
} catch { return }
}
const itemsToSend = Object.entries(picks.value).map(([relPath, action]) => ({ relPath, action }))
syncing.value = true
progress.value = {}
const r = await window.api.syncApply({
peerId: props.peer.deviceId,
relationId: relation.value?.relationId,
localDir: relation.value?.syncLocalDir || localDir.value || undefined,
items: itemsToSend,
})
syncing.value = false
if (r.done > 0) ElMessage.success(`同步完成 ${r.done} 项`)
if (r.failed > 0) {
const firstErr = (r.results || []).find(x => !x.ok)
ElMessage.error(`失败 ${r.failed} 个${firstErr?.reason ? ' (首个错误: ' + firstErr.reason + ')' : ''}`)
}
if (r.peerConflicts?.length) {
ElMessage.warning(`对方那边有 ${r.peerConflicts.length} 个文件也改过, 需要双方一起处理`)
}
await scan()
}
function onRowClick(row: SyncDiffItem) {
if (row.action === 'conflict') openConflict(row)
}
onMounted(async () => {
unsubs.push(window.api.on('sync:progress', (p: { relPath: string; sent: number; total: number }) => {
progress.value = { ...progress.value, [p.relPath]: { sent: p.sent, total: p.total } }
}))
// 关系变化 (对方接受/拒绝/被吊销) -> 刷新面板
unsubs.push(window.api.on('relation:changed', () => { loadRelations() }))
unsubs.push(window.api.on('relation:rejected', (p: any) => {
ElMessage.warning(p?.byMe ? '你拒绝了对方的请求' : '对方拒绝了你的请求')
loadRelations()
}))
// 对方删了文件推过来 -> 本地已按规则处理
unsubs.push(window.api.on('sync:applied', (p: any) => {
const n = p?.applied?.length || 0
if (n > 0) ElMessage.info(`对方删除了 ${n} 个文件, 已按规则同步处理`)
}))
await loadRelations()
})
onUnmounted(() => { for (const u of unsubs) try { u() } catch {} })
watch(() => props.peer?.deviceId, async () => {
result.value = null
picks.value = {}
conflictChoice.value = {}
errorMsg.value = ''
await loadRelations()
})
watch(relation, (r) => {
if (r) {
myGrants.value = [...(r.myGrants || [])]
localDir.value = r.syncLocalDir || ''
autoSyncOn.value = !!r.syncEnabled
}
}, { immediate: true })
</script>
<template>
<div class="sync-panel">
<!-- ===== 关系区 ===== -->
<section class="relation-box">
<header class="rhead">
<el-icon><Connection /></el-icon>
<span>同步关系</span>
<el-tag v-if="relation" type="success" size="small" disable-transitions>已建立</el-tag>
<el-tag v-else-if="pendingRelation" type="warning" size="small" disable-transitions>等待对方确认</el-tag>
<el-tag v-else type="info" size="small" disable-transitions>未建立</el-tag>
</header>
<!-- 已建立: 配置 -->
<div v-if="relation" class="rbody">
<div class="line">
<span class="k">我的同步目录</span>
<span class="v mono">{{ relation.syncLocalDir || '(未设置)' }}</span>
<el-button size="small" link @click="changeLocalDir"><el-icon><Folder /></el-icon>更换</el-button>
</div>
<div class="line">
<span class="k">实际同步位置</span>
<span class="v mono dim">{{ relation.syncLocalDir }}/{{ relation.syncSubdir }}</span>
</div>
<div class="line">
<span class="k">同步范围</span>
<span class="v dim">对方为这个关系开放的目录 (只读, 对方决定)</span>
</div>
<div class="line">
<span class="k">对方开放给我</span>
<span class="v">
<el-tag v-if="relation.peerGrants.includes('drive')" size="small" type="info" disable-transitions>浏览网盘</el-tag>
<el-tag v-if="relation.peerGrants.includes('sync')" size="small" type="info" disable-transitions>同步文件</el-tag>
<span v-if="!relation.peerGrants.length" class="none">无</span>
</span>
</div>
<div class="line">
<span class="k">我开放给对方</span>
<span class="v grants">
<el-checkbox-group v-model="myGrants" size="small">
<el-checkbox v-for="g in ALL_GRANTS" :key="g" :label="g" :value="g">{{ RELATION_GRANT_LABEL[g] }}</el-checkbox>
</el-checkbox-group>
<el-button size="small" link type="primary" @click="saveGrants">保存</el-button>
</span>
</div>
<div class="line">
<span class="k">自动同步</span>
<span class="v">
<el-switch v-model="autoSyncOn" @change="toggleAutoSync" />
<span class="dim">每分钟检查一次; 两边都改过的文件会跳过, 不会自动覆盖</span>
</span>
</div>
<div class="line">
<span class="k">上次同步</span>
<span class="v dim">
{{ relation.lastSyncAt ? fmtTime(relation.lastSyncAt) : '还没同步过' }}
<el-tag v-if="relation.lastError" type="danger" size="small" disable-transitions>{{ relation.lastError }}</el-tag>
</span>
</div>
<div class="line">
<el-button size="small" type="danger" plain @click="revokeRelation">
<el-icon><Lock /></el-icon>吊销关系
</el-button>
</div>
</div>
<!-- 等待确认 -->
<div v-else-if="pendingRelation" class="rbody">
<p class="hint">已向对方发出请求, 等对方在弹窗里确认。</p>
<div class="line">
<span class="k">我的同步目录</span>
<span class="v mono">{{ pendingRelation.syncLocalDir }}</span>
</div>
</div>
<!-- 未建立: 发起 -->
<div v-else class="rbody">
<p class="hint">
同步需要先建立关系 —— 点下面的按钮, <b>对方会直接弹窗</b>确认, 不用邀请码。
</p>
<div class="line">
<span class="k">本机同步目录</span>
<el-input v-model="newLocalDir" placeholder="选择要同步的本机文件夹" size="small" style="flex:1">
<template #append>
<el-button @click="pickNewLocalDir">
<el-icon><Folder /></el-icon>选择
</el-button>
</template>
</el-input>
</div>
<div class="line">
<span class="k">我开放给对方</span>
<span class="v grants">
<el-checkbox-group v-model="newGrants" size="small">
<el-checkbox v-for="g in ALL_GRANTS" :key="g" :label="g" :value="g">{{ RELATION_GRANT_LABEL[g] }}</el-checkbox>
</el-checkbox-group>
</span>
</div>
<div class="line">
<el-button type="primary" size="small" :loading="sending" @click="requestRelation">
<el-icon><Connection /></el-icon>发起同步关系
</el-button>
</div>
</div>
</section>
<!-- ===== 同步区 ===== -->
<section class="sync-box">
<div class="row actions">
<el-button type="primary" :loading="scanning" :disabled="!props.peer" @click="scan">
<el-icon><Refresh /></el-icon>扫描变更
</el-button>
<el-button type="success" :loading="syncing" :disabled="!canSync" @click="syncSelected">
<el-icon><Upload /></el-icon>同步所选 ({{ pickedCount }})
</el-button>
<el-button link @click="pickAll(true)">全选可同步项</el-button>
<el-button link @click="pickAll(false)">清空</el-button>
</div>
<div v-if="errorMsg" class="empty-tip warn">
<p>{{ errorMsg }}</p>
<small v-if="!relation">对方需要升级到带同步盘的版本, 并和你建立关系</small>
</div>
<div v-else-if="!result" class="empty-tip">
<p>点"扫描变更"看看两边有什么不同</p>
<small>扫描只读取双方文件信息, 不会改动任何数据</small>
</div>
<template v-else>
<div class="stats-bar">
<span class="stat upload">待上传 <b>{{ stats?.upload ?? 0 }}</b></span>
<span class="stat download">待下载 <b>{{ stats?.download ?? 0 }}</b></span>
<span class="stat del">将删除 <b>{{ (stats?.deleteLocal ?? 0) + (stats?.deleteRemote ?? 0) }}</b></span>
<span class="stat conflict">冲突 <b>{{ stats?.conflict ?? 0 }}</b></span>
<span class="stat same">一致 <b>{{ stats?.same ?? 0 }}</b></span>
<span class="spacer" />
<el-button v-if="unresolvedConflicts.length" type="danger" link @click="openConflict(unresolvedConflicts[0])">
<el-icon><Warning /></el-icon>{{ unresolvedConflicts.length }} 个冲突待处理
</el-button>
</div>
<el-table :data="changes" size="small" highlight-current-row @row-click="onRowClick" class="sync-table">
<el-table-column width="40" align="center">
<template #header>
<el-checkbox :model-value="pickedCount > 0" @change="pickAll(!!$event)" />
</template>
<template #default="{ row }">
<el-checkbox
:model-value="isPicked(row.relPath)"
:disabled="row.action === 'same'"
@change="togglePick(row, !!$event)"
@click.stop
/>
</template>
</el-table-column>
<el-table-column prop="relPath" label="文件 (相对路径)" min-width="220" show-overflow-tooltip />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusTag(row).type" size="small" disable-transitions>{{ statusTag(row).text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="本机" min-width="160">
<template #default="{ row }">
<div v-if="row.local" class="side">
<span class="sz">{{ fmtBytes(row.local.size) }}</span>
<span class="tm">{{ fmtTime(row.local.mtime) }}</span>
</div>
<span v-else class="side none">—</span>
</template>
</el-table-column>
<el-table-column label="对方" min-width="160">
<template #default="{ row }">
<div v-if="row.remote" class="side">
<span class="sz">{{ fmtBytes(row.remote.size) }}</span>
<span class="tm">{{ fmtTime(row.remote.mtime) }}</span>
</div>
<span v-else class="side none">—</span>
</template>
</el-table-column>
<el-table-column label="说明" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span class="note">{{ row.note }}</span>
</template>
</el-table-column>
<el-table-column width="90" align="center">
<template #default="{ row }">
<el-button v-if="row.action === 'conflict'" size="small" type="danger" link @click.stop="openConflict(row)">对比</el-button>
<el-progress
v-else-if="progress[row.relPath]"
:percentage="progress[row.relPath].total ? Math.round(progress[row.relPath].sent / progress[row.relPath].total * 100) : 0"
:stroke-width="6"
style="width: 70px"
/>
<span v-else-if="row.action === 'upload'" class="dir-hint"><el-icon><Upload /></el-icon></span>
<span v-else-if="row.action === 'download'" class="dir-hint"><el-icon><Download /></el-icon></span>
<span v-else-if="row.action === 'delete-local' || row.action === 'delete-remote'" class="dir-hint del">
<el-icon><Delete /></el-icon>
</span>
</template>
</el-table-column>
</el-table>
<p class="tip">
删除会传播: 你这边删掉的, 对方那份也会被删 (反过来也一样)。两边都改过的文件会标成冲突, 让你选方向, 不会静默覆盖。
</p>
</template>
</section>
<!-- 冲突对比弹窗 -->
<el-dialog v-model="conflictVisible" title="文件冲突 — 两边都改过" width="620px">
<div v-if="conflictItem" class="conflict-box">
<div class="cpath">{{ conflictItem.relPath }}</div>
<div class="cmp">
<div class="col">
<div class="chead">本机</div>
<div class="row"><span>大小</span><b>{{ fmtBytes(conflictItem.local?.size) }}</b></div>
<div class="row"><span>修改时间</span><b>{{ fmtTime(conflictItem.local?.mtime) }}</b></div>
<div class="row"><span>SHA256</span><b class="mono">{{ shortHash(conflictItem.local?.hash) }}</b></div>
</div>
<div class="col">
<div class="chead">对方</div>
<div class="row"><span>大小</span><b>{{ fmtBytes(conflictItem.remote?.size) }}</b></div>
<div class="row"><span>修改时间</span><b>{{ fmtTime(conflictItem.remote?.mtime) }}</b></div>
<div class="row"><span>SHA256</span><b class="mono">{{ shortHash(conflictItem.remote?.hash) }}</b></div>
</div>
</div>
<p class="cwarn">两边都改过, 没法自动判断谁该覆盖谁 —— 请手动选一个方向, 另一份会保留。</p>
</div>
<template #footer>
<el-button @click="resolveConflict('skip')">跳过这个文件</el-button>
<el-button type="warning" @click="resolveConflict('download')">
<el-icon><Download /></el-icon>用对方覆盖本机
</el-button>
<el-button type="primary" @click="resolveConflict('upload')">
<el-icon><Upload /></el-icon>用本机覆盖对方
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.sync-panel { display: flex; flex-direction: column; height: 100%; gap: 10px; padding: 10px 12px; box-sizing: border-box; overflow: hidden; }
.relation-box { border: 1px solid var(--el-border-color-lighter); border-radius: 8px; padding: 10px 12px; flex-shrink: 0; }
.rhead { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.rbody { display: flex; flex-direction: column; gap: 6px; }
.line { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.line .k { width: 92px; color: var(--el-text-color-secondary); flex-shrink: 0; }
.line .v { flex: 1; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.line .v.mono { font-family: ui-monospace, Menlo, Consolas, monospace; word-break: break-all; }
.line .v.none { color: var(--el-text-color-placeholder); }
.line .v.dim { color: var(--el-text-color-secondary); font-size: 11px; }
.grants { gap: 10px; }
.hint { margin: 0 0 6px; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.6; }
.sync-box { display: flex; flex-direction: column; gap: 8px; flex: 1; min-height: 0; }
.row { display: flex; align-items: center; gap: 8px; }
.row.actions { gap: 10px; }
.stats-bar { display: flex; align-items: center; gap: 14px; font-size: 12px; color: var(--el-text-color-secondary); }
.stats-bar .stat b { color: var(--el-text-color-primary); }
.stats-bar .spacer { flex: 1; }
.stat.upload b { color: var(--el-color-warning); }
.stat.download b { color: var(--el-color-primary); }
.stat.del b { color: var(--el-color-danger); }
.stat.conflict b { color: var(--el-color-danger); }
.sync-table { flex: 1; min-height: 0; }
.side { display: flex; flex-direction: column; line-height: 1.35; }
.side .sz { font-size: 12px; }
.side .tm { font-size: 11px; color: var(--el-text-color-secondary); }
.side.none { color: var(--el-text-color-placeholder); }
.note { font-size: 12px; color: var(--el-text-color-secondary); }
.dir-hint { color: var(--el-text-color-placeholder); }
.dir-hint.del { color: var(--el-color-danger); }
.tip { margin: 0; font-size: 11px; color: var(--el-text-color-placeholder); line-height: 1.5; }
.empty-tip { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--el-text-color-secondary); }
.empty-tip p { margin: 0; }
.empty-tip small { color: var(--el-text-color-placeholder); }
.empty-tip.warn p { color: var(--el-color-danger); }
.conflict-box { display: flex; flex-direction: column; gap: 12px; }
.cpath { font-size: 13px; font-weight: 600; word-break: break-all; }
.cmp { display: flex; gap: 12px; }
.cmp .col { flex: 1; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; padding: 10px 12px; }
.cmp .chead { font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 8px; }
.cmp .row { display: flex; justify-content: space-between; font-size: 12px; line-height: 2; }
.cmp .row span { color: var(--el-text-color-secondary); }
.mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11px; }
.cwarn { margin: 0; font-size: 12px; color: var(--el-color-warning); line-height: 1.6; }
</style>
+3 -2
View File
@@ -261,8 +261,9 @@ export const useApprovalStore = defineStore('approval', () => {
queue.value = [...queue.value, req]
}
async function reply(requestId: string, ok: boolean, remember = false) {
await window.api.remoteApprovalReply(requestId, ok, remember)
// extra: relation 请求要带回"我开放给对方的能力"和我这边的同步目录
async function reply(requestId: string, ok: boolean, remember = false, extra?: { grants?: string[]; syncLocalDir?: string; syncRemotePath?: string }) {
await window.api.remoteApprovalReply(requestId, ok, remember, extra)
queue.value = queue.value.filter(r => r.requestId !== requestId)
}