From 4fc86edd4ebc7898531b7bbf93602adfeb28718c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timeless=E5=B0=8F=E5=B8=85?= <1018008088@qq.com> Date: Tue, 20 Oct 2026 19:30:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(sync):=20=E5=90=8C=E6=AD=A5=E7=9B=98=20+?= =?UTF-8?q?=20=E5=85=B3=E7=B3=BB=E5=B1=82=20(=E5=85=88=E5=BB=BA=E5=85=B3?= =?UTF-8?q?=E7=B3=BB,=20=E5=86=8D=E5=81=9A=E6=96=87=E4=BB=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 关系层 (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 / 三方对比判定表 / 安全要点 / 已知不一致。 --- AGENTS.md | 37 +- docs/relation-and-sync.md | 601 ++++++++++++++ package.json | 2 + scripts/selftest/main.ts | 745 ++++++++++++++++- scripts/selftest/sync-e2e.ts | 270 +++++++ src/main/auto-sync.ts | 177 ++++ src/main/file-server.ts | 628 ++++++++++++++- src/main/index.ts | 30 +- src/main/ipc.ts | 251 +++++- src/main/protocol.ts | 60 ++ src/main/relation-service.ts | 501 ++++++++++++ src/main/relations.ts | 307 +++++++ src/main/remote/approval.ts | 55 +- src/main/settings.ts | 6 + src/main/sync-manifest.ts | 640 +++++++++++++++ src/main/sync.ts | 754 ++++++++++++++++++ src/preload/index.ts | 24 +- src/renderer/src/api.ts | 120 ++- src/renderer/src/components/ChatView.vue | 10 +- .../src/components/remote/ApprovalDialog.vue | 123 ++- .../src/components/remote/SyncPanel.vue | 633 +++++++++++++++ src/renderer/src/stores/remote.ts | 5 +- 22 files changed, 5905 insertions(+), 74 deletions(-) create mode 100644 docs/relation-and-sync.md create mode 100644 scripts/selftest/sync-e2e.ts create mode 100644 src/main/auto-sync.ts create mode 100644 src/main/relation-service.ts create mode 100644 src/main/relations.ts create mode 100644 src/main/sync-manifest.ts create mode 100644 src/main/sync.ts create mode 100644 src/renderer/src/components/remote/SyncPanel.vue diff --git a/AGENTS.md b/AGENTS.md index b3fb812..5a5f404 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/relation-and-sync.md b/docs/relation-and-sync.md new file mode 100644 index 0000000..74e3038 --- /dev/null +++ b/docs/relation-and-sync.md @@ -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 // 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-` 再 `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 分支) | diff --git a/package.json b/package.json index d0c1b90..427bd8e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/selftest/main.ts b/scripts/selftest/main.ts index e885b23..c28f973 100644 --- a/scripts/selftest/main.ts +++ b/scripts/selftest/main.ts @@ -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 { return new Promise((resolve, reject) => { @@ -228,6 +296,123 @@ function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 50): Promise(fn: () => T | undefined | null | false, timeoutMs = 5000, stepMs = 30): Promise { + 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; 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([['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([['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([ + ['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([['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([['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([['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 diff --git a/scripts/selftest/sync-e2e.ts b/scripts/selftest/sync-e2e.ts new file mode 100644 index 0000000..a6c6b75 --- /dev/null +++ b/scripts/selftest/sync-e2e.ts @@ -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) }) diff --git a/src/main/auto-sync.ts b/src/main/auto-sync.ts new file mode 100644 index 0000000..95c235c --- /dev/null +++ b/src/main/auto-sync.ts @@ -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() + /** relationId -> 当前连续失败次数 */ + private failures = new Map() + /** 正在跑的关系 (防重入) */ + private running = new Set() + + 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 { + 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 { + 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) + } +} diff --git a/src/main/file-server.ts b/src/main/file-server.ts index b0285d7..e014c2f 100644 --- a/src/main/file-server.ts +++ b/src/main/file-server.ts @@ -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= 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 { + 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 diff --git a/src/main/index.ts b/src/main/index.ts index 584ca41..6f42a40 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 访问 (渐进式升级)。 } } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index aca8cfd..2de57c7 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 } = {}) => { diff --git a/src/main/protocol.ts b/src/main/protocol.ts index d58a5bd..62d47a0 100644 --- a/src/main/protocol.ts +++ b/src/main/protocol.ts @@ -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 +} // ============ 远程终端 ============ diff --git a/src/main/relation-service.ts b/src/main/relation-service.ts new file mode 100644 index 0000000..71ddb19 --- /dev/null +++ b/src/main/relation-service.ts @@ -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() + + 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 { + // 已经有关系了就别重复弹 (直接回绝) + 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' }) + } +} diff --git a/src/main/relations.ts b/src/main/relations.ts new file mode 100644 index 0000000..56d7e23 --- /dev/null +++ b/src/main/relations.ts @@ -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 = { + 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 | 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) +} diff --git a/src/main/remote/approval.ts b/src/main/remote/approval.ts index 66c8e26..0fbcef4 100644 --- a/src/main/remote/approval.ts +++ b/src/main/remote/approval.ts @@ -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 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() const approvedAt = new Map() 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() -export function enqueueApproval(req: ApprovalRequest): Promise<{ ok: boolean; remember?: boolean }> { +export function enqueueApproval(req: ApprovalRequest): Promise { 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 } diff --git a/src/main/settings.ts b/src/main/settings.ts index 431c54b..eaaf567 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -30,6 +30,10 @@ export interface Settings { driveEnabled: boolean /** 网盘根目录 (对外共享的目录, 对端只能看到这个目录里的内容) */ driveDir: string + /** 文件夹同步: 上次用的本地目录 */ + syncLocalDir: string + /** 文件夹同步: 上次用的对方网盘子目录 (相对网盘根) */ + syncRemotePath: string } export const store = new Store({ @@ -52,6 +56,8 @@ export const store = new Store({ auditRetentionDays: 30, driveEnabled: true, driveDir: paths.defaultDriveDir, + syncLocalDir: '', + syncRemotePath: '', }, }) diff --git a/src/main/sync-manifest.ts b/src/main/sync-manifest.ts new file mode 100644 index 0000000..1b1b755 --- /dev/null +++ b/src/main/sync-manifest.ts @@ -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 +} + +/** 发给对方的"我的当前状态" */ +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 +} + +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 { + const m = new Map() + 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() // 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): 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): 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, +): SyncPlan { + const localMap = new Map(localFiles.map(f => [f.relPath, f])) + const allPaths = new Set([ + ...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 +} diff --git a/src/main/sync.ts b/src/main/sync.ts new file mode 100644 index 0000000..e994175 --- /dev/null +++ b/src/main/sync.ts @@ -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 + 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() + + // 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 { + 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() + 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 { + 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 } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index b2dbfee..4ba7f11 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/api.ts b/src/renderer/src/api.ts index 70d6e44..3560e7d 100644 --- a/src/renderer/src/api.ts +++ b/src/renderer/src/api.ts @@ -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 = { + 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 drivePickDir: () => Promise + // 文件夹同步 + syncPickLocalDir: () => Promise + syncScan: (args: { peerId: string; localDir?: string; remotePath?: string; relationId?: string }) => Promise + syncApply: (args: { + peerId: string; localDir?: string; remotePath?: string; relationId?: string; + items: { relPath: string; action: 'upload' | 'download' | 'delete-local' | 'delete-remote' | 'skip' }[] + }) => Promise + + // 关系 (同步盘): 对方那边直接弹窗, 不用邀请码 + relationList: () => Promise + 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 - remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) => Promise + remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean, extra?: { grants?: string[]; syncLocalDir?: string; syncRemotePath?: string }) => Promise auditList: (args?: { limit?: number; since?: number; sessionId?: string }) => Promise auditPrune: (olderThanMs?: number) => Promise } \ No newline at end of file diff --git a/src/renderer/src/components/ChatView.vue b/src/renderer/src/components/ChatView.vue index 6db9b7d..6f23f77 100644 --- a/src/renderer/src/components/ChatView.vue +++ b/src/renderer/src/components/ChatView.vue @@ -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('chat') // 切到别的设备时回到 chat tab watch(() => session.activePeerId, () => { activeTab.value = 'chat' }) @@ -169,6 +170,9 @@ async function onGlobalDrop(e: DragEvent) { + @@ -216,6 +220,8 @@ async function onGlobalDrop(e: DragEvent) { + +
diff --git a/src/renderer/src/components/remote/ApprovalDialog.vue b/src/renderer/src/components/remote/ApprovalDialog.vue index 3f5c13d..608c7f6 100644 --- a/src/renderer/src/components/remote/ApprovalDialog.vue +++ b/src/renderer/src/components/remote/ApprovalDialog.vue @@ -1,16 +1,80 @@ @@ -18,7 +82,47 @@ async function reply(ok: boolean) {