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:
@@ -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.
|
||||
|
||||
@@ -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 分支) |
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 } = {}) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// ============ 远程终端 ============
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user