feat(sync): 同步盘 + 关系层 (先建关系, 再做文件同步)

## 关系层 (src/main/relations.ts, relation-service.ts)

原来"发现即信任": UDP 广播包里明文带着 driveToken, 同网段任何进程拿到就能浏览
你的整个网盘, 而且没有"授权"这个概念。现在拆成三层:

  发现 (临时) -> 关系 (持久, 用户显式建立) -> 授权 (分级, 双方各自独立)

建立关系的方式跟远程终端的授权闸门**完全一致** —— 对方直接弹窗, 不用邀请码:
A 点"发起同步关系" -> WS 帧 relation:request -> B 侧弹窗 (复用 ApprovalDialog,
kind='relation') -> 同意 -> relation:accept -> 双方落库并交换 per-peer 令牌。

- 令牌: myToken=我签发给对方的, peerToken=对方签发给我的
- **driveToken 不再进广播包**; file-server 在关系令牌匹配不上时回退到旧的全局
  token, 但只允许"只读浏览网盘" —— 不给同步区, 也不给写/删
- 关系存 JSON 文件 (原子写), 不做 schema 迁移
- 授权能力: drive(浏览我的网盘) / sync(与我同步文件), 双方各自独立勾选
- 远程终端/端口转发/USB 仍走原来的 remoteAllowPeers 白名单, 没有并进关系层
- 同一对端 60s 内只弹一次窗 (WS 是"发现即建立"的, 不限流就能被刷屏)

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

用户的同步目录可以是任意文件夹 (~/文档/工作), 不必待在网盘目录里。

  /drive/*  网盘   -> 共享目录, 要 drive 授权
  /sync/*   同步区 -> 某关系专属的同步目录, 要 sync 授权, 路径严格限制在该目录内

顶层目录可以同时和多个设备同步: 每个关系自动分配一个子目录 (~/备份/MECHREVO/,
~/备份/lenovo/), 物理隔开, 同名文件不会互相覆盖 —— "统一目录多个聊天备份"成立。

## 文件关系表 (src/main/sync-manifest.ts) —— 同步盘地基

核心是**区分"文件被删了"和"文件本来就不存在"**:

- **墓碑**: 删除不抹记录, 只标 state='deleted'。直接抹掉的话, 对方离线回来会把
  已删文件当成"新增"重新下载 -> 删除永远删不掉; 对方从备份恢复也会让删除被
  静默撤销。有墓碑 + seq 比较就都能挡住。
- **seq = Lamport 时钟**: hash 只能答"一样不一样", seq 才能答"谁更晚" —— 判定
  删除方向全靠它。扫描顺序必须是 advanceClock -> reconcileLocal -> mergePeer
  (先收消息再产生事件), 否则跨机器的 seq 不可比, 删除方向会判反。
- **三方对比**: 基线(上次两边一致的 hash) + 本地 + 远端。缺基线才退回 mtime 兜底。
- 墓碑留 90 天 (比大品牌默认 30 天保守), 且要"对方已越过该 seq"才清理。
- **删除传播**: 对方推关系表过来时, 比对基线确认我没改过才删本地那份; 改过则
  报冲突 —— 绝不静默丢数据。
- 冲突: 手动同步弹窗对比让用户选方向; 自动同步跳过并记录, 不替用户决定。

## 其余

- 自动同步 (src/main/auto-sync.ts): 60s 轮询 + 指数退避, 对方上线/开关打开时
  立刻跑一轮。
- 同步面板 (SyncPanel.vue): 关系区 + 变更列表 (含"将删除对方/本机") + 冲突对比
  弹窗 + 自动同步开关。
- 关系授权弹窗复用远程终端那套 ApprovalDialog。
- 目录穿越防护: 上传/下载/删除的路径都严格限制在各自根目录内, 且**软链归一后
  再比一次** (同步目录是用户任意文件夹, 里面出现软链很常见)。
- 跨关系校验: 令牌与 relationId 绑定, A 关系的对方不能读写 B 关系的同步目录。
- 关系令牌在 IPC 返回给渲染层时剥掉, 界面拿不到凭据。
- 建关系/授权/同步/删除传播都写 audit_log。

## 测试

- npm run selftest       64 通过 / 0 失败 (R关系 / M墓碑删除传播 / Y同步盘)
- npm run selftest:sync  26 条全通过 (双节点端到端: 建立关系->同步->删->传播->防复活)

## 文档

docs/relation-and-sync.md —— 三层模型 / 关系建立 / 墓碑与 seq / 三方对比判定表 /
安全要点 / 已知不一致。
This commit is contained in:
2026-10-20 19:30:25 +08:00
parent 090bc3593f
commit 4fc86edd4e
22 changed files with 5905 additions and 74 deletions
+236 -15
View File
@@ -18,6 +18,11 @@ import { ChatServer } from './chat-server'
import { ChatClient } from './chat-client'
import { FileServer, uploadFile, uploadBuffer } from './file-server'
import { listDrive, getDriveRoot, downloadDriveFile, collectDriveFiles } from './drive'
import { scanSync, applySyncItems, type SyncApplyItem } from './sync'
import { RelationStore, type Grant } from './relations'
import { ManifestStore } from './sync-manifest'
import { AutoSync } from './auto-sync'
import { RelationService } from './relation-service'
import { broadcastToRenderer, focusMainWindow } from './window'
import { rebuildMenu } from './tray'
import { notify, setBadgeCount } from './notify'
@@ -26,7 +31,7 @@ import { ForwardManager } from './remote/forward'
import { UsbBridgeManager } from './remote/usb'
import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval'
import { computeWsState } from './ws-state'
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig } from './protocol'
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig, WsFrame } from './protocol'
import { DEFAULT_PORTS } from './protocol'
interface NetCtx {
@@ -38,6 +43,11 @@ interface NetCtx {
terminal: TerminalManager
forward: ForwardManager
usb: UsbBridgeManager
relations: RelationStore
manifestStore: ManifestStore
relationService: RelationService
/** 自动同步 (在 bindNetworkContext 里创建) */
autoSync?: AutoSync
}
let ctx: NetCtx | null = null
@@ -93,8 +103,60 @@ function disarmPendingMetaTimer(fileId: string) {
}
}
/**
* 找一个对端 (优先用在线的 discovery 信息, 离线时退回设备表)。
* 有关系时把 driveToken 换成关系签发的 per-peer 令牌。
*/
function findPeer(peerId: string): DeviceInfo | null {
const p = ctx?.discovery.getPeers().find(x => x.deviceId === peerId)
if (p) {
// 有关系时用关系签发的 per-peer 令牌 (广播包里已经不再带全局 driveToken 了)
const rel = ctx?.relations.getActiveRelationByPeer(peerId)
if (rel?.peerToken) return { ...(p as DeviceInfo), driveToken: rel.peerToken }
return p as DeviceInfo
}
const d = listDevices().find(x => x.device_id === peerId) as any
if (!d) return null
return {
deviceId: d.device_id, name: d.name, hostname: d.hostname, platform: d.platform,
version: d.version, appVersion: d.app_version, address: d.last_ip || '127.0.0.1',
chatPort: DEFAULT_PORTS.chat, filePort: DEFAULT_PORTS.file, ts: d.last_seen,
driveToken: d.drive_token || undefined,
} as DeviceInfo
}
// 关系对渲染层的视图: **剥掉令牌** (界面不需要, 也不该拿到凭据)
function publicRelation(r: any) {
if (!r) return r
const { myToken, peerToken, ...rest } = r
return rest
}
// 关系帧路由: 建立 / 接受 / 拒绝 (两个方向都走这里)
// 返回 true = 这个帧已经处理完了, 调用方不用再管
function routeRelationFrame(peer: DeviceInfo, frame: WsFrame): boolean { if (frame.type === 'relation:request') {
ctx?.relationService.handleRelationRequest(peer, frame.payload)
.catch((e) => console.warn('[relation] handle request error:', e?.message))
return true
}
if (frame.type === 'relation:accept') { ctx?.relationService.handleRelationAccept(peer, frame.payload); return true }
if (frame.type === 'relation:reject') { ctx?.relationService.handleRelationReject(peer, frame.payload); return true }
if (frame.type === 'relation:revoked') { ctx?.relationService.handleRelationRevoked(peer, frame.payload.relationId); return true }
return false
}
export function bindNetworkContext(c: NetCtx) {
ctx = c
// 自动同步: 关系开了开关就定时跑一轮 (冲突跳过, 不静默覆盖)
c.autoSync = new AutoSync({
relations: c.relations,
manifestStore: c.manifestStore,
findPeer: (peerId) => findPeer(peerId),
notify: (event, payload) => broadcastToRenderer(event, payload),
})
c.autoSync.start()
// 网络事件 -> 渲染进程
c.discovery.on('found', (d) => {
// 用户已删除的设备 (ignored=1) — 静默忽略, 不进 sidebar, 不重连
@@ -213,6 +275,7 @@ export function bindNetworkContext(c: NetCtx) {
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
return
}
if (routeRelationFrame(peer, frame)) return
})
// chat-server 收到的所有 ws 帧 (来自其他设备的入站)
@@ -233,6 +296,7 @@ export function bindNetworkContext(c: NetCtx) {
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
return
}
if (routeRelationFrame(peer, frame)) return
})
c.chatServer.on('connect', (_ws, peer) => {
@@ -242,6 +306,8 @@ export function bindNetworkContext(c: NetCtx) {
broadcastToRenderer('device:online', { deviceId: peer.deviceId, address: peer.address })
// 对端上线 -> 触发离线消息 flush
flushPendingFor(peer.deviceId)
// 对端上线 -> 立刻试一次同步 (不用等下一个轮询周期)
c.autoSync?.trigger()
})
// chat-client WS 状态不广播给渲染层, 渲染层只看 discovery 的 online/offline
@@ -298,6 +364,18 @@ export function bindNetworkContext(c: NetCtx) {
broadcastToRenderer('drive:inbound', { name: info.name, size: info.size, peerId: peer?.deviceId, peerName: who })
})
// 对端往我的网盘上传了文件 (文件夹同步的上行) -> 本机提示 + 审计
c.fileServer.on('driveUploaded', (info: { relPath: string; size: number; fromAddr: string }) => {
const peer = ctx?.discovery.getPeers().find(p => p.address === info.fromAddr)
const who = peer ? peer.name : info.fromAddr || '某台设备'
recordAudit({
action: 'drive.upload', source: peer?.deviceId || info.fromAddr,
result: 'ok',
payload: { size: info.size, path: info.relPath },
})
broadcastToRenderer('drive:inbound', { name: basename(info.relPath), size: info.size, peerId: peer?.deviceId, peerName: who })
})
// 对端发起撤回 — 本地默默删 (含磁盘文件), 不弹框不提示
c.chatServer.on('recall', (from, messageId) => {
const row = getMessage(messageId)
@@ -1034,23 +1112,13 @@ export function registerIpc() {
// ===== 网盘 (浏览对方共享目录 + 选文件下载) =====
// 找 peer: 优先用 discovery 的在线列表, 退回 DB 里的历史记录
function findPeer(peerId: string): DeviceInfo | null {
const p = ctx?.discovery.getPeers().find(x => x.deviceId === peerId)
if (p) return p as DeviceInfo
const d = listDevices().find(x => x.device_id === peerId) as any
if (!d) return null
return {
deviceId: d.device_id, name: d.name, hostname: d.hostname, platform: d.platform,
version: d.version, appVersion: d.app_version, address: d.last_ip || '127.0.0.1',
chatPort: DEFAULT_PORTS.chat, filePort: DEFAULT_PORTS.file, ts: d.last_seen,
driveToken: d.drive_token || undefined,
} as DeviceInfo
}
ipcMain.handle('drive:list', async (_e, args: { peerId: string; path?: string }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备' }
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
return listDrive(peer, args.path || '')
})
@@ -1058,6 +1126,7 @@ export function registerIpc() {
if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备' }
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
return getDriveRoot(peer)
})
@@ -1127,6 +1196,150 @@ export function registerIpc() {
return r.filePaths[0]
})
// ===== 文件夹同步 (本地目录 <-> 对方网盘目录) =====
ipcMain.handle('sync:pickLocalDir', async () => {
const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
if (!w) return null
const cur = getSettings().syncLocalDir
const r = await dialog.showOpenDialog(w, {
properties: ['openDirectory', 'createDirectory'],
defaultPath: cur && existsSync(cur) ? cur : undefined,
})
if (r.canceled || !r.filePaths[0]) return null
return r.filePaths[0]
})
// 扫描两边, 生成变更列表 (只读磁盘; 关系表会被更新, 但那只是记录"我这边变了")
ipcMain.handle('sync:scan', async (_e, args: {
peerId: string; localDir?: string; remotePath?: string; relationId?: string
}) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备' }
const rel = args.relationId ? ctx.relations.getRelation(args.relationId) : undefined
// 有关系就用关系签发的 per-peer 令牌; 没有关系退回旧的全局 token (老版本对端)
let token = ''
if (rel && rel.state === 'active') {
if (!rel.peerToken) return { ok: false, reason: '关系令牌缺失, 请重新建立关系' }
token = rel.peerToken
} else {
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
token = peer.driveToken
}
// 有关系时用**实际同步根** (顶层目录 + 按关系隔离的子目录)
const localDir = args.localDir || (rel ? ctx.relationService.localRootOf(rel) : getSettings().syncLocalDir)
if (!localDir) return { ok: false, reason: '还没选本地目录' }
const remotePath = args.remotePath ?? (rel ? ctx.relationService.remotePathOf(rel) : (getSettings().syncRemotePath ?? ''))
const r = await scanSync({
peer, localDir, remotePath, token,
relationId: rel?.relationId,
store: ctx.manifestStore,
selfDeviceId: ctx.self.deviceId,
})
if (r.ok && !rel) {
updateSettings({ syncLocalDir: r.localDir, syncRemotePath: r.remotePath })
}
return r
})
// 执行用户勾选的同步项
ipcMain.handle('sync:apply', async (_e, args: {
peerId: string; localDir?: string; remotePath?: string; relationId?: string;
items: SyncApplyItem[]
}) => {
if (!ctx) return { ok: false, reason: 'no ctx', results: [], done: 0, failed: 0 }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备', results: [], done: 0, failed: 0 }
const rel = args.relationId ? ctx.relations.getRelation(args.relationId) : undefined
let token = ''
if (rel && rel.state === 'active') {
if (!rel.peerToken) return { ok: false, reason: '关系令牌缺失, 请重新建立关系', results: [], done: 0, failed: 0 }
token = rel.peerToken
} else {
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系', results: [], done: 0, failed: 0 }
token = peer.driveToken
}
// 有关系时用**实际同步根** (顶层目录 + 按关系隔离的子目录)
const localDir = args.localDir || (rel ? ctx.relationService.localRootOf(rel) : getSettings().syncLocalDir)
if (!localDir) return { ok: false, reason: '还没选本地目录', results: [], done: 0, failed: 0 }
const remotePath = args.remotePath ?? (rel ? ctx.relationService.remotePathOf(rel) : (getSettings().syncRemotePath ?? ''))
const items = (args.items || []).filter(i => i && i.relPath)
const r = await applySyncItems({
peer, localDir, remotePath, token,
relationId: rel?.relationId,
store: ctx.manifestStore,
selfDeviceId: ctx.self.deviceId,
items,
onProgress: (relPath, sent, total) => {
broadcastToRenderer('sync:progress', { peerId: args.peerId, relPath, sent, total })
},
})
recordAudit({
action: 'sync.apply', target: args.peerId,
result: r.failed === 0 ? 'ok' : 'error',
payload: { count: items.length, done: r.done, failed: r.failed, localDir, remotePath },
})
return r
})
// ===== 关系 (同步盘) =====
//
// 关系 = 两个设备之间的持久绑定 + 分级授权。
// 建立方式跟远程终端一样: **对方直接弹窗**, 不用邀请码。
ipcMain.handle('relation:list', () => (ctx?.relations.listRelations() || []).map(publicRelation))
// 发起建立关系 -> 对方那边弹窗
ipcMain.handle('relation:request', (_e, args: {
peerId: string; syncLocalDir: string; syncRemotePath?: string; grants: Grant[]
}) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备' }
return ctx.relationService.requestRelation({
peerId: peer.deviceId,
peerName: peer.name,
peerAddress: peer.address,
peerChatPort: peer.chatPort,
peerFilePort: peer.filePort,
syncLocalDir: args.syncLocalDir || '',
syncRemotePath: args.syncRemotePath || '',
grants: (args.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync'),
})
})
// 改我开放给对方的能力
ipcMain.handle('relation:setGrants', (_e, args: { relationId: string; grants: Grant[] }) => {
const grants = (args.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync')
const r = ctx?.relations.setMyGrants(args.relationId, grants)
return { ok: !!r, relation: publicRelation(r) }
})
// 改同步配置 (本地目录 / 对方网盘子目录 / 自动同步开关)
ipcMain.handle('relation:setSync', (_e, args: {
relationId: string; syncLocalDir?: string; syncRemotePath?: string; syncEnabled?: boolean
}) => {
const patch: any = {}
if (args.syncLocalDir !== undefined) patch.syncLocalDir = args.syncLocalDir
if (args.syncRemotePath !== undefined) patch.syncRemotePath = args.syncRemotePath
if (args.syncEnabled !== undefined) patch.syncEnabled = !!args.syncEnabled
const r = ctx?.relations.setSyncConfig(args.relationId, patch)
if (r?.syncEnabled) ctx?.autoSync?.trigger(r.relationId)
return { ok: !!r, relation: publicRelation(r) }
})
// 吊销关系 (令牌立刻失效)
ipcMain.handle('relation:revoke', (_e, args: { relationId: string }) =>
ctx?.relationService.revokeRelation(args.relationId) || { ok: false, reason: 'no ctx' })
// 彻底删除关系记录
ipcMain.handle('relation:delete', (_e, args: { relationId: string }) => {
const ok = ctx?.relations.deleteRelation(args.relationId) || false
try { ctx?.manifestStore.deleteManifest(args.relationId) } catch {}
return { ok }
})
// ---- 应用控制 ----
ipcMain.handle('app:hide', () => {
const w = BrowserWindow.getAllWindows()[0]
@@ -1284,8 +1497,16 @@ export function registerIpc() {
ipcMain.handle('remote:approval:list', () => listPendingApprovals())
ipcMain.handle('remote:approval:reply', (_e, args: { requestId: string; ok: boolean; remember?: boolean }) => {
return replyApproval(args.requestId, args.ok, args.remember)
ipcMain.handle('remote:approval:reply', (_e, args: {
requestId: string; ok: boolean; remember?: boolean
grants?: string[]; syncLocalDir?: string; syncRemotePath?: string
}) => {
return replyApproval(args.requestId, args.ok, {
remember: args.remember,
grants: args.grants,
syncLocalDir: args.syncLocalDir,
syncRemotePath: args.syncRemotePath,
})
})
ipcMain.handle('audit:list', (_e, args: { limit?: number; since?: number; sessionId?: string } = {}) => {