diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index c65057a72b4..9097e9c3882 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -1190,6 +1190,8 @@ const config: ForgeConfig = { 'Cindy uses Apple Events to read Contacts you import and to add or update Contacts you explicitly export.', NSContactsUsageDescription: 'Cindy accesses Contacts only when you import them or explicitly export additions or updates.', + NSLocalNetworkUsageDescription: + 'Cindy uses your local network to sync end-to-end encrypted Smart Contacts directly between your online desktop devices.', CFBundleDocumentTypes: [ { CFBundleTypeName: 'Folder', @@ -1334,6 +1336,12 @@ const config: ForgeConfig = { // SILK/WASM 解码隔离在线程中,避免阻塞 Electron main。 target: 'preload', }, + { + entry: 'src/main/contacts-sync/contactsSyncCodecWorker.ts', + config: 'vite.contacts-sync-codec-worker.config.ts', + // 大通讯录 JSON/gzip/crypto 隔离在线程中,避免阻塞 Electron main。 + target: 'preload', + }, { entry: 'src/main/watcher-host/watcherHostProcess.ts', config: 'vite.watcher-host.config.ts', diff --git a/apps/desktop/src/main/contacts-sync/__tests__/codecWorkerClient.test.ts b/apps/desktop/src/main/contacts-sync/__tests__/codecWorkerClient.test.ts new file mode 100644 index 00000000000..fb235b59a3a --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/__tests__/codecWorkerClient.test.ts @@ -0,0 +1,224 @@ +import { EventEmitter } from 'node:events'; +import { randomUUID } from 'node:crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const harness = vi.hoisted(() => ({ + workers: 0, + activeWorkers: 0, + maxActiveWorkers: 0, + terminated: 0, + respond: false, + responseDelayMs: 0, + responseData: undefined as unknown, + requests: [] as Array>, + transferLists: [] as Array, +})); + +vi.mock('node:worker_threads', () => ({ + Worker: class extends EventEmitter { + constructor() { + super(); + harness.workers += 1; + harness.activeWorkers += 1; + harness.maxActiveWorkers = Math.max(harness.maxActiveWorkers, harness.activeWorkers); + } + + postMessage(request: Record, transferList: ArrayBuffer[] = []): void { + harness.requests.push(request); + harness.transferLists.push(transferList); + if (!harness.respond) return; + const respond = () => { + this.emit('message', { + id: request.id, + ok: true, + data: + harness.responseData ?? + (request.type === 'encode' + ? { + transferId: randomUUID(), + total: 1, + iv: Buffer.alloc(12).toString('base64'), + tag: Buffer.alloc(16).toString('base64'), + ciphertext: new Uint8Array([1, 2, 3]), + materialized: false, + } + : { materialized: false }), + }); + }; + if (harness.responseDelayMs > 0) setTimeout(respond, harness.responseDelayMs); + else queueMicrotask(respond); + } + + async terminate(): Promise { + harness.terminated += 1; + harness.activeWorkers -= 1; + return 0; + } + }, +})); + +const { generateContactsSyncIdentity } = await import('../crypto.js'); +const { workerContactsSyncCodec } = await import('../contactsSyncCodecWorkerClient.js'); + +describe('contacts sync codec worker client', () => { + beforeEach(() => { + harness.workers = 0; + harness.activeWorkers = 0; + harness.maxActiveWorkers = 0; + harness.terminated = 0; + harness.respond = false; + harness.responseDelayMs = 0; + harness.responseData = undefined; + harness.requests = []; + harness.transferLists = []; + }); + + it('marks an active database worker cancelled and waits for its SQLite task to unwind', async () => { + harness.respond = true; + harness.responseDelayMs = 100; + const own = generateContactsSyncIdentity(); + const peer = generateContactsSyncIdentity(); + const controller = new AbortController(); + const task = workerContactsSyncCodec.encode( + { + database: { source: { dbPath: '/tmp/contacts.db' } }, + ownPrivateKey: own.privateKey, + ownPublicKey: own.publicKey, + peerPublicKey: peer.publicKey, + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + }, + controller.signal, + ); + await vi.waitFor(() => expect(harness.requests).toHaveLength(1)); + controller.abort(); + + const cancellation = harness.requests[0]?.cancellation; + expect(cancellation).toBeInstanceOf(SharedArrayBuffer); + expect(Atomics.load(new Int32Array(cancellation as SharedArrayBuffer), 0)).toBe(1); + await expect(task).rejects.toMatchObject({ name: 'AbortError' }); + expect(harness.terminated).toBe(1); + }); + + it('production encode sends only a database descriptor and receives bounded bytes', async () => { + harness.respond = true; + const own = generateContactsSyncIdentity(); + const peer = generateContactsSyncIdentity(); + const result = await workerContactsSyncCodec.encode({ + database: { + source: { dbPath: '/tmp/contacts.db' }, + knownClocks: [{ nodeId: 'node-a', counter: 2 }], + requestReply: true, + }, + ownPrivateKey: own.privateKey, + ownPublicKey: own.publicKey, + peerPublicKey: peer.publicKey, + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + }); + + expect(result.frames).toHaveLength(1); + expect(harness.requests[0]).toMatchObject({ + type: 'encode', + options: { + database: { source: { dbPath: '/tmp/contacts.db' } }, + }, + }); + expect((harness.requests[0]?.options as { message?: unknown }).message).toBeUndefined(); + expect(harness.transferLists[0]).toEqual([]); + }); + + it('serializes database-bound workers while leaving the general worker budget independent', async () => { + harness.respond = true; + harness.responseDelayMs = 100; + const own = generateContactsSyncIdentity(); + const peer = generateContactsSyncIdentity(); + const options = { + database: { source: { dbPath: '/tmp/contacts.db' } }, + ownPrivateKey: own.privateKey, + ownPublicKey: own.publicKey, + peerPublicKey: peer.publicKey, + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + }; + + const tasks = [ + workerContactsSyncCodec.encode(options), + workerContactsSyncCodec.encode(options), + ]; + await vi.waitFor(() => expect(harness.requests).toHaveLength(1)); + expect(harness.maxActiveWorkers).toBe(1); + await Promise.all(tasks); + + expect(harness.requests).toHaveLength(2); + expect(harness.maxActiveWorkers).toBe(1); + }); + + it.each([ + [[{ nodeId: 'node-a', counter: 0 }], 'zero counter'], + [[{ nodeId: 'node with spaces', counter: 1 }], 'invalid node id'], + [ + [ + { nodeId: 'node-a', counter: 1 }, + { nodeId: 'node-a', counter: 2 }, + ], + 'duplicate node id', + ], + ])('rejects applied-state clocks with %s', async (clocks) => { + harness.respond = true; + harness.responseData = { + version: 1, + type: 'applied-state', + changed: true, + clocks, + }; + const own = generateContactsSyncIdentity(); + const peer = generateContactsSyncIdentity(); + + await expect( + workerContactsSyncCodec.decode({ + ciphertext: new Uint8Array([1, 2, 3]), + iv: Buffer.alloc(12).toString('base64'), + tag: Buffer.alloc(16).toString('base64'), + ownPrivateKey: own.privateKey, + expectedPeerPublicKey: peer.publicKey, + srcDeviceId: 'device-b', + dstDeviceId: 'device-a', + transferId: randomUUID(), + totalChunks: 1, + databaseSource: { dbPath: '/tmp/contacts.db' }, + }), + ).rejects.toThrow(/invalid contacts sync decode result/); + }); + + it('bounds the global queue and aborts active plus queued owner work', async () => { + const own = generateContactsSyncIdentity(); + const peer = generateContactsSyncIdentity(); + const options = { + message: { version: 1 as const, type: 'state' as const, state: { contacts: [] } }, + ownPrivateKey: own.privateKey, + ownPublicKey: own.publicKey, + peerPublicKey: peer.publicKey, + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + }; + const controllers = Array.from({ length: 10 }, () => new AbortController()); + const tasks = controllers.map((controller) => + workerContactsSyncCodec.encode(options, controller.signal), + ); + await vi.waitFor(() => expect(harness.workers).toBe(2)); + + await expect( + workerContactsSyncCodec.encode(options, new AbortController().signal), + ).rejects.toThrow(/queue is full/); + + for (const controller of controllers) controller.abort(); + const results = await Promise.allSettled(tasks); + expect(results).toHaveLength(10); + expect( + results.every( + (result) => result.status === 'rejected' && result.reason?.name === 'AbortError', + ), + ).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/contacts-sync/__tests__/crypto-wire.test.ts b/apps/desktop/src/main/contacts-sync/__tests__/crypto-wire.test.ts new file mode 100644 index 00000000000..00a873613d6 --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/__tests__/crypto-wire.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from 'vitest'; +import { randomBytes } from 'node:crypto'; + +import { + decryptContactsSyncBytes, + encryptContactsSyncBytes, + generateContactsSyncIdentity, + publicKeyFromPrivate, +} from '../crypto.js'; +import { + ContactsSyncWireDecoder, + encodeContactsSyncMessage, + isContactsSyncWireFrame, +} from '../wire.js'; +import { + CONTACTS_SYNC_MAX_DECOMPRESSED_BYTES, + encodeContactsSyncJsonInProcess, + inProcessContactsSyncCodec, +} from '../contactsSyncCodec.js'; + +describe('contacts sync crypto and wire', () => { + it('发送端在压缩前拒绝超过接收端明文上限的状态', () => { + const a = generateContactsSyncIdentity(); + const b = generateContactsSyncIdentity(); + const oversized = { + byteLength: CONTACTS_SYNC_MAX_DECOMPRESSED_BYTES + 1, + } as Uint8Array; + expect(() => + encodeContactsSyncJsonInProcess(oversized, { + ownPrivateKey: a.privateKey, + ownPublicKey: a.publicKey, + peerPublicKey: b.publicKey, + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + }), + ).toThrow(/decompressed bytes/); + }); + + it('两台设备派生同一共享密钥,服务端拿到公钥仍不能解密', () => { + const a = generateContactsSyncIdentity(); + const b = generateContactsSyncIdentity(); + const context = { + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + transferId: 'transfer-1', + totalChunks: 1, + }; + const encrypted = encryptContactsSyncBytes( + Buffer.from('private contacts'), + a.privateKey, + b.publicKey, + context, + ); + + expect( + decryptContactsSyncBytes(encrypted, b.privateKey, a.publicKey, context).toString('utf8'), + ).toBe('private contacts'); + expect(() => + decryptContactsSyncBytes( + encrypted, + generateContactsSyncIdentity().privateKey, + a.publicKey, + context, + ), + ).toThrow(); + }); + + it('私钥导出的公钥可用于检测落盘密钥损坏', () => { + const identity = generateContactsSyncIdentity(); + expect(publicKeyFromPrivate(identity.privateKey)).toBe(identity.publicKey); + }); + + it('大状态会分片、乱序抵达后仍可完整解密', async () => { + const a = generateContactsSyncIdentity(); + const b = generateContactsSyncIdentity(); + const state = { opaqueTestData: randomBytes(600_000).toString('base64') }; + const frames = await encodeContactsSyncMessage( + { + message: { version: 1, type: 'state', state, requestReply: true }, + ownPrivateKey: a.privateKey, + ownPublicKey: a.publicKey, + peerPublicKey: b.publicKey, + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + }, + inProcessContactsSyncCodec, + ); + expect(frames.length).toBeGreaterThan(1); + expect(frames.every(isContactsSyncWireFrame)).toBe(true); + + const decoder = new ContactsSyncWireDecoder(inProcessContactsSyncCodec); + let result: Awaited> = null; + for (const frame of [...frames].reverse()) { + result = + (await decoder.accept({ + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + frame, + ownPrivateKey: b.privateKey, + expectedPeerPublicKey: a.publicKey, + })) ?? result; + } + expect(result).toEqual({ version: 1, type: 'state', state, requestReply: true }); + }); + + it('篡改目标设备、分片元数据或密文都会认证失败', async () => { + const a = generateContactsSyncIdentity(); + const b = generateContactsSyncIdentity(); + const frames = await encodeContactsSyncMessage( + { + message: { version: 1, type: 'state', state: { contacts: [] } }, + ownPrivateKey: a.privateKey, + ownPublicKey: a.publicKey, + peerPublicKey: b.publicKey, + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + }, + inProcessContactsSyncCodec, + ); + const changed = { + ...frames[0]!, + data: Buffer.from('tampered').toString('base64'), + }; + const decoder = new ContactsSyncWireDecoder(inProcessContactsSyncCodec); + await expect( + decoder.accept({ + srcDeviceId: 'device-a', + dstDeviceId: 'device-c', + frame: changed, + ownPrivateKey: b.privateKey, + expectedPeerPublicKey: a.publicKey, + }), + ).rejects.toThrow(); + }); + + it('持续收到新分片时按最后活动时间续期传输', async () => { + const a = generateContactsSyncIdentity(); + const b = generateContactsSyncIdentity(); + const state = { opaqueTestData: randomBytes(600_000).toString('base64') }; + const frames = await encodeContactsSyncMessage( + { + message: { version: 1, type: 'state', state }, + ownPrivateKey: a.privateKey, + ownPublicKey: a.publicKey, + peerPublicKey: b.publicKey, + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + }, + inProcessContactsSyncCodec, + ); + expect(frames.length).toBeGreaterThan(2); + + const decoder = new ContactsSyncWireDecoder(inProcessContactsSyncCodec); + let result: Awaited> = null; + for (const [index, frame] of frames.entries()) { + result = + (await decoder.accept({ + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + frame, + ownPrivateKey: b.privateKey, + expectedPeerPublicKey: a.publicKey, + now: index * 90_000, + })) ?? result; + } + + expect((frames.length - 1) * 90_000).toBeGreaterThan(2 * 60 * 1000); + expect(result).toEqual({ version: 1, type: 'state', state }); + }); + + it('重复分片不会为停滞传输续期', async () => { + const a = generateContactsSyncIdentity(); + const b = generateContactsSyncIdentity(); + const state = { opaqueTestData: randomBytes(600_000).toString('base64') }; + const frames = await encodeContactsSyncMessage( + { + message: { version: 1, type: 'state', state }, + ownPrivateKey: a.privateKey, + ownPublicKey: a.publicKey, + peerPublicKey: b.publicKey, + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + }, + inProcessContactsSyncCodec, + ); + expect(frames.length).toBeGreaterThan(2); + + const decoder = new ContactsSyncWireDecoder(inProcessContactsSyncCodec); + const accept = (frame: (typeof frames)[number], now: number) => + decoder.accept({ + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + frame, + ownPrivateKey: b.privateKey, + expectedPeerPublicKey: a.publicKey, + now, + }); + await expect(accept(frames[0]!, 0)).resolves.toBeNull(); + await expect(accept(frames[0]!, 110_000)).resolves.toBeNull(); + + let result: Awaited> = null; + for (const [index, frame] of frames.slice(1).entries()) { + result = (await accept(frame, 130_000 + index)) ?? result; + } + expect(result).toBeNull(); + }); + + it('reset 后丢弃仍在 worker 解码的旧结果', async () => { + const a = generateContactsSyncIdentity(); + const b = generateContactsSyncIdentity(); + const message = { version: 1 as const, type: 'state' as const, state: { contacts: [] } }; + const [frame] = await encodeContactsSyncMessage( + { + message, + ownPrivateKey: a.privateKey, + ownPublicKey: a.publicKey, + peerPublicKey: b.publicKey, + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + }, + inProcessContactsSyncCodec, + ); + const deferred: { release?: () => void; signal?: AbortSignal } = {}; + const decoder = new ContactsSyncWireDecoder({ + encode: inProcessContactsSyncCodec.encode, + decode: async (_options, signal) => { + deferred.signal = signal; + await new Promise((resolve) => { + deferred.release = resolve; + }); + return message; + }, + }); + + const decoding = decoder.accept({ + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + frame: frame!, + ownPrivateKey: b.privateKey, + expectedPeerPublicKey: a.publicKey, + }); + await vi.waitFor(() => expect(deferred.release).toBeTypeOf('function')); + decoder.reset(); + expect(deferred.signal?.aborted).toBe(true); + deferred.release?.(); + + await expect(decoding).resolves.toBeNull(); + }); + + it('拒绝伪造或超界的 wire 帧', () => { + expect(isContactsSyncWireFrame({ version: 1, type: 'key', publicKey: 'not-a-key' })).toBe( + false, + ); + expect( + isContactsSyncWireFrame({ + version: 1, + type: 'cipher-chunk', + senderPublicKey: generateContactsSyncIdentity().publicKey, + transferId: 'x', + index: 2, + total: 2, + iv: 'x', + tag: 'x', + compression: 'gzip', + data: 'eA==', + }), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/contacts-sync/__tests__/driver.test.ts b/apps/desktop/src/main/contacts-sync/__tests__/driver.test.ts new file mode 100644 index 00000000000..1448b0bfb70 --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/__tests__/driver.test.ts @@ -0,0 +1,943 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const harness = vi.hoisted(() => { + const emptyState = { + version: 1 as const, + clocks: [], + contacts: [], + identities: [], + events: [], + groups: [], + memberships: [], + relations: [], + }; + const wireFrames = [ + { + version: 1, + type: 'cipher-chunk', + senderPublicKey: 'own-public', + transferId: 'transfer', + index: 0, + total: 1, + iv: 'iv', + tag: 'tag', + compression: 'gzip', + data: 'data', + }, + ]; + return { + mode: 'cloud' as 'cloud' | 'local' | 'signed-out', + ownerId: 'owner-a' as string | null, + settings: new Map(), + contactsChangeToken: 'initial-token' as string | null, + syncRequestToken: 'initial-request' as string | null, + syncSettingIntent: null as null | { token: string; enabled: boolean }, + nextSyncSettingIntent: 0, + runtimeStatus: null as null | Record, + emptyState, + syncMaterialized: false, + activateSync: vi.fn(() => emptyState), + prepareDatabase: vi.fn(async () => ({ materialized: harness.syncMaterialized })), + decoderAccept: vi.fn(async (...args: unknown[]) => { + void args; + return null; + }), + broadcastContactsChanged: vi.fn(), + wireFrames, + encodeContactsSyncMessage: vi.fn(async (options?: { signal?: AbortSignal }) => { + void options; + return { frames: wireFrames, materialized: false }; + }), + peerPublicKey: 'peer-public' as string | null, + prepareKeyStore: vi.fn(async (): Promise => undefined), + pinPeerPublicKey: vi.fn(async (_: string, publicKey: string) => { + const firstSeen = harness.peerPublicKey === null; + harness.peerPublicKey = publicKey; + return firstSeen; + }), + lanStart: vi.fn(), + lanSend: vi.fn(), + lanStop: vi.fn(), + relaySend: vi.fn(), + keyReset: vi.fn(), + writeRuntimeStatus: vi.fn((status: Record) => { + harness.runtimeStatus = status; + }), + writeSyncRequest: vi.fn(() => { + harness.syncRequestToken = `request-${Date.now()}`; + }), + writeDeviceSync: vi.fn(async (enabled: boolean) => { + if (harness.ownerId) harness.settings.set(harness.ownerId, enabled); + }), + commitSettingIntent: vi.fn(async (intent: { token: string; enabled: boolean }) => { + if ( + harness.syncSettingIntent?.token !== intent.token || + harness.syncSettingIntent.enabled !== intent.enabled + ) { + return false; + } + await harness.writeDeviceSync(intent.enabled); + return true; + }), + }; +}); + +vi.mock('@cindy/maker-core', () => ({ + createContactsSyncDelta: (state: unknown) => state, +})); + +vi.mock('../../logger.js', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +vi.mock('../../appSessionState.js', () => ({ + getActiveAppSession: () => ({ mode: harness.mode, dataOwnerId: harness.ownerId }), + activeOwnerScopeKey: () => `${harness.mode}:${harness.ownerId ?? 'none'}`, +})); + +vi.mock('../../maker-host/maker-contacts-host.js', () => ({ + getDesktopContactsManager: () => ({ + getDbPath: () => '/tmp/test-contacts.db', + getStore: () => ({ + activateDeviceSync: harness.activateSync, + readDeviceSyncState: () => harness.emptyState, + activateDeviceSyncWithResult: () => ({ + state: harness.activateSync(), + materialized: harness.syncMaterialized, + }), + readDeviceSyncStateWithResult: () => ({ + state: harness.emptyState, + materialized: harness.syncMaterialized, + }), + mergeDeviceSyncState: () => false, + }), + }), +})); + +vi.mock('../../localDb/betterSqliteFactory.js', () => ({ + resolveBetterSqliteModuleEntry: () => '/tmp/better-sqlite3.js', + resolveBetterSqliteNativeBinding: () => undefined, +})); + +vi.mock('../contactsSyncCodecWorkerClient.js', () => ({ + prepareContactsSyncDatabase: harness.prepareDatabase, +})); + +vi.mock('../../maker-host/contacts-settings-store.js', () => ({ + readContactsSettings: () => ({ + enabled: false, + deviceSyncEnabled: harness.ownerId ? (harness.settings.get(harness.ownerId) ?? false) : false, + }), + readContactsDeviceSyncSettingIntent: () => harness.syncSettingIntent, + writeContactsDeviceSyncSettingIntent: vi.fn(async (enabled: boolean) => { + const intent = { token: `intent-${++harness.nextSyncSettingIntent}`, enabled }; + harness.syncSettingIntent = intent; + return intent; + }), + commitContactsDeviceSyncSettingIntent: harness.commitSettingIntent, +})); + +vi.mock('../../maker-host/contacts-change-events.js', () => ({ + onLocalContactsChanged: () => vi.fn(), + readContactsChangeToken: () => harness.contactsChangeToken, +})); + +vi.mock('../../maker-host/contacts-change-broadcast.js', () => ({ + broadcastContactsChanged: harness.broadcastContactsChanged, +})); + +vi.mock('../keyStore.js', () => ({ + contactsSyncKeyStore: { + prepare: harness.prepareKeyStore, + getIdentity: () => ({ publicKey: 'own-public', privateKey: 'own-private' }), + getPeerPublicKey: () => harness.peerPublicKey, + pinPeerPublicKey: harness.pinPeerPublicKey, + resetMemory: harness.keyReset, + }, +})); + +vi.mock('../lanTransport.js', () => ({ + LanContactsSyncTransport: class { + start(): void { + harness.lanStart(); + } + stop(): void { + harness.lanStop(); + } + send(deviceId: string, frame: unknown): Promise { + return harness.lanSend(deviceId, frame); + } + }, +})); + +vi.mock('../wire.js', () => ({ + createContactsSyncKeyFrame: () => ({ version: 1, type: 'key', publicKey: 'own-public' }), + encodeContactsSyncDatabaseState: harness.encodeContactsSyncMessage, + isContactsSyncWireFrame: (raw: unknown) => + typeof raw === 'object' && raw !== null && 'type' in raw, + ContactsSyncWireDecoder: class { + async accept(...args: unknown[]): Promise { + return harness.decoderAccept(...args); + } + reset(): void {} + }, +})); + +vi.mock('../statusStore.js', () => ({ + readContactsSyncRequestToken: () => harness.syncRequestToken, + readPersistedContactsSyncStatus: () => ({ + lastSyncAt: null, + lastSyncDeviceId: null, + lastSyncDeviceName: null, + lastRoute: null, + }), + readPersistedContactsSyncRuntimeStatus: () => harness.runtimeStatus, + writeContactsSyncRequestToken: harness.writeSyncRequest, + writePersistedContactsSyncStatus: vi.fn(), + writePersistedContactsSyncRuntimeStatus: harness.writeRuntimeStatus, +})); + +const driver = await import('../driver.js'); + +beforeEach(() => { + harness.mode = 'cloud'; + harness.ownerId = 'owner-a'; + harness.settings.clear(); + harness.contactsChangeToken = 'initial-token'; + harness.syncRequestToken = 'initial-request'; + harness.syncSettingIntent = null; + harness.nextSyncSettingIntent = 0; + harness.runtimeStatus = null; + harness.syncMaterialized = false; + harness.activateSync.mockClear(); + harness.prepareDatabase.mockClear(); + harness.prepareDatabase.mockImplementation(async () => ({ + materialized: harness.syncMaterialized, + })); + harness.decoderAccept.mockReset(); + harness.decoderAccept.mockResolvedValue(null); + harness.broadcastContactsChanged.mockClear(); + harness.encodeContactsSyncMessage.mockReset(); + harness.encodeContactsSyncMessage.mockImplementation(async () => ({ + frames: harness.wireFrames, + materialized: false, + })); + harness.peerPublicKey = 'peer-public'; + harness.pinPeerPublicKey.mockClear(); + harness.prepareKeyStore.mockClear(); + harness.lanStart.mockReset(); + harness.lanSend.mockReset(); + harness.lanStop.mockReset(); + harness.relaySend.mockReset(); + harness.keyReset.mockReset(); + harness.writeRuntimeStatus.mockClear(); + harness.writeSyncRequest.mockClear(); + harness.writeDeviceSync.mockReset(); + harness.writeDeviceSync.mockImplementation(async (enabled: boolean) => { + if (harness.ownerId) harness.settings.set(harness.ownerId, enabled); + }); + harness.commitSettingIntent.mockReset(); + harness.commitSettingIntent.mockImplementation(async (intent) => { + if ( + harness.syncSettingIntent?.token !== intent.token || + harness.syncSettingIntent.enabled !== intent.enabled + ) { + return false; + } + await harness.writeDeviceSync(intent.enabled); + return true; + }); + driver.__testing.reset(); +}); + +afterEach(() => { + driver.__testing.reset(); +}); + +describe('contacts sync runtime ownership', () => { + it('local mode reports sign-in required and cannot enable device sync', async () => { + harness.mode = 'local'; + harness.ownerId = 'local-v1'; + driver.__testing.reset(); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => null, + listOnlineDesktopDevices: () => [], + isPeerAllowed: () => false, + sendRelayFrame: harness.relaySend, + }); + + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ + available: false, + enabled: false, + phase: 'off', + }); + await expect(driver.setContactsDeviceSyncEnabled(true)).rejects.toThrow( + /signed-in cloud account/, + ); + expect(harness.settings.get('local-v1')).toBeUndefined(); + expect(harness.relaySend).not.toHaveBeenCalled(); + }); + + it('does not relay an old owner transfer after the active account changes mid-send', async () => { + let finishLanSend: ((sent: boolean) => void) | null = null; + harness.lanSend.mockImplementation( + () => + new Promise((resolve) => { + finishLanSend = resolve; + }), + ); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [{ deviceId: 'peer-device', deviceName: 'Peer Desktop' }], + isPeerAllowed: (deviceId) => deviceId === 'peer-device', + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + + const enabling = driver.setContactsDeviceSyncEnabled(true); + await vi.waitFor(() => expect(harness.lanSend).toHaveBeenCalledTimes(1)); + + harness.ownerId = 'owner-b'; + expect(driver.getContactsDeviceSyncStatus().enabled).toBe(false); + expect(harness.lanStop).toHaveBeenCalled(); + + expect(finishLanSend).not.toBeNull(); + finishLanSend!(false); + await enabling; + + expect(harness.relaySend).not.toHaveBeenCalled(); + expect(driver.getContactsDeviceSyncStatus().phase).toBe('off'); + }); + + it('aborts an old owner codec task when the active account changes', async () => { + let oldOwnerSignal: AbortSignal | undefined; + harness.encodeContactsSyncMessage.mockImplementation( + (options?: { signal?: AbortSignal }) => + new Promise<{ frames: typeof harness.wireFrames; materialized: boolean }>((_, reject) => { + oldOwnerSignal = options?.signal; + options?.signal?.addEventListener( + 'abort', + () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + { once: true }, + ); + }), + ); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [{ deviceId: 'peer-device', deviceName: 'Peer Desktop' }], + isPeerAllowed: (deviceId) => deviceId === 'peer-device', + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + + const enabling = driver.setContactsDeviceSyncEnabled(true); + await vi.waitFor(() => expect(oldOwnerSignal).toBeDefined()); + harness.ownerId = 'owner-b'; + driver.getContactsDeviceSyncStatus(); + + expect(oldOwnerSignal?.aborted).toBe(true); + await enabling; + expect(harness.relaySend).not.toHaveBeenCalled(); + }); + + it('applies a sync toggle written by another shared-userData instance', async () => { + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [], + isPeerAllowed: () => false, + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + + harness.settings.set('owner-a', true); + driver.pollContactsDeviceSyncSettingChange(); + await vi.waitFor(() => { + expect(harness.prepareDatabase).toHaveBeenCalled(); + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ + enabled: true, + phase: 'waiting', + }); + }); + + harness.settings.set('owner-a', false); + driver.pollContactsDeviceSyncSettingChange(); + expect(harness.lanStop).toHaveBeenCalledTimes(1); + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ + enabled: false, + phase: 'off', + }); + }); + + it('notifies renderers when local reconciliation rematerializes a hidden record', async () => { + harness.syncMaterialized = true; + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [], + isPeerAllowed: () => false, + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + + await driver.setContactsDeviceSyncEnabled(true); + + expect(harness.broadcastContactsChanged).toHaveBeenCalledWith({ origin: 'remote' }); + }); + + it('reads the Device Link holder status in a passive shared-userData instance', () => { + harness.settings.set('owner-a', true); + harness.runtimeStatus = { + available: true, + enabled: true, + phase: 'up-to-date', + onlineDeviceCount: 2, + errorCode: null, + lastSyncAt: '2026-08-01T00:00:00.000Z', + lastSyncDeviceId: 'peer-device', + lastSyncDeviceName: 'Office Desktop', + lastRoute: 'relay', + updatedAt: Date.now(), + }; + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [{ deviceId: 'peer-device', deviceName: 'Peer Desktop' }], + isPeerAllowed: () => true, + sendRelayFrame: harness.relaySend, + }); + + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ + enabled: true, + phase: 'up-to-date', + onlineDeviceCount: 2, + lastSyncDeviceName: 'Office Desktop', + }); + expect(harness.writeRuntimeStatus).not.toHaveBeenCalled(); + expect(harness.relaySend).not.toHaveBeenCalled(); + }); + + it('ignores a stale holder status in a passive instance', () => { + harness.settings.set('owner-a', true); + driver.__testing.reset(); + harness.runtimeStatus = { + available: true, + enabled: true, + phase: 'up-to-date', + onlineDeviceCount: 2, + errorCode: null, + lastSyncAt: null, + lastSyncDeviceId: null, + lastSyncDeviceName: null, + lastRoute: null, + updatedAt: Date.now() - 60_000, + }; + + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ + enabled: true, + phase: 'waiting', + onlineDeviceCount: 0, + }); + }); + + it('pushes holder status changes to passive-instance listeners', () => { + harness.settings.set('owner-a', true); + harness.runtimeStatus = { + available: true, + enabled: true, + phase: 'syncing', + onlineDeviceCount: 1, + errorCode: null, + lastSyncAt: null, + lastSyncDeviceId: null, + lastSyncDeviceName: null, + lastRoute: null, + updatedAt: Date.now(), + }; + const listener = vi.fn(); + const unsubscribe = driver.onContactsDeviceSyncStatusChanged(listener); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ phase: 'syncing', onlineDeviceCount: 1 }), + ); + unsubscribe(); + }); + + it('delegates sync-now from a passive instance to the Device Link holder', async () => { + harness.settings.set('owner-a', true); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [{ deviceId: 'peer-device', deviceName: 'Peer Desktop' }], + isPeerAllowed: () => true, + sendRelayFrame: harness.relaySend, + }); + + await driver.broadcastContactsNow(true); + + expect(harness.writeSyncRequest).toHaveBeenCalledTimes(1); + expect(harness.relaySend).not.toHaveBeenCalled(); + expect(harness.writeRuntimeStatus).not.toHaveBeenCalled(); + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ + enabled: true, + phase: 'syncing', + }); + }); + + it('stops publishing and sending after an initialized holder is demoted', async () => { + harness.settings.set('owner-a', true); + harness.lanSend.mockResolvedValue(false); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [{ deviceId: 'peer-device', deviceName: 'Peer Desktop' }], + isPeerAllowed: () => true, + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + await vi.waitFor(() => + expect(harness.relaySend.mock.calls.some(([, frame]) => frame.type === 'cipher-chunk')).toBe( + true, + ), + ); + + driver.setContactsDeviceLinkOwnerActive(false); + harness.relaySend.mockClear(); + harness.writeRuntimeStatus.mockClear(); + harness.writeSyncRequest.mockClear(); + harness.runtimeStatus = { + available: true, + enabled: true, + phase: 'up-to-date', + onlineDeviceCount: 1, + errorCode: null, + lastSyncAt: '2026-08-01T00:00:00.000Z', + lastSyncDeviceId: 'peer-device', + lastSyncDeviceName: 'Peer Desktop', + lastRoute: 'relay', + updatedAt: Date.now(), + }; + + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ + phase: 'up-to-date', + onlineDeviceCount: 1, + }); + await driver.broadcastContactsNow(true); + expect(harness.writeSyncRequest).toHaveBeenCalledTimes(1); + expect(harness.relaySend).not.toHaveBeenCalled(); + expect(harness.writeRuntimeStatus).not.toHaveBeenCalled(); + }); + + it('times out a delegated sync-now request when no holder consumes it', async () => { + harness.settings.set('owner-a', true); + driver.__testing.reset(); + vi.useFakeTimers(); + try { + await driver.broadcastContactsNow(true); + expect(driver.getContactsDeviceSyncStatus().phase).toBe('syncing'); + + await vi.advanceTimersByTimeAsync(10_001); + expect(driver.getContactsDeviceSyncStatus().phase).toBe('waiting'); + } finally { + vi.useRealTimers(); + } + }); + + it('does not let the delegated timeout overwrite a fresh holder syncing status', async () => { + harness.settings.set('owner-a', true); + driver.__testing.reset(); + vi.useFakeTimers(); + try { + await driver.broadcastContactsNow(true); + await vi.advanceTimersByTimeAsync(9_000); + harness.runtimeStatus = { + available: true, + enabled: true, + phase: 'syncing', + onlineDeviceCount: 1, + errorCode: null, + lastSyncAt: null, + lastSyncDeviceId: null, + lastSyncDeviceName: null, + lastRoute: null, + updatedAt: Date.now(), + }; + expect(driver.getContactsDeviceSyncStatus().phase).toBe('syncing'); + + await vi.advanceTimersByTimeAsync(2_000); + expect(driver.getContactsDeviceSyncStatus().phase).toBe('syncing'); + } finally { + vi.useRealTimers(); + } + }); + + it('consumes a passive-instance sync-now token in the Device Link holder', async () => { + harness.settings.set('owner-a', true); + harness.lanSend.mockResolvedValue(false); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [{ deviceId: 'peer-device', deviceName: 'Peer Desktop' }], + isPeerAllowed: (deviceId) => deviceId === 'peer-device', + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + await vi.waitFor(() => + expect(harness.relaySend.mock.calls.some(([, frame]) => frame.type === 'cipher-chunk')).toBe( + true, + ), + ); + harness.relaySend.mockClear(); + + harness.syncRequestToken = 'request-from-passive'; + driver.pollContactsDeviceSyncCrossProcessState(); + + await vi.waitFor(() => + expect(harness.relaySend.mock.calls.some(([, frame]) => frame.type === 'cipher-chunk')).toBe( + true, + ), + ); + }); + + it('stops immediately and stays off when persisting disable is blocked then fails', async () => { + harness.settings.set('owner-a', true); + let rejectWrite: ((error: Error) => void) | null = null; + harness.writeDeviceSync.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectWrite = reject; + }), + ); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [], + isPeerAllowed: () => false, + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + await vi.waitFor(() => expect(harness.lanStart).toHaveBeenCalledTimes(1)); + + const disabling = driver.setContactsDeviceSyncEnabled(false); + expect(harness.lanStop).toHaveBeenCalledTimes(1); + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ enabled: false, phase: 'off' }); + driver.pollContactsDeviceSyncSettingChange(); + expect(driver.getContactsDeviceSyncStatus().enabled).toBe(false); + + const failed = disabling.then( + () => null, + (error: unknown) => error, + ); + await vi.waitFor(() => expect(rejectWrite).not.toBeNull()); + rejectWrite!(new Error('disk unavailable')); + await expect(failed).resolves.toEqual(expect.objectContaining({ message: 'disk unavailable' })); + driver.pollContactsDeviceSyncSettingChange(); + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ enabled: false, phase: 'error' }); + + // 另一实例稍后成功写入 durable false,本机错误态应自动收敛。 + harness.settings.set('owner-a', false); + driver.pollContactsDeviceSyncSettingChange(); + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ + enabled: false, + phase: 'off', + errorCode: null, + }); + }); + + it('does not apply an old owner disable failure to the new owner status', async () => { + harness.settings.set('owner-a', true); + let rejectWrite: ((error: Error) => void) | null = null; + harness.writeDeviceSync.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectWrite = reject; + }), + ); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [], + isPeerAllowed: () => false, + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + + const disabling = driver.setContactsDeviceSyncEnabled(false); + const failed = disabling.then( + () => null, + (error: unknown) => error, + ); + await vi.waitFor(() => expect(rejectWrite).not.toBeNull()); + harness.ownerId = 'owner-b'; + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ + enabled: false, + phase: 'off', + errorCode: null, + }); + rejectWrite!(new Error('old owner write failed')); + await expect(failed).resolves.toEqual( + expect.objectContaining({ message: 'old owner write failed' }), + ); + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ + enabled: false, + phase: 'off', + errorCode: null, + }); + }); + + it('does not let an in-flight enable override a later disable intent', async () => { + let releasePrepare: (() => void) | null = null; + harness.prepareKeyStore.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePrepare = resolve; + }), + ); + + const enabling = driver.setContactsDeviceSyncEnabled(true); + await vi.waitFor(() => expect(harness.prepareKeyStore).toHaveBeenCalledTimes(1)); + const disabling = driver.setContactsDeviceSyncEnabled(false); + await disabling; + + expect(releasePrepare).not.toBeNull(); + releasePrepare!(); + await enabling; + + expect(harness.settings.get('owner-a')).toBe(false); + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ enabled: false, phase: 'off' }); + expect(harness.writeDeviceSync.mock.calls.map(([value]) => value)).toEqual([false]); + }); + + it('does not let another process disable be overwritten by an in-flight enable', async () => { + let releasePrepare: (() => void) | null = null; + harness.prepareKeyStore.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePrepare = resolve; + }), + ); + + const enabling = driver.setContactsDeviceSyncEnabled(true); + await vi.waitFor(() => expect(harness.prepareKeyStore).toHaveBeenCalledTimes(1)); + harness.syncSettingIntent = { token: 'intent-from-other-process', enabled: false }; + harness.settings.set('owner-a', false); + releasePrepare!(); + await enabling; + + expect(harness.settings.get('owner-a')).toBe(false); + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ enabled: false, phase: 'off' }); + expect(harness.writeDeviceSync).not.toHaveBeenCalledWith(true); + }); + + it('releases local disable suppression when a newer cross-process enable wins', async () => { + harness.settings.set('owner-a', true); + let releaseCommit: ((committed: boolean) => void) | null = null; + harness.commitSettingIntent.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseCommit = resolve; + }), + ); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [], + isPeerAllowed: () => false, + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + + const disabling = driver.setContactsDeviceSyncEnabled(false); + await vi.waitFor(() => expect(releaseCommit).not.toBeNull()); + harness.syncSettingIntent = { token: 'newer-enable', enabled: true }; + harness.settings.set('owner-a', true); + releaseCommit!(false); + await disabling; + + await vi.waitFor(() => + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ enabled: true }), + ); + }); + + it('stays off and recovers durable false after a crash between disable intent and commit', async () => { + harness.settings.set('owner-a', true); + harness.syncSettingIntent = { token: 'pending-disable', enabled: false }; + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [], + isPeerAllowed: () => false, + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ enabled: false, phase: 'off' }); + expect(harness.relaySend).not.toHaveBeenCalled(); + driver.pollContactsDeviceSyncSettingChange(); + + await vi.waitFor(() => expect(harness.settings.get('owner-a')).toBe(false)); + expect(driver.getContactsDeviceSyncStatus()).toMatchObject({ enabled: false, phase: 'off' }); + }); + + it('does not answer a key frame after demotion while pinning is in flight', async () => { + harness.settings.set('owner-a', true); + harness.peerPublicKey = null; + let releasePin: ((firstSeen: boolean) => void) | null = null; + harness.pinPeerPublicKey.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePin = resolve; + }), + ); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [{ deviceId: 'peer-device', deviceName: 'Peer Desktop' }], + isPeerAllowed: (deviceId) => deviceId === 'peer-device', + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + await vi.waitFor(() => + expect(harness.relaySend.mock.calls.some(([, frame]) => frame.type === 'key')).toBe(true), + ); + harness.relaySend.mockClear(); + + driver.handleIncomingContactsRelayFrame('peer-device', { + version: 1, + type: 'key', + publicKey: 'peer-public', + }); + await vi.waitFor(() => expect(harness.pinPeerPublicKey).toHaveBeenCalledTimes(1)); + driver.setContactsDeviceLinkOwnerActive(false); + releasePin!(true); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.relaySend).not.toHaveBeenCalled(); + }); + + it('responds to an incoming key even after a recent proactive announcement', async () => { + harness.settings.set('owner-a', true); + harness.peerPublicKey = null; + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [{ deviceId: 'peer-device', deviceName: 'Peer Desktop' }], + isPeerAllowed: (deviceId) => deviceId === 'peer-device', + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + const keyFrameCount = () => + harness.relaySend.mock.calls.filter(([, frame]) => frame.type === 'key').length; + + await vi.waitFor(() => expect(keyFrameCount()).toBe(1)); + driver.handleIncomingContactsRelayFrame('peer-device', { + version: 1, + type: 'key', + publicKey: 'peer-public', + }); + await vi.waitFor(() => expect(keyFrameCount()).toBe(2)); + + // 对端对响应的回声不能形成 key ping-pong;响应有独立的短节流。 + driver.handleIncomingContactsRelayFrame('peer-device', { + version: 1, + type: 'key', + publicKey: 'peer-public', + }); + await vi.waitFor(() => expect(keyFrameCount()).toBe(2)); + }); + + it('shares one cold database preparation across a max-size 128-chunk transfer', async () => { + harness.settings.set('owner-a', true); + let releasePreparation!: (value: { materialized: boolean }) => void; + harness.prepareDatabase.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePreparation = resolve; + }), + ); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [{ deviceId: 'peer-device', deviceName: 'Peer Desktop' }], + isPeerAllowed: (deviceId) => deviceId === 'peer-device', + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + await vi.waitFor(() => expect(harness.prepareDatabase).toHaveBeenCalledTimes(1)); + + for (let index = 0; index < 128; index += 1) { + driver.handleIncomingContactsRelayFrame('peer-device', { + version: 1, + type: 'cipher-chunk', + senderPublicKey: 'peer-public', + transferId: 'large-transfer', + index, + total: 128, + iv: 'iv', + tag: 'tag', + compression: 'gzip', + data: 'data', + }); + } + expect(harness.prepareDatabase).toHaveBeenCalledTimes(1); + + releasePreparation({ materialized: false }); + await vi.waitFor(() => expect(harness.decoderAccept).toHaveBeenCalledTimes(128)); + expect(harness.prepareDatabase).toHaveBeenCalledTimes(1); + }); + + it('serializes database encoding when broadcasting to more peers than the worker queue', async () => { + harness.settings.set('owner-a', true); + let activeEncodes = 0; + let maxActiveEncodes = 0; + harness.encodeContactsSyncMessage.mockImplementation(async () => { + activeEncodes += 1; + maxActiveEncodes = Math.max(maxActiveEncodes, activeEncodes); + await new Promise((resolve) => setTimeout(resolve, 1)); + activeEncodes -= 1; + return { frames: harness.wireFrames, materialized: false }; + }); + const peers = Array.from({ length: 12 }, (_, index) => ({ + deviceId: `peer-${index}`, + deviceName: `Peer ${index}`, + })); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => peers, + isPeerAllowed: () => true, + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + + await vi.waitFor(() => + expect( + harness.relaySend.mock.calls.filter(([, frame]) => frame.type === 'cipher-chunk'), + ).toHaveLength(12), + ); + expect(harness.encodeContactsSyncMessage).toHaveBeenCalledTimes(12); + expect(maxActiveEncodes).toBe(1); + }); + + it('broadcasts a contact change written by another shared-userData instance', async () => { + harness.settings.set('owner-a', true); + harness.lanSend.mockResolvedValue(false); + driver.initContactsDeviceSync({ + getSelfDeviceId: () => 'self-device', + listOnlineDesktopDevices: () => [{ deviceId: 'peer-device', deviceName: 'Peer Desktop' }], + isPeerAllowed: (deviceId) => deviceId === 'peer-device', + sendRelayFrame: harness.relaySend, + }); + driver.setContactsDeviceLinkOwnerActive(true); + await vi.waitFor(() => + expect(harness.relaySend.mock.calls.some(([, frame]) => frame.type === 'cipher-chunk')).toBe( + true, + ), + ); + harness.relaySend.mockClear(); + + harness.contactsChangeToken = 'passive-instance-write'; + driver.pollContactsDeviceSyncDataChange(); + await vi.waitFor(() => + expect(harness.relaySend.mock.calls.some(([, frame]) => frame.type === 'cipher-chunk')).toBe( + true, + ), + ); + + const sent = harness.relaySend.mock.calls.length; + driver.pollContactsDeviceSyncDataChange(); + expect(harness.relaySend).toHaveBeenCalledTimes(sent); + }); +}); diff --git a/apps/desktop/src/main/contacts-sync/__tests__/keyStore.test.ts b/apps/desktop/src/main/contacts-sync/__tests__/keyStore.test.ts new file mode 100644 index 00000000000..0b232196b63 --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/__tests__/keyStore.test.ts @@ -0,0 +1,206 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + ContactsSyncKeyStore, + isContactsSyncSecureStorageAvailable, +} from '../keyStore.js'; +import { generateContactsSyncIdentity } from '../crypto.js'; + +describe('contacts sync key store', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + function createStore(options?: { encryptionAvailable?: boolean; file?: string }) { + const dir = options?.file + ? path.dirname(options.file) + : fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-contacts-sync-key-')); + if (!options?.file) tempDirs.push(dir); + const file = options?.file ?? path.join(dir, 'key.enc'); + const store = new ContactsSyncKeyStore({ + filePath: () => file, + isEncryptionAvailable: () => options?.encryptionAvailable ?? true, + encrypt: (text) => Buffer.from(`encrypted:${text}`, 'utf8'), + decrypt: (bytes) => { + const value = bytes.toString('utf8'); + if (!value.startsWith('encrypted:')) throw new Error('bad ciphertext'); + return value.slice('encrypted:'.length); + }, + }); + return { store, file }; + } + + function createSharedFile(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-contacts-sync-key-shared-')); + tempDirs.push(dir); + return path.join(dir, 'key.enc'); + } + + it('设备私钥只以加密文本落盘,重启后身份稳定', async () => { + const { store, file } = createStore(); + await store.prepare(); + const identity = store.getIdentity(); + const onDisk = fs.readFileSync(file, 'utf8'); + expect(onDisk).not.toContain(identity.privateKey); + + store.resetMemory(); + await store.prepare(); + expect(store.getIdentity()).toEqual(identity); + }); + + it('首次绑定对端公钥,之后拒绝同 deviceId 换钥', async () => { + const { store } = createStore(); + await store.prepare(); + const first = generateContactsSyncIdentity().publicKey; + const second = generateContactsSyncIdentity().publicKey; + await expect(store.pinPeerPublicKey('device-b', first)).resolves.toBe(true); + await expect(store.pinPeerPublicKey('device-b', first)).resolves.toBe(false); + await expect(store.pinPeerPublicKey('device-b', second)).rejects.toThrow(/identity changed/); + expect(store.getPeerPublicKey('device-b')).toBe(first); + }); + + it('共享 userData 的实例采用同一设备身份,并在最新磁盘基线上合并 peer pin', async () => { + const file = createSharedFile(); + const firstStore = createStore({ file }).store; + const secondStore = createStore({ file }).store; + await Promise.all([firstStore.prepare(), secondStore.prepare()]); + const firstIdentity = firstStore.getIdentity(); + expect(secondStore.getIdentity()).toEqual(firstIdentity); + + // 两边此时都持有同一旧缓存;后写者仍须保留前一实例刚写入的 pin。 + const peerB = generateContactsSyncIdentity().publicKey; + const peerC = generateContactsSyncIdentity().publicKey; + await Promise.all([ + expect(firstStore.pinPeerPublicKey('device-b', peerB)).resolves.toBe(true), + expect(secondStore.pinPeerPublicKey('device-c', peerC)).resolves.toBe(true), + ]); + + const reloaded = createStore({ file }).store; + await reloaded.prepare(); + expect(reloaded.getIdentity()).toEqual(firstIdentity); + expect(reloaded.getPeerPublicKey('device-b')).toBe(peerB); + expect(reloaded.getPeerPublicKey('device-c')).toBe(peerC); + expect(fs.readdirSync(path.dirname(file)).sort()).toEqual(['key.enc']); + }); + + it('旧缓存实例幂等接受其它实例已写入的 pin 后立即刷新本地可见值', async () => { + const file = createSharedFile(); + const staleStore = createStore({ file }).store; + const writerStore = createStore({ file }).store; + await staleStore.prepare(); + await writerStore.prepare(); + const peerKey = generateContactsSyncIdentity().publicKey; + + await expect(writerStore.pinPeerPublicKey('device-b', peerKey)).resolves.toBe(true); + expect(staleStore.getPeerPublicKey('device-b')).toBeNull(); + await expect(staleStore.pinPeerPublicKey('device-b', peerKey)).resolves.toBe(false); + expect(staleStore.getPeerPublicKey('device-b')).toBe(peerKey); + }); + + it('密钥读取失败会释放跨进程锁,修复文件后可重新初始化', async () => { + const file = createSharedFile(); + fs.writeFileSync(file, 'broken ciphertext', 'utf8'); + await expect(createStore({ file }).store.prepare()).rejects.toThrow(/unreadable/); + + fs.unlinkSync(file); + const repaired = createStore({ file }).store; + await repaired.prepare(); + const identity = repaired.getIdentity(); + expect(identity.publicKey).toBeTruthy(); + expect(fs.existsSync(`${file}.lock`)).toBe(false); + }); + + it('安全存储不可用时 fail closed,不生成明文私钥文件', async () => { + const { store, file } = createStore({ encryptionAvailable: false }); + await expect(store.prepare()).rejects.toThrow(/secure storage is unavailable/); + expect(fs.existsSync(file)).toBe(false); + }); + + it('Linux basic_text 后端即使声称可加密也 fail closed', () => { + expect( + isContactsSyncSecureStorageAvailable({ + platform: 'linux', + encryptionAvailable: true, + backend: 'basic_text', + }), + ).toBe(false); + expect( + isContactsSyncSecureStorageAvailable({ + platform: 'linux', + encryptionAvailable: true, + backend: 'gnome_libsecret', + }), + ).toBe(true); + expect( + isContactsSyncSecureStorageAvailable({ + platform: 'darwin', + encryptionAvailable: true, + backend: 'basic_text', + }), + ).toBe(true); + }); + + it('等待另一实例的锁时不阻塞事件循环', async () => { + const file = createSharedFile(); + fs.writeFileSync(`${file}.lock`, JSON.stringify({ pid: process.pid, startedAt: Date.now() })); + const store = createStore({ file }).store; + let timerFired = false; + + const preparing = store.prepare(); + await new Promise((resolve) => { + setTimeout(() => { + timerFired = true; + fs.unlinkSync(`${file}.lock`); + resolve(); + }, 20); + }); + + expect(timerFired).toBe(true); + await preparing; + expect(store.getIdentity().publicKey).toBeTruthy(); + }); + + it('重置会作废仍在等待锁的旧操作', async () => { + const file = createSharedFile(); + fs.writeFileSync(`${file}.lock`, JSON.stringify({ pid: process.pid, startedAt: Date.now() })); + const store = createStore({ file }).store; + + const preparing = store.prepare(); + store.resetMemory(); + fs.unlinkSync(`${file}.lock`); + + await expect(preparing).rejects.toThrow(/invalidated/); + expect(() => store.getIdentity()).toThrow(/not prepared/); + expect(store.getPeerPublicKey('device-b')).toBeNull(); + }); + + it('peer pin 达到上限后拒绝新设备且不写出不可读文件', async () => { + const { store, file } = createStore(); + await store.prepare(); + const identity = store.getIdentity(); + const peerKey = generateContactsSyncIdentity().publicKey; + const peers = Object.fromEntries( + Array.from({ length: 1_000 }, (_, index) => [`device-${index}`, peerKey]), + ); + const plaintext = JSON.stringify({ version: 1, ...identity, peers }); + fs.writeFileSync(file, Buffer.from(`encrypted:${plaintext}`, 'utf8').toString('base64')); + store.resetMemory(); + await store.prepare(); + const before = fs.readFileSync(file, 'utf8'); + + await expect( + store.pinPeerPublicKey('device-over-limit', generateContactsSyncIdentity().publicKey), + ).rejects.toThrow(/peer limit exceeded/); + expect(fs.readFileSync(file, 'utf8')).toBe(before); + + store.resetMemory(); + await store.prepare(); + expect(store.getIdentity()).toEqual(identity); + expect(store.getPeerPublicKey('device-999')).toBe(peerKey); + }); +}); diff --git a/apps/desktop/src/main/contacts-sync/__tests__/lanTransport.test.ts b/apps/desktop/src/main/contacts-sync/__tests__/lanTransport.test.ts new file mode 100644 index 00000000000..1d940438f70 --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/__tests__/lanTransport.test.ts @@ -0,0 +1,169 @@ +import net, { type Socket } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { inProcessContactsSyncCodec } from '../contactsSyncCodec.js'; +import { generateContactsSyncIdentity } from '../crypto.js'; +import { LanContactsSyncTransport } from '../lanTransport.js'; +import { ContactsSyncWireDecoder, encodeContactsSyncMessage } from '../wire.js'; + +interface LanTransportInternals { + endpoints: Map; + directRetryAfter: Map; + tcpServer: net.Server | null; + handleConnection(socket: Socket): void; +} + +describe('contacts sync LAN transport', () => { + const servers: net.Server[] = []; + + afterEach(async () => { + await Promise.all( + servers + .splice(0) + .map((server) => new Promise((resolve) => server.close(() => resolve()))), + ); + }); + + it('同网端点需证明持有设备私钥,伪造信标端点会回退 relay', async () => { + const aIdentity = generateContactsSyncIdentity(); + const bIdentity = generateContactsSyncIdentity(); + type DecodedFrame = Awaited>; + let resolveFrame: ((value: DecodedFrame) => void) | null = null; + const received = new Promise((resolve) => { + resolveFrame = resolve; + }); + const decoder = new ContactsSyncWireDecoder(inProcessContactsSyncCodec); + const b = new LanContactsSyncTransport({ + getSelf: () => ({ + deviceId: 'device-b', + publicKey: bIdentity.publicKey, + privateKey: bIdentity.privateKey, + }), + isPeerAllowed: (deviceId, publicKey) => + deviceId === 'device-a' && publicKey === aIdentity.publicKey, + onFrame: (srcDeviceId, frame) => { + void decoder + .accept({ + srcDeviceId, + dstDeviceId: 'device-b', + frame, + ownPrivateKey: bIdentity.privateKey, + expectedPeerPublicKey: aIdentity.publicKey, + }) + .then((message) => resolveFrame?.(message)); + }, + logger: { debug: () => {}, warn: () => {} }, + }); + const bInternals = b as unknown as LanTransportInternals; + const server = net.createServer((socket) => bInternals.handleConnection(socket)); + servers.push(server); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen({ host: '127.0.0.1', port: 0 }, () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('LAN transport test server did not expose a TCP port'); + } + + const a = new LanContactsSyncTransport({ + getSelf: () => ({ + deviceId: 'device-a', + publicKey: aIdentity.publicKey, + privateKey: aIdentity.privateKey, + }), + isPeerAllowed: (deviceId, publicKey) => + deviceId === 'device-b' && publicKey === bIdentity.publicKey, + onFrame: () => {}, + logger: { debug: () => {}, warn: () => {} }, + }); + const aInternals = a as unknown as LanTransportInternals; + const [frame] = await encodeContactsSyncMessage( + { + message: { + version: 1, + type: 'state', + state: { privateContact: 'Alice' }, + }, + ownPrivateKey: aIdentity.privateKey, + ownPublicKey: aIdentity.publicKey, + peerPublicKey: bIdentity.publicKey, + srcDeviceId: 'device-a', + dstDeviceId: 'device-b', + }, + inProcessContactsSyncCodec, + ); + expect(frame).toBeDefined(); + expect(JSON.stringify(frame)).not.toContain('Alice'); + expect(await a.send('device-b', frame!)).toBe(false); + + // 攻击者重放 B 的公开 beacon 后可以接住 TCP,但拿不到 B 的 X25519 私钥, + // 因而无法返回认证 ACK;发送方必须报告 false,让上层继续走 relay。 + let impostorConnections = 0; + const impostor = net.createServer((socket) => { + impostorConnections += 1; + socket.once('data', (bytes) => socket.end(bytes)); + }); + servers.push(impostor); + await new Promise((resolve, reject) => { + impostor.once('error', reject); + impostor.listen({ host: '127.0.0.1', port: 0 }, () => resolve()); + }); + const impostorAddress = impostor.address(); + if (!impostorAddress || typeof impostorAddress === 'string') { + throw new Error('LAN impostor test server did not expose a TCP port'); + } + aInternals.endpoints.set('device-b', { + address: '127.0.0.1', + port: impostorAddress.port, + publicKey: bIdentity.publicKey, + seenAt: Date.now(), + }); + expect(await a.send('device-b', frame!)).toBe(false); + expect(impostorConnections).toBe(1); + + // 攻击者即使立即重放同一合法设备的公开 beacon,冷却期内也不能让后续 + // 分片再次连接伪端点;send() 必须立即返回 false 让上层走 relay。 + aInternals.endpoints.set('device-b', { + address: '127.0.0.1', + port: impostorAddress.port, + publicKey: bIdentity.publicKey, + seenAt: Date.now(), + }); + expect(await a.send('device-b', frame!)).toBe(false); + expect(impostorConnections).toBe(1); + + // 冷却结束后合法设备仍可恢复直连。 + aInternals.directRetryAfter.set('device-b', Date.now() - 1); + aInternals.endpoints.set('device-b', { + address: '127.0.0.1', + port: address.port, + publicKey: bIdentity.publicKey, + seenAt: Date.now(), + }); + expect(await a.send('device-b', frame!)).toBe(true); + await expect(received).resolves.toEqual({ + version: 1, + type: 'state', + state: { privateContact: 'Alice' }, + }); + }); + + it('限制未认证 TCP 并发连接数', () => { + const identity = generateContactsSyncIdentity(); + const transport = new LanContactsSyncTransport({ + getSelf: () => ({ + deviceId: 'device-a', + publicKey: identity.publicKey, + privateKey: identity.privateKey, + }), + isPeerAllowed: () => false, + onFrame: () => {}, + logger: { debug: () => {}, warn: () => {} }, + }); + transport.start(); + const internals = transport as unknown as LanTransportInternals; + expect(internals.tcpServer?.maxConnections).toBe(32); + transport.stop(); + }); +}); diff --git a/apps/desktop/src/main/contacts-sync/__tests__/sender.test.ts b/apps/desktop/src/main/contacts-sync/__tests__/sender.test.ts new file mode 100644 index 00000000000..783c89a36b8 --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/__tests__/sender.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { ContactsSyncWireFrame } from '../wire.js'; + +const harness = vi.hoisted(() => ({ + frames: [0, 1, 2].map((index) => ({ + version: 1, + type: 'cipher-chunk', + senderPublicKey: 'own-public', + transferId: 'same-transfer', + index, + total: 3, + iv: 'iv', + tag: 'tag', + compression: 'gzip', + data: `chunk-${index}`, + })), +})); + +vi.mock('../wire.js', () => ({ + encodeContactsSyncDatabaseState: async () => ({ + frames: harness.frames, + materialized: false, + }), +})); + +const { ContactsSyncOutbound } = await import('../sender.js'); + +describe('contacts sync outbound', () => { + it('retries the same relay frame after backpressure before advancing', async () => { + const attempts: number[] = []; + let rejectMiddleFrame = true; + const transport = { + getSelfDeviceId: () => 'self-device', + isPeerAllowed: (deviceId: string) => deviceId === 'peer-device', + sendRelayFrame: (_deviceId: string, frame: ContactsSyncWireFrame) => { + if (frame.type !== 'cipher-chunk') throw new Error('unexpected key frame'); + attempts.push(frame.index); + if (frame.index === 1 && rejectMiddleFrame) { + rejectMiddleFrame = false; + throw Object.assign(new Error('full'), { code: 'BACKPRESSURE' }); + } + }, + }; + const codecAbortController = new AbortController(); + const outbound = new ContactsSyncOutbound({ + getGeneration: () => 1, + getOwnerId: () => 'owner-a', + getTransport: () => transport, + getDirectTransport: () => null, + getCodecAbortSignal: () => codecAbortController.signal, + isEnabled: () => true, + getIdentity: () => ({ privateKey: 'own-private', publicKey: 'own-public' }), + getPeerPublicKey: () => 'peer-public', + getDatabaseSource: () => ({ dbPath: '/tmp/test-contacts.db' }), + getKnownClocks: () => undefined, + onLocalMaterialized: vi.fn(), + announceKey: vi.fn(), + onError: vi.fn(), + }); + + await outbound.send('peer-device', true); + + expect(attempts).toEqual([0, 1, 1, 2]); + expect(new Set(harness.frames.map((frame) => frame.transferId))).toEqual( + new Set(['same-transfer']), + ); + }); +}); diff --git a/apps/desktop/src/main/contacts-sync/__tests__/statusStore.test.ts b/apps/desktop/src/main/contacts-sync/__tests__/statusStore.test.ts new file mode 100644 index 00000000000..f52565f4882 --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/__tests__/statusStore.test.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const harness = vi.hoisted(() => ({ + root: '', + ownerId: 'owner-a' as string | null, +})); + +vi.mock('../../appSessionState.js', () => ({ + getActiveAppSession: () => ({ dataOwnerId: harness.ownerId }), + ownerScopedUserDataPath: (...parts: string[]) => + path.join(harness.root, 'owners', harness.ownerId ?? 'none', ...parts), +})); + +const store = await import('../statusStore.js'); + +describe('contacts sync cross-process status store', () => { + beforeEach(() => { + harness.root = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-contacts-sync-status-')); + harness.ownerId = 'owner-a'; + }); + + afterEach(() => { + fs.rmSync(harness.root, { recursive: true, force: true }); + }); + + it('shares bounded runtime status and sync requests only inside the active owner scope', () => { + const runtime = { + available: true, + enabled: true, + phase: 'up-to-date' as const, + onlineDeviceCount: 2, + errorCode: null, + lastSyncAt: '2026-08-01T00:00:00.000Z', + lastSyncDeviceId: 'peer-device', + lastSyncDeviceName: 'Office Desktop', + lastRoute: 'relay' as const, + updatedAt: Date.now(), + }; + store.writePersistedContactsSyncRuntimeStatus(runtime); + store.writeContactsSyncRequestToken(); + const ownerARequest = store.readContactsSyncRequestToken(); + + expect(store.readPersistedContactsSyncRuntimeStatus()).toEqual(runtime); + expect(ownerARequest).toMatch(/^[0-9a-f-]{36}$/); + const ownerAFiles = fs.readdirSync(path.join(harness.root, 'owners', 'owner-a')); + expect(ownerAFiles.some((name) => name.endsWith('.tmp'))).toBe(false); + + harness.ownerId = 'owner-b'; + expect(store.readPersistedContactsSyncRuntimeStatus()).toBeNull(); + expect(store.readContactsSyncRequestToken()).toBeNull(); + store.writeContactsSyncRequestToken(); + expect(store.readContactsSyncRequestToken()).not.toBe(ownerARequest); + + harness.ownerId = null; + expect(store.readPersistedContactsSyncRuntimeStatus()).toBeNull(); + expect(store.readContactsSyncRequestToken()).toBeNull(); + }); + + it('rejects malformed or oversized runtime status files', () => { + const file = path.join( + harness.root, + 'owners', + 'owner-a', + 'contacts-device-sync-runtime.v1.json', + ); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + JSON.stringify({ + available: true, + enabled: true, + phase: 'up-to-date', + onlineDeviceCount: 10_001, + errorCode: null, + updatedAt: Date.now(), + }), + 'utf8', + ); + + expect(store.readPersistedContactsSyncRuntimeStatus()).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/contacts-sync/contactsSyncCodec.ts b/apps/desktop/src/main/contacts-sync/contactsSyncCodec.ts new file mode 100644 index 00000000000..1ac4d9afc3b --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/contactsSyncCodec.ts @@ -0,0 +1,252 @@ +/** + * 通讯录同步的 CPU 密集型编解码核心。 + * + * 生产环境只在专用 worker_threads 中调用这些同步函数;Main 通过 + * ContactsSyncCodec 的异步接口使用,避免大状态 JSON/gzip/加密阻塞事件循环。 + */ + +import { randomUUID } from 'node:crypto'; +import { gunzipSync, gzipSync } from 'node:zlib'; +import { + CONTACTS_SYNC_CHUNK_BYTES, + CONTACTS_SYNC_MAX_CHUNKS, + CONTACTS_SYNC_WIRE_VERSION, + type ContactsSyncCipherChunkFrame, +} from '@cindy/device-link'; +import type { ContactsSyncClock } from '@cindy/maker-core'; + +import { + decryptContactsSyncBytes, + encryptContactsSyncBytes, + type ContactsSyncEncryptionContext, +} from './crypto.js'; + +export const CONTACTS_SYNC_MAX_COMPRESSED_BYTES = + CONTACTS_SYNC_CHUNK_BYTES * CONTACTS_SYNC_MAX_CHUNKS; +export const CONTACTS_SYNC_MAX_DECOMPRESSED_BYTES = 128 * 1024 * 1024; + +export interface ContactsSyncStateMessage { + version: 1; + type: 'state'; + state: unknown; + requestReply?: boolean; +} + +interface ContactsSyncEncodeCommonOptions { + ownPrivateKey: string; + ownPublicKey: string; + peerPublicKey: string; + srcDeviceId: string; + dstDeviceId: string; +} + +export interface ContactsSyncDatabaseSource { + dbPath: string; + betterSqliteModulePath?: string; + nativeBinding?: string; +} + +export interface ContactsSyncMessageEncodeOptions extends ContactsSyncEncodeCommonOptions { + message: ContactsSyncStateMessage; + database?: never; +} + +export interface ContactsSyncDatabaseEncodeOptions extends ContactsSyncEncodeCommonOptions { + message?: never; + database: { + source: ContactsSyncDatabaseSource; + knownClocks?: ContactsSyncClock[]; + requestReply?: boolean; + }; +} + +export type ContactsSyncEncodeOptions = + | ContactsSyncMessageEncodeOptions + | ContactsSyncDatabaseEncodeOptions; + +export interface ContactsSyncDecodeOptions { + ciphertext: Uint8Array; + iv: string; + tag: string; + ownPrivateKey: string; + expectedPeerPublicKey: string; + srcDeviceId: string; + dstDeviceId: string; + transferId: string; + totalChunks: number; + databaseSource?: ContactsSyncDatabaseSource; +} + +export interface ContactsSyncAppliedStateResult { + version: 1; + type: 'applied-state'; + changed: boolean; + clocks: ContactsSyncClock[]; + requestReply?: boolean; +} + +export type ContactsSyncDecodeResult = ContactsSyncStateMessage | ContactsSyncAppliedStateResult; + +export interface ContactsSyncEncodeResult { + frames: ContactsSyncCipherChunkFrame[]; + materialized: boolean; +} + +export interface ContactsSyncEncodedPayload { + transferId: string; + total: number; + iv: string; + tag: string; + ciphertext: Uint8Array; + materialized: boolean; +} + +export interface ContactsSyncCodec { + encode( + options: ContactsSyncEncodeOptions, + signal?: AbortSignal, + ): Promise; + decode( + options: ContactsSyncDecodeOptions, + signal?: AbortSignal, + ): Promise; +} + +export type ContactsSyncCodecWorkerRequest = { + id: string; + cancellation?: SharedArrayBuffer; +} & + ( + | { type: 'encode'; options: ContactsSyncEncodeOptions } + | { type: 'decode'; options: ContactsSyncDecodeOptions } + | { type: 'prepare'; source: ContactsSyncDatabaseSource } + ); + +export interface ContactsSyncCodecWorkerResponse { + id: string; + ok: boolean; + data?: unknown; + error?: string; +} + +export function encodeContactsSyncMessageInProcess( + options: ContactsSyncMessageEncodeOptions, +): ContactsSyncEncodeResult { + const payload = encodeContactsSyncJsonInProcess( + Buffer.from(JSON.stringify(options.message), 'utf8'), + options, + ); + return { + frames: createContactsSyncFrames(payload, options.ownPublicKey), + materialized: false, + }; +} + +export function encodeContactsSyncJsonInProcess( + json: Uint8Array, + options: ContactsSyncEncodeCommonOptions, + materialized = false, +): ContactsSyncEncodedPayload { + if (json.byteLength > CONTACTS_SYNC_MAX_DECOMPRESSED_BYTES) { + throw new Error(`contacts sync state is too large (${json.byteLength} decompressed bytes)`); + } + const transferId = randomUUID(); + const compressed = gzipSync(json); + if (compressed.length > CONTACTS_SYNC_MAX_COMPRESSED_BYTES) { + throw new Error(`contacts sync state is too large (${compressed.length} compressed bytes)`); + } + const total = Math.max(1, Math.ceil(compressed.length / CONTACTS_SYNC_CHUNK_BYTES)); + const context: ContactsSyncEncryptionContext = { + srcDeviceId: options.srcDeviceId, + dstDeviceId: options.dstDeviceId, + transferId, + totalChunks: total, + }; + const encrypted = encryptContactsSyncBytes( + compressed, + options.ownPrivateKey, + options.peerPublicKey, + context, + ); + const ciphertext = copyBytes(encrypted.ciphertext); + return { transferId, total, iv: encrypted.iv, tag: encrypted.tag, ciphertext, materialized }; +} + +export function createContactsSyncFrames( + payload: ContactsSyncEncodedPayload, + senderPublicKey: string, +): ContactsSyncCipherChunkFrame[] { + const frames: ContactsSyncCipherChunkFrame[] = []; + const ciphertext = Buffer.from(payload.ciphertext); + for (let index = 0; index < payload.total; index += 1) { + frames.push({ + version: CONTACTS_SYNC_WIRE_VERSION, + type: 'cipher-chunk', + senderPublicKey, + transferId: payload.transferId, + index, + total: payload.total, + iv: payload.iv, + tag: payload.tag, + compression: 'gzip', + data: ciphertext + .subarray( + index * CONTACTS_SYNC_CHUNK_BYTES, + Math.min((index + 1) * CONTACTS_SYNC_CHUNK_BYTES, ciphertext.length), + ) + .toString('base64'), + }); + } + return frames; +} + +export function decodeContactsSyncMessageInProcess( + options: ContactsSyncDecodeOptions, +): ContactsSyncStateMessage { + const context: ContactsSyncEncryptionContext = { + srcDeviceId: options.srcDeviceId, + dstDeviceId: options.dstDeviceId, + transferId: options.transferId, + totalChunks: options.totalChunks, + }; + const compressed = decryptContactsSyncBytes( + { iv: options.iv, tag: options.tag, ciphertext: Buffer.from(options.ciphertext) }, + options.ownPrivateKey, + options.expectedPeerPublicKey, + context, + ); + const json = gunzipSync(compressed, { + maxOutputLength: CONTACTS_SYNC_MAX_DECOMPRESSED_BYTES, + }).toString('utf8'); + const message: unknown = JSON.parse(json); + if (!isContactsSyncStateMessage(message)) throw new Error('invalid contacts sync message'); + return message; +} + +export function isContactsSyncStateMessage(value: unknown): value is ContactsSyncStateMessage { + if (!isRecord(value)) return false; + return ( + value.version === 1 && + value.type === 'state' && + value.state !== undefined && + (value.requestReply === undefined || typeof value.requestReply === 'boolean') + ); +} + +export const inProcessContactsSyncCodec: ContactsSyncCodec = { + encode: async (options) => { + if (!options.message) throw new Error('in-process contacts codec requires a message'); + return encodeContactsSyncMessageInProcess(options); + }, + decode: async (options) => decodeContactsSyncMessageInProcess(options), +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function copyBytes(value: Uint8Array): Uint8Array { + const copy = new Uint8Array(new ArrayBuffer(value.byteLength)); + copy.set(value); + return copy; +} diff --git a/apps/desktop/src/main/contacts-sync/contactsSyncCodecWorker.ts b/apps/desktop/src/main/contacts-sync/contactsSyncCodecWorker.ts new file mode 100644 index 00000000000..f522feaaadd --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/contactsSyncCodecWorker.ts @@ -0,0 +1,191 @@ +// eslint-disable-next-line no-restricted-imports -- bounded contacts codec work is isolated from Main. +import { parentPort } from 'node:worker_threads'; +import { createRequire } from 'node:module'; +import type Database from 'better-sqlite3'; +import { + createContactsSyncDelta, + MakerContactsStore, + type ContactsSyncState, +} from '@cindy/maker-core/contacts-sync-worker'; + +import { + decodeContactsSyncMessageInProcess, + encodeContactsSyncJsonInProcess, + type ContactsSyncDatabaseSource, + type ContactsSyncEncodedPayload, + type ContactsSyncCodecWorkerRequest, + type ContactsSyncCodecWorkerResponse, +} from './contactsSyncCodec.js'; + +const port = parentPort; +if (!port) throw new Error('contacts sync codec must run in a worker thread'); + +port.once('message', (request: ContactsSyncCodecWorkerRequest) => { + let response: ContactsSyncCodecWorkerResponse; + let transferList: ArrayBuffer[] = []; + try { + const data = + request.type === 'encode' + ? encodeRequest(request) + : request.type === 'decode' + ? decodeRequest(request) + : prepareRequest(request); + if (isEncodedPayload(data)) transferList = [data.ciphertext.buffer]; + response = { id: request.id, ok: true, data }; + } catch (error) { + response = { + id: request.id, + ok: false, + error: error instanceof Error ? error.message : 'contacts sync codec failed', + }; + } + port.postMessage(response, transferList); +}); + +function prepareRequest( + request: Extract, +): { materialized: boolean } { + throwIfCancelled(request); + return withContactsStore( + request.source, + (store) => { + throwIfCancelled(request); + const result = store.prepareDeviceSyncStateForTransfer(); + return { materialized: result.materialized }; + }, + true, + ); +} + +function encodeRequest( + request: Extract, +): ContactsSyncEncodedPayload { + throwIfCancelled(request); + const options = request.options; + if (options.message) { + return encodeContactsSyncJsonInProcess( + Buffer.from(JSON.stringify(options.message), 'utf8'), + options, + ); + } + + return withContactsStore(options.database.source, (store) => { + throwIfCancelled(request); + const result = store.prepareDeviceSyncStateForTransfer(); + const state = options.database.knownClocks + ? createContactsSyncDelta(result.state, options.database.knownClocks) + : result.state; + throwIfCancelled(request); + const message = { + version: 1 as const, + type: 'state' as const, + state, + ...(options.database.requestReply ? { requestReply: true } : {}), + }; + return encodeContactsSyncJsonInProcess( + Buffer.from(JSON.stringify(message), 'utf8'), + options, + result.materialized, + ); + }); +} + +function decodeRequest( + request: Extract, +): unknown { + throwIfCancelled(request); + const message = decodeContactsSyncMessageInProcess(request.options); + const source = request.options.databaseSource; + if (!source) return message; + + return withContactsStore(source, (store) => { + throwIfCancelled(request); + const changed = store.mergeDeviceSyncStateForTransfer(message.state); + const state = message.state as ContactsSyncState; + return { + version: 1 as const, + type: 'applied-state' as const, + changed, + clocks: state.clocks.map((clock) => ({ ...clock })), + ...(message.requestReply ? { requestReply: true } : {}), + }; + }); +} + +function withContactsStore( + source: ContactsSyncDatabaseSource, + task: (store: MakerContactsStore) => T, + runStartupMaintenance = false, +): T { + assertDatabaseSource(source); + const workerRequire = createRequire( + typeof __filename === 'string' ? __filename : import.meta.url, + ); + const loaded = workerRequire(source.betterSqliteModulePath ?? 'better-sqlite3') as + | typeof import('better-sqlite3') + | { default: typeof import('better-sqlite3') }; + const DatabaseConstructor = 'default' in loaded ? loaded.default : loaded; + const options: Database.Options = source.nativeBinding + ? { nativeBinding: source.nativeBinding } + : {}; + const db = new DatabaseConstructor(source.dbPath, options); + try { + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); + const store = new MakerContactsStore({ + db, + logger: NOOP_LOGGER, + skipStartupMaintenance: !runStartupMaintenance, + }); + store.init(); + return task(store); + } finally { + db.close(); + } +} + +const NOOP_LOGGER = { + trace() {}, + debug() {}, + info() {}, + warn() {}, + error() {}, + fatal() {}, + child() { + return NOOP_LOGGER; + }, +}; + +function assertDatabaseSource(source: ContactsSyncDatabaseSource): void { + if ( + !source || + typeof source.dbPath !== 'string' || + source.dbPath.length === 0 || + source.dbPath.length > 4096 || + (source.betterSqliteModulePath !== undefined && + (typeof source.betterSqliteModulePath !== 'string' || + source.betterSqliteModulePath.length > 4096)) || + (source.nativeBinding !== undefined && + (typeof source.nativeBinding !== 'string' || source.nativeBinding.length > 4096)) + ) { + throw new Error('invalid contacts sync database source'); + } +} + +function isEncodedPayload(value: unknown): value is ContactsSyncEncodedPayload { + return ( + typeof value === 'object' && + value !== null && + 'ciphertext' in value && + value.ciphertext instanceof Uint8Array + ); +} + +function throwIfCancelled(request: ContactsSyncCodecWorkerRequest): void { + const buffer = request.cancellation; + if (!buffer) return; + if (buffer.byteLength !== 4) throw new Error('invalid contacts sync cancellation flag'); + if (Atomics.load(new Int32Array(buffer), 0) !== 0) { + throw new Error('contacts sync codec aborted'); + } +} diff --git a/apps/desktop/src/main/contacts-sync/contactsSyncCodecWorkerClient.ts b/apps/desktop/src/main/contacts-sync/contactsSyncCodecWorkerClient.ts new file mode 100644 index 00000000000..63c2b451464 --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/contactsSyncCodecWorkerClient.ts @@ -0,0 +1,385 @@ +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +// eslint-disable-next-line no-restricted-imports -- CPU-heavy contacts codec work must stay off Main. +import { Worker } from 'node:worker_threads'; +import { + CONTACTS_SYNC_CHUNK_BYTES, + CONTACTS_SYNC_MAX_CHUNKS, + isContactsSyncWireFrame as isSharedContactsSyncWireFrame, + type ContactsSyncCipherChunkFrame, +} from '@cindy/device-link'; + +import { isValidContactsSyncPublicKey } from './crypto.js'; +import { + CONTACTS_SYNC_MAX_COMPRESSED_BYTES, + createContactsSyncFrames, + isContactsSyncStateMessage, + type ContactsSyncAppliedStateResult, + type ContactsSyncCodec, + type ContactsSyncDecodeResult, + type ContactsSyncCodecWorkerRequest, + type ContactsSyncCodecWorkerResponse, + type ContactsSyncDecodeOptions, + type ContactsSyncDatabaseSource, + type ContactsSyncEncodedPayload, +} from './contactsSyncCodec.js'; + +const CODEC_TIMEOUT_MS = 60_000; +const DATABASE_WORKER_HARD_KILL_GRACE_MS = 30_000; +const MAX_CONCURRENT_CODEC_WORKERS = 2; +const MAX_CONCURRENT_DATABASE_WORKERS = 1; +const MAX_QUEUED_CODEC_TASKS = 8; +const MAX_QUEUED_CODEC_BYTES = MAX_QUEUED_CODEC_TASKS * CONTACTS_SYNC_MAX_COMPRESSED_BYTES; +let activeWorkers = 0; +let activeDatabaseWorkers = 0; +let queuedBytes = 0; + +interface CodecWorkerWaiter { + weight: number; + databaseBound: boolean; + signal?: AbortSignal; + grant(): void; + reject(error: Error): void; + cleanup(): void; +} + +const waiters: CodecWorkerWaiter[] = []; + +/** + * 限制 worker 并发,避免 N 台设备同时校准时用多个 gzip/crypto 任务抢满 CPU。 + */ +async function acquireCodecWorkerSlot( + weight: number, + signal: AbortSignal | undefined, + deadline: number, + databaseBound: boolean, +): Promise { + if (signal?.aborted) throw codecAbortedError(); + if ( + activeWorkers < MAX_CONCURRENT_CODEC_WORKERS && + (!databaseBound || activeDatabaseWorkers < MAX_CONCURRENT_DATABASE_WORKERS) + ) { + activeWorkers += 1; + if (databaseBound) activeDatabaseWorkers += 1; + return; + } + if (waiters.length >= MAX_QUEUED_CODEC_TASKS || queuedBytes + weight > MAX_QUEUED_CODEC_BYTES) { + throw new Error('contacts sync codec queue is full'); + } + + await new Promise((resolve, reject) => { + let settled = false; + const cleanup = (): void => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + }; + const removeAndReject = (error: Error): void => { + if (settled) return; + settled = true; + const index = waiters.indexOf(waiter); + if (index >= 0) { + waiters.splice(index, 1); + queuedBytes -= weight; + } + cleanup(); + reject(error); + }; + const onAbort = (): void => removeAndReject(codecAbortedError()); + const waiter: CodecWorkerWaiter = { + weight, + databaseBound, + signal, + grant: () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }, + reject: removeAndReject, + cleanup, + }; + const remaining = Math.max(1, deadline - Date.now()); + const timer = setTimeout( + () => removeAndReject(new Error('contacts sync codec timed out')), + remaining, + ); + timer.unref?.(); + waiters.push(waiter); + queuedBytes += weight; + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + }); +} + +function releaseCodecWorkerSlot(databaseBound: boolean): void { + activeWorkers -= 1; + if (databaseBound) activeDatabaseWorkers -= 1; + while (waiters.length > 0) { + const nextIndex = waiters.findIndex( + (waiter) => + !waiter.databaseBound || activeDatabaseWorkers < MAX_CONCURRENT_DATABASE_WORKERS, + ); + if (nextIndex < 0) return; + const [waiter] = waiters.splice(nextIndex, 1); + if (!waiter) return; + queuedBytes -= waiter.weight; + waiter.cleanup(); + if (waiter.signal?.aborted) { + waiter.reject(codecAbortedError()); + continue; + } + activeWorkers += 1; + if (waiter.databaseBound) activeDatabaseWorkers += 1; + waiter.grant(); + return; + } +} + +async function runCodecWorker( + request: ContactsSyncCodecWorkerRequest, + transferList: ArrayBuffer[] = [], + signal?: AbortSignal, + weight = CONTACTS_SYNC_MAX_COMPRESSED_BYTES, +): Promise { + const deadline = Date.now() + CODEC_TIMEOUT_MS; + const databaseBound = requestTouchesDatabase(request); + const cancellation = databaseBound ? new Int32Array(new SharedArrayBuffer(4)) : null; + const dispatchedRequest: ContactsSyncCodecWorkerRequest = cancellation + ? { ...request, cancellation: cancellation.buffer as SharedArrayBuffer } + : request; + await acquireCodecWorkerSlot(weight, signal, deadline, databaseBound); + let worker: Worker | null = null; + try { + if (signal?.aborted) throw codecAbortedError(); + const currentWorker = new Worker(path.join(__dirname, 'contactsSyncCodecWorker.js')); + worker = currentWorker; + return await new Promise((resolve, reject) => { + let settled = false; + let deferredCancellation: Error | null = null; + let hardKillTimer: NodeJS.Timeout | undefined; + const finish = (operation: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (hardKillTimer) clearTimeout(hardKillTimer); + signal?.removeEventListener('abort', onAbort); + operation(); + }; + const deferDatabaseCancellation = (error: Error): void => { + deferredCancellation ??= markDatabaseMayHaveChanged(error); + hardKillTimer ??= setTimeout( + () => finish(() => reject(deferredCancellation!)), + DATABASE_WORKER_HARD_KILL_GRACE_MS, + ); + hardKillTimer.unref?.(); + }; + const onAbort = (): void => { + const error = codecAbortedError(); + if (databaseBound) { + Atomics.store(cancellation!, 0, 1); + deferDatabaseCancellation(error); + } + else finish(() => reject(error)); + }; + const remaining = deadline - Date.now(); + const timer = setTimeout( + () => { + const error = new Error('contacts sync codec timed out'); + if (databaseBound) { + Atomics.store(cancellation!, 0, 1); + deferDatabaseCancellation(error); + } + else finish(() => reject(error)); + }, + Math.max(1, remaining), + ); + timer.unref?.(); + signal?.addEventListener('abort', onAbort, { once: true }); + currentWorker.once('error', (error) => finish(() => reject(error))); + currentWorker.once('exit', (code) => + finish(() => reject(new Error(`contacts sync codec worker exited (${code})`))), + ); + currentWorker.once('message', (value: unknown) => { + if (deferredCancellation) { + finish(() => reject(deferredCancellation!)); + return; + } + if (!isWorkerResponse(value) || value.id !== request.id) { + finish(() => reject(new Error('invalid contacts sync codec response'))); + return; + } + if (!value.ok) { + finish(() => reject(new Error(value.error ?? 'contacts sync codec failed'))); + return; + } + finish(() => resolve(value.data)); + }); + try { + currentWorker.postMessage(dispatchedRequest, transferList); + } catch (error) { + finish(() => reject(error)); + } + if (signal?.aborted) onAbort(); + }); + } finally { + try { + if (worker) await worker.terminate(); + } finally { + releaseCodecWorkerSlot(databaseBound); + } + } +} + +function requestTouchesDatabase(request: ContactsSyncCodecWorkerRequest): boolean { + return ( + request.type === 'prepare' || + (request.type === 'encode' && Boolean(request.options.database)) || + (request.type === 'decode' && Boolean(request.options.databaseSource)) + ); +} + +export async function prepareContactsSyncDatabase( + source: ContactsSyncDatabaseSource, + signal?: AbortSignal, +): Promise<{ materialized: boolean }> { + const value = await runCodecWorker({ id: randomUUID(), type: 'prepare', source }, [], signal); + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + typeof (value as { materialized?: unknown }).materialized !== 'boolean' + ) { + throw new Error('invalid contacts sync prepare result'); + } + return value as { materialized: boolean }; +} + +export const workerContactsSyncCodec: ContactsSyncCodec = { + async encode(options, signal) { + const value = await runCodecWorker({ id: randomUUID(), type: 'encode', options }, [], signal); + if (!isEncodedPayload(value)) throw new Error('invalid contacts sync encode result'); + const frames = createContactsSyncFrames(value, options.ownPublicKey); + if (!isCipherChunkFrames(frames)) throw new Error('invalid contacts sync encode frames'); + return { frames, materialized: value.materialized }; + }, + async decode(options, signal) { + const ciphertext = copyBytes(options.ciphertext); + const transferableOptions: ContactsSyncDecodeOptions = { + ...options, + ciphertext, + }; + const value = await runCodecWorker( + { + id: randomUUID(), + type: 'decode', + options: transferableOptions, + }, + [ciphertext.buffer], + signal, + ciphertext.byteLength, + ); + if (!isDecodeResult(value)) throw new Error('invalid contacts sync decode result'); + if (options.databaseSource && value.type !== 'applied-state') { + throw new Error('contacts sync state was not applied in worker'); + } + return value; + }, +}; + +function codecAbortedError(): Error { + const error = new Error('contacts sync codec aborted'); + error.name = 'AbortError'; + return error; +} + +function markDatabaseMayHaveChanged(error: Error): Error { + return Object.assign(error, { contactsDatabaseMayHaveChanged: true as const }); +} + +function isCipherChunkFrames(value: unknown): value is ContactsSyncCipherChunkFrame[] { + return ( + Array.isArray(value) && + value.every( + (frame) => + isSharedContactsSyncWireFrame(frame) && + frame.type === 'cipher-chunk' && + isValidContactsSyncPublicKey(frame.senderPublicKey), + ) + ); +} + +function isEncodedPayload(value: unknown): value is ContactsSyncEncodedPayload { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const payload = value as Partial; + return ( + typeof payload.transferId === 'string' && + payload.transferId.length >= 1 && + payload.transferId.length <= 128 && + !payload.transferId.includes('\u0000') && + Number.isInteger(payload.total) && + (payload.total ?? 0) >= 1 && + (payload.total ?? CONTACTS_SYNC_MAX_CHUNKS + 1) <= CONTACTS_SYNC_MAX_CHUNKS && + typeof payload.iv === 'string' && + payload.iv.length <= 64 && + typeof payload.tag === 'string' && + payload.tag.length <= 64 && + payload.ciphertext instanceof Uint8Array && + payload.ciphertext.byteLength <= CONTACTS_SYNC_MAX_COMPRESSED_BYTES + 32 && + payload.total === + Math.max(1, Math.ceil(payload.ciphertext.byteLength / CONTACTS_SYNC_CHUNK_BYTES)) && + typeof payload.materialized === 'boolean' + ); +} + +function isDecodeResult(value: unknown): value is ContactsSyncDecodeResult { + return isContactsSyncStateMessage(value) || isAppliedStateResult(value); +} + +function isAppliedStateResult(value: unknown): value is ContactsSyncAppliedStateResult { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const result = value as Partial; + const seenNodeIds = new Set(); + return ( + result.version === 1 && + result.type === 'applied-state' && + typeof result.changed === 'boolean' && + Array.isArray(result.clocks) && + result.clocks.length <= 256 && + result.clocks.every((clock) => { + if (typeof clock !== 'object' || clock === null || Array.isArray(clock)) return false; + const candidate = clock as { nodeId?: unknown; counter?: unknown }; + if ( + typeof candidate.nodeId !== 'string' || + candidate.nodeId.length < 1 || + candidate.nodeId.length > 128 || + !/^[A-Za-z0-9._:-]+$/.test(candidate.nodeId) || + !Number.isSafeInteger(candidate.counter) || + (candidate.counter as number) <= 0 || + seenNodeIds.has(candidate.nodeId) + ) { + return false; + } + seenNodeIds.add(candidate.nodeId); + return true; + }) && + (result.requestReply === undefined || typeof result.requestReply === 'boolean') + ); +} + +function isWorkerResponse(value: unknown): value is ContactsSyncCodecWorkerResponse { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as { id?: unknown }).id === 'string' && + typeof (value as { ok?: unknown }).ok === 'boolean' && + ((value as { error?: unknown }).error === undefined || + typeof (value as { error?: unknown }).error === 'string') + ); +} + +function copyBytes(value: Uint8Array): Uint8Array { + const copy = new Uint8Array(new ArrayBuffer(value.byteLength)); + copy.set(value); + return copy; +} diff --git a/apps/desktop/src/main/contacts-sync/crypto.ts b/apps/desktop/src/main/contacts-sync/crypto.ts new file mode 100644 index 00000000000..386a3f82c8b --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/crypto.ts @@ -0,0 +1,245 @@ +/** + * 智能通讯录设备同步的纯加密原语。 + * + * - 每台设备持久化一对 X25519 密钥; + * - 两台设备用 ECDH 得到共享秘密,再经 HKDF 派生本功能专用 AES-256-GCM 密钥; + * - AAD 绑定源/目标 deviceId 与本次 transfer,密文被挪给别的设备或分片元数据 + * 被篡改时都会认证失败。 + * + * 本文件没有 Electron / 文件系统依赖,便于做确定性单测。 + */ + +import { + createPrivateKey, + createPublicKey, + createCipheriv, + createDecipheriv, + createHmac, + diffieHellman, + generateKeyPairSync, + hkdfSync, + randomBytes, + timingSafeEqual, + type KeyObject, +} from 'node:crypto'; + +const KEY_INFO_PREFIX = 'cindy:contacts-device-sync:v1'; +const LAN_AUTH_INFO = 'cindy:contacts-device-sync:lan-auth:v1'; +const IV_BYTES = 12; +const TAG_BYTES = 16; + +export interface ContactsSyncExportedIdentity { + publicKey: string; + privateKey: string; +} + +export interface ContactsSyncEncryptedBytes { + iv: string; + tag: string; + ciphertext: Buffer; +} + +export interface ContactsSyncEncryptionContext { + srcDeviceId: string; + dstDeviceId: string; + transferId: string; + totalChunks: number; +} + +export interface ContactsSyncLanAuthContext { + kind: 'request' | 'ack'; + srcDeviceId: string; + dstDeviceId: string; + challenge: string; + senderPublicKey: string; + transferId: string; + index: number; + total: number; + iv: string; + tag: string; + data: string; +} + +export function generateContactsSyncIdentity(): ContactsSyncExportedIdentity { + const { publicKey, privateKey } = generateKeyPairSync('x25519'); + return { + publicKey: exportPublicKey(publicKey), + privateKey: exportPrivateKey(privateKey), + }; +} + +export function publicKeyFromPrivate(privateKey: string): string { + return exportPublicKey(createPublicKey(importPrivateKey(privateKey))); +} + +export function isValidContactsSyncPublicKey(value: unknown): value is string { + if (typeof value !== 'string' || value.length < 32 || value.length > 256) return false; + try { + const key = importPublicKey(value); + return key.asymmetricKeyType === 'x25519' && exportPublicKey(key) === value; + } catch { + return false; + } +} + +export function encryptContactsSyncBytes( + plaintext: Buffer, + ownPrivateKey: string, + peerPublicKey: string, + context: ContactsSyncEncryptionContext, +): ContactsSyncEncryptedBytes { + const key = deriveEncryptionKey(ownPrivateKey, peerPublicKey, context); + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_BYTES }); + cipher.setAAD(buildAad(context)); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return { + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64'), + ciphertext, + }; +} + +export function decryptContactsSyncBytes( + encrypted: ContactsSyncEncryptedBytes, + ownPrivateKey: string, + peerPublicKey: string, + context: ContactsSyncEncryptionContext, +): Buffer { + const iv = decodeExactBase64(encrypted.iv, IV_BYTES, 'iv'); + const tag = decodeExactBase64(encrypted.tag, TAG_BYTES, 'tag'); + const key = deriveEncryptionKey(ownPrivateKey, peerPublicKey, context); + const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_BYTES }); + decipher.setAAD(buildAad(context)); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted.ciphertext), decipher.final()]); +} + +/** + * Authenticate a LAN endpoint with the same pinned X25519 identities used for + * payload encryption. A replayed multicast beacon cannot produce the fresh + * request/ack proof, so the sender falls back to relay instead of treating a + * TCP write to an impostor as delivery. + */ +export function createContactsSyncLanProof( + ownPrivateKey: string, + peerPublicKey: string, + context: ContactsSyncLanAuthContext, +): string { + const key = deriveSharedKey(ownPrivateKey, peerPublicKey, LAN_AUTH_INFO); + return createHmac('sha256', key).update(buildLanAuthMessage(context)).digest('base64'); +} + +export function verifyContactsSyncLanProof( + proof: string, + ownPrivateKey: string, + peerPublicKey: string, + context: ContactsSyncLanAuthContext, +): boolean { + let provided: Buffer; + try { + provided = decodeExactBase64(proof, 32, 'LAN proof'); + } catch { + return false; + } + const expected = Buffer.from( + createContactsSyncLanProof(ownPrivateKey, peerPublicKey, context), + 'base64', + ); + return timingSafeEqual(provided, expected); +} + +function deriveEncryptionKey( + ownPrivateKey: string, + peerPublicKey: string, + context: ContactsSyncEncryptionContext, +): Buffer { + const orderedDeviceIds = [context.srcDeviceId, context.dstDeviceId].sort().join('\u0000'); + return deriveSharedKey( + ownPrivateKey, + peerPublicKey, + `${KEY_INFO_PREFIX}\u0000${orderedDeviceIds}`, + ); +} + +function deriveSharedKey(ownPrivateKey: string, peerPublicKey: string, info: string): Buffer { + const shared = diffieHellman({ + privateKey: importPrivateKey(ownPrivateKey), + publicKey: importPublicKey(peerPublicKey), + }); + return Buffer.from(hkdfSync('sha256', shared, Buffer.alloc(0), Buffer.from(info, 'utf8'), 32)); +} + +function buildLanAuthMessage(context: ContactsSyncLanAuthContext): Buffer { + return Buffer.from( + [ + LAN_AUTH_INFO, + context.kind, + context.srcDeviceId, + context.dstDeviceId, + context.challenge, + context.senderPublicKey, + context.transferId, + String(context.index), + String(context.total), + context.iv, + context.tag, + context.data, + ].join('\u0000'), + 'utf8', + ); +} + +function buildAad(context: ContactsSyncEncryptionContext): Buffer { + return Buffer.from( + [ + KEY_INFO_PREFIX, + context.srcDeviceId, + context.dstDeviceId, + context.transferId, + String(context.totalChunks), + ].join('\u0000'), + 'utf8', + ); +} + +function importPublicKey(value: string): KeyObject { + return createPublicKey({ + key: decodeBase64(value, 'public key'), + format: 'der', + type: 'spki', + }); +} + +function importPrivateKey(value: string): KeyObject { + const key = createPrivateKey({ + key: decodeBase64(value, 'private key'), + format: 'der', + type: 'pkcs8', + }); + if (key.asymmetricKeyType !== 'x25519') throw new Error('private key is not X25519'); + return key; +} + +function exportPublicKey(key: KeyObject): string { + return (key.export({ format: 'der', type: 'spki' }) as Buffer).toString('base64'); +} + +function exportPrivateKey(key: KeyObject): string { + return (key.export({ format: 'der', type: 'pkcs8' }) as Buffer).toString('base64'); +} + +function decodeBase64(value: string, label: string): Buffer { + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) throw new Error(`invalid ${label}`); + const decoded = Buffer.from(value, 'base64'); + if (decoded.length === 0 || decoded.toString('base64') !== value) { + throw new Error(`invalid ${label}`); + } + return decoded; +} + +function decodeExactBase64(value: string, bytes: number, label: string): Buffer { + const decoded = decodeBase64(value, label); + if (decoded.length !== bytes) throw new Error(`invalid ${label} length`); + return decoded; +} diff --git a/apps/desktop/src/main/contacts-sync/driver.ts b/apps/desktop/src/main/contacts-sync/driver.ts new file mode 100644 index 00000000000..e2f4285d644 --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/driver.ts @@ -0,0 +1,965 @@ +/** + * 智能通讯录 Desktop ↔ Desktop 同步驱动。 + * + * 数据层交换完整、可重复合并的状态;传输层优先同一局域网 TCP,失败自动走 + * Device Link relay。两条路搬运的是同一份逐设备 AES-GCM 密文。整个流程都是 + * 确定性程序逻辑,不调用模型、不产生 token 消耗。 + */ + +import type { ContactsSyncClock } from '@cindy/maker-core'; + +import { createLogger } from '../logger.js'; +import { activeOwnerScopeKey, getActiveAppSession } from '../appSessionState.js'; +import { getDesktopContactsManager } from '../maker-host/maker-contacts-host.js'; +import { + commitContactsDeviceSyncSettingIntent, + readContactsDeviceSyncSettingIntent, + readContactsSettings, + writeContactsDeviceSyncSettingIntent, +} from '../maker-host/contacts-settings-store.js'; +import { + onLocalContactsChanged, + readContactsChangeToken, +} from '../maker-host/contacts-change-events.js'; +import { broadcastContactsChanged } from '../maker-host/contacts-change-broadcast.js'; +import { + resolveBetterSqliteModuleEntry, + resolveBetterSqliteNativeBinding, +} from '../localDb/betterSqliteFactory.js'; +import type { ContactsSyncDatabaseSource } from './contactsSyncCodec.js'; +import { prepareContactsSyncDatabase } from './contactsSyncCodecWorkerClient.js'; +import { contactsSyncKeyStore } from './keyStore.js'; +import { LanContactsSyncTransport } from './lanTransport.js'; +import { + createContactsSyncKeyFrame, + isContactsSyncWireFrame, + ContactsSyncWireDecoder, + type ContactsSyncCipherChunkFrame, + type ContactsSyncWireFrame, +} from './wire.js'; +import { ContactsSyncOutbound } from './sender.js'; +import { + readContactsSyncRequestToken, + readPersistedContactsSyncStatus, + readPersistedContactsSyncRuntimeStatus, + writeContactsSyncRequestToken, + writePersistedContactsSyncStatus, + writePersistedContactsSyncRuntimeStatus, + type PersistedContactsSyncStatus, +} from './statusStore.js'; +import { + contactsDeviceSyncErrorCode, + emptyContactsDeviceSyncStatus, + type ContactsDeviceSyncPhase, + type ContactsDeviceSyncStatus, +} from './statusModel.js'; + +const log = createLogger('contacts-device-sync'); +const BROADCAST_DEBOUNCE_MS = 2_000; +const BROADCAST_INTERVAL_MS = 30 * 60 * 1000; +const KEY_ANNOUNCEMENT_RETRY_MS = 10_000; +const SYNC_ATTEMPT_TIMEOUT_MS = 10_000; +const CROSS_PROCESS_STATUS_POLL_MS = 2_000; +const RUNTIME_STATUS_HEARTBEAT_MS = 5_000; +const RUNTIME_STATUS_STALE_MS = 15_000; + +export interface ContactsSyncPeer { + deviceId: string; + deviceName: string; + publicKey?: string | null; +} + +export interface ContactsDeviceSyncTransport { + getSelfDeviceId(): string | null; + listOnlineDesktopDevices(): ContactsSyncPeer[]; + isPeerAllowed(deviceId: string): boolean; + sendRelayFrame(deviceId: string, frame: ContactsSyncWireFrame): void; +} + +let transport: ContactsDeviceSyncTransport | null = null; +/** transport 每个实例都会注入;只有仲裁 acquire 后才允许持有 Device Link 能力。 */ +let deviceLinkOwnerActive = false; +let lan: LanContactsSyncTransport | null = null; +const decoder = new ContactsSyncWireDecoder(); +const peerKnownClocks = new Map(); +let debounceTimer: NodeJS.Timeout | null = null; +let intervalTimer: NodeJS.Timeout | null = null; +let syncAttemptTimer: NodeJS.Timeout | null = null; +let crossProcessStatusTimer: NodeJS.Timeout | null = null; +let initialized = false; +let unsubscribeLocalChanges: (() => void) | null = null; +let runtimeGeneration = 0; +let codecAbortController = new AbortController(); +let localPreparation: + | { + generation: number; + ownerScopeKey: string; + promise: Promise; + } + | null = null; +const announcedTo = new Map(); +const respondedToKeyAnnouncement = new Map(); +const statusListeners = new Set<(status: ContactsDeviceSyncStatus) => void>(); +/** 关闭落盘失败时仍保持本进程 fail-closed,直到明确重试成功。 */ +const locallyDisabledOwners = new Set(); +let liveStatus: ContactsDeviceSyncStatus = emptyContactsDeviceSyncStatus(); +/** undefined 强制首次 init/get 按当前 owner 装载,避免模块求值期提前碰 userData。 */ +let statusOwnerId: string | null | undefined; +let observedContactsChangeToken: string | null | undefined; +let observedSyncRequestToken: string | null | undefined; +let usingSharedRuntimeStatus = false; +let lastRuntimeStatusWriteAt = 0; +let settingsIntentGeneration = 0; +let recoveringDisableIntentToken: string | null = null; +const activeDisableIntentTokens = new Set(); +let activeDisableIntentWrites = 0; +const outbound = new ContactsSyncOutbound({ + getGeneration: () => runtimeGeneration, + getOwnerId: () => getActiveAppSession().dataOwnerId, + getTransport: () => transport, + getDirectTransport: () => lan, + getCodecAbortSignal: () => codecAbortController.signal, + isEnabled, + getIdentity: () => contactsSyncKeyStore.getIdentity(), + getPeerPublicKey: (deviceId) => contactsSyncKeyStore.getPeerPublicKey(deviceId), + getDatabaseSource: getContactsSyncDatabaseSource, + getKnownClocks: (deviceId) => peerKnownClocks.get(deviceId), + onLocalMaterialized: () => broadcastContactsChanged({ origin: 'remote' }), + announceKey, + onError: recordError, +}); + +export function initContactsDeviceSync(next: ContactsDeviceSyncTransport): void { + transport = next; + deviceLinkOwnerActive = false; + initialized = true; + ensureCurrentOwnerStatus(); + unsubscribeLocalChanges?.(); + unsubscribeLocalChanges = onLocalContactsChanged(notifyLocalContactsChanged); + if (intervalTimer) clearInterval(intervalTimer); + intervalTimer = setInterval(() => { + if (deviceLinkOwnerActive && isEnabled()) runSyncTask(() => broadcastContactsNow(true)); + }, BROADCAST_INTERVAL_MS); + intervalTimer.unref?.(); + pollContactsDeviceSyncCrossProcessState(); +} + +/** Device Link 多实例仲裁的权威持有态;transport 是否存在不能用于判定 ownership。 */ +export function setContactsDeviceLinkOwnerActive(active: boolean): void { + ensureCurrentOwnerStatus(); + if (deviceLinkOwnerActive === active) return; + deviceLinkOwnerActive = active; + if (!active) { + stopContactsDeviceSyncRuntime(); + pollContactsDeviceSyncCrossProcessState(); + return; + } + + usingSharedRuntimeStatus = false; + liveStatus = buildInitialStatus(); + refreshOnlineCount(false); + emitStatus(); + if (isEnabled()) { + prepareAndRun(() => { + startLan(); + setPhase(onlinePeers().length > 0 ? 'syncing' : 'waiting'); + runSyncTask(() => broadcastContactsNow(true)); + }); + } +} + +export function stopContactsDeviceSyncRuntime(): void { + runtimeGeneration += 1; + codecAbortController.abort(); + codecAbortController = new AbortController(); + localPreparation = null; + lan?.stop(); + lan = null; + decoder.reset(); + announcedTo.clear(); + respondedToKeyAnnouncement.clear(); + peerKnownClocks.clear(); + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = null; + if (syncAttemptTimer) clearTimeout(syncAttemptTimer); + syncAttemptTimer = null; + contactsSyncKeyStore.resetMemory(); + setPhase(isEnabled() ? 'waiting' : 'off'); +} + +export function disposeContactsDeviceSync(): void { + deviceLinkOwnerActive = false; + stopContactsDeviceSyncRuntime(); + if (intervalTimer) clearInterval(intervalTimer); + intervalTimer = null; + transport = null; + initialized = false; + unsubscribeLocalChanges?.(); + unsubscribeLocalChanges = null; +} + +export function onContactsDeviceSyncStatusChanged( + listener: (status: ContactsDeviceSyncStatus) => void, +): () => void { + statusListeners.add(listener); + startCrossProcessStatusPolling(); + return () => { + statusListeners.delete(listener); + if (statusListeners.size === 0 && crossProcessStatusTimer) { + clearInterval(crossProcessStatusTimer); + crossProcessStatusTimer = null; + } + }; +} + +export function getContactsDeviceSyncStatus(): ContactsDeviceSyncStatus { + ensureCurrentOwnerStatus(); + pollContactsDeviceSyncCrossProcessState(); + if (deviceLinkOwnerActive) refreshOnlineCount(false); + return { ...liveStatus }; +} + +export async function setContactsDeviceSyncEnabled(enabled: boolean): Promise { + ensureCurrentOwnerStatus(); + const intentGeneration = ++settingsIntentGeneration; + const ownerId = getActiveAppSession().dataOwnerId; + const ownerScopeKey = activeOwnerScopeKey(); + if (enabled && !isCloudSession()) { + throw new Error('contacts device sync requires a signed-in cloud account'); + } + if (!enabled) { + // “关闭”是隐私意图:先让当前进程立即停传,再等待任何跨进程落盘。 + if (ownerId) locallyDisabledOwners.add(ownerId); + liveStatus = { ...liveStatus, enabled: false, errorCode: null }; + stopContactsDeviceSyncRuntime(); + setPhase('off'); + activeDisableIntentWrites += 1; + } + let persistedIntent: Awaited>; + try { + persistedIntent = await writeContactsDeviceSyncSettingIntent(enabled); + } catch (error) { + if (!enabled) activeDisableIntentWrites = Math.max(0, activeDisableIntentWrites - 1); + if (settingsIntentGeneration === intentGeneration && activeOwnerScopeKey() === ownerScopeKey) { + recordError(error); + } + throw error; + } + if (!enabled) { + activeDisableIntentWrites = Math.max(0, activeDisableIntentWrites - 1); + activeDisableIntentTokens.add(persistedIntent.token); + } + const intentIsCurrent = () => { + if (settingsIntentGeneration !== intentGeneration || activeOwnerScopeKey() !== ownerScopeKey) { + return false; + } + const current = readContactsDeviceSyncSettingIntent(); + // 关闭是隐私方向:意图文件读坏/瞬时不可读时仍继续 durable false;只有读到一个 + // 明确的后发意图才让路。开启则必须精确匹配 token,任何异常都 fail closed。 + return sameSettingIntent(current, persistedIntent) || (!enabled && current === null); + }; + if (!intentIsCurrent()) { + activeDisableIntentTokens.delete(persistedIntent.token); + reconcileSupersededDisable(enabled, ownerId, ownerScopeKey); + return; + } + if (enabled) { + // await intent 期间本实例也可能被另一进程的 disable 轮询停掉;即使调用开始时 + // 已开启,也要重新 prepare,不能拿旧快照跳过密钥缓存恢复。 + try { + await prepareLocalSync(); + } catch (error) { + if (!intentIsCurrent()) return; + recordError(error); + throw error; + } + if (!intentIsCurrent()) return; + if (!(await commitContactsDeviceSyncSettingIntent(persistedIntent))) return; + if (!intentIsCurrent()) return; + if (ownerId) locallyDisabledOwners.delete(ownerId); + if (activeOwnerScopeKey() !== ownerScopeKey) { + ensureCurrentOwnerStatus(); + return; + } + liveStatus = { ...liveStatus, enabled: true, errorCode: null }; + startLan(); + setPhase(onlinePeers().length > 0 ? 'syncing' : 'waiting'); + await broadcastContactsNow(true); + return; + } + // 落盘失败时保留本进程的关闭抑制,不能由轮询读到旧的 true 后偷偷重启。 + try { + if (!(await commitContactsDeviceSyncSettingIntent(persistedIntent))) { + activeDisableIntentTokens.delete(persistedIntent.token); + reconcileSupersededDisable(enabled, ownerId, ownerScopeKey); + return; + } + activeDisableIntentTokens.delete(persistedIntent.token); + if (ownerId) locallyDisabledOwners.delete(ownerId); + if (activeOwnerScopeKey() !== ownerScopeKey) ensureCurrentOwnerStatus(); + } catch (error) { + activeDisableIntentTokens.delete(persistedIntent.token); + reconcileSupersededDisable(enabled, ownerId, ownerScopeKey); + if (settingsIntentGeneration === intentGeneration && activeOwnerScopeKey() === ownerScopeKey) { + recordError(error); + } else { + log.warn('contacts device sync setting write failed after owner changed', { + error: error instanceof Error ? error.message : String(error), + }); + } + throw error; + } +} + +function reconcileSupersededDisable( + enabled: boolean, + ownerId: string | null, + ownerScopeKey: string, +): void { + if (enabled || !ownerId || activeOwnerScopeKey() !== ownerScopeKey) return; + const latestIntent = readContactsDeviceSyncSettingIntent(); + if (latestIntent?.enabled !== true) return; + locallyDisabledOwners.delete(ownerId); + pollContactsDeviceSyncSettingChange(); +} + +function sameSettingIntent( + current: ReturnType, + expected: Awaited>, +): boolean { + return current?.token === expected.token && current.enabled === expected.enabled; +} + +/** Device Link 持有者定期调用,应用其它共享 userData 实例写入的同步开关。 */ +export function pollContactsDeviceSyncSettingChange(): void { + ensureCurrentOwnerStatus(); + const ownerId = getActiveAppSession().dataOwnerId; + const configuredEnabled = isConfiguredEnabled(); + recoverPendingDisableIntent(configuredEnabled); + if (!configuredEnabled && ownerId) locallyDisabledOwners.delete(ownerId); + const enabled = isEnabled(); + if (enabled === liveStatus.enabled) { + if ( + !enabled && + !configuredEnabled && + (liveStatus.phase !== 'off' || liveStatus.errorCode !== null) + ) { + liveStatus = { ...liveStatus, enabled: false, errorCode: null }; + stopContactsDeviceSyncRuntime(); + setPhase('off'); + } + return; + } + if (!enabled) { + liveStatus = { ...liveStatus, enabled: false, errorCode: null }; + stopContactsDeviceSyncRuntime(); + setPhase('off'); + return; + } + + liveStatus = { ...liveStatus, enabled: true, errorCode: null }; + prepareAndRun(() => { + setPhase(onlinePeers().length > 0 ? 'syncing' : 'waiting'); + runSyncTask(() => broadcastContactsNow(true)); + }); +} + +function recoverPendingDisableIntent(configuredEnabled: boolean): void { + const intent = readContactsDeviceSyncSettingIntent(); + if ( + !configuredEnabled || + intent?.enabled !== false || + activeDisableIntentWrites > 0 || + activeDisableIntentTokens.has(intent.token) || + recoveringDisableIntentToken === intent.token + ) { + return; + } + recoveringDisableIntentToken = intent.token; + runSyncTask(async () => { + try { + await commitContactsDeviceSyncSettingIntent(intent); + } finally { + if (recoveringDisableIntentToken === intent.token) recoveringDisableIntentToken = null; + } + pollContactsDeviceSyncSettingChange(); + }); +} + +export async function broadcastContactsNow(requestReply = true): Promise { + ensureCurrentOwnerStatus(); + if (!isEnabled()) return; + if (!deviceLinkOwnerActive || !transport) { + // 被动实例没有 Device Link;用无内容 token 委托同 userData 的持有者执行。 + writeContactsSyncRequestToken(); + setPhase('syncing'); + scheduleSyncAttemptTimeout(); + return; + } + await prepareLocalSync(); + startLan(); + const peers = onlinePeers(); + refreshOnlineCount(); + if (peers.length === 0) { + setPhase('waiting'); + return; + } + const context = outbound.capture(); + if (!context) return; + setPhase('syncing'); + // 用户强制同步、重连和首次启用都做完整校准;普通本地变化走已知版本增量。 + if (requestReply) peerKnownClocks.clear(); + // DB-bound encode 有意串行:N 台在线设备不应把正常 fan-out 塞爆全局 worker 队列。 + for (const peer of peers) { + if (!outbound.isCurrent(context)) break; + await outbound.ensureKeyThenSend(peer.deviceId, requestReply, context); + } + if (outbound.isCurrent(context)) scheduleSyncAttemptTimeout(); +} + +export function handleContactsDeviceLinkStatusChanged(online: boolean): void { + if (!deviceLinkOwnerActive) return; + if (!online) { + stopContactsDeviceSyncRuntime(); + refreshOnlineCount(); + return; + } + // 账号/区域切换后 driver 仍是同一进程级实例;从当前 owner 的文件重建可见状态。 + ensureCurrentOwnerStatus(); + liveStatus = buildInitialStatus(); + emitStatus(); + if (!isEnabled()) return; + prepareAndRun(() => { + startLan(); + runSyncTask(() => broadcastContactsNow(true)); + }); +} + +/** 通讯录本地写入后的去抖入口;远端物化不走这里,避免广播风暴。 */ +export function notifyLocalContactsChanged(): void { + observedContactsChangeToken = readContactsChangeToken(); + if (!isEnabled() || !initialized) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + runSyncTask(() => broadcastContactsNow(false)); + }, BROADCAST_DEBOUNCE_MS); + debounceTimer.unref?.(); +} + +/** Device Link 持有者调用;发现其它共享 userData 实例的本地写入后立即补发。 */ +export function pollContactsDeviceSyncDataChange(): void { + ensureCurrentOwnerStatus(); + const token = readContactsChangeToken(); + if (observedContactsChangeToken === undefined) { + observedContactsChangeToken = token; + return; + } + if (token === observedContactsChangeToken) return; + observedContactsChangeToken = token; + if (!isEnabled() || !initialized) return; + runSyncTask(() => broadcastContactsNow(false)); +} + +/** + * 同机多实例桥:持有者发布无内容运行态并消费“立即同步” token;被动实例读取 + * 持有者状态供本进程窗口展示。由常驻状态订阅定期调用,也可由 Device Link tick 调用。 + */ +export function pollContactsDeviceSyncCrossProcessState(): void { + ensureCurrentOwnerStatus(); + if (deviceLinkOwnerActive && transport) { + const requestToken = readContactsSyncRequestToken(); + if (observedSyncRequestToken === undefined) { + observedSyncRequestToken = requestToken; + } else if (requestToken !== observedSyncRequestToken) { + observedSyncRequestToken = requestToken; + if (requestToken && isEnabled() && initialized) { + runSyncTask(() => broadcastContactsNow(true)); + } + } + publishRuntimeStatus(false); + return; + } + + const shared = readPersistedContactsSyncRuntimeStatus(); + const sharedAge = shared ? Date.now() - shared.updatedAt : Number.POSITIVE_INFINITY; + const fresh = + shared && sharedAge >= -RUNTIME_STATUS_STALE_MS && sharedAge <= RUNTIME_STATUS_STALE_MS; + if (fresh && shared.enabled === isEnabled() && shared.available === isCloudSession()) { + // 持有者已经接管状态机后,本地 delegated-request 超时不得再覆盖真实 syncing。 + if (syncAttemptTimer) clearTimeout(syncAttemptTimer); + syncAttemptTimer = null; + const nextStatus: ContactsDeviceSyncStatus = { + available: shared.available, + enabled: shared.enabled, + phase: shared.phase, + onlineDeviceCount: shared.onlineDeviceCount, + errorCode: shared.errorCode, + lastSyncAt: shared.lastSyncAt, + lastSyncDeviceId: shared.lastSyncDeviceId, + lastSyncDeviceName: shared.lastSyncDeviceName, + lastRoute: shared.lastRoute, + }; + if (!sameStatus(liveStatus, nextStatus)) { + liveStatus = nextStatus; + usingSharedRuntimeStatus = true; + emitStatus(); + } else { + usingSharedRuntimeStatus = true; + } + return; + } + if (usingSharedRuntimeStatus || liveStatus.enabled !== isEnabled()) { + usingSharedRuntimeStatus = false; + liveStatus = buildInitialStatus(); + emitStatus(); + } +} + +export function handleContactsPeerPresenceChanged(peer: { + deviceId: string; + online: boolean; +}): void { + if (!deviceLinkOwnerActive) return; + if (!peer.online) { + announcedTo.delete(peer.deviceId); + respondedToKeyAnnouncement.delete(peer.deviceId); + peerKnownClocks.delete(peer.deviceId); + } + refreshOnlineCount(); + if (!peer.online || !isEnabled() || !transport?.isPeerAllowed(peer.deviceId)) return; + prepareAndRun(() => { + announceKey(peer.deviceId); + const pinned = contactsSyncKeyStore.getPeerPublicKey(peer.deviceId); + if (pinned) runSyncTask(() => outbound.send(peer.deviceId, true)); + }); +} + +/** relay 入站。key 帧只允许走这条同账号认证通道;LAN 只收已经加密的 cipher 帧。 */ +export function handleIncomingContactsRelayFrame(srcDeviceId: string, raw: unknown): void { + if ( + !deviceLinkOwnerActive || + !isEnabled() || + !transport?.isPeerAllowed(srcDeviceId) || + !isContactsSyncWireFrame(raw) + ) { + return; + } + if (raw.type === 'key') { + prepareAndRun(async (isCurrent) => { + const firstSeen = await contactsSyncKeyStore.pinPeerPublicKey(srcDeviceId, raw.publicKey); + if (!isCurrent() || !deviceLinkOwnerActive || !transport?.isPeerAllowed(srcDeviceId)) return; + if (firstSeen) log.info(`pinned contacts sync peer ${shortId(srcDeviceId)}`); + respondToKey(srcDeviceId); + startLan(); + runSyncTask(() => outbound.send(srcDeviceId, true)); + }); + return; + } + handleIncomingCipherFrame(srcDeviceId, raw, 'relay'); +} + +function handleIncomingCipherFrame( + srcDeviceId: string, + frame: ContactsSyncCipherChunkFrame, + route: 'lan' | 'relay', +): void { + if (!deviceLinkOwnerActive || !isEnabled() || !transport?.isPeerAllowed(srcDeviceId)) return; + prepareAndRun(async (isCurrent) => { + const selfDeviceId = requireSelfDeviceId(); + const identity = contactsSyncKeyStore.getIdentity(); + const peerKey = contactsSyncKeyStore.getPeerPublicKey(srcDeviceId); + if (!peerKey) { + announceKey(srcDeviceId); + return; + } + const message = await decoder.accept({ + srcDeviceId, + dstDeviceId: selfDeviceId, + frame, + ownPrivateKey: identity.privateKey, + expectedPeerPublicKey: peerKey, + databaseSource: getContactsSyncDatabaseSource(), + }); + if (!message || !isCurrent()) return; + if (message.type !== 'applied-state') { + throw new Error('contacts sync worker did not apply the decoded state'); + } + + peerKnownClocks.set( + srcDeviceId, + message.clocks.map((clock) => ({ ...clock })), + ); + recordSuccessfulSync(srcDeviceId, route); + if (message.changed) { + broadcastContactsChanged({ origin: 'remote' }); + log.info(`merged contacts state from ${shortId(srcDeviceId)} via ${route}`); + } + if (message.changed || message.requestReply === true) { + runSyncTask(() => outbound.send(srcDeviceId, false)); + } + }); +} + +function announceKey(deviceId: string): void { + if (!transport || !transport.isPeerAllowed(deviceId)) return; + const now = Date.now(); + const lastAnnouncedAt = announcedTo.get(deviceId); + if (lastAnnouncedAt !== undefined && now - lastAnnouncedAt < KEY_ANNOUNCEMENT_RETRY_MS) return; + sendKeyAnnouncement(deviceId, now); +} + +/** 入站 key 的握手响应独立节流,不能被此前主动公告占掉;否则连续开启两台设备会卡住。 */ +function respondToKey(deviceId: string): void { + if (!transport || !transport.isPeerAllowed(deviceId)) return; + const now = Date.now(); + const lastRespondedAt = respondedToKeyAnnouncement.get(deviceId); + if (lastRespondedAt !== undefined && now - lastRespondedAt < KEY_ANNOUNCEMENT_RETRY_MS) return; + sendKeyAnnouncement(deviceId, now); + respondedToKeyAnnouncement.set(deviceId, now); +} + +function sendKeyAnnouncement(deviceId: string, announcedAt: number): void { + if (!transport || !transport.isPeerAllowed(deviceId)) return; + const identity = contactsSyncKeyStore.getIdentity(); + transport.sendRelayFrame(deviceId, createContactsSyncKeyFrame(identity.publicKey)); + announcedTo.set(deviceId, announcedAt); +} + +function prepareLocalSync(): Promise { + if (!isCloudSession()) { + return Promise.reject(new Error('contacts device sync requires a signed-in cloud account')); + } + const generation = runtimeGeneration; + const ownerScopeKey = activeOwnerScopeKey(); + if ( + localPreparation?.generation === generation && + localPreparation.ownerScopeKey === ownerScopeKey + ) { + return localPreparation.promise; + } + + const promise = (async () => { + await contactsSyncKeyStore.prepare(); + const result = await prepareContactsSyncDatabase( + getContactsSyncDatabaseSource(), + codecAbortController.signal, + ); + if ( + result.materialized && + runtimeGeneration === generation && + activeOwnerScopeKey() === ownerScopeKey + ) { + broadcastContactsChanged({ origin: 'remote' }); + } + })(); + const preparation = { generation, ownerScopeKey, promise }; + localPreparation = preparation; + void promise.catch(() => { + if (localPreparation === preparation) localPreparation = null; + }); + return promise; +} + +let contactsSyncDatabaseRuntime: + | Pick + | undefined; + +function getContactsSyncDatabaseSource(): ContactsSyncDatabaseSource { + contactsSyncDatabaseRuntime ??= { + betterSqliteModulePath: resolveBetterSqliteModuleEntry(), + nativeBinding: resolveBetterSqliteNativeBinding(), + }; + return { + dbPath: getDesktopContactsManager().getDbPath(), + ...contactsSyncDatabaseRuntime, + }; +} + +function startLan(): void { + if (!deviceLinkOwnerActive || !transport || lan || !isEnabled()) return; + const selfDeviceId = transport.getSelfDeviceId(); + if (!selfDeviceId) return; + const identity = contactsSyncKeyStore.getIdentity(); + lan = new LanContactsSyncTransport({ + getSelf: () => { + const deviceId = transport?.getSelfDeviceId(); + return deviceId + ? { + deviceId, + publicKey: identity.publicKey, + privateKey: identity.privateKey, + } + : null; + }, + isPeerAllowed: (deviceId, publicKey) => + transport?.isPeerAllowed(deviceId) === true && + contactsSyncKeyStore.getPeerPublicKey(deviceId) === publicKey, + onFrame: (srcDeviceId, frame) => handleIncomingCipherFrame(srcDeviceId, frame, 'lan'), + logger: { + debug: (message, meta) => log.debug(message, meta), + warn: (message, meta) => log.warn(message, meta), + }, + }); + lan.start(); +} + +function prepareAndRun(action: (isCurrent: () => boolean) => void | Promise): void { + const generation = runtimeGeneration; + const ownerScopeKey = activeOwnerScopeKey(); + const isCurrent = () => + runtimeGeneration === generation && activeOwnerScopeKey() === ownerScopeKey && isEnabled(); + void (async () => { + await prepareLocalSync(); + if (!isCurrent()) return; + startLan(); + await action(isCurrent); + })().catch((error) => { + if (runtimeGeneration === generation && activeOwnerScopeKey() === ownerScopeKey) { + recordError(error); + } + }); +} + +function runSyncTask(task: () => Promise): void { + const generation = runtimeGeneration; + const ownerScopeKey = activeOwnerScopeKey(); + void task().catch((error) => { + if (runtimeGeneration === generation && activeOwnerScopeKey() === ownerScopeKey) { + recordError(error); + } + }); +} + +function onlinePeers(): ContactsSyncPeer[] { + if (!deviceLinkOwnerActive) return []; + return ( + transport + ?.listOnlineDesktopDevices() + .filter((peer) => transport?.isPeerAllowed(peer.deviceId)) ?? [] + ); +} + +function requireSelfDeviceId(): string { + const deviceId = transport?.getSelfDeviceId(); + if (!deviceId) throw new Error('contacts sync device link is not online'); + return deviceId; +} + +function recordSuccessfulSync(deviceId: string, route: 'lan' | 'relay'): void { + if (syncAttemptTimer) clearTimeout(syncAttemptTimer); + syncAttemptTimer = null; + const peer = onlinePeers().find((candidate) => candidate.deviceId === deviceId); + const persisted: PersistedContactsSyncStatus = { + lastSyncAt: new Date().toISOString(), + lastSyncDeviceId: deviceId, + lastSyncDeviceName: peer?.deviceName ?? shortId(deviceId), + lastRoute: route, + }; + try { + writePersistedContactsSyncStatus(persisted); + } catch (error) { + log.warn('contacts sync status persistence failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + liveStatus = { + ...liveStatus, + ...persisted, + phase: 'up-to-date', + errorCode: null, + }; + emitStatus(); +} + +function recordError(error: unknown): void { + const databaseMayHaveChanged = + typeof error === 'object' && + error !== null && + 'contactsDatabaseMayHaveChanged' in error && + error.contactsDatabaseMayHaveChanged === true; + if (databaseMayHaveChanged) { + // 原子事务可能已提交但 ACK 尚未返回;保守刷新覆盖这个窄窗口。 + localPreparation = null; + broadcastContactsChanged({ origin: 'remote' }); + if (deviceLinkOwnerActive && isEnabled() && !debounceTimer) { + debounceTimer = setTimeout(() => { + debounceTimer = null; + prepareAndRun(() => broadcastContactsNow(true)); + }, BROADCAST_DEBOUNCE_MS); + debounceTimer.unref?.(); + } + } + if (syncAttemptTimer) clearTimeout(syncAttemptTimer); + syncAttemptTimer = null; + const message = error instanceof Error ? error.message : String(error); + const errorCode = contactsDeviceSyncErrorCode(error); + liveStatus = { ...liveStatus, phase: 'error', errorCode }; + emitStatus(); + log.warn('contacts device sync failed', { error: message, errorCode }); +} + +function scheduleSyncAttemptTimeout(): void { + if (liveStatus.phase !== 'syncing') return; + if (syncAttemptTimer) clearTimeout(syncAttemptTimer); + syncAttemptTimer = setTimeout(() => { + syncAttemptTimer = null; + if (liveStatus.phase === 'syncing') setPhase('waiting'); + }, SYNC_ATTEMPT_TIMEOUT_MS); + syncAttemptTimer.unref?.(); +} + +function buildInitialStatus(): ContactsDeviceSyncStatus { + if (!isCloudSession()) return emptyContactsDeviceSyncStatus(); + const enabled = isEnabled(); + return { + available: true, + enabled, + phase: enabled ? 'waiting' : 'off', + onlineDeviceCount: 0, + errorCode: null, + ...readPersistedContactsSyncStatus(), + }; +} + +function isEnabled(): boolean { + const session = getActiveAppSession(); + const ownerId = session.dataOwnerId; + return ( + session.mode === 'cloud' && + ownerId !== null && + !locallyDisabledOwners.has(ownerId) && + readContactsDeviceSyncSettingIntent()?.enabled !== false && + readContactsSettings().deviceSyncEnabled + ); +} + +function isConfiguredEnabled(): boolean { + return isCloudSession() ? readContactsSettings().deviceSyncEnabled : false; +} + +function isCloudSession(): boolean { + const session = getActiveAppSession(); + return session.mode === 'cloud' && Boolean(session.dataOwnerId); +} + +function setPhase(phase: ContactsDeviceSyncPhase): void { + if (liveStatus.phase === phase && liveStatus.enabled === isEnabled()) return; + liveStatus = { ...liveStatus, enabled: isEnabled(), phase }; + emitStatus(); +} + +function refreshOnlineCount(emit = true): void { + const onlineDeviceCount = onlinePeers().length; + if (liveStatus.onlineDeviceCount === onlineDeviceCount) return; + liveStatus = { ...liveStatus, onlineDeviceCount }; + if (emit) emitStatus(); +} + +function emitStatus(): void { + publishRuntimeStatus(true); + const snapshot = { ...liveStatus }; + for (const listener of statusListeners) { + try { + listener(snapshot); + } catch (error) { + log.warn('contacts sync status listener failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +function publishRuntimeStatus(force: boolean): void { + if (!deviceLinkOwnerActive || !transport || !isCloudSession()) return; + const now = Date.now(); + if (!force && now - lastRuntimeStatusWriteAt < RUNTIME_STATUS_HEARTBEAT_MS) return; + try { + writePersistedContactsSyncRuntimeStatus({ ...liveStatus, updatedAt: now }); + lastRuntimeStatusWriteAt = now; + } catch (error) { + log.warn('contacts sync runtime status persistence failed', { + error: error instanceof Error ? error.message : String(error), + }); + } +} + +function startCrossProcessStatusPolling(): void { + if (crossProcessStatusTimer) return; + pollContactsDeviceSyncCrossProcessState(); + crossProcessStatusTimer = setInterval( + pollContactsDeviceSyncCrossProcessState, + CROSS_PROCESS_STATUS_POLL_MS, + ); + crossProcessStatusTimer.unref?.(); +} + +function sameStatus(a: ContactsDeviceSyncStatus, b: ContactsDeviceSyncStatus): boolean { + return ( + a.available === b.available && + a.enabled === b.enabled && + a.phase === b.phase && + a.onlineDeviceCount === b.onlineDeviceCount && + a.errorCode === b.errorCode && + a.lastSyncAt === b.lastSyncAt && + a.lastSyncDeviceId === b.lastSyncDeviceId && + a.lastSyncDeviceName === b.lastSyncDeviceName && + a.lastRoute === b.lastRoute + ); +} + +function ensureCurrentOwnerStatus(): void { + const ownerId = getActiveAppSession().dataOwnerId; + if (ownerId === statusOwnerId) return; + settingsIntentGeneration += 1; + runtimeGeneration += 1; + codecAbortController.abort(); + codecAbortController = new AbortController(); + localPreparation = null; + lan?.stop(); + lan = null; + decoder.reset(); + announcedTo.clear(); + respondedToKeyAnnouncement.clear(); + peerKnownClocks.clear(); + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = null; + if (syncAttemptTimer) clearTimeout(syncAttemptTimer); + syncAttemptTimer = null; + contactsSyncKeyStore.resetMemory(); + statusOwnerId = ownerId; + observedContactsChangeToken = readContactsChangeToken(); + observedSyncRequestToken = readContactsSyncRequestToken(); + usingSharedRuntimeStatus = false; + lastRuntimeStatusWriteAt = 0; + recoveringDisableIntentToken = null; + activeDisableIntentTokens.clear(); + activeDisableIntentWrites = 0; + liveStatus = buildInitialStatus(); + emitStatus(); +} + +function shortId(deviceId: string): string { + return deviceId.slice(0, 8); +} + +export const __testing = { + reset(): void { + disposeContactsDeviceSync(); + if (crossProcessStatusTimer) clearInterval(crossProcessStatusTimer); + crossProcessStatusTimer = null; + deviceLinkOwnerActive = false; + locallyDisabledOwners.clear(); + statusOwnerId = getActiveAppSession().dataOwnerId; + observedContactsChangeToken = readContactsChangeToken(); + observedSyncRequestToken = readContactsSyncRequestToken(); + usingSharedRuntimeStatus = false; + lastRuntimeStatusWriteAt = 0; + settingsIntentGeneration += 1; + recoveringDisableIntentToken = null; + activeDisableIntentTokens.clear(); + activeDisableIntentWrites = 0; + liveStatus = buildInitialStatus(); + statusListeners.clear(); + }, +}; diff --git a/apps/desktop/src/main/contacts-sync/keyStore.ts b/apps/desktop/src/main/contacts-sync/keyStore.ts new file mode 100644 index 00000000000..a73dc7519f3 --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/keyStore.ts @@ -0,0 +1,282 @@ +/** + * 通讯录同步设备密钥的 owner-scoped 安全落盘。 + * + * 私钥和首次见到的对端公钥一起由 Electron safeStorage 加密;明文只存在于 main + * 进程内存。对同一 deviceId 的公钥采用 TOFU pin:首次记录,之后变化即拒绝, + * 防止普通中转链路在后续连接中替换设备身份。 + */ + +import { safeStorage } from 'electron'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +import { getActiveAppSession, ownerScopedUserDataPath } from '../appSessionState.js'; +import { withCrossProcessLock } from '../device-link/crossProcessLock.js'; +import { + generateContactsSyncIdentity, + isValidContactsSyncPublicKey, + publicKeyFromPrivate, + type ContactsSyncExportedIdentity, +} from './crypto.js'; + +const STORE_VERSION = 1; +const FILE_NAME = 'contacts-device-sync-key.v1.enc'; +const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,160}$/; +const LOCK_WAIT_MS = 12_000; +const MAX_PEER_PINS = 1_000; + +interface StoredContactsSyncKeys extends ContactsSyncExportedIdentity { + version: typeof STORE_VERSION; + peers: Record; +} + +export interface ContactsSyncKeyStoreDeps { + filePath(): string | null; + isEncryptionAvailable(): boolean; + encrypt(plaintext: string): Buffer; + decrypt(ciphertext: Buffer): string; +} + +export class ContactsSyncKeyStore { + private loadedPath: string | null = null; + private data: StoredContactsSyncKeys | null = null; + private generation = 0; + + constructor(private readonly deps: ContactsSyncKeyStoreDeps = desktopDeps) {} + + /** + * 首次读/建密钥可能等待另一个 Desktop 实例,必须 await,不能阻塞 Electron Main。 + * 后续握手和加解密只读内存缓存,保持同步热路径。 + */ + async prepare(): Promise { + const file = this.requireFile(); + const generation = this.selectFile(file); + if (this.data) return; + await withKeyFileLock(file, () => { + this.assertOperationCurrent(file, generation); + const data = this.readOrCreateLocked(file); + this.loadedPath = file; + this.data = data; + }); + this.assertOperationCurrent(file, generation); + } + + getIdentity(): ContactsSyncExportedIdentity { + const data = this.requireLoaded(); + return { publicKey: data.publicKey, privateKey: data.privateKey }; + } + + getPeerPublicKey(deviceId: string): string | null { + assertDeviceId(deviceId); + const file = this.deps.filePath(); + if (!file || !this.deps.isEncryptionAvailable()) return null; + this.selectFile(file); + // LAN stop / owner 切换可能与最后一个 socket 回调交错;未 prepare 时按“未 pin”拒绝, + // 不能让一个迟到的 allowlist 查询因缓存已清空而向 EventEmitter 抛异常。 + return this.data?.peers[deviceId] ?? null; + } + + /** + * 首次见到该 deviceId 时落盘;已有 pin 一致返回 false,不一致 fail closed。 + */ + async pinPeerPublicKey(deviceId: string, publicKey: string): Promise { + assertDeviceId(deviceId); + if (!isValidContactsSyncPublicKey(publicKey)) { + throw new Error('invalid contacts sync peer public key'); + } + const file = this.requireFile(); + const generation = this.selectFile(file); + const result = await withKeyFileLock(file, () => { + this.assertOperationCurrent(file, generation); + // 共享 userData 的另一实例可能刚写入新 pin;锁内强制从磁盘取最新基线, + // 不能用本实例缓存做 read-modify-write,否则会静默覆盖对方的 pin。 + const data = this.readOrCreateLocked(file); + this.loadedPath = file; + this.data = data; + const current = data.peers[deviceId]; + if (current === publicKey) return false; + if (current) throw new Error('contacts sync peer identity changed'); + if (Object.keys(data.peers).length >= MAX_PEER_PINS) { + throw new Error('contacts sync peer limit exceeded'); + } + const next: StoredContactsSyncKeys = { + ...data, + peers: { ...data.peers, [deviceId]: publicKey }, + }; + this.persistLocked(file, next); + this.loadedPath = file; + this.data = next; + return true; + }); + this.assertOperationCurrent(file, generation); + return result; + } + + resetMemory(): void { + this.generation += 1; + this.loadedPath = null; + this.data = null; + } + + private requireLoaded(): StoredContactsSyncKeys { + const file = this.requireFile(); + this.selectFile(file); + if (!this.data) throw new Error('contacts sync device key is not prepared'); + return this.data; + } + + private selectFile(file: string): number { + if (this.loadedPath !== file) { + this.generation += 1; + this.loadedPath = file; + this.data = null; + } + return this.generation; + } + + private assertOperationCurrent(file: string, generation: number): void { + if ( + this.generation !== generation || + this.deps.filePath() !== file || + !this.deps.isEncryptionAvailable() + ) { + throw new Error('contacts sync key operation was invalidated'); + } + } + + private requireFile(): string { + const file = this.deps.filePath(); + if (!file) throw new Error('contacts sync requires an authenticated owner'); + if (!this.deps.isEncryptionAvailable()) { + throw new Error('secure storage is unavailable for contacts sync'); + } + return file; + } + + /** 调用方必须持有 file 对应的锁。 */ + private readOrCreateLocked(file: string): StoredContactsSyncKeys { + if (!fs.existsSync(file)) { + const identity = generateContactsSyncIdentity(); + const created: StoredContactsSyncKeys = { + version: STORE_VERSION, + ...identity, + peers: {}, + }; + this.persistLocked(file, created); + return created; + } + try { + const encoded = fs.readFileSync(file, 'utf8'); + const plaintext = this.deps.decrypt(Buffer.from(encoded, 'base64')); + const parsed: unknown = JSON.parse(plaintext); + if (!isStoredKeys(parsed)) throw new Error('invalid contacts sync key document'); + if (publicKeyFromPrivate(parsed.privateKey) !== parsed.publicKey) { + throw new Error('contacts sync public/private key mismatch'); + } + return parsed; + } catch (error) { + throw new Error('stored contacts sync device key is unreadable', { cause: error }); + } + } + + /** 调用方必须持有 file 对应的锁。 */ + private persistLocked(file: string, data: StoredContactsSyncKeys): void { + const temp = `${file}.${process.pid}.${randomUUID()}.tmp`; + fs.mkdirSync(path.dirname(file), { recursive: true }); + try { + fs.writeFileSync(temp, this.deps.encrypt(JSON.stringify(data)).toString('base64'), { + encoding: 'utf8', + mode: 0o600, + }); + fs.renameSync(temp, file); + } finally { + try { + fs.unlinkSync(temp); + } catch { + // rename 成功后临时文件已不存在;失败时尽力清理,原文件保持不变。 + } + } + } +} + +/** + * 复用已有异步跨进程锁:等待只占 Promise,不阻塞 Electron Main。锁用 PID + mtime + * 心跳确认陈旧 owner,释放前复核归属,避免多个等待者 compare-then-unlink;任何 + * busy / unavailable 都 fail closed,不能在没有互斥时读改写密钥文件。 + */ +async function withKeyFileLock(file: string, task: () => T): Promise { + await fsp.mkdir(path.dirname(file), { recursive: true }); + return withCrossProcessLock( + `${file}.lock`, + { label: 'contacts-sync-key', waitMs: LOCK_WAIT_MS }, + async (status) => { + if (!status.held) { + throw new Error(`contacts sync key lock ${status.reason}`); + } + return task(); + }, + ); +} + +function isStoredKeys(value: unknown): value is StoredContactsSyncKeys { + if (!isRecord(value) || value.version !== STORE_VERSION) return false; + if ( + !isValidContactsSyncPublicKey(value.publicKey) || + typeof value.privateKey !== 'string' || + value.privateKey.length > 512 || + !isRecord(value.peers) + ) { + return false; + } + const peers = Object.entries(value.peers); + return ( + peers.length <= MAX_PEER_PINS && + peers.every( + ([deviceId, publicKey]) => + DEVICE_ID_PATTERN.test(deviceId) && isValidContactsSyncPublicKey(publicKey), + ) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function assertDeviceId(deviceId: string): void { + if (!DEVICE_ID_PATTERN.test(deviceId)) throw new Error('invalid contacts sync device id'); +} + +const desktopDeps: ContactsSyncKeyStoreDeps = { + filePath: () => (getActiveAppSession().dataOwnerId ? ownerScopedUserDataPath(FILE_NAME) : null), + isEncryptionAvailable: isDesktopContactsSyncSecureStorageAvailable, + encrypt: (plaintext) => safeStorage.encryptString(plaintext), + decrypt: (ciphertext) => safeStorage.decryptString(ciphertext), +}; + +export const contactsSyncKeyStore = new ContactsSyncKeyStore(); + +function isDesktopContactsSyncSecureStorageAvailable(): boolean { + try { + return isContactsSyncSecureStorageAvailable({ + platform: process.platform, + encryptionAvailable: safeStorage.isEncryptionAvailable(), + backend: + process.platform === 'linux' ? safeStorage.getSelectedStorageBackend() : undefined, + }); + } catch { + return false; + } +} + +export function isContactsSyncSecureStorageAvailable(options: { + platform: NodeJS.Platform; + encryptionAvailable: boolean; + backend?: string; +}): boolean { + return ( + options.encryptionAvailable && + (options.platform !== 'linux' || options.backend !== 'basic_text') + ); +} diff --git a/apps/desktop/src/main/contacts-sync/lanTransport.ts b/apps/desktop/src/main/contacts-sync/lanTransport.ts new file mode 100644 index 00000000000..4384688a59b --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/lanTransport.ts @@ -0,0 +1,500 @@ +/** + * 同一局域网内的通讯录密文直连。 + * + * relay presence 仍负责证明“这是同账号且在线的桌面设备”;本模块只在局域网用 + * UDP multicast 公布临时 TCP 端口。multicast 信标仅用于发现候选端点;每次发送 + * 仍须由 relay pin 的设备密钥完成 request / ACK 双向认证。实际 TCP 内容仍是 + * wire.ts 的 AES-GCM 密文,局域网监听者看不到通讯录。 + * + * multicast / TCP 任一失败都只会让 send() 返回 false,调用方自动退回 relay。 + */ + +import dgram, { type RemoteInfo, type Socket as DgramSocket } from 'node:dgram'; +import net, { type Server, type Socket } from 'node:net'; +import { randomBytes } from 'node:crypto'; + +import { isContactsSyncWireFrame, type ContactsSyncCipherChunkFrame } from './wire.js'; +import { + createContactsSyncLanProof, + isValidContactsSyncPublicKey, + verifyContactsSyncLanProof, + type ContactsSyncLanAuthContext, +} from './crypto.js'; + +const MULTICAST_GROUP = '239.255.67.67'; +const MULTICAST_PORT = 53546; +const BEACON_INTERVAL_MS = 5_000; +const ENDPOINT_TTL_MS = 15_000; +const CONNECT_TIMEOUT_MS = 1_500; +const DIRECT_RETRY_COOLDOWN_MS = 60_000; +const MAX_PACKET_BYTES = 512 * 1024; +const MAX_CONCURRENT_CONNECTIONS = 32; +const MAGIC = 'cindy-contacts-sync'; + +interface LanBeacon { + magic: typeof MAGIC; + version: 1; + deviceId: string; + publicKey: string; + port: number; +} + +interface LanPacket { + version: 1; + srcDeviceId: string; + dstDeviceId: string; + challenge: string; + proof: string; + frame: ContactsSyncCipherChunkFrame; +} + +interface LanAck { + version: 1; + srcDeviceId: string; + dstDeviceId: string; + challenge: string; + transferId: string; + index: number; + proof: string; +} + +interface PeerEndpoint { + address: string; + port: number; + publicKey: string; + seenAt: number; +} + +export interface LanContactsSyncLogger { + debug(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; +} + +export interface LanContactsSyncTransportOptions { + getSelf(): { deviceId: string; publicKey: string; privateKey: string } | null; + isPeerAllowed(deviceId: string, publicKey: string): boolean; + onFrame(srcDeviceId: string, frame: ContactsSyncCipherChunkFrame): void; + logger: LanContactsSyncLogger; +} + +export class LanContactsSyncTransport { + private tcpServer: Server | null = null; + private udpSocket: DgramSocket | null = null; + private beaconTimer: NodeJS.Timeout | null = null; + private tcpPort: number | null = null; + private readonly endpoints = new Map(); + /** 未认证候选端点失败后按设备退避,避免伪信标让每个分片都付一次 TCP 超时。 */ + private readonly directRetryAfter = new Map(); + private readonly activeSockets = new Set(); + private generation = 0; + + constructor(private readonly options: LanContactsSyncTransportOptions) {} + + start(): void { + if (this.tcpServer) return; + const generation = ++this.generation; + const server = net.createServer((socket) => { + if (this.activeSockets.size >= MAX_CONCURRENT_CONNECTIONS) { + socket.destroy(); + return; + } + this.activeSockets.add(socket); + socket.once('close', () => this.activeSockets.delete(socket)); + this.handleConnection(socket); + }); + server.maxConnections = MAX_CONCURRENT_CONNECTIONS; + this.tcpServer = server; + server.on('error', (error) => { + this.options.logger.warn('contacts sync LAN TCP server failed', { + error: error.message, + }); + if (this.tcpServer === server) this.stop(); + }); + server.listen({ host: '0.0.0.0', port: 0, exclusive: false }, () => { + if (generation !== this.generation || this.tcpServer !== server) return; + const address = server.address(); + if (!address || typeof address === 'string') { + this.stop(); + return; + } + this.tcpPort = address.port; + this.startDiscovery(generation); + }); + server.unref(); + } + + stop(): void { + this.generation += 1; + if (this.beaconTimer) clearInterval(this.beaconTimer); + this.beaconTimer = null; + this.udpSocket?.close(); + this.udpSocket = null; + this.tcpServer?.close(); + this.tcpServer = null; + for (const socket of this.activeSockets) socket.destroy(); + this.activeSockets.clear(); + this.tcpPort = null; + this.endpoints.clear(); + this.directRetryAfter.clear(); + } + + /** + * 找到近期同网段端点时尝试直发;只有收到认证 ACK 才算成功,否则返回 false, + * 由调用方走 relay。 + */ + async send(deviceId: string, frame: ContactsSyncCipherChunkFrame): Promise { + const self = this.options.getSelf(); + const now = Date.now(); + const retryAfter = this.directRetryAfter.get(deviceId); + if (retryAfter !== undefined) { + if (retryAfter > now) return false; + this.directRetryAfter.delete(deviceId); + } + const endpoint = this.endpoints.get(deviceId); + if ( + !self || + !endpoint || + now - endpoint.seenAt > ENDPOINT_TTL_MS || + !this.options.isPeerAllowed(deviceId, endpoint.publicKey) + ) { + if (endpoint) this.endpoints.delete(deviceId); + return false; + } + const challenge = randomBytes(24).toString('base64'); + const authContext = buildLanAuthContext('request', self.deviceId, deviceId, challenge, frame); + const body = Buffer.from( + JSON.stringify({ + version: 1, + srcDeviceId: self.deviceId, + dstDeviceId: deviceId, + challenge, + proof: createContactsSyncLanProof(self.privateKey, endpoint.publicKey, authContext), + frame, + } satisfies LanPacket), + 'utf8', + ); + if (body.length > MAX_PACKET_BYTES) return false; + const packet = encodePacket(body); + + return new Promise((resolve) => { + let settled = false; + let response = Buffer.alloc(0); + let expectedResponseBytes: number | null = null; + const finish = (sent: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + if (!sent) { + this.endpoints.delete(deviceId); + this.directRetryAfter.set(deviceId, Date.now() + DIRECT_RETRY_COOLDOWN_MS); + } else { + this.directRetryAfter.delete(deviceId); + } + resolve(sent); + }; + const socket = net.createConnection({ host: endpoint.address, port: endpoint.port }); + const timer = setTimeout(() => finish(false), CONNECT_TIMEOUT_MS); + timer.unref?.(); + socket.once('error', () => finish(false)); + socket.once('end', () => finish(false)); + socket.on('data', (chunk: Buffer) => { + response = Buffer.concat([response, chunk]); + if (expectedResponseBytes === null && response.length >= 4) { + expectedResponseBytes = response.readUInt32BE(0); + response = response.subarray(4); + if (expectedResponseBytes <= 0 || expectedResponseBytes > 4_096) { + finish(false); + return; + } + } + if (expectedResponseBytes === null || response.length < expectedResponseBytes) return; + let ack: unknown; + try { + ack = JSON.parse(response.subarray(0, expectedResponseBytes).toString('utf8')); + } catch { + finish(false); + return; + } + if ( + !isLanAck(ack) || + ack.srcDeviceId !== deviceId || + ack.dstDeviceId !== self.deviceId || + ack.challenge !== challenge || + ack.transferId !== frame.transferId || + ack.index !== frame.index + ) { + finish(false); + return; + } + finish( + verifyContactsSyncLanProof(ack.proof, self.privateKey, endpoint.publicKey, { + ...authContext, + kind: 'ack', + }), + ); + }); + socket.once('connect', () => { + socket.write(packet); + }); + }); + } + + private startDiscovery(generation: number): void { + const udp = dgram.createSocket({ type: 'udp4', reuseAddr: true }); + this.udpSocket = udp; + udp.on('error', (error) => { + this.options.logger.debug('contacts sync LAN discovery unavailable; relay remains active', { + error: error.message, + }); + if (this.udpSocket === udp) { + udp.close(); + this.udpSocket = null; + } + }); + udp.on('message', (message, remote) => this.handleBeacon(message, remote)); + udp.bind(MULTICAST_PORT, '0.0.0.0', () => { + if (generation !== this.generation || this.udpSocket !== udp) return; + try { + udp.addMembership(MULTICAST_GROUP); + udp.setMulticastTTL(1); + udp.setMulticastLoopback(false); + } catch (error) { + this.options.logger.debug( + 'contacts sync LAN multicast setup failed; relay remains active', + { + error: error instanceof Error ? error.message : String(error), + }, + ); + udp.close(); + this.udpSocket = null; + return; + } + this.sendBeacon(); + this.beaconTimer = setInterval(() => this.sendBeacon(), BEACON_INTERVAL_MS); + this.beaconTimer.unref?.(); + }); + udp.unref(); + } + + private sendBeacon(): void { + const self = this.options.getSelf(); + if (!self || !this.udpSocket || !this.tcpPort) return; + const beacon: LanBeacon = { + magic: MAGIC, + version: 1, + deviceId: self.deviceId, + publicKey: self.publicKey, + port: this.tcpPort, + }; + const bytes = Buffer.from(JSON.stringify(beacon), 'utf8'); + this.udpSocket.send(bytes, MULTICAST_PORT, MULTICAST_GROUP, (error) => { + if (error) { + this.options.logger.debug('contacts sync LAN beacon failed; relay remains active', { + error: error.message, + }); + } + }); + } + + private handleBeacon(message: Buffer, remote: RemoteInfo): void { + if (message.length > 2_048) return; + let parsed: unknown; + try { + parsed = JSON.parse(message.toString('utf8')); + } catch { + return; + } + if (!isLanBeacon(parsed)) return; + const self = this.options.getSelf(); + if (!self || parsed.deviceId === self.deviceId) return; + if (!this.options.isPeerAllowed(parsed.deviceId, parsed.publicKey)) return; + const retryAfter = this.directRetryAfter.get(parsed.deviceId); + if (retryAfter !== undefined) { + if (retryAfter > Date.now()) return; + this.directRetryAfter.delete(parsed.deviceId); + } + this.endpoints.set(parsed.deviceId, { + address: normalizeRemoteAddress(remote.address), + port: parsed.port, + publicKey: parsed.publicKey, + seenAt: Date.now(), + }); + } + + private handleConnection(socket: Socket): void { + let buffer = Buffer.alloc(0); + let expected: number | null = null; + let handled = false; + socket.setTimeout(CONNECT_TIMEOUT_MS, () => socket.destroy()); + socket.on('data', (chunk: Buffer) => { + if (handled || buffer.length + chunk.length > MAX_PACKET_BYTES + 4) { + socket.destroy(); + return; + } + buffer = Buffer.concat([buffer, chunk]); + if (expected === null && buffer.length >= 4) { + expected = buffer.readUInt32BE(0); + buffer = buffer.subarray(4); + if (expected <= 0 || expected > MAX_PACKET_BYTES) { + socket.destroy(); + return; + } + } + if (expected === null || buffer.length < expected) return; + handled = true; + const body = buffer.subarray(0, expected); + let parsed: unknown; + try { + parsed = JSON.parse(body.toString('utf8')); + } catch { + socket.destroy(); + return; + } + const self = this.options.getSelf(); + if ( + !self || + !isLanPacket(parsed) || + parsed.dstDeviceId !== self.deviceId || + !this.options.isPeerAllowed(parsed.srcDeviceId, parsed.frame.senderPublicKey) + ) { + socket.destroy(); + return; + } + const authContext = buildLanAuthContext( + 'request', + parsed.srcDeviceId, + parsed.dstDeviceId, + parsed.challenge, + parsed.frame, + ); + if ( + !verifyContactsSyncLanProof( + parsed.proof, + self.privateKey, + parsed.frame.senderPublicKey, + authContext, + ) + ) { + socket.destroy(); + return; + } + try { + this.options.onFrame(parsed.srcDeviceId, parsed.frame); + const ack: LanAck = { + version: 1, + srcDeviceId: self.deviceId, + dstDeviceId: parsed.srcDeviceId, + challenge: parsed.challenge, + transferId: parsed.frame.transferId, + index: parsed.frame.index, + proof: createContactsSyncLanProof(self.privateKey, parsed.frame.senderPublicKey, { + ...authContext, + kind: 'ack', + }), + }; + socket.end(encodePacket(Buffer.from(JSON.stringify(ack), 'utf8'))); + } catch { + socket.destroy(); + } + }); + socket.on('error', () => { + // 单次直连失败由发送方回退 relay;接收侧无需制造用户可见噪音。 + }); + } +} + +function isLanBeacon(value: unknown): value is LanBeacon { + if (!isRecord(value)) return false; + return ( + value.magic === MAGIC && + value.version === 1 && + isDeviceId(value.deviceId) && + isValidContactsSyncPublicKey(value.publicKey) && + Number.isInteger(value.port) && + (value.port as number) > 0 && + (value.port as number) <= 65_535 + ); +} + +function isLanPacket(value: unknown): value is LanPacket { + if ( + !isRecord(value) || + value.version !== 1 || + !isDeviceId(value.srcDeviceId) || + !isDeviceId(value.dstDeviceId) || + !isExactBase64(value.challenge, 24) || + !isExactBase64(value.proof, 32) + ) { + return false; + } + return isContactsSyncWireFrame(value.frame) && value.frame.type === 'cipher-chunk'; +} + +function isLanAck(value: unknown): value is LanAck { + return ( + isRecord(value) && + value.version === 1 && + isDeviceId(value.srcDeviceId) && + isDeviceId(value.dstDeviceId) && + isExactBase64(value.challenge, 24) && + typeof value.transferId === 'string' && + value.transferId.length > 0 && + value.transferId.length <= 128 && + Number.isInteger(value.index) && + (value.index as number) >= 0 && + isExactBase64(value.proof, 32) + ); +} + +function buildLanAuthContext( + kind: ContactsSyncLanAuthContext['kind'], + srcDeviceId: string, + dstDeviceId: string, + challenge: string, + frame: ContactsSyncCipherChunkFrame, +): ContactsSyncLanAuthContext { + return { + kind, + srcDeviceId, + dstDeviceId, + challenge, + senderPublicKey: frame.senderPublicKey, + transferId: frame.transferId, + index: frame.index, + total: frame.total, + iv: frame.iv, + tag: frame.tag, + data: frame.data, + }; +} + +function encodePacket(body: Buffer): Buffer { + const packet = Buffer.allocUnsafe(body.length + 4); + packet.writeUInt32BE(body.length, 0); + body.copy(packet, 4); + return packet; +} + +function isExactBase64(value: unknown, bytes: number): value is string { + if ( + typeof value !== 'string' || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) { + return false; + } + const decoded = Buffer.from(value, 'base64'); + return decoded.length === bytes && decoded.toString('base64') === value; +} + +function isDeviceId(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9._:-]{1,160}$/.test(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeRemoteAddress(value: string): string { + return value.startsWith('::ffff:') ? value.slice('::ffff:'.length) : value; +} diff --git a/apps/desktop/src/main/contacts-sync/sender.ts b/apps/desktop/src/main/contacts-sync/sender.ts new file mode 100644 index 00000000000..627bf4246da --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/sender.ts @@ -0,0 +1,167 @@ +import type { ContactsSyncClock } from '@cindy/maker-core'; + +import type { LanContactsSyncTransport } from './lanTransport.js'; +import type { ContactsSyncDatabaseSource } from './contactsSyncCodec.js'; +import { encodeContactsSyncDatabaseState, type ContactsSyncWireFrame } from './wire.js'; + +interface OutboundTransport { + getSelfDeviceId(): string | null; + isPeerAllowed(deviceId: string): boolean; + sendRelayFrame(deviceId: string, frame: ContactsSyncWireFrame): void | Promise; +} + +const RELAY_BACKPRESSURE_RETRY_MS = 50; +const RELAY_BACKPRESSURE_TIMEOUT_MS = 30_000; + +export interface ContactsSyncOutboundContext { + generation: number; + ownerId: string; + selfDeviceId: string; + transport: OutboundTransport; + directTransport: LanContactsSyncTransport | null; + codecAbortSignal: AbortSignal; +} + +interface ContactsSyncOutboundDependencies { + getGeneration(): number; + getOwnerId(): string | null; + getTransport(): OutboundTransport | null; + getDirectTransport(): LanContactsSyncTransport | null; + getCodecAbortSignal(): AbortSignal; + isEnabled(): boolean; + getIdentity(): { privateKey: string; publicKey: string }; + getPeerPublicKey(deviceId: string): string | null; + getDatabaseSource(): ContactsSyncDatabaseSource; + getKnownClocks(deviceId: string): ContactsSyncClock[] | undefined; + onLocalMaterialized(): void; + announceKey(deviceId: string): void; + onError(error: unknown): void; +} + +/** + * 发送期间始终使用启动时捕获的 owner、transport 与 LAN 实例。 + * 任何 await 之后都会重新验证上下文,避免账号切换时把旧 owner 的密文转发给新连接。 + */ +export class ContactsSyncOutbound { + constructor(private readonly deps: ContactsSyncOutboundDependencies) {} + + capture(): ContactsSyncOutboundContext | null { + const ownerId = this.deps.getOwnerId(); + const transport = this.deps.getTransport(); + const selfDeviceId = transport?.getSelfDeviceId(); + if (!ownerId || !transport || !selfDeviceId || !this.deps.isEnabled()) return null; + return { + generation: this.deps.getGeneration(), + ownerId, + selfDeviceId, + transport, + directTransport: this.deps.getDirectTransport(), + codecAbortSignal: this.deps.getCodecAbortSignal(), + }; + } + + isCurrent(context: ContactsSyncOutboundContext): boolean { + return ( + context.generation === this.deps.getGeneration() && + context.transport === this.deps.getTransport() && + context.ownerId === this.deps.getOwnerId() && + context.selfDeviceId === context.transport.getSelfDeviceId() && + context.codecAbortSignal === this.deps.getCodecAbortSignal() && + !context.codecAbortSignal.aborted && + this.deps.isEnabled() + ); + } + + async ensureKeyThenSend( + deviceId: string, + requestReply: boolean, + context: ContactsSyncOutboundContext, + ): Promise { + try { + if (!this.canSend(context, deviceId)) return; + if (!this.deps.getPeerPublicKey(deviceId)) { + this.deps.announceKey(deviceId); + return; + } + await this.send(deviceId, requestReply, context); + } catch (error) { + if (this.isCurrent(context)) this.deps.onError(error); + } + } + + async send( + deviceId: string, + requestReply: boolean, + expectedContext?: ContactsSyncOutboundContext, + ): Promise { + const context = expectedContext ?? this.capture(); + if (!context || !this.canSend(context, deviceId)) return; + + const identity = this.deps.getIdentity(); + const peerKey = this.deps.getPeerPublicKey(deviceId); + if (!peerKey) { + this.deps.announceKey(deviceId); + return; + } + const knownClocks = this.deps.getKnownClocks(deviceId); + const encoded = await encodeContactsSyncDatabaseState({ + database: { + source: this.deps.getDatabaseSource(), + ...(knownClocks ? { knownClocks } : {}), + ...(requestReply ? { requestReply: true } : {}), + }, + ownPrivateKey: identity.privateKey, + ownPublicKey: identity.publicKey, + peerPublicKey: peerKey, + srcDeviceId: context.selfDeviceId, + dstDeviceId: deviceId, + signal: context.codecAbortSignal, + }); + if (!this.canSend(context, deviceId)) return; + if (encoded.materialized) this.deps.onLocalMaterialized(); + + for (const frame of encoded.frames) { + if (!this.canSend(context, deviceId)) return; + let sentDirect: boolean | undefined; + try { + sentDirect = await context.directTransport?.send(deviceId, frame); + } catch (error) { + if (!this.isCurrent(context)) return; + throw error; + } + if (!this.canSend(context, deviceId)) return; + if (!sentDirect) await this.sendRelayFrame(context, deviceId, frame); + } + } + + private async sendRelayFrame( + context: ContactsSyncOutboundContext, + deviceId: string, + frame: ContactsSyncWireFrame, + ): Promise { + const deadline = Date.now() + RELAY_BACKPRESSURE_TIMEOUT_MS; + while (this.canSend(context, deviceId)) { + try { + await context.transport.sendRelayFrame(deviceId, frame); + return; + } catch (error) { + if (!isBackpressure(error) || Date.now() >= deadline) throw error; + await delay(RELAY_BACKPRESSURE_RETRY_MS); + } + } + } + + private canSend(context: ContactsSyncOutboundContext, deviceId: string): boolean { + return this.isCurrent(context) && context.transport.isPeerAllowed(deviceId); + } +} + +function isBackpressure(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && 'code' in error && error.code === 'BACKPRESSURE' + ); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/desktop/src/main/contacts-sync/statusModel.ts b/apps/desktop/src/main/contacts-sync/statusModel.ts new file mode 100644 index 00000000000..410a1db6526 --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/statusModel.ts @@ -0,0 +1,36 @@ +import type { PersistedContactsSyncStatus } from './statusStore.js'; + +export type ContactsDeviceSyncPhase = 'off' | 'waiting' | 'syncing' | 'up-to-date' | 'error'; + +export type ContactsDeviceSyncErrorCode = + 'secure-storage-unavailable' | 'peer-identity-changed' | 'sync-failed'; + +export interface ContactsDeviceSyncStatus extends PersistedContactsSyncStatus { + /** Device Link is account-scoped; local/signed-out sessions cannot participate. */ + available: boolean; + enabled: boolean; + phase: ContactsDeviceSyncPhase; + onlineDeviceCount: number; + errorCode: ContactsDeviceSyncErrorCode | null; +} + +export function emptyContactsDeviceSyncStatus(): ContactsDeviceSyncStatus { + return { + available: false, + enabled: false, + phase: 'off', + onlineDeviceCount: 0, + errorCode: null, + lastSyncAt: null, + lastSyncDeviceId: null, + lastSyncDeviceName: null, + lastRoute: null, + }; +} + +export function contactsDeviceSyncErrorCode(error: unknown): ContactsDeviceSyncErrorCode { + const message = error instanceof Error ? error.message : String(error); + if (/secure storage/i.test(message)) return 'secure-storage-unavailable'; + if (/peer identity changed/i.test(message)) return 'peer-identity-changed'; + return 'sync-failed'; +} diff --git a/apps/desktop/src/main/contacts-sync/statusStore.ts b/apps/desktop/src/main/contacts-sync/statusStore.ts new file mode 100644 index 00000000000..4381af60a18 --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/statusStore.ts @@ -0,0 +1,169 @@ +/** 同步成功摘要与同机跨实例运行态。只含状态/设备显示信息,不含任何通讯录内容。 */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { getActiveAppSession, ownerScopedUserDataPath } from '../appSessionState.js'; + +const FILE_NAME = 'contacts-device-sync-status.v1.json'; +const RUNTIME_FILE_NAME = 'contacts-device-sync-runtime.v1.json'; +const SYNC_REQUEST_FILE_NAME = 'contacts-device-sync-request.v1'; + +export interface PersistedContactsSyncStatus { + lastSyncAt: string | null; + lastSyncDeviceId: string | null; + lastSyncDeviceName: string | null; + lastRoute: 'lan' | 'relay' | null; +} + +/** Device Link 持有者给同机被动实例看的无内容运行态。 */ +export interface PersistedContactsSyncRuntimeStatus extends PersistedContactsSyncStatus { + available: boolean; + enabled: boolean; + phase: 'off' | 'waiting' | 'syncing' | 'up-to-date' | 'error'; + onlineDeviceCount: number; + errorCode: 'secure-storage-unavailable' | 'peer-identity-changed' | 'sync-failed' | null; + updatedAt: number; +} + +const EMPTY_STATUS: PersistedContactsSyncStatus = { + lastSyncAt: null, + lastSyncDeviceId: null, + lastSyncDeviceName: null, + lastRoute: null, +}; + +export function readPersistedContactsSyncStatus(): PersistedContactsSyncStatus { + const file = statusPath(); + if (!file || !fs.existsSync(file)) return { ...EMPTY_STATUS }; + try { + const value: unknown = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!isRecord(value)) return { ...EMPTY_STATUS }; + return { + lastSyncAt: boundedNullableText(value.lastSyncAt, 64), + lastSyncDeviceId: boundedNullableText(value.lastSyncDeviceId, 160), + lastSyncDeviceName: boundedNullableText(value.lastSyncDeviceName, 256), + lastRoute: value.lastRoute === 'lan' || value.lastRoute === 'relay' ? value.lastRoute : null, + }; + } catch { + return { ...EMPTY_STATUS }; + } +} + +export function writePersistedContactsSyncStatus(status: PersistedContactsSyncStatus): void { + const file = statusPath(FILE_NAME); + if (!file) return; + writeAtomic(file, JSON.stringify(status, null, 2)); +} + +export function readPersistedContactsSyncRuntimeStatus(): PersistedContactsSyncRuntimeStatus | null { + const file = statusPath(RUNTIME_FILE_NAME); + if (!file || !fs.existsSync(file)) return null; + try { + const value: unknown = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!isRecord(value)) return null; + const phase = runtimePhase(value.phase); + const errorCode = runtimeErrorCode(value.errorCode); + if ( + typeof value.available !== 'boolean' || + typeof value.enabled !== 'boolean' || + !phase || + !Number.isInteger(value.onlineDeviceCount) || + (value.onlineDeviceCount as number) < 0 || + (value.onlineDeviceCount as number) > 10_000 || + !Number.isFinite(value.updatedAt) || + (value.updatedAt as number) <= 0 || + (value.errorCode !== null && !errorCode) + ) { + return null; + } + return { + available: value.available, + enabled: value.enabled, + phase, + onlineDeviceCount: value.onlineDeviceCount as number, + errorCode, + updatedAt: value.updatedAt as number, + lastSyncAt: boundedNullableText(value.lastSyncAt, 64), + lastSyncDeviceId: boundedNullableText(value.lastSyncDeviceId, 160), + lastSyncDeviceName: boundedNullableText(value.lastSyncDeviceName, 256), + lastRoute: value.lastRoute === 'lan' || value.lastRoute === 'relay' ? value.lastRoute : null, + }; + } catch { + return null; + } +} + +export function writePersistedContactsSyncRuntimeStatus( + status: PersistedContactsSyncRuntimeStatus, +): void { + const file = statusPath(RUNTIME_FILE_NAME); + if (!file) return; + writeAtomic(file, JSON.stringify(status)); +} + +/** 被动实例写一次性 token;内容不含联系人,最后写入获胜即可。 */ +export function writeContactsSyncRequestToken(): void { + const file = statusPath(SYNC_REQUEST_FILE_NAME); + if (!file) return; + writeAtomic(file, randomUUID()); +} + +export function readContactsSyncRequestToken(): string | null { + const file = statusPath(SYNC_REQUEST_FILE_NAME); + if (!file) return null; + try { + const value = fs.readFileSync(file, 'utf8'); + return value.length > 0 && value.length <= 64 ? value : null; + } catch { + return null; + } +} + +function writeAtomic(file: string, text: string): void { + const temp = `${file}.${process.pid}.${randomUUID()}.tmp`; + fs.mkdirSync(path.dirname(file), { recursive: true }); + try { + fs.writeFileSync(temp, text, { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(temp, file); + } finally { + try { + fs.unlinkSync(temp); + } catch { + // rename 成功后临时文件已不存在;失败清理也只能 best-effort。 + } + } +} + +function statusPath(fileName = FILE_NAME): string | null { + return getActiveAppSession().dataOwnerId ? ownerScopedUserDataPath(fileName) : null; +} + +function boundedNullableText(value: unknown, max: number): string | null { + return typeof value === 'string' && value.length > 0 && value.length <= max ? value : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function runtimePhase(value: unknown): PersistedContactsSyncRuntimeStatus['phase'] | null { + return value === 'off' || + value === 'waiting' || + value === 'syncing' || + value === 'up-to-date' || + value === 'error' + ? value + : null; +} + +function runtimeErrorCode( + value: unknown, +): PersistedContactsSyncRuntimeStatus['errorCode'] { + return value === 'secure-storage-unavailable' || + value === 'peer-identity-changed' || + value === 'sync-failed' + ? value + : null; +} diff --git a/apps/desktop/src/main/contacts-sync/wire.ts b/apps/desktop/src/main/contacts-sync/wire.ts new file mode 100644 index 00000000000..f5a9f3e561a --- /dev/null +++ b/apps/desktop/src/main/contacts-sync/wire.ts @@ -0,0 +1,209 @@ +/** + * 智能通讯录同步的设备间 wire 格式。 + * + * relay 和局域网 TCP 都只搬运这里定义的帧。Main 只做有界分片组装;完整状态的 + * JSON/gzip/加解密由专用 worker 完成,避免大通讯录阻塞 Electron 事件循环。 + */ + +import { + CONTACTS_SYNC_CHUNK_BYTES, + CONTACTS_SYNC_WIRE_VERSION, + DL_CONTACTS_SYNC_CHANNEL, + isContactsSyncWireFrame as isSharedContactsSyncWireFrame, + type ContactsSyncCipherChunkFrame, + type ContactsSyncKeyFrame, + type ContactsSyncWireFrame, +} from '@cindy/device-link'; + +import { isValidContactsSyncPublicKey } from './crypto.js'; +import { + CONTACTS_SYNC_MAX_COMPRESSED_BYTES, + type ContactsSyncCodec, + type ContactsSyncDatabaseEncodeOptions, + type ContactsSyncDatabaseSource, + type ContactsSyncDecodeResult, + type ContactsSyncEncodeResult, + type ContactsSyncEncodeOptions, + type ContactsSyncStateMessage, +} from './contactsSyncCodec.js'; +import { workerContactsSyncCodec } from './contactsSyncCodecWorkerClient.js'; + +export { CONTACTS_SYNC_WIRE_VERSION, DL_CONTACTS_SYNC_CHANNEL }; +export type { ContactsSyncCipherChunkFrame, ContactsSyncKeyFrame, ContactsSyncWireFrame }; +export type { ContactsSyncStateMessage }; + +const MAX_ACTIVE_TRANSFERS_PER_PEER = 4; +const TRANSFER_TTL_MS = 2 * 60 * 1000; + +interface PendingTransfer { + lastActivityAt: number; + senderPublicKey: string; + total: number; + iv: string; + tag: string; + chunks: Map; + totalBytes: number; +} + +export function createContactsSyncKeyFrame(publicKey: string): ContactsSyncKeyFrame { + if (!isValidContactsSyncPublicKey(publicKey)) throw new Error('invalid contacts sync public key'); + return { version: CONTACTS_SYNC_WIRE_VERSION, type: 'key', publicKey }; +} + +export function encodeContactsSyncMessage( + options: ContactsSyncEncodeOptions & { signal?: AbortSignal }, + codec: ContactsSyncCodec = workerContactsSyncCodec, +): Promise { + const { signal, ...codecOptions } = options; + return codec.encode(codecOptions, signal).then((result) => result.frames); +} + +export function encodeContactsSyncDatabaseState( + options: ContactsSyncDatabaseEncodeOptions & { signal?: AbortSignal }, + codec: ContactsSyncCodec = workerContactsSyncCodec, +): Promise { + const { signal, ...codecOptions } = options; + return codec.encode(codecOptions, signal); +} + +export function isContactsSyncWireFrame(value: unknown): value is ContactsSyncWireFrame { + if (!isSharedContactsSyncWireFrame(value)) return false; + return isValidContactsSyncPublicKey( + value.type === 'key' ? value.publicKey : value.senderPublicKey, + ); +} + +export class ContactsSyncWireDecoder { + private readonly pending = new Map(); + private readonly decoding = new Map(); + private decodeAbortController = new AbortController(); + private generation = 0; + + constructor(private readonly codec: ContactsSyncCodec = workerContactsSyncCodec) {} + + async accept(options: { + srcDeviceId: string; + dstDeviceId: string; + frame: ContactsSyncCipherChunkFrame; + ownPrivateKey: string; + expectedPeerPublicKey: string; + databaseSource?: ContactsSyncDatabaseSource; + now?: number; + }): Promise { + const now = options.now ?? Date.now(); + this.prune(now); + const frame = options.frame; + if (frame.senderPublicKey !== options.expectedPeerPublicKey) { + throw new Error('contacts sync peer key mismatch'); + } + const chunk = decodeCanonicalBase64(frame.data); + if (chunk.length > CONTACTS_SYNC_CHUNK_BYTES) { + throw new Error('contacts sync chunk is too large'); + } + + const key = `${options.srcDeviceId}\u0000${frame.transferId}`; + if (this.decoding.has(key)) return null; + let transfer = this.pending.get(key); + if (!transfer) { + const peerPrefix = `${options.srcDeviceId}\u0000`; + const pendingForPeer = [...this.pending].filter(([item]) => item.startsWith(peerPrefix)); + const decodingForPeer = [...this.decoding.keys()].filter((item) => + item.startsWith(peerPrefix), + ).length; + if (pendingForPeer.length + decodingForPeer >= MAX_ACTIVE_TRANSFERS_PER_PEER) { + const oldestPending = pendingForPeer.reduce<[string, PendingTransfer] | undefined>( + (oldest, candidate) => + !oldest || candidate[1].lastActivityAt < oldest[1].lastActivityAt ? candidate : oldest, + undefined, + ); + if (!oldestPending) return null; + this.pending.delete(oldestPending[0]); + } + transfer = { + lastActivityAt: now, + senderPublicKey: frame.senderPublicKey, + total: frame.total, + iv: frame.iv, + tag: frame.tag, + chunks: new Map(), + totalBytes: 0, + }; + this.pending.set(key, transfer); + } else if ( + transfer.senderPublicKey !== frame.senderPublicKey || + transfer.total !== frame.total || + transfer.iv !== frame.iv || + transfer.tag !== frame.tag + ) { + this.pending.delete(key); + throw new Error('contacts sync transfer metadata changed'); + } + + if (!transfer.chunks.has(frame.index)) { + transfer.chunks.set(frame.index, chunk); + transfer.totalBytes += chunk.length; + transfer.lastActivityAt = now; + if (transfer.totalBytes > CONTACTS_SYNC_MAX_COMPRESSED_BYTES + 32) { + this.pending.delete(key); + throw new Error('contacts sync transfer is too large'); + } + } + if (transfer.chunks.size !== transfer.total) return null; + + this.pending.delete(key); + const decodeToken = Symbol(key); + const generation = this.generation; + const decodeSignal = this.decodeAbortController.signal; + this.decoding.set(key, decodeToken); + const ciphertext = Buffer.concat( + Array.from({ length: transfer.total }, (_, index) => { + const value = transfer!.chunks.get(index); + if (!value) throw new Error('contacts sync transfer has a missing chunk'); + return value; + }), + ); + try { + const message = await this.codec.decode( + { + ciphertext, + iv: transfer.iv, + tag: transfer.tag, + ownPrivateKey: options.ownPrivateKey, + expectedPeerPublicKey: options.expectedPeerPublicKey, + srcDeviceId: options.srcDeviceId, + dstDeviceId: options.dstDeviceId, + transferId: frame.transferId, + totalChunks: frame.total, + ...(options.databaseSource ? { databaseSource: options.databaseSource } : {}), + }, + decodeSignal, + ); + return generation === this.generation ? message : null; + } finally { + if (this.decoding.get(key) === decodeToken) this.decoding.delete(key); + } + } + + reset(): void { + this.generation += 1; + this.decodeAbortController.abort(); + this.decodeAbortController = new AbortController(); + this.pending.clear(); + this.decoding.clear(); + } + + private prune(now: number): void { + for (const [key, transfer] of this.pending) { + if (now - transfer.lastActivityAt > TRANSFER_TTL_MS) this.pending.delete(key); + } + } +} + +function decodeCanonicalBase64(value: string): Buffer { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new Error('invalid contacts sync chunk encoding'); + } + const decoded = Buffer.from(value, 'base64'); + if (decoded.toString('base64') !== value) throw new Error('invalid contacts sync chunk encoding'); + return decoded; +} diff --git a/apps/desktop/src/main/device-link/index.ts b/apps/desktop/src/main/device-link/index.ts index 80bef96e30c..1fc497ef7c1 100644 --- a/apps/desktop/src/main/device-link/index.ts +++ b/apps/desktop/src/main/device-link/index.ts @@ -16,6 +16,7 @@ import WebSocket from 'ws'; import { DeviceLinkClient, CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2, + DL_CONTACTS_SYNC_CHANNEL, DL_SUBSCRIBE_CHANNEL, DL_UNSUBSCRIBE_CHANNEL, type DeviceLinkConnectionIssue, @@ -75,6 +76,16 @@ import { type MobileSessionEventKind, } from './mobileNotify'; import { getClientEndpoint } from '../clientEndpointsService'; +import { + handleContactsDeviceLinkStatusChanged, + handleContactsPeerPresenceChanged, + handleIncomingContactsRelayFrame, + initContactsDeviceSync, + pollContactsDeviceSyncCrossProcessState, + pollContactsDeviceSyncDataChange, + pollContactsDeviceSyncSettingChange, + setContactsDeviceLinkOwnerActive, +} from '../contacts-sync/driver'; // register.ts 从 device-link/index 导入 setBusyProbe;改用 busyReporter 后在此 re-export 保持其导入不变。 export { setBusyProbe }; @@ -103,8 +114,10 @@ let ownershipStoreCache: { db: unknown; store: OwnershipStore } | null = null; * settings 文件(被动实例的设置页也能改授权,见 settings-store 多实例语义):持有者 * 每 5s 对比快照,变化则补发 presence / 踢断新撤销的控制端。非持有者恒为 null。 */ -let appliedSettingsSnapshot: { remoteControlEnabled: boolean; revokedControllers: string[] } | null = - null; +let appliedSettingsSnapshot: { + remoteControlEnabled: boolean; + revokedControllers: string[]; +} | null = null; /** * 「保持电脑唤醒」已应用基线。与被控授权不同:keepAwake 是**每个进程各自持有**一个 * blocker、与 relay 持有权无关,故所有实例(含被动实例)都要跟随共享 settings 的改写 @@ -122,6 +135,7 @@ const presenceAvailableByDevice = new Map(); */ const presenceOnlineByDevice = new Map(); const presencePlatformByDevice = new Map(); +const presenceNameByDevice = new Map(); let unsubscribeDictionaryChanged: (() => void) | null = null; /** @@ -274,6 +288,7 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v // 改动谁也不会主动推。清空在线视图,让重连后的 presence 重新走一遍握手。 if (status !== 'online') presenceOnlineByDevice.clear(); broadcast(DEVICE_LINK_PUSH.STATUS_CHANGED, { status }); + handleContactsDeviceLinkStatusChanged(status === 'online'); if (status === 'online') replayActiveSubscriptions('ws-online'); }); // 连接问题(鉴权失效/被顶号/超限/版本不符)→ 推给 renderer,让设置页与 @@ -295,10 +310,12 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v presenceAvailableByDevice.set(snap.deviceId, available); presenceOnlineByDevice.set(snap.deviceId, snap.online); presencePlatformByDevice.set(snap.deviceId, snap.platform); + presenceNameByDevice.set(snap.deviceId, snap.selfName || snap.deviceName); void rememberLastKnownDeviceName(snap.deviceId, snap.deviceName); // best-effort 名称缓存,不阻塞 presence 处理 broadcast(DEVICE_LINK_PUSH.PRESENCE_CHANGED, snap); // 被控端兜底:对等控制端下线 → 清掉它在本机的订阅 registry(防僵尸订阅持续 sendPush)。 if (!snap.online) handleControllerOffline(snap.deviceId); + handleContactsPeerPresenceChanged({ deviceId: snap.deviceId, online: snap.online }); if (available && wasAvailable === false) { replayActiveSubscriptions(`presence-online:${snap.deviceId.slice(0, 8)}`, snap.deviceId); } @@ -306,8 +323,8 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v // 流动),但撤销过的设备必须排除 —— 判定统一走 shouldExchangeDictionaryWith, // 三个入口共用一份条件。 if ( - wasOnline !== true - && shouldExchangeDictionaryWith({ + wasOnline !== true && + shouldExchangeDictionaryWith({ online: snap.online, platform: snap.platform, revoked: isDeviceRevoked(snap.deviceId), @@ -361,15 +378,29 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v // 入站与出站走同一份准入判定:这条通道承载的是可写 CRDT 状态,只接受电脑 // 对端。手机在这套设计里是只读消费者(走 invoke 拉快照),不该能推状态过来 // 改桌面词典 —— 出站已经这么把关了,入站漏掉就等于白设。 - if (shouldExchangeDictionaryWith({ - online: true, - platform: presencePlatformByDevice.get(env.src), - revoked: isDeviceRevoked(env.src), - })) { + if ( + shouldExchangeDictionaryWith({ + online: true, + platform: presencePlatformByDevice.get(env.src), + revoked: isDeviceRevoked(env.src), + }) + ) { handleIncomingDictionaryState(env.src, p.payload); } return; } + if (p?.channel === DL_CONTACTS_SYNC_CHANNEL) { + if ( + shouldExchangeDictionaryWith({ + online: true, + platform: presencePlatformByDevice.get(env.src), + revoked: isDeviceRevoked(env.src), + }) + ) { + handleIncomingContactsRelayFrame(env.src, p.payload); + } + return; + } broadcast(DEVICE_LINK_PUSH.REMOTE_PUSH, { deviceId: env.src, channel: p.channel, @@ -384,13 +415,43 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v }, listOnlineDesktopDevices: () => [...presenceOnlineByDevice.entries()] - .filter(([deviceId, online]) => shouldExchangeDictionaryWith({ - online, - platform: presencePlatformByDevice.get(deviceId), - revoked: isDeviceRevoked(deviceId), - })) + .filter(([deviceId, online]) => + shouldExchangeDictionaryWith({ + online, + platform: presencePlatformByDevice.get(deviceId), + revoked: isDeviceRevoked(deviceId), + }), + ) .map(([deviceId]) => deviceId), }); + initContactsDeviceSync({ + getSelfDeviceId: () => client?.getSelfDeviceId() ?? null, + listOnlineDesktopDevices: () => + [...presenceOnlineByDevice.entries()] + .filter( + ([deviceId, online]) => + deviceId !== client?.getSelfDeviceId() && + shouldExchangeDictionaryWith({ + online, + platform: presencePlatformByDevice.get(deviceId), + revoked: isDeviceRevoked(deviceId), + }), + ) + .map(([deviceId]) => ({ + deviceId, + deviceName: presenceNameByDevice.get(deviceId) ?? deviceId.slice(0, 8), + })), + isPeerAllowed: (deviceId) => + deviceId !== client?.getSelfDeviceId() && + shouldExchangeDictionaryWith({ + online: presenceOnlineByDevice.get(deviceId) === true, + platform: presencePlatformByDevice.get(deviceId), + revoked: isDeviceRevoked(deviceId), + }), + sendRelayFrame: (deviceId, frame) => { + client?.sendPush(deviceId, DL_CONTACTS_SYNC_CHANNEL, frame); + }, + }); if (unsubscribeDictionaryChanged) unsubscribeDictionaryChanged(); unsubscribeDictionaryChanged = onVoiceInputDictionaryChanged((options) => { if (options?.immediate) broadcastDictionaryNow(); @@ -423,9 +484,14 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v if (!authManager.getAuthState().isAuthenticated) return; linkTornDown = false; client?.start(); + setContactsDeviceLinkOwnerActive(true); refreshAppliedSettingsSnapshot(); + pollContactsDeviceSyncSettingChange(); + pollContactsDeviceSyncDataChange(); + pollContactsDeviceSyncCrossProcessState(); }, onDemote: () => { + setContactsDeviceLinkOwnerActive(false); appliedSettingsSnapshot = null; teardownActiveLink(); }, @@ -587,6 +653,7 @@ function teardownActiveLink(): void { // client 为 null 时 sendPush 也是 no-op。 presenceOnlineByDevice.clear(); presencePlatformByDevice.clear(); + presenceNameByDevice.clear(); resetSubscriptionRefs(); resetBusyDedupe(); // 重置 busy dedupe,避免重连后首个真实 busy 状态被旧值压掉 client.stop(); @@ -739,6 +806,9 @@ function refreshAppliedSettingsSnapshot(): void { */ function pollExternalSettingsChange(): void { if (!client || !arbiter?.isOwner()) return; + pollContactsDeviceSyncSettingChange(); + pollContactsDeviceSyncDataChange(); + pollContactsDeviceSyncCrossProcessState(); const prev = appliedSettingsSnapshot; const { remoteControlEnabled, revokedControllers } = readDeviceLinkSettings(); appliedSettingsSnapshot = { remoteControlEnabled, revokedControllers: [...revokedControllers] }; @@ -861,11 +931,13 @@ export async function remoteSubscribe( if (!client) throw new Error('[DEVICE_LINK_NOT_CONNECTED] device-link client not initialized'); return client.invoke(deviceId, { channel: DL_SUBSCRIBE_CHANNEL, - args: [{ - topics, - controllerName: deviceName(), - capabilities: [CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2], - }], + args: [ + { + topics, + controllerName: deviceName(), + capabilities: [CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2], + }, + ], }); } diff --git a/apps/desktop/src/main/im/telegram/__tests__/contactsAutoRegister.test.ts b/apps/desktop/src/main/im/telegram/__tests__/contactsAutoRegister.test.ts new file mode 100644 index 00000000000..c77181c2296 --- /dev/null +++ b/apps/desktop/src/main/im/telegram/__tests__/contactsAutoRegister.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const harness = vi.hoisted(() => ({ + createContact: vi.fn(), + emitLocalContactsChanged: vi.fn(), + resolve: vi.fn(() => []), +})); + +vi.mock('../../../logger.js', () => ({ + createLogger: () => ({ info: vi.fn(), debug: vi.fn() }), +})); + +vi.mock('../../../maker-host/maker-contacts-host.js', () => ({ + getDesktopContactsManager: () => ({ + getStore: () => ({ + createContact: harness.createContact, + resolve: harness.resolve, + }), + }), +})); + +vi.mock('../../../maker-host/contacts-settings-store.js', () => ({ + readContactsSettingsState: () => ({ value: { enabled: true } }), +})); + +vi.mock('../../../maker-host/contacts-change-events.js', () => ({ + emitLocalContactsChanged: harness.emitLocalContactsChanged, +})); + +import { autoRegisterTelegramSpeaker } from '../contactsAutoRegister.js'; + +describe('Telegram contacts auto-register', () => { + beforeEach(() => { + harness.createContact.mockReset(); + harness.emitLocalContactsChanged.mockReset(); + harness.resolve.mockReset().mockReturnValue([]); + }); + + it('建档成功后触发共享本地变更事件', () => { + autoRegisterTelegramSpeaker( + { id: 'telegram-event-1', name: 'Alice', isOwner: false }, + { chatName: '项目群' }, + ); + + expect(harness.createContact).toHaveBeenCalledTimes(1); + expect(harness.emitLocalContactsChanged).toHaveBeenCalledTimes(1); + }); + + it('建档失败时不触发变更事件', () => { + harness.createContact.mockImplementationOnce(() => { + throw new Error('database busy'); + }); + + autoRegisterTelegramSpeaker( + { id: 'telegram-event-2', name: 'Bob', isOwner: false }, + { chatName: '项目群' }, + ); + + expect(harness.emitLocalContactsChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/im/telegram/contactsAutoRegister.ts b/apps/desktop/src/main/im/telegram/contactsAutoRegister.ts index 5abe1ed4acb..6e13a9f1237 100644 --- a/apps/desktop/src/main/im/telegram/contactsAutoRegister.ts +++ b/apps/desktop/src/main/im/telegram/contactsAutoRegister.ts @@ -14,6 +14,7 @@ */ import { createLogger } from '../../logger.js'; +import { emitLocalContactsChanged } from '../../maker-host/contacts-change-events.js'; import { getDesktopContactsManager } from '../../maker-host/maker-contacts-host.js'; import { readContactsSettingsState } from '../../maker-host/contacts-settings-store.js'; @@ -57,6 +58,7 @@ export function autoRegisterTelegramSpeaker( ...(speaker.username ? [{ platform: 'telegram', value: `@${speaker.username}` }] : []), ], }); + emitLocalContactsChanged(); // 去重标记只在成功路径落下: resolve/create 瞬时失败(DB busy/管理器未 // 就绪)不标记, 该发言人下次发言可重试 — 尽力而为但可恢复。 seenTelegramIds.add(speaker.id); diff --git a/apps/desktop/src/main/maker-host/__tests__/contactsChangeEvents.test.ts b/apps/desktop/src/main/maker-host/__tests__/contactsChangeEvents.test.ts new file mode 100644 index 00000000000..fa9de004ba3 --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/contactsChangeEvents.test.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterAll, describe, expect, it, vi } from 'vitest'; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'contacts-change-events-test-')); + +vi.mock('../../appSessionState.js', () => ({ + ownerScopedUserDataPath: (...parts: string[]) => path.join(tmpDir, ...parts), +})); + +const { emitLocalContactsChanged, onLocalContactsChanged, readContactsChangeToken } = + await import('../contacts-change-events.js'); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('contacts change events', () => { + it('persists a content-free token for other shared-userData processes', () => { + const listener = vi.fn(); + const unsubscribe = onLocalContactsChanged(listener); + expect(readContactsChangeToken()).toBeNull(); + + emitLocalContactsChanged(); + const first = readContactsChangeToken(); + expect(first).toMatch(/^[0-9a-f-]{36}$/); + expect(listener).toHaveBeenCalledTimes(1); + + emitLocalContactsChanged(); + expect(readContactsChangeToken()).not.toBe(first); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + }); +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/contactsSettingsStore.test.ts b/apps/desktop/src/main/maker-host/__tests__/contactsSettingsStore.test.ts new file mode 100644 index 00000000000..ff0c65384ca --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/contactsSettingsStore.test.ts @@ -0,0 +1,110 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const harness = vi.hoisted(() => ({ ownerId: 'owner-a' })); +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'contacts-settings-store-test-')); + +vi.mock('../../appSessionState.js', () => ({ + activeOwnerScopeKey: () => `cloud:${harness.ownerId}`, + getActiveAppSession: () => ({ mode: 'cloud', dataOwnerId: harness.ownerId }), + ownerScopedUserDataPath: (...parts: string[]) => path.join(tmpDir, harness.ownerId, ...parts), +})); + +vi.mock('../logger-adapter.js', () => ({ + desktopMakerLogger: { + child: () => ({ info: vi.fn(), warn: vi.fn() }), + }, +})); + +const { + commitContactsDeviceSyncSettingIntent, + readContactsDeviceSyncSettingIntent, + readContactsSettings, + writeContactsDeviceSyncEnabled, + writeContactsDeviceSyncSettingIntent, + writeContactsEnabled, +} = await import('../contacts-settings-store.js'); + +beforeEach(() => { + harness.ownerId = 'owner-a'; + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.mkdirSync(tmpDir, { recursive: true }); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('contacts settings store cross-process intent', () => { + it('keeps the latest device-sync intent isolated by owner', async () => { + const ownerAIntent = await writeContactsDeviceSyncSettingIntent(true); + expect(readContactsDeviceSyncSettingIntent()).toEqual(ownerAIntent); + + harness.ownerId = 'owner-b'; + expect(readContactsDeviceSyncSettingIntent()).toBeNull(); + const ownerBIntent = await writeContactsDeviceSyncSettingIntent(false); + expect(readContactsDeviceSyncSettingIntent()).toEqual(ownerBIntent); + + harness.ownerId = 'owner-a'; + expect(readContactsDeviceSyncSettingIntent()).toEqual(ownerAIntent); + }); + + it('rejects queued writes instead of applying them after an owner switch', async () => { + const ownerADir = path.join(tmpDir, 'owner-a'); + fs.mkdirSync(ownerADir, { recursive: true }); + const lock = path.join(ownerADir, 'contacts-settings.json.lock'); + fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, startedAt: Date.now() })); + + const first = writeContactsEnabled(true); + const second = writeContactsDeviceSyncEnabled(true); + await new Promise((resolve) => setTimeout(resolve, 20)); + harness.ownerId = 'owner-b'; + fs.unlinkSync(lock); + + await expect(first).rejects.toThrow(/scope changed/); + await expect(second).rejects.toThrow(/scope changed/); + expect(fs.existsSync(path.join(ownerADir, 'contacts-settings.json'))).toBe(false); + }); + + it('keeps intent verification and durable setting write in one critical section', async () => { + await writeContactsDeviceSyncEnabled(true); + const ownerADir = path.join(tmpDir, 'owner-a'); + const settingsLock = path.join(ownerADir, 'contacts-settings.json.lock'); + fs.writeFileSync(settingsLock, JSON.stringify({ pid: process.pid, startedAt: Date.now() })); + + const disableIntent = await writeContactsDeviceSyncSettingIntent(false); + const disabling = commitContactsDeviceSyncSettingIntent(disableIntent); + const intentLock = path.join(ownerADir, 'contacts-device-sync-setting-intent.v1.json.lock'); + await vi.waitFor(() => expect(fs.existsSync(intentLock)).toBe(true)); + + let newerIntentPublished = false; + const newerIntentPromise = writeContactsDeviceSyncSettingIntent(true).then((intent) => { + newerIntentPublished = true; + return intent; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(newerIntentPublished).toBe(false); + + fs.unlinkSync(settingsLock); + await expect(disabling).resolves.toBe(true); + const newerIntent = await newerIntentPromise; + await expect(commitContactsDeviceSyncSettingIntent(newerIntent)).resolves.toBe(true); + expect(readContactsSettings().deviceSyncEnabled).toBe(true); + }); + + it('commits durable false when the published disable intent becomes unreadable', async () => { + await writeContactsDeviceSyncEnabled(true); + const intent = await writeContactsDeviceSyncSettingIntent(false); + fs.writeFileSync( + path.join(tmpDir, 'owner-a', 'contacts-device-sync-setting-intent.v1.json'), + 'broken', + 'utf8', + ); + + await expect(commitContactsDeviceSyncSettingIntent(intent)).resolves.toBe(true); + expect(readContactsSettings().deviceSyncEnabled).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/overrideSettingsFile.test.ts b/apps/desktop/src/main/maker-host/__tests__/overrideSettingsFile.test.ts index 021fe7c7cd5..e62ce437bcd 100644 --- a/apps/desktop/src/main/maker-host/__tests__/overrideSettingsFile.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/overrideSettingsFile.test.ts @@ -17,9 +17,9 @@ const DEFAULTS: TestSettings = { nested: { a: 1, b: 2 }, }; -function createTempStore() { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xdt-override-settings-')); - const file = path.join(dir, 'settings.json'); +function createTempStore(existing?: { dir: string; file: string }) { + const dir = existing?.dir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'xdt-override-settings-')); + const file = existing?.file ?? path.join(dir, 'settings.json'); const log = { info: vi.fn(), warn: vi.fn(), @@ -198,4 +198,27 @@ describe('createOverrideSettingsFile', () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + + it('serializes atomic patches from separate instances without losing unrelated keys', async () => { + const first = createTempStore(); + const second = createTempStore({ dir: first.dir, file: first.file }); + try { + // 两个实例都先缓存同一份旧状态,复现共享 userData 的并发读改写窗口。 + expect(first.store.read()).toEqual(DEFAULTS); + expect(second.store.read()).toEqual(DEFAULTS); + + await Promise.all([ + first.store.writePatchAtomic({ enabled: false }), + second.store.writePatchAtomic({ limit: 9 }), + ]); + + expect(JSON.parse(fs.readFileSync(first.file, 'utf-8'))).toEqual({ + enabled: false, + limit: 9, + }); + expect(fs.existsSync(`${first.file}.lock`)).toBe(false); + } finally { + fs.rmSync(first.dir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/src/main/maker-host/contacts-change-broadcast.ts b/apps/desktop/src/main/maker-host/contacts-change-broadcast.ts new file mode 100644 index 00000000000..e6368219711 --- /dev/null +++ b/apps/desktop/src/main/maker-host/contacts-change-broadcast.ts @@ -0,0 +1,14 @@ +/** 把通讯录变更广播给 renderer,并按来源通知设备同步驱动。 */ + +import { BrowserWindow } from 'electron'; + +import { emitLocalContactsChanged } from './contacts-change-events.js'; + +export const CONTACTS_CHANGED_CHANNEL = 'maker:contacts:changed'; + +export function broadcastContactsChanged(options: { origin?: 'local' | 'remote' } = {}): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(CONTACTS_CHANGED_CHANNEL); + } + if ((options.origin ?? 'local') === 'local') emitLocalContactsChanged(); +} diff --git a/apps/desktop/src/main/maker-host/contacts-change-events.ts b/apps/desktop/src/main/maker-host/contacts-change-events.ts new file mode 100644 index 00000000000..fab7683e5e7 --- /dev/null +++ b/apps/desktop/src/main/maker-host/contacts-change-events.ts @@ -0,0 +1,50 @@ +/** Main 内部的通讯录本地变更事件;不携带联系人内容。 */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { ownerScopedUserDataPath } from '../appSessionState.js'; + +const listeners = new Set<() => void>(); +const CHANGE_TOKEN_FILENAME = 'change-token'; + +function changeTokenPath(): string { + return ownerScopedUserDataPath('maker-contacts', CHANGE_TOKEN_FILENAME); +} + +/** Device Link 持有者轮询的无内容版本标记;读取失败按“暂无标记”处理。 */ +export function readContactsChangeToken(): string | null { + try { + return fs.readFileSync(changeTokenPath(), 'utf8') || null; + } catch { + return null; + } +} + +function persistContactsChangeToken(): void { + try { + const file = changeTokenPath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + // token 不含联系人数据;跨进程唯一即可,不承担排序或计数语义。 + fs.writeFileSync(file, randomUUID(), 'utf8'); + } catch { + // 标记失败只退化到 30 分钟校准,不能把已成功的联系人写入伪装成失败。 + } +} + +export function onLocalContactsChanged(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function emitLocalContactsChanged(): void { + persistContactsChangeToken(); + for (const listener of listeners) { + try { + listener(); + } catch { + // 后台同步监听失败不能把已成功的通讯录写入伪装成 IPC 失败。 + } + } +} diff --git a/apps/desktop/src/main/maker-host/contacts-settings-store.ts b/apps/desktop/src/main/maker-host/contacts-settings-store.ts index 24f6ba6b4a4..2be2e2e0582 100644 --- a/apps/desktop/src/main/maker-host/contacts-settings-store.ts +++ b/apps/desktop/src/main/maker-host/contacts-settings-store.ts @@ -2,17 +2,20 @@ * contacts-settings-store —— 智能通讯录开关的 main 端持久化 source of truth。 * * 落盘文件: /contacts-settings.json - * { "enabled": false } + * { "enabled": false, "deviceSyncEnabled": false } * * 默认 false —— 通讯录是个人数据采集类功能, 必须用户主动开启(开 = 允许 agent * 自动采集人物信息, 单开关语义, 无独立"自动采集"子开关)。开关只 gate agent 侧 * (cindy_contacts MCP server 注册 + 工具级拦截); 设置页管理 UI 不受 gate — * 关着也能浏览/清理已有数据。 * - * 形态与 chat-embedding-settings-store 完全一致(createOverrideSettingsFile: - * 同步 R/W + .tmp 原子写 + 内存 cache + 坏文件回退默认值)。 + * 形态基于 createOverrideSettingsFile:同步读 + 跨进程锁内原子 patch + 内存 cache + * + 坏文件回退默认值。共享 userData 的多个实例修改不同开关时不会互相覆盖。 */ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; import path from 'node:path'; import { desktopMakerLogger } from './logger-adapter.js'; @@ -20,31 +23,163 @@ import { createOverrideSettingsFile, type OverrideSettingsState, } from './override-settings-file.js'; -import { getActiveAppSession, ownerScopedUserDataPath } from '../appSessionState.js'; +import { + activeOwnerScopeKey, + getActiveAppSession, + ownerScopedUserDataPath, +} from '../appSessionState.js'; +import { withCrossProcessLock } from '../device-link/crossProcessLock.js'; const log = desktopMakerLogger.child('contacts-settings-store'); +const DEVICE_SYNC_INTENT_FILE_NAME = 'contacts-device-sync-setting-intent.v1.json'; export interface ContactsSettings { enabled: boolean; + /** 在本账号的 Desktop 设备之间自动同步;与 agent 是否可访问通讯录相互独立。 */ + deviceSyncEnabled: boolean; } const DEFAULTS: ContactsSettings = { enabled: false, + deviceSyncEnabled: false, }; function settingsFilePath(rootPath?: string): string { return path.join(rootPath ?? ownerScopedUserDataPath(), 'contacts-settings.json'); } +function deviceSyncIntentFilePath(): string { + return ownerScopedUserDataPath(DEVICE_SYNC_INTENT_FILE_NAME); +} + function normalize(raw: unknown): ContactsSettings { if (!raw || typeof raw !== 'object') return { ...DEFAULTS }; const r = raw as Record; return { enabled: typeof r.enabled === 'boolean' ? r.enabled : DEFAULTS.enabled, + deviceSyncEnabled: + typeof r.deviceSyncEnabled === 'boolean' ? r.deviceSyncEnabled : DEFAULTS.deviceSyncEnabled, }; } const stores = new Map>>(); +const settingsWriteChains = new Map>(); + +/** 捕获调用时的 owner store,并让同进程写入保持用户操作顺序。 */ +function enqueueSettingsWrite(scopeKey: string, task: () => Promise): Promise { + const previous = settingsWriteChains.get(scopeKey) ?? Promise.resolve(); + const run = () => { + if (activeOwnerScopeKey() !== scopeKey) { + throw new Error('contacts settings scope changed before queued write'); + } + return task(); + }; + const next = previous.then(run, run); + settingsWriteChains.set( + scopeKey, + next.then( + () => undefined, + () => undefined, + ), + ); + return next; +} + +export interface ContactsDeviceSyncSettingIntent { + token: string; + enabled: boolean; +} + +/** 跨进程可见的开关意图;长耗时 enable 在提交前用 token 判断是否已被后发操作取代。 */ +export async function writeContactsDeviceSyncSettingIntent( + enabled: boolean, +): Promise { + const file = deviceSyncIntentFilePath(); + const scopeKey = activeOwnerScopeKey(); + const intent = { token: randomUUID(), enabled }; + await fsp.mkdir(path.dirname(file), { recursive: true }); + await withCrossProcessLock( + `${file}.lock`, + { label: 'contacts-device-sync-intent', waitMs: 12_000 }, + async (status) => { + if (!status.held) throw new Error(`contacts device sync intent lock ${status.reason}`); + if (activeOwnerScopeKey() !== scopeKey || deviceSyncIntentFilePath() !== file) { + throw new Error('contacts device sync intent scope changed while waiting'); + } + const temp = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + await fsp.writeFile(temp, JSON.stringify(intent), { encoding: 'utf8', mode: 0o600 }); + await fsp.rename(temp, file); + } finally { + await fsp.rm(temp, { force: true }).catch(() => undefined); + } + }, + ); + return intent; +} + +/** + * 持 intent 锁复核 token 后再写 durable setting。新 intent 无法插进“复核→落盘”窗口, + * 因而多个实例的相反操作会按这把锁的提交顺序收敛。 + */ +export async function commitContactsDeviceSyncSettingIntent( + intent: ContactsDeviceSyncSettingIntent, +): Promise { + const file = deviceSyncIntentFilePath(); + const scopeKey = activeOwnerScopeKey(); + const store = currentStore(); + await fsp.mkdir(path.dirname(file), { recursive: true }); + return withCrossProcessLock( + `${file}.lock`, + { label: 'contacts-device-sync-intent', waitMs: 12_000 }, + async (status) => { + if (!status.held) throw new Error(`contacts device sync intent lock ${status.reason}`); + if (activeOwnerScopeKey() !== scopeKey || deviceSyncIntentFilePath() !== file) { + throw new Error('contacts device sync intent scope changed while committing'); + } + const currentIntent = readDeviceSyncIntentFile(file); + if (!sameDeviceSyncIntent(currentIntent, intent)) { + // 关闭是隐私方向:锁内确认没有一个可读的新意图时仍提交 durable false。 + // 开启必须精确匹配 token;读坏/缺失一律 fail closed。 + if (intent.enabled || currentIntent !== null) return false; + } + await enqueueSettingsWrite(scopeKey, () => + store.writePatchAtomic({ deviceSyncEnabled: intent.enabled }), + ); + log.info('contacts device sync setting written', { deviceSyncEnabled: intent.enabled }); + return true; + }, + ); +} + +export function readContactsDeviceSyncSettingIntent(): ContactsDeviceSyncSettingIntent | null { + if (!getActiveAppSession().dataOwnerId) return null; + return readDeviceSyncIntentFile(deviceSyncIntentFilePath()); +} + +function readDeviceSyncIntentFile(file: string): ContactsDeviceSyncSettingIntent | null { + try { + const value: unknown = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!value || typeof value !== 'object') return null; + const token = (value as { token?: unknown }).token; + const enabled = (value as { enabled?: unknown }).enabled; + return typeof token === 'string' && + token.length > 0 && + token.length <= 64 && + typeof enabled === 'boolean' + ? { token, enabled } + : null; + } catch { + return null; + } +} + +function sameDeviceSyncIntent( + current: ContactsDeviceSyncSettingIntent | null, + expected: ContactsDeviceSyncSettingIntent, +): boolean { + return current?.token === expected.token && current.enabled === expected.enabled; +} function currentStore() { const ownerRoot = getActiveAppSession().dataOwnerId ? ownerScopedUserDataPath() : null; @@ -57,6 +192,7 @@ function currentStore() { normalize, log, label: 'contacts', + scopeKey: activeOwnerScopeKey, }); stores.set(key, store); } @@ -65,19 +201,38 @@ function currentStore() { /** 同步读 —— 第一次从磁盘, 后续走内存 cache。 */ export function readContactsSettings(): ContactsSettings { - return currentStore().read(); + const store = currentStore(); + store.invalidateIfChanged(); + return store.read(); } export function readContactsSettingsState(): OverrideSettingsState { - return currentStore().readState(); + const store = currentStore(); + store.invalidateIfChanged(); + return store.readState(); } /** 同步写 enabled + 更新 cache; 失败抛错让 IPC handler 反馈给 UI。 */ -export function writeContactsEnabled(enabled: boolean): void { - currentStore().writePatch({ enabled }); - log.info('contacts setting written', { enabled }); +export async function writeContactsEnabled(enabled: boolean): Promise { + const scopeKey = activeOwnerScopeKey(); + const store = currentStore(); + await enqueueSettingsWrite(scopeKey, async () => { + await store.writePatchAtomic({ enabled }); + log.info('contacts setting written', { enabled }); + }); +} + +export async function writeContactsDeviceSyncEnabled(deviceSyncEnabled: boolean): Promise { + const scopeKey = activeOwnerScopeKey(); + const store = currentStore(); + await enqueueSettingsWrite(scopeKey, async () => { + await store.writePatchAtomic({ deviceSyncEnabled }); + log.info('contacts device sync setting written', { deviceSyncEnabled }); + }); } -export function resetContactsSettings(): ContactsSettings { - return currentStore().reset(); +export function resetContactsSettings(): Promise { + const scopeKey = activeOwnerScopeKey(); + const store = currentStore(); + return enqueueSettingsWrite(scopeKey, () => store.resetAtomic()); } diff --git a/apps/desktop/src/main/maker-host/override-settings-file.ts b/apps/desktop/src/main/maker-host/override-settings-file.ts index 906f40874e9..2e74f1747b9 100644 --- a/apps/desktop/src/main/maker-host/override-settings-file.ts +++ b/apps/desktop/src/main/maker-host/override-settings-file.ts @@ -1,5 +1,7 @@ import fs from 'node:fs'; +import { withCrossProcessLock } from '../device-link/crossProcessLock.js'; + interface Logger { info(message: string, meta?: Record): void; warn(message: string, meta?: Record): void; @@ -16,7 +18,11 @@ export interface OverrideSettingsFile { read(): T; readState(): OverrideSettingsState; writePatch(patch: Partial, options?: { preserveDefaults?: boolean }): void; + /** 跨进程锁内强制现读盘上 overrides,再合并 patch 并原子替换文件。 */ + writePatchAtomic(patch: Partial, options?: { preserveDefaults?: boolean }): Promise; reset(): T; + /** 跨进程锁内删除 override 文件。 */ + resetAtomic(): Promise; /** * 文件被进程外修改(用户/agent 手改配置)时失效缓存,下次 read 现读。 * mtime 守卫:文件没变时零开销(一次 stat),不重读不重复打 loaded 日志。 @@ -41,6 +47,8 @@ export function createOverrideSettingsFile(options: { }) => Record; log: Logger; label: string; + /** owner/session 跨 await 切换时让原子写 fail closed。 */ + scopeKey?: () => string; }): OverrideSettingsFile { let cached: CachedState | null = null; let cachedResolvedPath: string | null = null; @@ -142,6 +150,31 @@ export function createOverrideSettingsFile(options: { writeOverrides(nextOverrides); } + async function writePatchAtomic( + patch: Partial, + writeOptions?: { preserveDefaults?: boolean }, + ): Promise { + const file = options.filePath(); + const scopeKey = options.scopeKey?.(); + fs.mkdirSync(pathDirname(file), { recursive: true }); + await withCrossProcessLock( + `${file}.lock`, + { label: `${options.label}-settings`, waitMs: 12_000 }, + async (status) => { + if (!status.held) { + throw new Error(`${options.label} settings are busy in another process`); + } + if (options.filePath() !== file || options.scopeKey?.() !== scopeKey) { + throw new Error( + `${options.label} settings scope changed while waiting for the write lock`, + ); + } + invalidate(); + writePatch(patch, writeOptions); + }, + ); + } + function writeOverrides(overrides: Record): void { if (Object.keys(overrides).length === 0) { reset(); @@ -189,14 +222,44 @@ export function createOverrideSettingsFile(options: { return cached.value; } + async function resetAtomic(): Promise { + const file = options.filePath(); + const scopeKey = options.scopeKey?.(); + fs.mkdirSync(pathDirname(file), { recursive: true }); + return withCrossProcessLock( + `${file}.lock`, + { label: `${options.label}-settings`, waitMs: 12_000 }, + async (status) => { + if (!status.held) { + throw new Error(`${options.label} settings are busy in another process`); + } + if (options.filePath() !== file || options.scopeKey?.() !== scopeKey) { + throw new Error( + `${options.label} settings scope changed while waiting for the write lock`, + ); + } + invalidate(); + return reset(); + }, + ); + } + return { read: () => readState().value, readState, writePatch, + writePatchAtomic, reset, + resetAtomic, invalidateIfChanged, }; + function invalidate(): void { + cached = null; + cachedFileMtimeMs = null; + cachedResolvedPath = null; + } + function invalidateIfPathChanged(): void { const currentPath = options.filePath(); if (cachedResolvedPath === null || cachedResolvedPath === currentPath) return; diff --git a/apps/desktop/src/main/maker-ipc/__tests__/contactsIpc.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/contactsIpc.test.ts index 1f871f1e946..f12e8a6596e 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/contactsIpc.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/contactsIpc.test.ts @@ -18,12 +18,27 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] }, })); +vi.mock('../../contacts-sync/driver.js', () => ({ + broadcastContactsNow: vi.fn(), + getContactsDeviceSyncStatus: vi.fn(), + onContactsDeviceSyncStatusChanged: vi.fn(), + setContactsDeviceSyncEnabled: vi.fn(), +})); + import { createContactsIpcHandlers } from '../contacts-ipc.js'; import { MAKER_INVOKE } from '../channels.js'; function noopLogger() { const noop = () => {}; - const l = { trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop, child: () => l }; + const l = { + trace: noop, + debug: noop, + info: noop, + warn: noop, + error: noop, + fatal: noop, + child: () => l, + }; return l; } @@ -67,7 +82,9 @@ describe('contacts-ipc handlers', () => { }); await handlers[MAKER_INVOKE.CONTACTS_SETTINGS_SET]!(true); expect(enabled).toBe(true); - await expect(handlers[MAKER_INVOKE.CONTACTS_SETTINGS_SET]!('yes')).rejects.toThrow(/INVALID_PARAMS/); + await expect(handlers[MAKER_INVOKE.CONTACTS_SETTINGS_SET]!('yes')).rejects.toThrow( + /INVALID_PARAMS/, + ); }); it('开关落盘失败按 [CODE] 协议上抛, 不漏裸 Error(规则 13)', async () => { @@ -79,7 +96,55 @@ describe('contacts-ipc handlers', () => { }, broadcastChanged: () => {}, }); - await expect(handlers[MAKER_INVOKE.CONTACTS_SETTINGS_SET]!(true)).rejects.toThrow(/\[INTERNAL\]/); + await expect(handlers[MAKER_INVOKE.CONTACTS_SETTINGS_SET]!(true)).rejects.toThrow( + /\[INTERNAL\]/, + ); + }); + + it('设备同步状态、开关和立即同步走独立程序通道', async () => { + let syncEnabled = false; + const setDeviceSyncEnabled = vi.fn(async (value: boolean) => { + syncEnabled = value; + }); + const syncNow = vi.fn(async () => {}); + const status = () => ({ + enabled: syncEnabled, + phase: syncEnabled ? 'waiting' : 'off', + onlineDeviceCount: 0, + }); + handlers = createContactsIpcHandlers({ + getManager: () => manager, + readSettingsState: () => ({ value: { enabled }, isCustomized: enabled }), + writeEnabled: (value) => { + enabled = value; + }, + broadcastChanged: () => {}, + readDeviceSyncStatus: status, + setDeviceSyncEnabled, + syncNow, + }); + + expect(await handlers[MAKER_INVOKE.CONTACTS_SYNC_STATUS_GET]!()).toMatchObject({ + enabled: false, + phase: 'off', + }); + expect(await handlers[MAKER_INVOKE.CONTACTS_SYNC_ENABLED_SET]!(true)).toMatchObject({ + enabled: true, + phase: 'waiting', + }); + await handlers[MAKER_INVOKE.CONTACTS_SYNC_NOW]!(); + expect(setDeviceSyncEnabled).toHaveBeenCalledWith(true); + expect(syncNow).toHaveBeenCalledTimes(1); + await expect(handlers[MAKER_INVOKE.CONTACTS_SYNC_ENABLED_SET]!('yes')).rejects.toThrow( + /INVALID_PARAMS/, + ); + }); + + it('设备同步状态依赖缺失时 fail closed,不向 renderer 返回 undefined', async () => { + await expect(handlers[MAKER_INVOKE.CONTACTS_SYNC_ENABLED_SET]!(true)).rejects.toThrow( + /\[INTERNAL\]/, + ); + await expect(handlers[MAKER_INVOKE.CONTACTS_SYNC_NOW]!()).rejects.toThrow(/\[INTERNAL\]/); }); it('开关值变化时失效 Codex MCP, 同值重写不失效, 失效失败不影响落盘', async () => { @@ -132,13 +197,19 @@ describe('contacts-ipc handlers', () => { })) as { id: string }; expect(broadcasts).toBe(1); - const resolved = (await handlers[MAKER_INVOKE.CONTACTS_RESOLVE]!('zhang@example.com', undefined)) as Array<{ + const resolved = (await handlers[MAKER_INVOKE.CONTACTS_RESOLVE]!( + 'zhang@example.com', + undefined, + )) as Array<{ profile: { id: string }; }>; expect(resolved[0]!.profile.id).toBe(created.id); expect(broadcasts).toBe(1); // 查询不广播 - await handlers[MAKER_INVOKE.CONTACTS_APPEND_EVENT]!(created.id, { date: '2026-07-07', text: '入职' }); + await handlers[MAKER_INVOKE.CONTACTS_APPEND_EVENT]!(created.id, { + date: '2026-07-07', + text: '入职', + }); const got = (await handlers[MAKER_INVOKE.CONTACTS_GET]!(created.id)) as { events: unknown[] }; expect(got.events).toHaveLength(1); expect(broadcasts).toBe(2); @@ -171,22 +242,35 @@ describe('contacts-ipc handlers', () => { }); it('分组: create/set-members/list/delete', async () => { - const c = (await handlers[MAKER_INVOKE.CONTACTS_CREATE]!({ kind: 'person', displayName: 'A' })) as { + const c = (await handlers[MAKER_INVOKE.CONTACTS_CREATE]!({ + kind: 'person', + displayName: 'A', + })) as { + id: string; + }; + const g = (await handlers[MAKER_INVOKE.CONTACTS_GROUPS_CREATE]!('核心', undefined)) as { id: string; }; - const g = (await handlers[MAKER_INVOKE.CONTACTS_GROUPS_CREATE]!('核心', undefined)) as { id: string }; await handlers[MAKER_INVOKE.CONTACTS_GROUPS_SET_MEMBERS]!(g.id, { add: [c.id] }); - const groups = (await handlers[MAKER_INVOKE.CONTACTS_GROUPS_LIST]!()) as Array<{ memberCount: number }>; + const groups = (await handlers[MAKER_INVOKE.CONTACTS_GROUPS_LIST]!()) as Array<{ + memberCount: number; + }>; expect(groups[0]!.memberCount).toBe(1); await handlers[MAKER_INVOKE.CONTACTS_GROUPS_DELETE]!(g.id); expect(await handlers[MAKER_INVOKE.CONTACTS_GROUPS_LIST]!()).toEqual([]); }); it('关系边: add/remove relation', async () => { - const p1 = (await handlers[MAKER_INVOKE.CONTACTS_CREATE]!({ kind: 'person', displayName: 'A' })) as { + const p1 = (await handlers[MAKER_INVOKE.CONTACTS_CREATE]!({ + kind: 'person', + displayName: 'A', + })) as { id: string; }; - const o1 = (await handlers[MAKER_INVOKE.CONTACTS_CREATE]!({ kind: 'org', displayName: 'O' })) as { + const o1 = (await handlers[MAKER_INVOKE.CONTACTS_CREATE]!({ + kind: 'org', + displayName: 'O', + })) as { id: string; }; const rel = (await handlers[MAKER_INVOKE.CONTACTS_ADD_RELATION]!(p1.id, { diff --git a/apps/desktop/src/main/maker-ipc/channels.ts b/apps/desktop/src/main/maker-ipc/channels.ts index b4edf488a62..cf76845533f 100644 --- a/apps/desktop/src/main/maker-ipc/channels.ts +++ b/apps/desktop/src/main/maker-ipc/channels.ts @@ -338,6 +338,9 @@ export const MAKER_INVOKE = { */ CONTACTS_SETTINGS_GET: 'maker:contacts:settings:get', CONTACTS_SETTINGS_SET: 'maker:contacts:settings:set', + CONTACTS_SYNC_STATUS_GET: 'maker:contacts:sync:status:get', + CONTACTS_SYNC_ENABLED_SET: 'maker:contacts:sync:enabled:set', + CONTACTS_SYNC_NOW: 'maker:contacts:sync:now', CONTACTS_LIST: 'maker:contacts:list', CONTACTS_GET: 'maker:contacts:get', CONTACTS_CREATE: 'maker:contacts:create', diff --git a/apps/desktop/src/main/maker-ipc/contacts-ipc.ts b/apps/desktop/src/main/maker-ipc/contacts-ipc.ts index 9785e14a38a..27c859002d7 100644 --- a/apps/desktop/src/main/maker-ipc/contacts-ipc.ts +++ b/apps/desktop/src/main/maker-ipc/contacts-ipc.ts @@ -18,6 +18,12 @@ import { BrowserWindow, ipcMain } from 'electron'; import { createLogger } from '../logger.js'; +import { + broadcastContactsNow, + getContactsDeviceSyncStatus, + onContactsDeviceSyncStatusChanged, + setContactsDeviceSyncEnabled, +} from '../contacts-sync/driver.js'; import { ContactsError, @@ -35,16 +41,22 @@ import { readContactsSettingsState, writeContactsEnabled, } from '../maker-host/contacts-settings-store.js'; +import { broadcastContactsChanged } from '../maker-host/contacts-change-broadcast.js'; import { isIpcError, type IpcErrorCode } from '../../shared/ipc-errors.js'; +import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; -export const CONTACTS_CHANGED_CHANNEL = 'maker:contacts:changed'; +export { + CONTACTS_CHANGED_CHANNEL, + broadcastContactsChanged, +} from '../maker-host/contacts-change-broadcast.js'; +export const CONTACTS_SYNC_STATUS_CHANGED_CHANNEL = 'maker:contacts:sync:status-changed'; const log = createLogger('contactsIpc'); export interface ContactsIpcDeps { getManager: () => MakerContactsManager; readSettingsState: () => { value: { enabled: boolean }; isCustomized: boolean }; - writeEnabled: (enabled: boolean) => void; + writeEnabled: (enabled: boolean) => void | Promise; broadcastChanged: () => void; /** * 开关值变化后失效 Codex 本地 app-server(可选, 生产注入; 测试可省略)。 @@ -59,6 +71,9 @@ export interface ContactsIpcDeps { * 返回给 renderer 提示"对 Codex 延迟生效", 静默成功会掩盖开关与 Codex 实际状态失同步。 */ invalidateCodexMcp?: () => Promise; + readDeviceSyncStatus?: () => unknown | Promise; + setDeviceSyncEnabled?: (enabled: boolean) => Promise; + syncNow?: () => Promise; } /** @@ -110,7 +125,8 @@ export function createContactsIpcHandlers(deps: ContactsIpcDeps): Record { - if (typeof enabled !== 'boolean') throwIpcError('INVALID_PARAMS', 'enabled required (boolean)'); + if (typeof enabled !== 'boolean') + throwIpcError('INVALID_PARAMS', 'enabled required (boolean)'); const changed = deps.readSettingsState().value.enabled !== enabled; // Claude 侧生效点在下次 session start(mcp provider isEnabled 现读); Codex 的 // MCP flags 冻在 codexEnvironment 的 cached spawn 配置里, 值真变化时还要失效 @@ -118,7 +134,7 @@ export function createContactsIpcHandlers(deps: ContactsIpcDeps): Record { + if (!deps.readDeviceSyncStatus) throwIpcError('INTERNAL', 'contacts sync is unavailable'); + return await deps.readDeviceSyncStatus(); + }, + [MAKER_INVOKE.CONTACTS_SYNC_ENABLED_SET]: async (enabled) => { + if (typeof enabled !== 'boolean') { + throwIpcError('INVALID_PARAMS', 'device sync enabled required (boolean)'); + } + if (!deps.setDeviceSyncEnabled || !deps.readDeviceSyncStatus) { + throwIpcError('INTERNAL', 'contacts sync is unavailable'); + } + try { + await deps.setDeviceSyncEnabled(enabled); + return await deps.readDeviceSyncStatus(); + } catch (err) { + rethrowAsIpcError(err); + } + }, + [MAKER_INVOKE.CONTACTS_SYNC_NOW]: async () => { + if (!deps.syncNow || !deps.readDeviceSyncStatus) { + throwIpcError('INTERNAL', 'contacts sync is unavailable'); + } + try { + await deps.syncNow(); + return await deps.readDeviceSyncStatus(); + } catch (err) { + rethrowAsIpcError(err); + } + }, [MAKER_INVOKE.CONTACTS_LIST]: async (opts) => - query(() => store().listContacts((opts ?? {}) as Parameters['listContacts']>[0])), - [MAKER_INVOKE.CONTACTS_GET]: async (id) => query(() => store().getContact(requireString(id, 'id'))), + query(() => + store().listContacts( + (opts ?? {}) as Parameters['listContacts']>[0], + ), + ), + [MAKER_INVOKE.CONTACTS_GET]: async (id) => + query(() => store().getContact(requireString(id, 'id'))), [MAKER_INVOKE.CONTACTS_CREATE]: async (input) => mutate(() => store().createContact( - requireObject(input, 'input') as unknown as Parameters['createContact']>[0], + requireObject(input, 'input') as unknown as Parameters< + ReturnType['createContact'] + >[0], ), ), [MAKER_INVOKE.CONTACTS_UPDATE]: async (id, patch) => mutate(() => store().updateContact( requireString(id, 'id'), - requireObject(patch, 'patch') as unknown as Parameters['updateContact']>[1], + requireObject(patch, 'patch') as unknown as Parameters< + ReturnType['updateContact'] + >[1], ), ), [MAKER_INVOKE.CONTACTS_DELETE]: async (id) => @@ -157,7 +211,9 @@ export function createContactsIpcHandlers(deps: ContactsIpcDeps): Record - mutate(() => store().merge(requireString(targetId, 'targetId'), requireString(sourceId, 'sourceId'))), + mutate(() => + store().merge(requireString(targetId, 'targetId'), requireString(sourceId, 'sourceId')), + ), [MAKER_INVOKE.CONTACTS_RESOLVE]: async (value, opts) => query(() => store().resolve( @@ -178,7 +234,9 @@ export function createContactsIpcHandlers(deps: ContactsIpcDeps): Record store().addIdentity( requireString(contactId, 'contactId'), - requireObject(input, 'input') as unknown as Parameters['addIdentity']>[1], + requireObject(input, 'input') as unknown as Parameters< + ReturnType['addIdentity'] + >[1], ), ), [MAKER_INVOKE.CONTACTS_REMOVE_IDENTITY]: async (identityId) => @@ -190,14 +248,18 @@ export function createContactsIpcHandlers(deps: ContactsIpcDeps): Record store().appendEvent( requireString(contactId, 'contactId'), - requireObject(input, 'input') as unknown as Parameters['appendEvent']>[1], + requireObject(input, 'input') as unknown as Parameters< + ReturnType['appendEvent'] + >[1], ), ), [MAKER_INVOKE.CONTACTS_ADD_RELATION]: async (fromId, input) => mutate(() => store().addRelation( requireString(fromId, 'fromId'), - requireObject(input, 'input') as unknown as Parameters['addRelation']>[1], + requireObject(input, 'input') as unknown as Parameters< + ReturnType['addRelation'] + >[1], ), ), [MAKER_INVOKE.CONTACTS_REMOVE_RELATION]: async (relationId) => @@ -235,8 +297,12 @@ export function createContactsIpcHandlers(deps: ContactsIpcDeps): Record { const gid = requireString(groupId, 'groupId'); const p = requireObject(payload, 'payload') as { add?: unknown; remove?: unknown }; - const add = Array.isArray(p.add) ? p.add.filter((x): x is string => typeof x === 'string') : []; - const remove = Array.isArray(p.remove) ? p.remove.filter((x): x is string => typeof x === 'string') : []; + const add = Array.isArray(p.add) + ? p.add.filter((x): x is string => typeof x === 'string') + : []; + const remove = Array.isArray(p.remove) + ? p.remove.filter((x): x is string => typeof x === 'string') + : []; if (add.length > 0) store().addToGroup(gid, add); if (remove.length > 0) store().removeFromGroup(gid, remove); return { added: add.length, removed: remove.length }; @@ -263,10 +329,11 @@ export function createContactsIpcHandlers(deps: ContactsIpcDeps): Record getContactsDeviceSyncStatus(), + setDeviceSyncEnabled: async (enabled) => { + await setContactsDeviceSyncEnabled(enabled); + }, + syncNow: async () => { + await broadcastContactsNow(true); + }, // 与 register.ts 自定义 MCP CRUD 的 invalidateCodex 同款语义与顺序: 先 dispose // app-server(含 busy 检查), 成功后再关 bridge/清 cache —— 若先关 bridge 而 dispose // 失败(busy), running 会话的 mcp_servers URL 会指向已停的 bridge。 @@ -290,23 +364,34 @@ export function registerContactsIpc(): void { const { restartCodexAfterAuthModeChange } = await import('../maker-host/index.js'); await restartCodexAfterAuthModeChange(); } catch (err) { - log.warn('restartCodexAfterAuthModeChange on contacts toggle failed — codex keeps stale MCP config until app restart or re-toggle', { - error: err instanceof Error ? err.message : String(err), - }); + log.warn( + 'restartCodexAfterAuthModeChange on contacts toggle failed — codex keeps stale MCP config until app restart or re-toggle', + { + error: err instanceof Error ? err.message : String(err), + }, + ); throw err; } try { - const { shutdownCodexEnvironment } = await import('../mcp-integrations/codexEnvironment.js'); + const { shutdownCodexEnvironment } = + await import('../mcp-integrations/codexEnvironment.js'); await shutdownCodexEnvironment(); } catch (err) { - log.warn('shutdownCodexEnvironment on contacts toggle failed — cached spawn config still stale', { - error: err instanceof Error ? err.message : String(err), - }); + log.warn( + 'shutdownCodexEnvironment on contacts toggle failed — cached spawn config still stale', + { + error: err instanceof Error ? err.message : String(err), + }, + ); throw err; } }, }); for (const [channel, handler] of Object.entries(handlers)) { - ipcMain.handle(channel, (_e, ...args) => handler(...args)); + ipcMain.handle(channel, (event, ...args) => { + assertTrustedAppRendererEvent(event); + return handler(...args); + }); } + onContactsDeviceSyncStatusChanged(broadcastContactsSyncStatus); } diff --git a/apps/desktop/src/main/mcp-integrations/mcp-providers.ts b/apps/desktop/src/main/mcp-integrations/mcp-providers.ts index c8c2d2c7c2d..0fa2a8ccb51 100644 --- a/apps/desktop/src/main/mcp-integrations/mcp-providers.ts +++ b/apps/desktop/src/main/mcp-integrations/mcp-providers.ts @@ -37,7 +37,7 @@ import { import { isIpcError } from '../../shared/ipc-errors.js'; import type { PluginRegistry } from '../maker-host/plugins/plugin-registry.js'; import { getDesktopContactsManager } from '../maker-host/maker-contacts-host.js'; -import { broadcastContactsChanged } from '../maker-ipc/contacts-ipc.js'; +import { broadcastContactsChanged } from '../maker-host/contacts-change-broadcast.js'; import { readContactsSettings } from '../maker-host/contacts-settings-store.js'; import { readSystemContacts, writeSystemContacts } from '../maker-host/system-contacts.js'; import { BUILTIN_LIZI_MCP_IDS, pluginIdForProviderName } from '../maker-host/plugins/builtin-plugins.js'; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c38fcceb817..70d2030ebfe 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -4673,6 +4673,12 @@ contextBridge.exposeInMainWorld('electronAPI', { // codexMcpRefreshed:false = 开关已落盘但 Codex 失效失败(会话正忙), 对 Codex 延迟生效 settingsSet: (enabled: boolean): Promise<{ enabled: boolean; codexMcpRefreshed?: boolean }> => ipcRenderer.invoke('maker:contacts:settings:set', enabled), + syncStatusGet: (): Promise => + ipcRenderer.invoke('maker:contacts:sync:status:get'), + syncEnabledSet: (enabled: boolean): Promise => + ipcRenderer.invoke('maker:contacts:sync:enabled:set', enabled), + syncNow: (): Promise => + ipcRenderer.invoke('maker:contacts:sync:now'), list: (opts?: unknown): Promise => ipcRenderer.invoke('maker:contacts:list', opts), get: (id: string): Promise => ipcRenderer.invoke('maker:contacts:get', id), create: (input: unknown): Promise => ipcRenderer.invoke('maker:contacts:create', input), @@ -4717,6 +4723,7 @@ contextBridge.exposeInMainWorld('electronAPI', { import: (records: unknown[], opts?: { groupId?: string }): Promise => ipcRenderer.invoke('maker:contacts:import', records, opts), onChanged: createIpcFanOut('maker:contacts:changed'), + onSyncStatusChanged: createIpcFanOut('maker:contacts:sync:status-changed'), }, // Codex app-server 当前进程启动冻结的鉴权注入方式(oauth-bearer = 走订阅 / env-key = 走网关 / provider-oauth = proxy 注入供应商 OAuth)。 diff --git a/apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx b/apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx index 836e7f0d4d4..476a5a284ef 100644 --- a/apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx @@ -9,7 +9,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as Dialog from '@radix-ui/react-dialog'; -import { Import, X } from 'lucide-react'; +import { Import, RefreshCw, X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { toast } from '@/lib/toast'; @@ -21,6 +21,7 @@ import { type ContactProfile, type ContactSummary, type ContactGroupWithCount, + type ContactsDeviceSyncStatus, type ContactsStats, } from '@/lib/contactsService'; import { ContactsListPane, type ContactsFilter } from './ContactsListPane'; @@ -37,9 +38,21 @@ interface Props { onOpenChange: (open: boolean) => void; /** "让 AI 整理"引导(空列表态展示): 由 ContactsSection 注入, 会关闭本浮层并跳新会话草稿 */ onAiOrganize?: () => void; + syncStatus?: ContactsDeviceSyncStatus | null; + syncPending?: boolean; + syncSummary?: string; + onSyncNow?: () => void; } -export function ContactsManagerDialog({ open, onOpenChange, onAiOrganize }: Props) { +export function ContactsManagerDialog({ + open, + onOpenChange, + onAiOrganize, + syncStatus, + syncPending = false, + syncSummary, + onSyncNow, +}: Props) { const { t } = useTranslation(); const { confirm } = useConfirmDialog(); @@ -242,9 +255,52 @@ export function ContactsManagerDialog({ open, onOpenChange, onAiOrganize }: Prop {t('settings.contacts.manager.title')} {statsLine && ( -

{statsLine}

+

+ {statsLine} +

+ )} + {syncSummary && ( +

+ {syncSummary} + {syncStatus?.enabled && syncStatus.onlineDeviceCount > 0 + ? ` · ${t('settings.contacts.sync.onlineDevices', { + count: syncStatus.onlineDeviceCount, + })}` + : ''} +

)} + {syncStatus?.enabled && onSyncNow && ( + + )} + )} + void handleSyncToggle(value)} + aria-label={t('settings.contacts.sync.toggleAria')} + /> + + + {/* 首次引导: 开启后库还是空的 → 三源起步(邮件 / IM 走预填草稿的 AI 会话, 系统通讯录/vCard 走既有导入弹窗), 让用户第一天就有一个非空的库 */} {enabled && stats && stats.people + stats.orgs === 0 && ( @@ -232,6 +398,10 @@ export function ContactsSection() { void handleSyncNow()} {...(enabled ? { onAiOrganize: startAiSession } : {})} /> diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index dd4eb739b6a..9fdb1cfb18a 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -3242,12 +3242,42 @@ }, "contacts": { "title": "Smart Contacts", - "description": "A people and organization directory built for agents: cross-platform identity resolution (email / Feishu / Slack / GitHub id → who is this), relationship context, and dated event records. Agents collect entries as they work; low-confidence ones land in a pending queue for your review. Data is stored on this device only; lookups and organizing are processed through your own model.", + "description": "A people and organization directory built for agents: cross-platform identity resolution (email / Feishu / Slack / GitHub id → who is this), relationship context, and dated event records. Agents collect entries as they work; low-confidence ones land in a pending queue for your review. Data stays on this device by default, with optional device sync; lookups and organizing are processed through your own model.", "enable": { "label": "Enable Smart Contacts", "description": "When on, agents can look up and automatically collect contact information (uncertain entries go to pending review). Turning it off does not affect browsing or managing existing data here.", "toggleAria": "Toggle Smart Contacts" }, + "sync": { + "label": "Sync across my devices", + "description": "Enable once on each desktop you want to include. Online devices sync automatically, connecting directly on the same local network when possible or using end-to-end encrypted relay data. Comparison is programmatic, with no model or token usage.", + "toggleAria": "Toggle contact device sync", + "syncNow": "Sync now", + "onlineDevices_one": "{{count}} other device online", + "onlineDevices_other": "{{count}} other devices online", + "route": { + "lan": "Local network", + "relay": "Encrypted relay" + }, + "status": { + "loading": "Reading sync status…", + "signInRequired": "Sign in to your Cindy account to sync between devices", + "off": "Sync is off", + "waiting": "Waiting for another sync-enabled desktop to come online", + "syncing": "Syncing securely…", + "ready": "Ready — devices will sync when they come online", + "lastSuccess": "{{device}} · {{time}} · {{route}}" + }, + "error": { + "secure-storage-unavailable": "System secure storage is unavailable, so the device key cannot be protected", + "peer-identity-changed": "A device identity changed; sync is paused to protect your data", + "sync-failed": "Sync failed and will retry when a device is next online" + }, + "toast": { + "enabled": "Device sync enabled", + "disabled": "Device sync disabled" + } + }, "stats": "{{people}} people / {{orgs}} orgs / {{groups}} groups", "kind": { "person": "Person", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 98956ff75ec..9cb8e9b5d72 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -3241,12 +3241,41 @@ }, "contacts": { "title": "スマート連絡先", - "description": "エージェントのための人物・組織データベース。プラットフォーム横断の身元照合(メール / Feishu / Slack / GitHub id → この人は誰か)、関係性の背景、日付付きイベント記録を備えています。エージェントが作業中に自動で収集し、確度の低い項目は確認待ちキューに入ります。データの保存先はこの端末のみで、検索や整理はお使いのモデルを通じて処理されます。", + "description": "エージェントのための人物・組織データベース。プラットフォーム横断の身元照合(メール / Feishu / Slack / GitHub id → この人は誰か)、関係性の背景、日付付きイベント記録を備えています。エージェントが作業中に自動で収集し、確度の低い項目は確認待ちキューに入ります。データは既定ではこの端末だけに保存され、必要に応じてデバイス間同期を有効にできます。検索や整理はお使いのモデルを通じて処理されます。", "enable": { "label": "スマート連絡先を有効にする", "description": "オンにすると、エージェントが連絡先情報を検索・自動収集できます(不確かな項目は確認待ちへ)。オフにしても、ここでの閲覧や管理には影響しません。", "toggleAria": "スマート連絡先を切り替え" }, + "sync": { + "label": "自分のデバイス間で同期", + "description": "参加させる各デスクトップで一度有効にします。オンラインのデバイスは自動同期し、同じローカルネットワークでは直接接続を優先します。それ以外はエンドツーエンド暗号化された中継データを使用します。比較はプログラム処理のみで、モデルや token は消費しません。", + "toggleAria": "連絡先のデバイス同期を切り替え", + "syncNow": "今すぐ同期", + "onlineDevices_other": "ほかのデバイス {{count}} 台がオンライン", + "route": { + "lan": "ローカルネットワーク", + "relay": "暗号化中継" + }, + "status": { + "loading": "同期状態を確認中…", + "signInRequired": "デバイス間で同期するには Cindy アカウントにログインしてください", + "off": "同期はオフです", + "waiting": "同期を有効にしたほかのデスクトップがオンラインになるのを待っています", + "syncing": "安全に同期中…", + "ready": "準備完了。デバイスがオンラインになると自動同期します", + "lastSuccess": "{{device}} · {{time}} · {{route}}" + }, + "error": { + "secure-storage-unavailable": "システムの安全なストレージを利用できないため、デバイス秘密鍵を保護できません", + "peer-identity-changed": "デバイスの識別情報が変わったため、データ保護のため同期を停止しました", + "sync-failed": "同期に失敗しました。次にデバイスがオンラインになったとき再試行します" + }, + "toast": { + "enabled": "デバイス同期を有効にしました", + "disabled": "デバイス同期を無効にしました" + } + }, "stats": "{{people}} 人 / {{orgs}} 組織 / {{groups}} グループ", "kind": { "person": "人物", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 15358043dc5..3bd678f9d11 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -3241,12 +3241,41 @@ }, "contacts": { "title": "스마트 연락처", - "description": "에이전트를 위한 인물·조직 데이터베이스입니다. 플랫폼 간 신원 조회(이메일 / Feishu / Slack / GitHub id → 이 사람이 누구인지), 관계 배경, 날짜별 이벤트 기록을 제공합니다. 에이전트가 작업 중 자동으로 수집하며, 확신도가 낮은 항목은 확인 대기 큐로 들어갑니다. 데이터는 이 기기에만 저장되며, 조회와 정리는 사용자의 모델을 통해 처리됩니다.", + "description": "에이전트를 위한 인물·조직 데이터베이스입니다. 플랫폼 간 신원 조회(이메일 / Feishu / Slack / GitHub id → 이 사람이 누구인지), 관계 배경, 날짜별 이벤트 기록을 제공합니다. 에이전트가 작업 중 자동으로 수집하며, 확신도가 낮은 항목은 확인 대기 큐로 들어갑니다. 데이터는 기본적으로 이 기기에만 저장되며 필요할 때 기기 간 동기화를 켤 수 있습니다. 조회와 정리는 사용자의 모델을 통해 처리됩니다.", "enable": { "label": "스마트 연락처 사용", "description": "켜면 에이전트가 연락처 정보를 조회하고 자동으로 수집할 수 있습니다(불확실한 항목은 확인 대기로 이동). 꺼도 여기에서 기존 데이터를 보고 관리하는 데는 영향이 없습니다.", "toggleAria": "스마트 연락처 전환" }, + "sync": { + "label": "내 기기 간 동기화", + "description": "참여할 각 데스크톱에서 한 번 켜세요. 온라인 기기는 자동으로 동기화하며, 같은 로컬 네트워크에서는 직접 연결을 우선합니다. 그 외에는 종단 간 암호화된 릴레이 데이터를 사용합니다. 비교는 프로그램으로만 처리되어 모델이나 token을 사용하지 않습니다.", + "toggleAria": "연락처 기기 동기화 전환", + "syncNow": "지금 동기화", + "onlineDevices_other": "다른 기기 {{count}}대 온라인", + "route": { + "lan": "로컬 네트워크", + "relay": "암호화 릴레이" + }, + "status": { + "loading": "동기화 상태 확인 중…", + "signInRequired": "기기 간 동기화를 사용하려면 Cindy 계정에 로그인하세요", + "off": "동기화 꺼짐", + "waiting": "동기화를 켠 다른 데스크톱 기기가 온라인이 되기를 기다리는 중", + "syncing": "안전하게 동기화 중…", + "ready": "준비됨 — 기기가 온라인이 되면 자동으로 동기화합니다", + "lastSuccess": "{{device}} · {{time}} · {{route}}" + }, + "error": { + "secure-storage-unavailable": "시스템 보안 저장소를 사용할 수 없어 기기 비밀 키를 보호할 수 없습니다", + "peer-identity-changed": "기기 식별 정보가 변경되어 데이터 보호를 위해 동기화를 중지했습니다", + "sync-failed": "동기화에 실패했습니다. 다음에 기기가 온라인이 되면 다시 시도합니다" + }, + "toast": { + "enabled": "기기 동기화를 켰습니다", + "disabled": "기기 동기화를 껐습니다" + } + }, "stats": "{{people}}명 / 조직 {{orgs}}개 / 그룹 {{groups}}개", "kind": { "person": "인물", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 6db415732bf..1c452dfcad7 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -3241,12 +3241,41 @@ }, "contacts": { "title": "智能通讯录", - "description": "为 Agent 建立的人物与组织档案库:跨平台身份反查(邮箱 / 飞书 / Slack / GitHub id → 这是谁)、关系背景与带日期的事件记录。Agent 在工作中自动沉淀,低置信条目进入待确认队列由你裁决。数据只保存在本机,查询与整理会经由你自己的模型处理。", + "description": "为 Agent 建立的人物与组织档案库:跨平台身份反查(邮箱 / 飞书 / Slack / GitHub id → 这是谁)、关系背景与带日期的事件记录。Agent 在工作中自动沉淀,低置信条目进入待确认队列由你裁决。数据默认只保存在本机,也可由你开启设备间同步;查询与整理会经由你自己的模型处理。", "enable": { "label": "启用智能通讯录", "description": "开启后 Agent 可以查询并自动采集人物信息(不确定的条目会进入待确认)。关闭不影响在此浏览和管理已有数据。", "toggleAria": "切换智能通讯录" }, + "sync": { + "label": "在我的设备之间同步", + "description": "在需要参与的每台桌面设备上开启一次。设备在线时自动同步;同一局域网优先直连,否则发送端到端加密的中转数据。纯程序比对,不调用模型,也不产生 token 消耗。", + "toggleAria": "切换通讯录设备同步", + "syncNow": "立即同步", + "onlineDevices_other": "{{count}} 台其他设备在线", + "route": { + "lan": "局域网直连", + "relay": "加密中转" + }, + "status": { + "loading": "正在读取同步状态…", + "signInRequired": "登录 Cindy 账号后可在设备间同步", + "off": "同步已关闭", + "waiting": "等待其他已开启同步的桌面设备上线", + "syncing": "正在安全同步…", + "ready": "已准备好,设备上线后会自动同步", + "lastSuccess": "{{device}} · {{time}} · {{route}}" + }, + "error": { + "secure-storage-unavailable": "系统安全存储不可用,无法保护设备私钥", + "peer-identity-changed": "设备身份发生变化,已暂停同步以保护数据", + "sync-failed": "同步失败,将在设备下次在线时重试" + }, + "toast": { + "enabled": "设备同步已开启", + "disabled": "设备同步已关闭" + } + }, "stats": "{{people}} 人 / {{orgs}} 组织 / {{groups}} 分组", "kind": { "person": "人物", diff --git a/apps/desktop/src/renderer/lib/contactsService.ts b/apps/desktop/src/renderer/lib/contactsService.ts index 699d481c378..e4d9f4ab5c8 100644 --- a/apps/desktop/src/renderer/lib/contactsService.ts +++ b/apps/desktop/src/renderer/lib/contactsService.ts @@ -58,18 +58,43 @@ export type { UpdateContactInput, }; +export interface ContactsDeviceSyncStatus { + available: boolean; + enabled: boolean; + phase: 'off' | 'waiting' | 'syncing' | 'up-to-date' | 'error'; + onlineDeviceCount: number; + lastSyncAt: string | null; + lastSyncDeviceId: string | null; + lastSyncDeviceName: string | null; + lastRoute: 'lan' | 'relay' | null; + errorCode: 'secure-storage-unavailable' | 'peer-identity-changed' | 'sync-failed' | null; +} + export const contactsService = { settingsGet: () => api().settingsGet(), settingsSet: (enabled: boolean) => api().settingsSet(enabled), + syncStatusGet: () => api().syncStatusGet() as Promise, + syncEnabledSet: (enabled: boolean) => + api().syncEnabledSet(enabled) as Promise, + syncNow: () => api().syncNow() as Promise, list: (opts?: ListContactsOptions) => api().list(opts) as Promise, get: (id: string) => api().get(id) as Promise, create: (input: CreateContactInput) => api().create(input) as Promise, - update: (id: string, patch: UpdateContactInput) => api().update(id, patch) as Promise, + update: (id: string, patch: UpdateContactInput) => + api().update(id, patch) as Promise, delete: (id: string) => api().delete(id), - merge: (targetId: string, sourceId: string) => api().merge(targetId, sourceId) as Promise, - search: (query: string, opts?: { kind?: 'person' | 'org'; status?: 'confirmed' | 'pending'; groupId?: string; limit?: number }) => - api().search(query, opts) as Promise, + merge: (targetId: string, sourceId: string) => + api().merge(targetId, sourceId) as Promise, + search: ( + query: string, + opts?: { + kind?: 'person' | 'org'; + status?: 'confirmed' | 'pending'; + groupId?: string; + limit?: number; + }, + ) => api().search(query, opts) as Promise, resolve: (value: string, opts?: { platform?: string; limit?: number }) => api().resolve(value, opts) as Promise, stats: () => api().stats() as Promise, @@ -86,7 +111,8 @@ export const contactsService = { removeRelation: (relationId: string) => api().removeRelation(relationId), groupsList: () => api().groupsList() as Promise, - groupsCreate: (name: string, description?: string) => api().groupsCreate(name, description) as Promise, + groupsCreate: (name: string, description?: string) => + api().groupsCreate(name, description) as Promise, groupsUpdate: (groupId: string, patch: { name?: string; description?: string }) => api().groupsUpdate(groupId, patch) as Promise, groupsDelete: (groupId: string) => api().groupsDelete(groupId), @@ -99,6 +125,8 @@ export const contactsService = { import: (records: ImportContactRecord[], opts?: Pick) => api().import(records, opts) as Promise, onChanged: (cb: () => void) => api().onChanged(cb), + onSyncStatusChanged: (cb: (status: ContactsDeviceSyncStatus) => void) => + api().onSyncStatusChanged((status) => cb(status as ContactsDeviceSyncStatus)), }; /** contacts UI 有专属文案的 IPC 错误码(settings.contacts.ipcError.* 四语言齐备) */ diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index bada6cc6d36..b9d622ab0b8 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -4413,6 +4413,9 @@ interface ElectronAPI { contacts: { settingsGet: () => Promise<{ enabled: boolean; isCustomized: boolean }>; settingsSet: (enabled: boolean) => Promise<{ enabled: boolean; codexMcpRefreshed?: boolean }>; + syncStatusGet: () => Promise; + syncEnabledSet: (enabled: boolean) => Promise; + syncNow: () => Promise; list: (opts?: unknown) => Promise; get: (id: string) => Promise; create: (input: unknown) => Promise; @@ -4441,6 +4444,7 @@ interface ElectronAPI { parseVcf: (text: string) => Promise; import: (records: unknown[], opts?: { groupId?: string }) => Promise; onChanged: (cb: () => void) => () => void; + onSyncStatusChanged: (cb: (status: unknown) => void) => () => void; }; /** Codex app-server 当前进程启动冻结的鉴权注入方式(oauth-bearer = 走订阅 / env-key = 走网关 / provider-oauth = proxy 注入供应商 OAuth) */ diff --git a/apps/desktop/vite.contacts-sync-codec-worker.config.ts b/apps/desktop/vite.contacts-sync-codec-worker.config.ts new file mode 100644 index 00000000000..1b6cccc9d68 --- /dev/null +++ b/apps/desktop/vite.contacts-sync-codec-worker.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + resolve: { + conditions: ['node'], + mainFields: ['module', 'jsnext:main', 'jsnext'], + }, + build: { + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, + }, +}); diff --git a/i18n/GLOSSARY.md b/i18n/GLOSSARY.md index 632e641c036..8290a642f77 100644 --- a/i18n/GLOSSARY.md +++ b/i18n/GLOSSARY.md @@ -160,6 +160,10 @@ OAuth 2.0 Device Authorization Grant 中由用户在另一设备验证页输入 钉钉机器人连接的产品名称,沿用官方品牌写法;先登记为 proposed,待产品术语评审后再决定是否固化。 +### End-to-end encryption + +设备间数据在发送端加密、接收端解密,中转服务只搬运密文。当前先按四语言常用安全术语登记为待讨论,避免 E2EE、端对端加密、End-to-End 暗号化等多套可见说法并存。 + ### Lark Lark 国际版 IM 服务的官方品牌名,四语统一保留原品牌写法;先登记为 proposed,待产品术语评审后再决定是否固化。 diff --git a/i18n/glossary.json b/i18n/glossary.json index 77e37696e24..70bef69aebc 100644 --- a/i18n/glossary.json +++ b/i18n/glossary.json @@ -1000,6 +1000,17 @@ }, "note": "语音输入的用户自定义术语表(人名、产品名、代号与其常见误识别写法),在同账号的电脑之间自动同步、手机只读查看。当前先采用四语直译并登记为待讨论术语,避免与「自定义词典」「术语表」「用户词库」等说法混用。" }, + { + "id": "end-to-end-encryption", + "status": "proposed", + "en": "End-to-end encryption", + "translations": { + "zh-CN": "端到端加密", + "ja": "エンドツーエンド暗号化", + "ko": "종단 간 암호화" + }, + "note": "设备间数据在发送端加密、接收端解密,中转服务只搬运密文。当前先按四语言常用安全术语登记为待讨论,避免 E2EE、端对端加密、End-to-End 暗号化等多套可见说法并存。" + }, { "id": "worker", "status": "decided", diff --git a/packages/device-link/src/__tests__/contactsSyncProtocol.test.ts b/packages/device-link/src/__tests__/contactsSyncProtocol.test.ts new file mode 100644 index 00000000000..991db7b2198 --- /dev/null +++ b/packages/device-link/src/__tests__/contactsSyncProtocol.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { + CONTACTS_SYNC_WIRE_VERSION, + DL_CONTACTS_SYNC_CHANNEL, + isContactsSyncWireFrame, +} from "../contactsSyncProtocol.js"; + +const publicKey = `${"A".repeat(59)}=`; + +describe("contacts sync protocol", () => { + it("pins the channel/version and accepts bounded key/cipher payloads", () => { + expect(DL_CONTACTS_SYNC_CHANNEL).toBe("device-link:contacts:sync:v1"); + expect(CONTACTS_SYNC_WIRE_VERSION).toBe(1); + expect( + isContactsSyncWireFrame({ version: 1, type: "key", publicKey }), + ).toBe(true); + expect( + isContactsSyncWireFrame({ + version: 1, + type: "cipher-chunk", + senderPublicKey: publicKey, + transferId: "transfer-1", + index: 0, + total: 1, + iv: "iv", + tag: "tag", + compression: "gzip", + data: "eA==", + }), + ).toBe(true); + }); + + it("rejects malformed keys and out-of-bounds chunk metadata", () => { + expect( + isContactsSyncWireFrame({ + version: 1, + type: "key", + publicKey: "not-a-key", + }), + ).toBe(false); + expect( + isContactsSyncWireFrame({ + version: 1, + type: "cipher-chunk", + senderPublicKey: publicKey, + transferId: "transfer-1", + index: 1, + total: 1, + iv: "iv", + tag: "tag", + compression: "gzip", + data: "eA==", + }), + ).toBe(false); + }); +}); diff --git a/packages/device-link/src/contactsSyncProtocol.ts b/packages/device-link/src/contactsSyncProtocol.ts new file mode 100644 index 00000000000..a65d84ae908 --- /dev/null +++ b/packages/device-link/src/contactsSyncProtocol.ts @@ -0,0 +1,79 @@ +/** + * Smart Contacts device-sync wire contract shared by every Device Link client. + * + * The relay treats push payloads as opaque, but channel names, versions and + * bounds still belong in the shared protocol layer so another client cannot + * accidentally implement a different frame shape. + */ + +export const CONTACTS_SYNC_WIRE_VERSION = 1; +export const DL_CONTACTS_SYNC_CHANNEL = "device-link:contacts:sync:v1"; + +export const CONTACTS_SYNC_CHUNK_BYTES = 256 * 1024; +export const CONTACTS_SYNC_MAX_CHUNKS = 128; + +export interface ContactsSyncKeyFrame { + version: typeof CONTACTS_SYNC_WIRE_VERSION; + type: "key"; + publicKey: string; +} + +export interface ContactsSyncCipherChunkFrame { + version: typeof CONTACTS_SYNC_WIRE_VERSION; + type: "cipher-chunk"; + senderPublicKey: string; + transferId: string; + index: number; + total: number; + iv: string; + tag: string; + compression: "gzip"; + data: string; +} + +export type ContactsSyncWireFrame = + ContactsSyncKeyFrame | ContactsSyncCipherChunkFrame; + +/** Protocol-level shape check. Hosts still parse the DER key before crypto use. */ +export function isContactsSyncWireFrame( + value: unknown, +): value is ContactsSyncWireFrame { + if (!isRecord(value) || value.version !== CONTACTS_SYNC_WIRE_VERSION) + return false; + if (value.type === "key") return isX25519SpkiBase64Shape(value.publicKey); + if (value.type !== "cipher-chunk") return false; + return ( + isX25519SpkiBase64Shape(value.senderPublicKey) && + isBoundedText(value.transferId, 128) && + Number.isInteger(value.index) && + (value.index as number) >= 0 && + Number.isInteger(value.total) && + (value.total as number) >= 1 && + (value.total as number) <= CONTACTS_SYNC_MAX_CHUNKS && + (value.index as number) < (value.total as number) && + isBoundedText(value.iv, 64) && + isBoundedText(value.tag, 64) && + value.compression === "gzip" && + isBoundedText( + value.data, + Math.ceil((CONTACTS_SYNC_CHUNK_BYTES * 4) / 3) + 8, + ) + ); +} + +/** Node exports an X25519 SPKI public key as 44 DER bytes = 60 base64 chars. */ +function isX25519SpkiBase64Shape(value: unknown): value is string { + return ( + typeof value === "string" && + value.length === 60 && + /^(?:[A-Za-z0-9+/]{4}){14}[A-Za-z0-9+/]{3}=$/.test(value) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isBoundedText(value: unknown, max: number): value is string { + return typeof value === "string" && value.length > 0 && value.length <= max; +} diff --git a/packages/device-link/src/index.ts b/packages/device-link/src/index.ts index de9861cd125..32421458040 100644 --- a/packages/device-link/src/index.ts +++ b/packages/device-link/src/index.ts @@ -13,3 +13,4 @@ export * from './client.js'; export * from './transport.js'; export * from './topics.js'; export * from './attachmentOssRef.js'; +export * from './contactsSyncProtocol.js'; diff --git a/packages/maker-core/package.json b/packages/maker-core/package.json index ef173f06de1..9dc27bc1613 100644 --- a/packages/maker-core/package.json +++ b/packages/maker-core/package.json @@ -7,7 +7,8 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./contacts-sync-worker": "./src/contacts/sync/worker-api.ts" }, "scripts": { "build": "tsc --noEmit", diff --git a/packages/maker-core/src/contacts/__tests__/contactsSync.test.ts b/packages/maker-core/src/contacts/__tests__/contactsSync.test.ts new file mode 100644 index 00000000000..660d122675f --- /dev/null +++ b/packages/maker-core/src/contacts/__tests__/contactsSync.test.ts @@ -0,0 +1,764 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import DatabaseCtor from "better-sqlite3"; +import type Database from "better-sqlite3"; + +import type { Logger } from "../../interfaces/logger.js"; +import { MakerContactsStore } from "../store.js"; +import { + createContactsSyncDelta, + mergeContactsSyncStates, +} from "../sync/merge.js"; +import { materializeContactsSyncState } from "../sync/materialize.js"; +import { + CONTACTS_SYNC_MAX_ROWS_PER_TABLE, + isValidContactsSyncState, +} from "../sync/validation.js"; +import { createEmptyContactsSyncState } from "../sync/types.js"; + +function noopLogger(): Logger { + const noop = () => {}; + const logger: Logger = { + trace: noop, + debug: noop, + info: noop, + warn: noop, + error: noop, + fatal: noop, + child: () => logger, + }; + return logger; +} + +describe("contacts device sync", () => { + const databases: Database.Database[] = []; + + afterEach(() => { + for (const db of databases.splice(0)) db.close(); + }); + + function createStore(config?: { maxIdentityValueLen: number }): MakerContactsStore { + const db = new DatabaseCtor(":memory:"); + databases.push(db); + const store = new MakerContactsStore({ db, logger: noopLogger(), config }); + store.init(); + return store; + } + + function stateOf(store: MakerContactsStore) { + const state = store.readDeviceSyncState(); + expect(state).not.toBeNull(); + return state!; + } + + function exchange( + target: MakerContactsStore, + source: MakerContactsStore, + ): void { + target.mergeDeviceSyncState(stateOf(source)); + } + + function failFtsRebuild(store: MakerContactsStore): () => void { + const fts = ( + store as unknown as { + fts: { rebuild: (docs: readonly unknown[]) => void }; + } + ).fts; + const original = fts.rebuild; + fts.rebuild = () => { + throw new Error("transient fts failure"); + }; + return () => { + fts.rebuild = original; + }; + } + + it("三台设备沿任意在线路径传播后最终一致", () => { + const a = createStore(); + const b = createStore(); + const c = createStore(); + a.activateDeviceSync(); + b.activateDeviceSync(); + c.activateDeviceSync(); + + const person = a.createContact({ + kind: "person", + displayName: "林一", + summary: "A 创建", + }); + exchange(b, a); + b.updateContact(person.id, { agentNotes: "B 补充" }); + exchange(c, b); + c.appendEvent(person.id, { date: "2026-07-31", text: "C 记录事件" }); + + exchange(a, c); + exchange(b, a); + exchange(c, b); + + for (const store of [a, b, c]) { + const profile = store.getContact(person.id); + expect(profile.summary).toBe("A 创建"); + expect(profile.agentNotes).toBe("B 补充"); + expect(profile.events.map((event) => event.text)).toContain("C 记录事件"); + } + expect(stateOf(a)).toEqual(stateOf(b)); + expect(stateOf(b)).toEqual(stateOf(c)); + }); + + it("接受小写映射扩展后仍在统一边界内的身份值", () => { + const store = createStore(); + const contact = store.createContact({ + kind: "person", + displayName: "Unicode Identity", + }); + store.addIdentity(contact.id, { + platform: "custom", + value: "İ".repeat(320), + }); + + const state = store.activateDeviceSync(); + expect(state.identities[0]?.value.value.normalizedValue).toHaveLength(640); + expect(isValidContactsSyncState(state)).toBe(true); + }); + + it("自定义身份长度配置不能放宽同步协议上限", () => { + const store = createStore({ maxIdentityValueLen: 400 }); + const contact = store.createContact({ + kind: "person", + displayName: "Configured Identity Limit", + }); + + expect(() => + store.addIdentity(contact.id, { + platform: "custom", + value: "x".repeat(321), + }), + ).toThrow("identity value too long (> 320)"); + }); + + it("状态合并保持幂等、交换和结合", () => { + const stores = [createStore(), createStore(), createStore()]; + for (const store of stores) store.activateDeviceSync(); + stores[0]!.createContact({ kind: "person", displayName: "A" }); + stores[1]!.createContact({ kind: "person", displayName: "B" }); + stores[2]!.createContact({ kind: "org", displayName: "C" }); + const [a, b, c] = stores.map(stateOf); + + expect(mergeContactsSyncStates(a!, a!)).toEqual(a); + expect(mergeContactsSyncStates(a!, b!)).toEqual( + mergeContactsSyncStates(b!, a!), + ); + expect( + mergeContactsSyncStates(mergeContactsSyncStates(a!, b!), c!), + ).toEqual(mergeContactsSyncStates(a!, mergeContactsSyncStates(b!, c!))); + }); + + it("已知对端版本后只发送缺失记录,增量合并结果与全量一致", () => { + const a = createStore(); + const b = createStore(); + a.activateDeviceSync(); + b.activateDeviceSync(); + const first = a.createContact({ kind: "person", displayName: "已同步" }); + const untouched = a.createContact({ + kind: "org", + displayName: "未修改组织", + }); + exchange(b, a); + const bBefore = stateOf(b); + + a.updateContact(first.id, { summary: "只改这一条" }); + const aAfter = stateOf(a); + const delta = createContactsSyncDelta(aAfter, bBefore.clocks); + + expect(delta.contacts.map((contact) => contact.id)).toEqual([first.id]); + expect(delta.contacts.some((contact) => contact.id === untouched.id)).toBe( + false, + ); + expect(mergeContactsSyncStates(bBefore, delta)).toEqual( + mergeContactsSyncStates(bBefore, aAfter), + ); + }); + + it("并发修改不同字段不会整张档案互相覆盖", () => { + const a = createStore(); + const b = createStore(); + a.activateDeviceSync(); + b.activateDeviceSync(); + const person = a.createContact({ kind: "person", displayName: "并发测试" }); + exchange(b, a); + + a.updateContact(person.id, { summary: "来自 A 的简介" }); + b.updateContact(person.id, { agentNotes: "来自 B 的提醒" }); + exchange(a, b); + exchange(b, a); + + expect(a.getContact(person.id).summary).toBe("来自 A 的简介"); + expect(a.getContact(person.id).agentNotes).toBe("来自 B 的提醒"); + expect(b.getContact(person.id)).toMatchObject({ + summary: "来自 A 的简介", + agentNotes: "来自 B 的提醒", + }); + }); + + it("删除胜过离线设备的并发旧档修改,不会复活联系人", () => { + const a = createStore(); + const b = createStore(); + a.activateDeviceSync(); + b.activateDeviceSync(); + const person = a.createContact({ kind: "person", displayName: "待删除" }); + exchange(b, a); + + a.deleteContact(person.id); + b.updateContact(person.id, { summary: "离线期间修改" }); + exchange(a, b); + exchange(b, a); + + expect(() => a.getContact(person.id)).toThrow(/not-found/); + expect(() => b.getContact(person.id)).toThrow(/not-found/); + }); + + it("重复投递幂等,同一身份冲突在不同设备选择相同赢家", () => { + const a = createStore(); + const b = createStore(); + a.activateDeviceSync(); + b.activateDeviceSync(); + a.createContact({ + kind: "person", + displayName: "甲", + identities: [{ platform: "email", value: "same@example.com" }], + }); + b.createContact({ + kind: "person", + displayName: "乙", + identities: [{ platform: "email", value: "same@example.com" }], + }); + + const aState = stateOf(a); + expect(b.mergeDeviceSyncState(aState)).toBe(true); + expect(b.mergeDeviceSyncState(aState)).toBe(false); + exchange(a, b); + + const aHit = a.resolve("same@example.com"); + const bHit = b.resolve("same@example.com"); + expect(aHit).toHaveLength(1); + expect(bHit).toHaveLength(1); + expect(aHit[0]!.profile.id).toBe(bHit[0]!.profile.id); + expect(a.listContacts({ status: "pending" })).toHaveLength(2); + expect(b.listContacts({ status: "pending" })).toHaveLength(2); + }); + + it("FTS 重建失败后重复接收相同状态仍会重试", () => { + const a = createStore(); + const b = createStore(); + a.activateDeviceSync(); + b.activateDeviceSync(); + const person = a.createContact({ kind: "person", displayName: "旧名称" }); + exchange(b, a); + a.updateContact(person.id, { displayName: "同步后的新名称" }); + const remote = stateOf(a); + + const restore = failFtsRebuild(b); + expect(b.mergeDeviceSyncState(remote)).toBe(true); + restore(); + expect(b.getContact(person.id).displayName).toBe("同步后的新名称"); + expect(b.search("同步后的新名称")).toHaveLength(0); + + expect(b.mergeDeviceSyncState(remote)).toBe(false); + expect(b.search("同步后的新名称")[0]?.contactId).toBe(person.id); + }); + + it("启动检查能识别行数相同但内容陈旧的 FTS", () => { + const a = createStore(); + const b = createStore(); + a.activateDeviceSync(); + b.activateDeviceSync(); + const person = a.createContact({ + kind: "person", + displayName: "启动前旧名称", + }); + exchange(b, a); + a.updateContact(person.id, { displayName: "启动后应恢复名称" }); + + const restore = failFtsRebuild(b); + expect(b.mergeDeviceSyncState(stateOf(a))).toBe(true); + restore(); + const db = databases.at(-1)!; + expect(db.prepare(`SELECT COUNT(*) AS count FROM contacts`).get()).toEqual({ + count: 1, + }); + expect( + db.prepare(`SELECT COUNT(*) AS count FROM contacts_fts`).get(), + ).toEqual({ count: 1 }); + + const restarted = new MakerContactsStore({ db, logger: noopLogger() }); + restarted.init(); + expect(restarted.search("启动后应恢复名称")[0]?.contactId).toBe(person.id); + }); + + it("worker 合并在 FTS 重建失败时原子回滚主表和同步状态", () => { + const a = createStore(); + const b = createStore(); + a.prepareDeviceSyncStateForTransfer(); + b.prepareDeviceSyncStateForTransfer(); + const person = a.createContact({ + kind: "person", + displayName: "必须原子落库", + }); + const remote = a.prepareDeviceSyncStateForTransfer().state; + + const restore = failFtsRebuild(b); + expect(() => b.mergeDeviceSyncStateForTransfer(remote)).toThrow( + /transient fts failure/, + ); + restore(); + expect(b.listContacts()).toHaveLength(0); + + expect(b.mergeDeviceSyncStateForTransfer(remote)).toBe(true); + expect(b.search("必须原子落库")[0]?.contactId).toBe(person.id); + }); + + it("分组成员移出后可以重新加入,并把后续再次移出同步给其他设备", () => { + const a = createStore(); + const b = createStore(); + a.activateDeviceSync(); + b.activateDeviceSync(); + const person = a.createContact({ kind: "person", displayName: "分组成员" }); + const group = a.createGroup("项目组"); + a.addToGroup(group.id, [person.id]); + exchange(b, a); + + a.removeFromGroup(group.id, [person.id]); + exchange(b, a); + expect(b.getContact(person.id).groups).toEqual([]); + + b.addToGroup(group.id, [person.id]); + exchange(a, b); + expect(a.getContact(person.id).groups.map((item) => item.id)).toEqual([ + group.id, + ]); + + a.removeFromGroup(group.id, [person.id]); + exchange(b, a); + expect(b.getContact(person.id).groups).toEqual([]); + }); + + it("保留仅大小写不同的合法分组及各自成员", () => { + const a = createStore(); + const b = createStore(); + a.activateDeviceSync(); + b.activateDeviceSync(); + const person = a.createContact({ kind: "person", displayName: "分组成员" }); + const upper = a.createGroup("A"); + const lower = a.createGroup("a"); + a.addToGroup(upper.id, [person.id]); + a.addToGroup(lower.id, [person.id]); + + exchange(b, a); + + expect(b.listGroups().map((group) => group.name)).toEqual(["A", "a"]); + expect( + b + .getContact(person.id) + .groups.map((group) => group.name) + .sort(), + ).toEqual(["A", "a"]); + }); + + it("本地赢家改名后无需对端回包也会重新物化此前隐藏的分组", () => { + const a = createStore(); + const b = createStore(); + a.activateDeviceSync(); + b.activateDeviceSync(); + a.createGroup("并发同名"); + b.createGroup("并发同名"); + + exchange(a, b); + exchange(b, a); + expect(a.listGroups()).toHaveLength(1); + expect(b.listGroups()).toHaveLength(1); + + const visibleWinner = a.listGroups()[0]!; + a.updateGroup(visibleWinner.id, { name: "赢家已改名" }); + const ftsRebuild = vi.spyOn( + (a as unknown as { fts: { rebuild: (docs: readonly unknown[]) => void } }) + .fts, + "rebuild", + ); + stateOf(a); + expect(ftsRebuild).toHaveBeenCalledTimes(1); + expect( + a + .listGroups() + .map((group) => group.name) + .sort(), + ).toEqual(["并发同名", "赢家已改名"]); + }); + + it("本地赢家删除后无需对端回包也会重新物化此前隐藏的分组", () => { + const a = createStore(); + const b = createStore(); + a.activateDeviceSync(); + b.activateDeviceSync(); + a.createGroup("并发同名"); + b.createGroup("并发同名"); + exchange(a, b); + exchange(b, a); + + const visibleWinner = a.listGroups()[0]!; + a.deleteGroup(visibleWinner.id); + stateOf(a); + + expect(a.listGroups()).toHaveLength(1); + expect(a.listGroups()[0]).toMatchObject({ name: "并发同名" }); + expect(a.listGroups()[0]!.id).not.toBe(visibleWinner.id); + }); + + it("同步接受并保留本地合法的长关系备注", () => { + const a = createStore(); + const b = createStore(); + const person = a.createContact({ kind: "person", displayName: "成员" }); + const org = a.createContact({ kind: "org", displayName: "组织" }); + const note = "长".repeat(16_385); + a.addRelation(person.id, { toId: org.id, relation: "任职", note }); + a.activateDeviceSync(); + b.activateDeviceSync(); + + expect(isValidContactsSyncState(stateOf(a))).toBe(true); + exchange(b, a); + expect(b.getContact(person.id).relations[0]?.note).toBe(note); + }); + + it("同步接受本地未设长度上限的身份、事件与分组文本", () => { + const a = createStore(); + const b = createStore(); + const label = "标".repeat(1_001); + const identityNote = "注".repeat(10_001); + const eventSource = "源".repeat(1_001); + const groupDescription = "组".repeat(16_385); + const person = a.createContact({ + kind: "person", + displayName: "长字段成员", + identities: [ + { + platform: "email", + value: "long-fields@example.com", + label, + note: identityNote, + }, + ], + }); + a.appendEvent(person.id, { + date: "2026-07-31", + text: "长来源事件", + source: eventSource, + }); + const group = a.createGroup("长描述组", groupDescription); + a.activateDeviceSync(); + b.activateDeviceSync(); + + expect(isValidContactsSyncState(stateOf(a))).toBe(true); + exchange(b, a); + const synced = b.getContact(person.id); + expect(synced.identities[0]).toMatchObject({ label, note: identityNote }); + expect(synced.events[0]?.source).toBe(eventSource); + expect( + b.listGroups().find((candidate) => candidate.id === group.id) + ?.description, + ).toBe(groupDescription); + }); + + it("联系人合并产生超过默认上限的合法身份后仍可激活并重读同步状态", () => { + const store = createStore(); + const identities = (prefix: string) => + Array.from({ length: 30 }, (_, index) => ({ + platform: "email", + value: `${prefix}-${index}@example.com`, + })); + const target = store.createContact({ + kind: "person", + displayName: "目标联系人", + identities: identities("target"), + }); + const source = store.createContact({ + kind: "person", + displayName: "来源联系人", + identities: identities("source"), + }); + store.merge(target.id, source.id); + expect(store.getContact(target.id).identities).toHaveLength(60); + + store.activateDeviceSync(); + expect(stateOf(store).identities).toHaveLength(60); + expect(store.getContact(target.id).identities).toHaveLength(60); + }); + + it("首次激活会纳入已有数据,之后能补记未经过 facade 的崩溃窗口写入", () => { + const store = createStore(); + const person = store.createContact({ + kind: "person", + displayName: "激活前已有", + }); + const initial = store.activateDeviceSync(); + expect(initial.contacts.some((contact) => contact.id === person.id)).toBe( + true, + ); + + const db = databases[0]!; + db.prepare( + `UPDATE contacts SET summary = ?, updated_at = ? WHERE id = ?`, + ).run("直接写入后的恢复", "2026-07-31T12:00:00.000Z", person.id); + const repaired = stateOf(store); + const synced = repaired.contacts.find( + (contact) => contact.id === person.id, + ); + expect(synced?.summary.value).toBe("直接写入后的恢复"); + }); + + it("深度校验拒绝畸形远端状态且不改本地数据", () => { + const store = createStore(); + store.activateDeviceSync(); + const person = store.createContact({ + kind: "person", + displayName: "安全边界", + }); + const before = stateOf(store); + const poisoned = structuredClone(before) as unknown as { + identities: Array<{ id: string; value: { value: unknown } }>; + }; + poisoned.identities.push({ + id: "bad", + value: { value: { contactId: person.id, platform: {}, value: "x" } }, + }); + + expect(() => store.mergeDeviceSyncState(poisoned)).toThrow( + /invalid contacts sync state/, + ); + expect(store.getContact(person.id).displayName).toBe("安全边界"); + expect(stateOf(store)).toEqual(before); + }); + + it("合法状态合并后超出 clock 上限时在持久化前拒绝", () => { + const store = createStore(); + store.activateDeviceSync(); + store.createContact({ kind: "person", displayName: "本地联系人" }); + const before = stateOf(store); + const remote = { + ...createEmptyContactsSyncState(), + clocks: Array.from({ length: 256 }, (_, index) => ({ + nodeId: `remote-${index}`, + counter: 1, + })), + }; + expect(isValidContactsSyncState(remote)).toBe(true); + + expect(() => store.mergeDeviceSyncState(remote)).toThrow( + /merged contacts sync state exceeds limits/, + ); + expect(stateOf(store)).toEqual(before); + }); + + it("首次激活捕获超限本地表时回滚且不留下损坏状态", () => { + const store = createStore(); + const person = store.createContact({ + kind: "person", + displayName: "超限联系人", + }); + const db = databases.at(-1)!; + db.prepare( + `WITH RECURSIVE seq(n) AS ( + SELECT 1 + UNION ALL + SELECT n + 1 FROM seq WHERE n < ? + ) + INSERT INTO contact_events(id, contact_id, date, text, source, created_at) + SELECT 'event-' || n, ?, '2026-07-31', '事件', '', '2026-07-31T00:00:00.000Z' + FROM seq`, + ).run(CONTACTS_SYNC_MAX_ROWS_PER_TABLE + 1, person.id); + + expect(() => store.activateDeviceSync()).toThrow( + /contacts sync state exceeds limits/, + ); + expect( + db.prepare(`SELECT COUNT(*) AS count FROM contacts_sync_state`).get(), + ).toEqual({ count: 0 }); + + db.prepare(`DELETE FROM contact_events`).run(); + expect(store.activateDeviceSync().events).toHaveLength(0); + }); + + it("同 stamp 的异常值按规范化 JSON 裁决,不受对象 key 顺序影响", () => { + const stamp = { counter: 1, nodeId: "node-a" }; + const left = { + ...createEmptyContactsSyncState(), + clocks: [{ nodeId: "node-a", counter: 1 }], + groups: [ + { + id: "same-group", + value: { + stamp, + value: { name: "A", description: "", createdAt: "2026-01-01" }, + }, + }, + ], + }; + const right = { + ...createEmptyContactsSyncState(), + clocks: [{ nodeId: "node-a", counter: 1 }], + groups: [ + { + id: "same-group", + value: { + stamp, + value: { createdAt: "2026-01-01", description: "", name: "Z" }, + }, + }, + ], + }; + + expect( + materializeContactsSyncState(mergeContactsSyncStates(left, right)) + .groups[0]?.name, + ).toBe("Z"); + expect( + materializeContactsSyncState(mergeContactsSyncStates(right, left)) + .groups[0]?.name, + ).toBe("Z"); + }); + + it("深度校验要求 clocks 覆盖全部内容 stamp", () => { + const store = createStore(); + store.activateDeviceSync(); + const person = store.createContact({ + kind: "person", + displayName: "时钟覆盖", + }); + stateOf(store); + store.updateContact(person.id, { summary: "第二次写入" }); + const state = stateOf(store); + expect(isValidContactsSyncState(state)).toBe(true); + + const poisoned = structuredClone(state); + const nodeId = poisoned.contacts[0]!.summary.stamp.nodeId; + const clock = poisoned.clocks.find((entry) => entry.nodeId === nodeId)!; + clock.counter = poisoned.contacts[0]!.summary.stamp.counter - 1; + expect(clock.counter).toBeGreaterThan(0); + expect(isValidContactsSyncState(poisoned)).toBe(false); + }); + + it("磁盘 projection 对每张表执行行数上限", () => { + const store = createStore(); + store.activateDeviceSync(); + const db = databases.at(-1)!; + const projection = { + contacts: new Array(CONTACTS_SYNC_MAX_ROWS_PER_TABLE + 1).fill(null), + identities: [], + events: [], + groups: [], + memberships: [], + relations: [], + }; + db.prepare( + `UPDATE contacts_sync_state SET projection_json = ? WHERE singleton = 1`, + ).run(JSON.stringify(projection)); + + expect(() => store.readDeviceSyncState()).toThrow( + /stored contacts sync projection is invalid/, + ); + }); + + it("磁盘 projection 拒绝数组中的畸形行", () => { + const store = createStore(); + store.activateDeviceSync(); + const db = databases.at(-1)!; + const projection = { + contacts: [42], + identities: [], + events: [], + groups: [], + memberships: [], + relations: [], + }; + db.prepare( + `UPDATE contacts_sync_state SET projection_json = ? WHERE singleton = 1`, + ).run(JSON.stringify(projection)); + + expect(() => store.readDeviceSyncState()).toThrow( + /stored contacts sync projection is invalid/, + ); + }); + + it("磁盘 projection 拒绝缺字段或重复 id 且不写入删除墓碑", () => { + const store = createStore(); + const person = store.createContact({ + kind: "person", + displayName: "不能被误删", + }); + store.activateDeviceSync(); + const db = databases.at(-1)!; + const before = db + .prepare(`SELECT state_json FROM contacts_sync_state WHERE singleton = 1`) + .get() as { state_json: string }; + + const incompleteProjection = { + contacts: [{ id: person.id }], + identities: [], + events: [], + groups: [], + memberships: [], + relations: [], + }; + db.prepare( + `UPDATE contacts_sync_state SET projection_json = ? WHERE singleton = 1`, + ).run(JSON.stringify(incompleteProjection)); + expect(() => store.readDeviceSyncState()).toThrow( + /stored contacts sync projection is invalid/, + ); + expect( + ( + db + .prepare( + `SELECT state_json FROM contacts_sync_state WHERE singleton = 1`, + ) + .get() as { state_json: string } + ).state_json, + ).toBe(before.state_json); + + const validProjection = { + contacts: [ + { + id: person.id, + kind: "person", + displayName: "不能被误删", + aliases: [], + summary: "", + narrative: "", + agentNotes: "", + status: "confirmed", + source: "manual", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }, + ], + identities: [], + events: [], + groups: [], + memberships: [], + relations: [], + }; + validProjection.contacts.push({ ...validProjection.contacts[0]! }); + db.prepare( + `UPDATE contacts_sync_state SET projection_json = ? WHERE singleton = 1`, + ).run(JSON.stringify(validProjection)); + expect(() => store.readDeviceSyncState()).toThrow( + /stored contacts sync projection is invalid/, + ); + expect( + ( + db + .prepare( + `SELECT state_json FROM contacts_sync_state WHERE singleton = 1`, + ) + .get() as { state_json: string } + ).state_json, + ).toBe(before.state_json); + }); +}); diff --git a/packages/maker-core/src/contacts/__tests__/store.test.ts b/packages/maker-core/src/contacts/__tests__/store.test.ts index bf9b5f204ce..60f5718969a 100644 --- a/packages/maker-core/src/contacts/__tests__/store.test.ts +++ b/packages/maker-core/src/contacts/__tests__/store.test.ts @@ -2,10 +2,11 @@ * MakerContactsStore 单测 — 用真 better-sqlite3 内存库跑全链路 * (CRUD / 身份唯一约束与冲突 / resolve 三级递降 / FTS 检索 / 事件流 / 分组 / merge / sanity rebuild)。 */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import DatabaseCtor from 'better-sqlite3'; import type Database from 'better-sqlite3'; +import { buildAllFtsDocs, buildFtsDoc } from '../rows.js'; import { MakerContactsStore } from '../store.js'; import { ContactsError } from '../types.js'; import type { Logger } from '../../interfaces/logger.js'; @@ -516,5 +517,46 @@ describe('MakerContactsStore', () => { store2.init(); expect(store2.search('林子航')[0]!.contactId).toBe(p.id); }); + + it('sanity check 使用固定次数的集合查询构建 FTS 投影', () => { + for (let index = 0; index < 50; index += 1) { + store.createContact({ + kind: 'person', + displayName: `批量联系人 ${index}`, + identities: [{ platform: 'email', value: `batch-${index}@example.com` }], + }); + } + + const prepare = vi.spyOn(db, 'prepare'); + const store2 = new MakerContactsStore({ db, logger: noopLogger() }); + store2.init(); + const prepareCount = prepare.mock.calls.length; + prepare.mockRestore(); + + // 初始化允许固定数量的 schema / sanity / 电话规范化查询,但不能退回每联系人 4 次查询。 + expect(prepareCount).toBeLessThan(20); + }); + + it('批量与单联系人 FTS 投影在时间戳并列时保持完全一致', () => { + const person = createNeo(); + const org = store.createContact({ kind: 'org', displayName: '云岚网络' }); + store.appendEvent(person.id, { date: '2026-08-01', text: '上午讨论方案' }); + store.appendEvent(person.id, { date: '2026-08-01', text: '下午确认方案' }); + store.addRelation(person.id, { toId: org.id, relation: '任职' }); + store.addRelation(org.id, { toId: person.id, relation: '合作' }); + + const sameStamp = '2026-08-01T00:00:00.000Z'; + db.prepare(`UPDATE contact_identities SET created_at = ?`).run(sameStamp); + db.prepare(`UPDATE contact_events SET created_at = ?`).run(sameStamp); + db.prepare(`UPDATE contact_relations SET created_at = ?`).run(sameStamp); + + const byContactId = (a: { contactId: string }, b: { contactId: string }) => + a.contactId.localeCompare(b.contactId); + const individual = [person.id, org.id] + .map((contactId) => buildFtsDoc(db, contactId)) + .filter((doc): doc is NonNullable => doc !== null) + .sort(byContactId); + expect(buildAllFtsDocs(db).sort(byContactId)).toEqual(individual); + }); }); }); diff --git a/packages/maker-core/src/contacts/fts.ts b/packages/maker-core/src/contacts/fts.ts index e40d6759d81..baa6a3d9799 100644 --- a/packages/maker-core/src/contacts/fts.ts +++ b/packages/maker-core/src/contacts/fts.ts @@ -216,6 +216,41 @@ export class ContactsFts { return -1; } } + + /** 派生索引须与主表投影逐字段一致;仅比较行数会漏掉陈旧文本。 */ + isConsistent(docs: readonly ContactFtsDoc[]): boolean { + try { + const rows = this.db + .prepare( + `SELECT contact_id AS contactId, kind, status, name, aliases, identities, + summary, narrative, events, relations + FROM ${TABLE} + ORDER BY contact_id COLLATE BINARY`, + ) + .all() as ContactFtsDoc[]; + const expected = [...docs].sort((a, b) => + a.contactId < b.contactId ? -1 : a.contactId > b.contactId ? 1 : 0, + ); + if (rows.length !== expected.length) return false; + return rows.every((row, index) => { + const doc = expected[index]!; + return ( + row.contactId === doc.contactId && + row.kind === doc.kind && + row.status === doc.status && + row.name === doc.name && + row.aliases === doc.aliases && + row.identities === doc.identities && + row.summary === doc.summary && + row.narrative === doc.narrative && + row.events === doc.events && + row.relations === doc.relations + ); + }); + } catch { + return false; + } + } } /** 与 memory/fts.ts 同策略: 整体包 phrase, 内部双引号 escape, 防语法注入 */ diff --git a/packages/maker-core/src/contacts/rows.ts b/packages/maker-core/src/contacts/rows.ts index c54e72f245c..b27a4bd8c75 100644 --- a/packages/maker-core/src/contacts/rows.ts +++ b/packages/maker-core/src/contacts/rows.ts @@ -80,14 +80,14 @@ export function mapEntity(row: ContactRow): ContactEntity { export function listIdentities(db: Database.Database, contactId: string): ContactIdentity[] { const rows = db - .prepare(`SELECT * FROM contact_identities WHERE contact_id = ? ORDER BY created_at`) + .prepare(`SELECT * FROM contact_identities WHERE contact_id = ? ORDER BY created_at, rowid`) .all(contactId) as IdentityRow[]; return rows.map(mapIdentity); } export function listEvents(db: Database.Database, contactId: string): ContactEvent[] { const rows = db - .prepare(`SELECT * FROM contact_events WHERE contact_id = ? ORDER BY date DESC, created_at DESC`) + .prepare(`SELECT * FROM contact_events WHERE contact_id = ? ORDER BY date DESC, created_at DESC, rowid`) .all(contactId) as Array<{ id: string; contact_id: string; @@ -123,15 +123,15 @@ export function listRelations(db: Database.Database, contactId: string): Related .prepare( `SELECT r.id AS relation_id, r.relation, r.note, 'out' AS direction, c.id AS other_id, c.display_name AS other_name, c.kind AS other_kind, - r.created_at AS sort_key + r.created_at AS sort_key, r.rowid AS relation_order FROM contact_relations r JOIN contacts c ON c.id = r.to_id WHERE r.from_id = ? UNION ALL SELECT r.id, r.relation, r.note, 'in', - c.id, c.display_name, c.kind, r.created_at + c.id, c.display_name, c.kind, r.created_at, r.rowid FROM contact_relations r JOIN contacts c ON c.id = r.from_id WHERE r.to_id = ? - ORDER BY sort_key`, + ORDER BY sort_key, relation_order`, ) .all(contactId, contactId) as Array<{ relation_id: string; @@ -179,3 +179,77 @@ export function buildFtsDoc(db: Database.Database, contactId: string): ContactFt relations, }; } + +function appendFtsText(partsByContact: Map, contactId: string, text: string): void { + const parts = partsByContact.get(contactId); + if (parts) { + parts.push(text); + } else { + partsByContact.set(contactId, [text]); + } +} + +/** + * 集合查询版全量 FTS 投影。启动一致性检查与同步后全量重建会走这里,避免对每个 + * contact 重复查询 identities / events / relations;单联系人写路径仍用 buildFtsDoc。 + */ +export function buildAllFtsDocs(db: Database.Database): ContactFtsDoc[] { + const contacts = db.prepare(`SELECT * FROM contacts`).all() as ContactRow[]; + const identitiesByContact = new Map(); + const eventsByContact = new Map(); + const relationsByContact = new Map(); + + const identities = db + .prepare(`SELECT contact_id, value, label FROM contact_identities ORDER BY contact_id, created_at, rowid`) + .all() as Array<{ contact_id: string; value: string; label: string }>; + for (const identity of identities) { + appendFtsText(identitiesByContact, identity.contact_id, `${identity.value} ${identity.label}`.trim()); + } + + const events = db + .prepare( + `SELECT contact_id, date, text FROM contact_events ORDER BY contact_id, date DESC, created_at DESC, rowid`, + ) + .all() as Array<{ contact_id: string; date: string; text: string }>; + for (const event of events) { + appendFtsText(eventsByContact, event.contact_id, `${event.date} ${event.text}`); + } + + const relations = db + .prepare( + `SELECT r.from_id AS contact_id, r.rowid AS relation_order, r.relation, r.note, + c.display_name AS other_name, r.created_at AS sort_key + FROM contact_relations r JOIN contacts c ON c.id = r.to_id + UNION ALL + SELECT r.to_id, r.rowid, r.relation, r.note, + c.display_name, r.created_at + FROM contact_relations r JOIN contacts c ON c.id = r.from_id + ORDER BY contact_id, sort_key, relation_order`, + ) + .all() as Array<{ + contact_id: string; + relation: string; + note: string; + other_name: string; + }>; + for (const relation of relations) { + appendFtsText( + relationsByContact, + relation.contact_id, + `${relation.relation} ${relation.other_name} ${relation.note}`.trim(), + ); + } + + return contacts.map((row) => ({ + contactId: row.id, + kind: row.kind as ContactFtsDoc['kind'], + status: row.status as ContactFtsDoc['status'], + name: row.display_name, + aliases: parseAliases(row.aliases).join(' '), + identities: identitiesByContact.get(row.id)?.join(' ') ?? '', + summary: row.summary, + narrative: row.narrative, + events: eventsByContact.get(row.id)?.join('\n') ?? '', + relations: relationsByContact.get(row.id)?.join('\n') ?? '', + })); +} diff --git a/packages/maker-core/src/contacts/schema.ts b/packages/maker-core/src/contacts/schema.ts index 1da7a5672ec..740d78c68e7 100644 --- a/packages/maker-core/src/contacts/schema.ts +++ b/packages/maker-core/src/contacts/schema.ts @@ -115,6 +115,16 @@ const MIGRATIONS: string[] = [ tokenize='porter unicode61' ); `, + // v3: 设备间同步的状态式 CRDT + 上次本地投影。absent row = 从未开启过同步; + // 开关关闭后保留 row,继续记录本地变化,重新开启时不会漏掉离线编辑。 + ` + CREATE TABLE contacts_sync_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + node_id TEXT NOT NULL, + state_json TEXT NOT NULL, + projection_json TEXT NOT NULL + ); + `, ]; /** diff --git a/packages/maker-core/src/contacts/store.ts b/packages/maker-core/src/contacts/store.ts index 7b1e6106a14..e8d3b3f9dad 100644 --- a/packages/maker-core/src/contacts/store.ts +++ b/packages/maker-core/src/contacts/store.ts @@ -11,7 +11,8 @@ * - db 文件路径与生命周期(manager 创建并持有 close) * - 功能开关(host 设置层)与工具暴露(@cindy/mcps 层) * - * 并发: better-sqlite3 同步 API + 单进程单实例(manager 池保证), 无跨进程写者。 + * 并发: 常规调用由 manager 复用单实例;同步 worker 的跨连接写通过 SQLite + * IMMEDIATE 事务把主表快照与 FTS 更新串成一致提交。 */ import { randomUUID } from 'node:crypto'; @@ -21,6 +22,7 @@ import { findSimilarContacts, loadNameSnapshot, scanDuplicatePairs, type NameSna import { ContactsFts } from './fts.js'; import { ContactsGroupsRepo } from './groups.js'; import { + buildAllFtsDocs, buildFtsDoc, listEvents, listGroupsOf, @@ -33,9 +35,13 @@ import { type IdentityRow, } from './rows.js'; import { initContactsSchema } from './schema.js'; +import { ContactsSyncRepository } from './sync/repository.js'; +import type { ContactsSyncState } from './sync/types.js'; import { ContactsError, DEFAULT_CONTACTS_CONFIG, + getMaxNormalizedIdentityValueLen, + getMaxSyncableIdentityValueLen, isContactKind, isContactSource, isContactStatus, @@ -71,30 +77,39 @@ export interface MakerContactsStoreDeps { db: Database.Database; logger: Logger; config?: Partial; + /** one-shot sync worker 已由 runtime cold prepare 做过维护时可跳过重复全表扫描。 */ + skipStartupMaintenance?: boolean; } export class MakerContactsStore { private readonly db: Database.Database; private readonly fts: ContactsFts; private readonly groupsRepo: ContactsGroupsRepo; + private readonly syncRepo: ContactsSyncRepository; private readonly logger: Logger; private readonly config: ContactsConfig; + private readonly skipStartupMaintenance: boolean; private initialized = false; + private ftsDirty = false; constructor(deps: MakerContactsStoreDeps) { this.db = deps.db; this.fts = new ContactsFts(deps.db); this.logger = deps.logger; this.config = { ...DEFAULT_CONTACTS_CONFIG, ...(deps.config ?? {}) }; + this.skipStartupMaintenance = deps.skipStartupMaintenance ?? false; this.groupsRepo = new ContactsGroupsRepo(deps.db, this.config); + this.syncRepo = new ContactsSyncRepository(deps.db, this.logger.child('sync')); } /** schema 迁移 + FTS sanity check. 幂等 */ init(): void { if (this.initialized) return; initContactsSchema(this.db); - this.sanityCheck(); - this.renormalizePhoneKeys(); + if (!this.skipStartupMaintenance) { + this.sanityCheck(); + this.renormalizePhoneKeys(); + } this.initialized = true; } @@ -139,8 +154,12 @@ export class MakerContactsStore { const value = i.value.trim(); const normalized = normalizeIdentityValue(i.value, platform); if (!normalized) throw new ContactsError('invalid-params', 'identity value must not be empty'); - if (value.length > this.config.maxIdentityValueLen) { - throw new ContactsError('invalid-params', `identity value too long (> ${this.config.maxIdentityValueLen})`); + const identityValueLimit = getMaxSyncableIdentityValueLen(this.config.maxIdentityValueLen); + if (value.length > identityValueLimit) { + throw new ContactsError('invalid-params', `identity value too long (> ${identityValueLimit})`); + } + if (normalized.length > getMaxNormalizedIdentityValueLen(this.config.maxIdentityValueLen)) { + throw new ContactsError('invalid-params', 'normalized identity value too long'); } const key = `${platform}\n${normalized}`; if (seenIdentity.has(key)) continue; @@ -249,11 +268,7 @@ export class MakerContactsStore { const affected = this.relatedContactIds(id); // ON DELETE CASCADE 带走 identities/events/group members/relations this.db.prepare(`DELETE FROM contacts WHERE id = ?`).run(id); - try { - this.fts.delete(id); - } catch (e) { - this.logger.warn('contacts fts delete failed (row removed, rebuild will heal)', { id, error: String(e) }); - } + this.reindexSafe(id); for (const otherId of affected) this.reindexSafe(otherId); } @@ -395,8 +410,12 @@ export class MakerContactsStore { const value = input.value.trim(); const normalized = normalizeIdentityValue(value, platform); if (!normalized) throw new ContactsError('invalid-params', 'identity value must not be empty'); - if (value.length > this.config.maxIdentityValueLen) { - throw new ContactsError('invalid-params', `identity value too long (> ${this.config.maxIdentityValueLen})`); + const identityValueLimit = getMaxSyncableIdentityValueLen(this.config.maxIdentityValueLen); + if (value.length > identityValueLimit) { + throw new ContactsError('invalid-params', `identity value too long (> ${identityValueLimit})`); + } + if (normalized.length > getMaxNormalizedIdentityValueLen(this.config.maxIdentityValueLen)) { + throw new ContactsError('invalid-params', 'normalized identity value too long'); } this.assertIdentityFree(platform, normalized, contactId); const count = ( @@ -556,10 +575,14 @@ export class MakerContactsStore { for (const i of input.identities ?? []) { try { normalizePlatform(i.platform); - if (!normalizeIdentityValue(i.value, i.platform)) throw new ContactsError('invalid-params', 'empty identity value'); - if (i.value.trim().length > this.config.maxIdentityValueLen) { + const normalized = normalizeIdentityValue(i.value, i.platform); + if (!normalized) throw new ContactsError('invalid-params', 'empty identity value'); + if (i.value.trim().length > getMaxSyncableIdentityValueLen(this.config.maxIdentityValueLen)) { throw new ContactsError('invalid-params', 'identity value too long'); } + if (normalized.length > getMaxNormalizedIdentityValueLen(this.config.maxIdentityValueLen)) { + throw new ContactsError('invalid-params', 'normalized identity value too long'); + } validIdentities.push(i); } catch { skippedIdentities.push({ platform: String(i.platform), value: String(i.value), reason: 'invalid' }); @@ -717,11 +740,7 @@ export class MakerContactsStore { }); tx(); - try { - this.fts.delete(sourceId); - } catch (e) { - this.logger.warn('merge: fts delete of source failed', { sourceId, error: String(e) }); - } + this.reindexSafe(sourceId); this.reindexSafe(targetId); for (const otherId of affected) this.reindexSafe(otherId); return { @@ -766,6 +785,71 @@ export class MakerContactsStore { this.groupsRepo.removeFromGroup(groupId, contactIds); } + // ── 设备间同步状态 ────────────────────────────────────────────────────── + + activateDeviceSync(): ContactsSyncState { + return this.activateDeviceSyncWithResult().state; + } + + activateDeviceSyncWithResult(): { + state: ContactsSyncState; + materialized: boolean; + } { + this.init(); + const result = this.syncRepo.activate(); + if (result.materialized || this.ftsDirty) this.rebuildFtsSafe(); + return result; + } + + readDeviceSyncState(): ContactsSyncState | null { + return this.readDeviceSyncStateWithResult()?.state ?? null; + } + + readDeviceSyncStateWithResult(): { + state: ContactsSyncState; + materialized: boolean; + } | null { + this.init(); + const result = this.syncRepo.readState(); + if (result && (result.materialized || this.ftsDirty)) this.rebuildFtsSafe(); + return result; + } + + /** + * Worker 专用:在同一个 IMMEDIATE 事务内协调同步状态与 FTS 投影。 + * 先拿 SQLite 写锁再读取主表,避免另一个连接在快照读取与 FTS 重建之间插入写入。 + */ + prepareDeviceSyncStateForTransfer(): { + state: ContactsSyncState; + materialized: boolean; + } { + this.init(); + const tx = this.db.transaction(() => { + const result = this.syncRepo.readState() ?? this.syncRepo.activate(); + if (result.materialized || this.ftsDirty) this.rebuildFtsStrict(); + return result; + }); + return tx.immediate(); + } + + mergeDeviceSyncState(state: unknown): boolean { + this.init(); + const changed = this.syncRepo.mergeRemoteState(state); + if (changed || this.ftsDirty) this.rebuildFtsSafe(); + return changed; + } + + /** Worker 专用:远端状态、SQLite 投影与 FTS 要么一起提交,要么一起回滚。 */ + mergeDeviceSyncStateForTransfer(state: unknown): boolean { + this.init(); + const tx = this.db.transaction(() => { + const changed = this.syncRepo.mergeRemoteState(state); + if (changed || this.ftsDirty) this.rebuildFtsStrict(); + return changed; + }); + return tx.immediate(); + } + // ── 统计 / 重置 ────────────────────────────────────────────────────────── stats(): ContactsStats { @@ -782,17 +866,19 @@ export class MakerContactsStore { /** 清空整个通讯录(UI 二次确认后调). 慎用 */ resetAll(): { removedCount: number } { this.init(); - const c = (this.db.prepare(`SELECT COUNT(*) AS c FROM contacts`).get() as { c: number }).c; const tx = this.db.transaction(() => { + const c = (this.db.prepare(`SELECT COUNT(*) AS c FROM contacts`).get() as { c: number }).c; this.db.exec(`DELETE FROM contacts; DELETE FROM contact_groups;`); + try { + this.fts.rebuild([]); + this.ftsDirty = false; + } catch { + this.ftsDirty = true; + // 保持既有语义:主表是 source of truth,FTS 失败留给下次 sanity 自愈。 + } + return c; }); - tx(); - try { - this.fts.rebuild([]); - } catch { - /* rebuild on next sanity */ - } - return { removedCount: c }; + return { removedCount: tx.immediate() }; } // ── 内部 ───────────────────────────────────────────────────────────────── @@ -864,12 +950,35 @@ export class MakerContactsStore { this.db.prepare(`UPDATE contacts SET updated_at = ? WHERE id = ?`).run(new Date().toISOString(), contactId); } + private rebuildFtsSafe(): void { + try { + const tx = this.db.transaction(() => this.rebuildFtsStrict()); + tx.immediate(); + } catch (e) { + this.ftsDirty = true; + this.logger.warn('contacts fts rebuild after sync failed (init sanity will heal)', { + error: String(e), + }); + } + } + + private rebuildFtsStrict(): void { + const docs = buildAllFtsDocs(this.db); + this.fts.rebuild(docs); + this.ftsDirty = false; + } + /** 拍平一个 contact 的全部可检索文本, 重建其 FTS 行. 失败只 warn(rebuild 自愈) */ private reindexSafe(contactId: string): void { try { - const doc = buildFtsDoc(this.db, contactId); - if (doc) this.fts.reindex(doc); + const tx = this.db.transaction(() => { + const doc = buildFtsDoc(this.db, contactId); + if (doc) this.fts.reindex(doc); + else this.fts.delete(contactId); + }); + tx.immediate(); } catch (e) { + this.ftsDirty = true; this.logger.warn('contacts fts reindex failed (will heal on next rebuild)', { contactId, error: String(e), @@ -877,17 +986,18 @@ export class MakerContactsStore { } } - /** 主表与 FTS count 不一致 → 全量 rebuild */ + /** 主表投影与 FTS 内容不一致 → 全量 rebuild */ private sanityCheck(): void { - const total = (this.db.prepare(`SELECT COUNT(*) AS c FROM contacts`).get() as { c: number }).c; - const ftsCount = this.fts.count(); - if (ftsCount === -1 || ftsCount !== total) { - this.logger.info('contacts fts inconsistent, rebuilding', { total, ftsCount }); - const ids = this.db.prepare(`SELECT id FROM contacts`).all() as Array<{ id: string }>; - const docs = ids - .map((r) => buildFtsDoc(this.db, r.id)) - .filter((d): d is NonNullable => d !== null); - this.fts.rebuild(docs); - } + const tx = this.db.transaction(() => { + const total = (this.db.prepare(`SELECT COUNT(*) AS c FROM contacts`).get() as { c: number }).c; + const ftsCount = this.fts.count(); + const docs = buildAllFtsDocs(this.db); + if (!this.fts.isConsistent(docs)) { + this.logger.info('contacts fts inconsistent, rebuilding', { total, ftsCount }); + this.fts.rebuild(docs); + } + this.ftsDirty = false; + }); + tx.immediate(); } } diff --git a/packages/maker-core/src/contacts/sync/capture.ts b/packages/maker-core/src/contacts/sync/capture.ts new file mode 100644 index 00000000000..66ff77e0971 --- /dev/null +++ b/packages/maker-core/src/contacts/sync/capture.ts @@ -0,0 +1,212 @@ +/** + * 把 SQLite 在两次观察之间的差异写进 CRDT 状态。 + * + * previous 是上次由同步层确认过的本地投影,而不是直接拿远端状态反推。这样 + * 因唯一约束被确定性隐藏的远端冲突行不会被误判成“本机删除”。 + */ + +import { + compareContactsSyncText, + nextContactsSyncStamp, + stableContactsSyncJson, +} from "./merge.js"; +import { + type ContactsDataSnapshot, + type ContactsSnapshotContact, + type ContactsSyncContact, + type ContactsSyncEntity, + type ContactsSyncStamp, + type ContactsSyncState, + type ContactsStampedValue, +} from "./types.js"; + +function equal(a: unknown, b: unknown): boolean { + return stableContactsSyncJson(a) === stableContactsSyncJson(b); +} + +function stamped( + value: T, + stamp: ContactsSyncStamp, +): ContactsStampedValue { + return { value, stamp }; +} + +function createContact( + row: ContactsSnapshotContact, + stamp: ContactsSyncStamp, +): ContactsSyncContact { + return { + id: row.id, + kind: stamped(row.kind, stamp), + displayName: stamped(row.displayName, stamp), + aliases: stamped(row.aliases, stamp), + summary: stamped(row.summary, stamp), + narrative: stamped(row.narrative, stamp), + agentNotes: stamped(row.agentNotes, stamp), + status: stamped(row.status, stamp), + source: stamped(row.source, stamp), + createdAt: stamped(row.createdAt, stamp), + updatedAt: stamped(row.updatedAt, stamp), + }; +} + +function updateContact( + existing: ContactsSyncContact, + previous: ContactsSnapshotContact, + current: ContactsSnapshotContact, + stamp: ContactsSyncStamp, +): ContactsSyncContact { + const next = { ...existing }; + if (previous.kind !== current.kind) next.kind = stamped(current.kind, stamp); + if (previous.displayName !== current.displayName) + next.displayName = stamped(current.displayName, stamp); + if (!equal(previous.aliases, current.aliases)) + next.aliases = stamped(current.aliases, stamp); + if (previous.summary !== current.summary) + next.summary = stamped(current.summary, stamp); + if (previous.narrative !== current.narrative) + next.narrative = stamped(current.narrative, stamp); + if (previous.agentNotes !== current.agentNotes) + next.agentNotes = stamped(current.agentNotes, stamp); + if (previous.status !== current.status) + next.status = stamped(current.status, stamp); + if (previous.source !== current.source) + next.source = stamped(current.source, stamp); + if (previous.createdAt !== current.createdAt) + next.createdAt = stamped(current.createdAt, stamp); + if (previous.updatedAt !== current.updatedAt) + next.updatedAt = stamped(current.updatedAt, stamp); + return next; +} + +function captureContacts( + state: ContactsSyncContact[], + previous: ContactsSnapshotContact[], + current: ContactsSnapshotContact[], + stamp: ContactsSyncStamp, +): ContactsSyncContact[] { + const records = new Map(state.map((record) => [record.id, record])); + const before = new Map(previous.map((row) => [row.id, row])); + const after = new Map(current.map((row) => [row.id, row])); + for (const id of new Set([...before.keys(), ...after.keys()])) { + const oldRow = before.get(id); + const newRow = after.get(id); + const existing = records.get(id); + if (newRow && !oldRow) { + if (!existing) records.set(id, createContact(newRow, stamp)); + continue; + } + if (newRow && oldRow) { + records.set( + id, + existing + ? updateContact(existing, oldRow, newRow, stamp) + : createContact(newRow, stamp), + ); + continue; + } + if (oldRow && existing && !existing.deleted) { + records.set(id, { ...existing, deleted: stamp }); + } + } + return [...records.values()].sort((a, b) => + compareContactsSyncText(a.id, b.id), + ); +} + +type RowWithId = { id: string }; + +function valueWithoutId(row: T): Omit { + return Object.fromEntries( + Object.entries(row).filter(([key]) => key !== "id"), + ) as Omit; +} + +function captureEntities( + state: Array>>, + previous: T[], + current: T[], + stamp: ContactsSyncStamp, + options: { reusableId?: boolean } = {}, +): Array>> { + const records = new Map(state.map((record) => [record.id, record])); + const before = new Map(previous.map((row) => [row.id, row])); + const after = new Map(current.map((row) => [row.id, row])); + for (const id of new Set([...before.keys(), ...after.keys()])) { + const oldRow = before.get(id); + const newRow = after.get(id); + const existing = records.get(id); + if (newRow && (!oldRow || !equal(oldRow, newRow))) { + records.set(id, { + id, + value: stamped(valueWithoutId(newRow), stamp), + ...(existing?.deleted ? { deleted: existing.deleted } : {}), + }); + continue; + } + if ( + oldRow && + !newRow && + existing && + (!existing.deleted || options.reusableId) + ) { + records.set(id, { ...existing, deleted: stamp }); + } + } + return [...records.values()].sort((a, b) => + compareContactsSyncText(a.id, b.id), + ); +} + +export function captureContactsSnapshot( + state: ContactsSyncState, + previous: ContactsDataSnapshot, + current: ContactsDataSnapshot, + nodeId: string, +): { state: ContactsSyncState; changed: boolean } { + if (equal(previous, current)) return { state, changed: false }; + const next = nextContactsSyncStamp(state, nodeId); + return { + changed: true, + state: { + ...next.state, + contacts: captureContacts( + state.contacts, + previous.contacts, + current.contacts, + next.stamp, + ), + identities: captureEntities( + state.identities, + previous.identities, + current.identities, + next.stamp, + ), + events: captureEntities( + state.events, + previous.events, + current.events, + next.stamp, + ), + groups: captureEntities( + state.groups, + previous.groups, + current.groups, + next.stamp, + ), + memberships: captureEntities( + state.memberships, + previous.memberships, + current.memberships, + next.stamp, + { reusableId: true }, + ), + relations: captureEntities( + state.relations, + previous.relations, + current.relations, + next.stamp, + ), + }, + }; +} diff --git a/packages/maker-core/src/contacts/sync/materialize.ts b/packages/maker-core/src/contacts/sync/materialize.ts new file mode 100644 index 00000000000..8b58e97e79b --- /dev/null +++ b/packages/maker-core/src/contacts/sync/materialize.ts @@ -0,0 +1,170 @@ +/** + * 把合并后的 CRDT 状态物化为 SQLite 可接受的逻辑快照。 + * + * 跨设备并发可能撞上数据库唯一约束(同一身份、同名分组、同一关系边)。 + * 这里按写入 stamp、再按稳定 id 选唯一赢家;未胜出的状态仍保留在 CRDT 中, + * 不会被下一次本地捕获误记成删除,后续可以接入“待确认冲突”界面。 + */ + +import { compareContactsSyncStamp, compareContactsSyncText } from "./merge.js"; +import { + type ContactsDataSnapshot, + type ContactsSnapshotContact, + type ContactsSnapshotEvent, + type ContactsSnapshotGroup, + type ContactsSnapshotIdentity, + type ContactsSnapshotMembership, + type ContactsSnapshotRelation, + type ContactsSyncEntity, + type ContactsSyncState, +} from "./types.js"; + +function byId(a: T, b: T): number { + return compareContactsSyncText(a.id, b.id); +} + +function liveEntities( + records: Array>, +): Array> { + return records.filter((record) => !record.deleted); +} + +/** + * 分组成员使用 (groupId, contactId) 复合键,移出后再加入会复用同一个 id。 + * 因此它与 UUID 实体不同:新增 stamp 晚于删除 stamp 时允许重新出现。 + */ +function liveReusableEntities( + records: Array>, +): Array> { + return records.filter( + (record) => + !record.deleted || + compareContactsSyncStamp(record.value.stamp, record.deleted) > 0, + ); +} + +function preferNewest( + records: Array>, +): Array> { + return [...records].sort((a, b) => { + const stampOrder = compareContactsSyncStamp(b.value.stamp, a.value.stamp); + return stampOrder !== 0 ? stampOrder : compareContactsSyncText(a.id, b.id); + }); +} + +function uniqueBy( + records: Array>, + keyOf: (value: T) => string, +): Array> { + const seen = new Set(); + const winners: Array> = []; + for (const record of preferNewest(records)) { + const key = keyOf(record.value.value); + if (seen.has(key)) continue; + seen.add(key); + winners.push(record); + } + return winners; +} + +export function materializeContactsSyncState( + state: ContactsSyncState, +): ContactsDataSnapshot { + const contacts = state.contacts + .filter((record) => !record.deleted) + .map((record) => ({ + id: record.id, + kind: record.kind.value, + displayName: record.displayName.value, + aliases: record.aliases.value, + summary: record.summary.value, + narrative: record.narrative.value, + agentNotes: record.agentNotes.value, + status: record.status.value, + source: record.source.value, + createdAt: record.createdAt.value, + updatedAt: record.updatedAt.value, + })) + .sort(byId); + const contactIds = new Set(contacts.map((contact) => contact.id)); + + // contact_groups.name 的 UNIQUE 与 ContactsGroupsRepo 都是精确字符串语义; + // A / a 可以合法共存,同步层不能自行收紧成大小写不敏感而吞掉其中一组。 + const groups = uniqueBy(liveEntities(state.groups), (value) => value.name) + .map((record) => ({ + id: record.id, + ...record.value.value, + })) + .sort(byId); + const groupIds = new Set(groups.map((group) => group.id)); + + const identityCandidates = liveEntities(state.identities).filter((record) => + contactIds.has(record.value.value.contactId), + ); + const identityOwners = new Map>(); + for (const record of identityCandidates) { + const value = record.value.value; + const key = `${value.platform}\u0000${value.normalizedValue}`; + const owners = identityOwners.get(key) ?? new Set(); + owners.add(value.contactId); + identityOwners.set(key, owners); + } + const conflictedContactIds = new Set(); + for (const owners of identityOwners.values()) { + if (owners.size <= 1) continue; + for (const contactId of owners) conflictedContactIds.add(contactId); + } + + const identities = uniqueBy( + identityCandidates, + (value) => `${value.platform}\u0000${value.normalizedValue}`, + ) + .map((record) => ({ + id: record.id, + ...record.value.value, + })) + .sort(byId); + + // 同一身份被并发分给不同联系人时,SQLite 只能物化一个确定性赢家;把所有 + // 相关档案标成待确认,避免另一边被隐藏后用户完全不知道发生过冲突。 + for (const contact of contacts) { + if (conflictedContactIds.has(contact.id)) contact.status = "pending"; + } + + const events = liveEntities(state.events) + .filter((record) => contactIds.has(record.value.value.contactId)) + .map((record) => ({ + id: record.id, + ...record.value.value, + })) + .sort(byId); + + const memberships = liveReusableEntities(state.memberships) + .filter( + (record) => + contactIds.has(record.value.value.contactId) && + groupIds.has(record.value.value.groupId), + ) + .map((record) => ({ + id: record.id, + ...record.value.value, + })) + .sort(byId); + + const relations = uniqueBy( + liveEntities(state.relations).filter( + (record) => + record.value.value.fromId !== record.value.value.toId && + contactIds.has(record.value.value.fromId) && + contactIds.has(record.value.value.toId), + ), + (value) => `${value.fromId}\u0000${value.toId}\u0000${value.relation}`, + ) + .map((record) => ({ + id: record.id, + ...record.value.value, + })) + .sort(byId); + + return { contacts, identities, events, groups, memberships, relations }; +} diff --git a/packages/maker-core/src/contacts/sync/merge.ts b/packages/maker-core/src/contacts/sync/merge.ts new file mode 100644 index 00000000000..aa1beea654a --- /dev/null +++ b/packages/maker-core/src/contacts/sync/merge.ts @@ -0,0 +1,239 @@ +/** + * 通讯录同步状态的纯函数合并。 + * + * 合并必须保持幂等、交换、结合。设备链路可以丢帧、重复或乱序,只要任意持有 + * 新状态的设备之后再次在线,N 台设备就会最终收敛。 + */ + +import { + CONTACTS_SYNC_VERSION, + createEmptyContactsSyncState, + type ContactsStampedValue, + type ContactsSyncClock, + type ContactsSyncContact, + type ContactsSyncEntity, + type ContactsSyncStamp, + type ContactsSyncState, +} from "./types.js"; + +/** Locale-independent UTF-16 ordering used anywhere sync output must converge. */ +export function compareContactsSyncText(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** JSON-compatible serialization with recursively sorted object keys. */ +export function stableContactsSyncJson(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + return `[${value.map((entry) => stableContactsSyncJson(entry)).join(",")}]`; + } + const record = value as Record; + const keys = Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort(compareContactsSyncText); + return `{${keys + .map( + (key) => `${JSON.stringify(key)}:${stableContactsSyncJson(record[key])}`, + ) + .join(",")}}`; +} + +export function compareContactsSyncStamp( + a: ContactsSyncStamp, + b: ContactsSyncStamp, +): number { + if (a.counter !== b.counter) return a.counter < b.counter ? -1 : 1; + return a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0; +} + +function maxStamp( + a: ContactsSyncStamp | undefined, + b: ContactsSyncStamp | undefined, +): ContactsSyncStamp | undefined { + if (!a) return b; + if (!b) return a; + return compareContactsSyncStamp(a, b) >= 0 ? a : b; +} + +function mergeStamped( + a: ContactsStampedValue, + b: ContactsStampedValue, +): ContactsStampedValue { + const order = compareContactsSyncStamp(a.stamp, b.stamp); + if (order > 0) return a; + if (order < 0) return b; + // 同 stamp 理论上来自同一次写入;异常状态仍按规范化 JSON 值稳定裁决, + // 不能让对象 key 的输入顺序影响跨设备赢家。 + return stableContactsSyncJson(a.value) >= stableContactsSyncJson(b.value) + ? a + : b; +} + +function mergeContact( + a: ContactsSyncContact, + b: ContactsSyncContact, +): ContactsSyncContact { + return { + id: a.id, + kind: mergeStamped(a.kind, b.kind), + displayName: mergeStamped(a.displayName, b.displayName), + aliases: mergeStamped(a.aliases, b.aliases), + summary: mergeStamped(a.summary, b.summary), + narrative: mergeStamped(a.narrative, b.narrative), + agentNotes: mergeStamped(a.agentNotes, b.agentNotes), + status: mergeStamped(a.status, b.status), + source: mergeStamped(a.source, b.source), + createdAt: mergeStamped(a.createdAt, b.createdAt), + updatedAt: mergeStamped(a.updatedAt, b.updatedAt), + ...(maxStamp(a.deleted, b.deleted) + ? { deleted: maxStamp(a.deleted, b.deleted)! } + : {}), + }; +} + +function mergeById( + a: Array>, + b: Array>, +): Array> { + const merged = new Map>(); + for (const record of [...a, ...b]) { + const existing = merged.get(record.id); + if (!existing) { + merged.set(record.id, record); + continue; + } + const deleted = maxStamp(existing.deleted, record.deleted); + merged.set(record.id, { + id: record.id, + value: mergeStamped(existing.value, record.value), + ...(deleted ? { deleted } : {}), + }); + } + return [...merged.values()].sort((left, right) => + compareContactsSyncText(left.id, right.id), + ); +} + +function mergeContacts( + a: ContactsSyncContact[], + b: ContactsSyncContact[], +): ContactsSyncContact[] { + const merged = new Map(); + for (const contact of [...a, ...b]) { + const existing = merged.get(contact.id); + merged.set( + contact.id, + existing ? mergeContact(existing, contact) : contact, + ); + } + return [...merged.values()].sort((left, right) => + compareContactsSyncText(left.id, right.id), + ); +} + +function mergeClocks( + a: ContactsSyncClock[], + b: ContactsSyncClock[], +): ContactsSyncClock[] { + const clocks = new Map(); + for (const clock of [...a, ...b]) { + clocks.set( + clock.nodeId, + Math.max(clocks.get(clock.nodeId) ?? 0, clock.counter), + ); + } + return [...clocks.entries()] + .map(([nodeId, counter]) => ({ nodeId, counter })) + .sort((left, right) => compareContactsSyncText(left.nodeId, right.nodeId)); +} + +export function mergeContactsSyncStates( + a: ContactsSyncState, + b: ContactsSyncState, +): ContactsSyncState { + if ( + a.version !== CONTACTS_SYNC_VERSION || + b.version !== CONTACTS_SYNC_VERSION + ) { + if (a.version === CONTACTS_SYNC_VERSION) return a; + if (b.version === CONTACTS_SYNC_VERSION) return b; + return createEmptyContactsSyncState(); + } + return { + version: CONTACTS_SYNC_VERSION, + clocks: mergeClocks(a.clocks, b.clocks), + contacts: mergeContacts(a.contacts, b.contacts), + identities: mergeById(a.identities, b.identities), + events: mergeById(a.events, b.events), + groups: mergeById(a.groups, b.groups), + memberships: mergeById(a.memberships, b.memberships), + relations: mergeById(a.relations, b.relations), + }; +} + +export function nextContactsSyncStamp( + state: ContactsSyncState, + nodeId: string, +): { state: ContactsSyncState; stamp: ContactsSyncStamp } { + // 磁盘与远端状态进入仓库前都会验证 clocks 覆盖全部内容 stamp,因此本地编辑 + // 只需扫描至多 256 个设备时钟,不再随联系人总量线性变慢。 + let observedMax = 0; + for (const clock of state.clocks) { + observedMax = Math.max(observedMax, clock.counter); + } + const counter = observedMax + 1; + const clocks = state.clocks.filter((clock) => clock.nodeId !== nodeId); + clocks.push({ nodeId, counter }); + clocks.sort((left, right) => + compareContactsSyncText(left.nodeId, right.nodeId), + ); + return { + state: { ...state, clocks }, + stamp: { counter, nodeId }, + }; +} + +/** + * 根据对端已经观察到的各节点 counter 生成记录级增量。 + * + * 联系人按字段打 stamp,但 wire 上仍发送完整联系人记录;只要任一字段是新的就 + * 纳入增量,接收端继续按字段 merge。这样不会为了一个 summary 修改重发整库, + * 同时保持增量本身仍是合法 ContactsSyncState,可复用同一套校验与合并。 + */ +export function createContactsSyncDelta( + state: ContactsSyncState, + knownClocks: ContactsSyncClock[], +): ContactsSyncState { + const known = new Map( + knownClocks.map((clock) => [clock.nodeId, clock.counter]), + ); + const isNew = (stamp: ContactsSyncStamp | undefined): boolean => + Boolean(stamp && stamp.counter > (known.get(stamp.nodeId) ?? 0)); + const contactIsNew = (contact: ContactsSyncContact): boolean => + isNew(contact.kind.stamp) || + isNew(contact.displayName.stamp) || + isNew(contact.aliases.stamp) || + isNew(contact.summary.stamp) || + isNew(contact.narrative.stamp) || + isNew(contact.agentNotes.stamp) || + isNew(contact.status.stamp) || + isNew(contact.source.stamp) || + isNew(contact.createdAt.stamp) || + isNew(contact.updatedAt.stamp) || + isNew(contact.deleted); + const entityIsNew = (entity: ContactsSyncEntity): boolean => + isNew(entity.value.stamp) || isNew(entity.deleted); + + return { + version: CONTACTS_SYNC_VERSION, + clocks: state.clocks.map((clock) => ({ ...clock })), + contacts: state.contacts.filter(contactIsNew), + identities: state.identities.filter(entityIsNew), + events: state.events.filter(entityIsNew), + groups: state.groups.filter(entityIsNew), + memberships: state.memberships.filter(entityIsNew), + relations: state.relations.filter(entityIsNew), + }; +} diff --git a/packages/maker-core/src/contacts/sync/repository.ts b/packages/maker-core/src/contacts/sync/repository.ts new file mode 100644 index 00000000000..f2c32c23a36 --- /dev/null +++ b/packages/maker-core/src/contacts/sync/repository.ts @@ -0,0 +1,238 @@ +/** + * 同步状态的 SQLite 持久层。 + * + * 同步未激活时表里没有 singleton 行,现有通讯录零额外写放大。首次激活会把 + * 当前库捕获为本设备状态;之后即使用户暂时关闭传输,下次读取也会从上次投影 + * 补记全部离线变化,重新开启时可以完整补发。 + */ + +import { randomUUID } from "node:crypto"; +import type Database from "better-sqlite3"; + +import type { Logger } from "../../interfaces/logger.js"; +import { ContactsError } from "../types.js"; +import { captureContactsSnapshot } from "./capture.js"; +import { materializeContactsSyncState } from "./materialize.js"; +import { mergeContactsSyncStates } from "./merge.js"; +import { readContactsSnapshot, writeContactsSnapshot } from "./snapshot.js"; +import { + createEmptyContactsSnapshot, + createEmptyContactsSyncState, + type ContactsDataSnapshot, + type ContactsSyncState, +} from "./types.js"; +import { + isValidContactsDataSnapshot, + isValidContactsSyncState, +} from "./validation.js"; + +interface PersistedSyncRow { + node_id: string; + state_json: string; + projection_json: string; +} + +export class ContactsSyncRepository { + constructor( + private readonly db: Database.Database, + private readonly logger: Logger, + ) {} + + isActive(): boolean { + return Boolean(this.readRow()); + } + + activate(): { state: ContactsSyncState; materialized: boolean } { + const tx = this.db.transaction(() => { + const existing = this.readRow(); + if (existing) { + const reconciled = this.reconcile(existing); + return { + state: reconciled.state, + materialized: reconciled.materialized, + }; + } + const nodeId = randomUUID(); + const current = readContactsSnapshot(this.db); + const captured = captureContactsSnapshot( + createEmptyContactsSyncState(), + createEmptyContactsSnapshot(), + current, + nodeId, + ); + this.insertRow(nodeId, captured.state, current); + return { state: captured.state, materialized: false }; + }); + return tx(); + } + + readState(): { state: ContactsSyncState; materialized: boolean } | null { + const tx = this.db.transaction(() => { + const row = this.readRow(); + if (!row) return null; + const reconciled = this.reconcile(row); + return { + state: reconciled.state, + materialized: reconciled.materialized, + }; + }); + return tx(); + } + + mergeRemoteState(raw: unknown): boolean { + if (!isValidContactsSyncState(raw)) { + throw new ContactsError("invalid-params", "invalid contacts sync state"); + } + const tx = this.db.transaction(() => { + let row = this.readRow(); + if (!row) { + const nodeId = randomUUID(); + const current = readContactsSnapshot(this.db); + const captured = captureContactsSnapshot( + createEmptyContactsSyncState(), + createEmptyContactsSnapshot(), + current, + nodeId, + ); + this.insertRow(nodeId, captured.state, current); + row = this.readRow(); + } + if (!row) + throw new ContactsError("io-error", "contacts sync activation failed"); + const local = this.reconcile(row); + const merged = mergeContactsSyncStates(local.state, raw); + if (!isValidContactsSyncState(merged)) { + throw new ContactsError( + "invalid-params", + "merged contacts sync state exceeds limits", + ); + } + const projection = materializeContactsSyncState(merged); + const stateUnchanged = + JSON.stringify(merged) === JSON.stringify(local.state); + const projectionUnchanged = + JSON.stringify(projection) === JSON.stringify(local.projection); + // CRDT 状态相同不代表 SQLite 投影一定最新:唯一约束隐藏的并发输家在 + // 赢家后续改名后可能重新可见。只有状态和当前投影都一致才可以幂等返回。 + if (stateUnchanged && projectionUnchanged) return local.materialized; + + writeContactsSnapshot(this.db, projection); + this.updateRow(row.node_id, merged, projection); + return true; + }); + return tx(); + } + + private reconcile(row: PersistedSyncRow): { + state: ContactsSyncState; + projection: ContactsDataSnapshot; + changed: boolean; + materialized: boolean; + } { + const state = this.parseState(row.state_json); + const previous = this.parseProjection(row.projection_json); + const current = readContactsSnapshot(this.db); + const captured = captureContactsSnapshot( + state, + previous, + current, + row.node_id, + ); + // 唯一约束隐藏的并发输家不在 previous/current 中,但仍保留在 CRDT state。 + // 当本地赢家改名或删除解除冲突时,必须立即从新状态重新物化;不能等对端回包。 + const projection = materializeContactsSyncState(captured.state); + const materializationRequired = + JSON.stringify(projection) !== JSON.stringify(current); + const projectionChanged = + JSON.stringify(previous) !== JSON.stringify(projection); + if (materializationRequired) { + writeContactsSnapshot(this.db, projection); + } + if (captured.changed || projectionChanged || materializationRequired) { + this.updateRow(row.node_id, captured.state, projection); + } + return { + state: captured.state, + projection, + changed: captured.changed, + materialized: materializationRequired, + }; + } + + private parseState(json: string): ContactsSyncState { + try { + const value: unknown = JSON.parse(json); + if (isValidContactsSyncState(value)) return value; + } catch { + // 统一落到下面的 fail-closed 错误。 + } + throw new ContactsError( + "io-error", + "stored contacts sync state is invalid", + ); + } + + private parseProjection(json: string): ContactsDataSnapshot { + try { + const value: unknown = JSON.parse(json); + if (isValidContactsDataSnapshot(value)) return value; + } catch { + // 统一落到下面的 fail-closed 错误。 + } + throw new ContactsError( + "io-error", + "stored contacts sync projection is invalid", + ); + } + + private readRow(): PersistedSyncRow | null { + return ( + (this.db + .prepare( + `SELECT node_id, state_json, projection_json + FROM contacts_sync_state WHERE singleton = 1`, + ) + .get() as PersistedSyncRow | undefined) ?? null + ); + } + + private insertRow( + nodeId: string, + state: ContactsSyncState, + projection: ContactsDataSnapshot, + ): void { + this.assertPersistableState(state); + this.db + .prepare( + `INSERT INTO contacts_sync_state(singleton, node_id, state_json, projection_json) + VALUES (1, ?, ?, ?)`, + ) + .run(nodeId, JSON.stringify(state), JSON.stringify(projection)); + this.logger.info("contacts device sync state initialized"); + } + + private updateRow( + nodeId: string, + state: ContactsSyncState, + projection: ContactsDataSnapshot, + ): void { + this.assertPersistableState(state); + this.db + .prepare( + `UPDATE contacts_sync_state + SET node_id = ?, state_json = ?, projection_json = ? + WHERE singleton = 1`, + ) + .run(nodeId, JSON.stringify(state), JSON.stringify(projection)); + } + + /** 所有本地捕获路径共用的最终写盘门,拒绝后由外层事务完整回滚。 */ + private assertPersistableState(state: ContactsSyncState): void { + if (!isValidContactsSyncState(state)) { + throw new ContactsError( + "invalid-params", + "contacts sync state exceeds limits", + ); + } + } +} diff --git a/packages/maker-core/src/contacts/sync/snapshot.ts b/packages/maker-core/src/contacts/sync/snapshot.ts new file mode 100644 index 00000000000..ce6c292dd81 --- /dev/null +++ b/packages/maker-core/src/contacts/sync/snapshot.ts @@ -0,0 +1,225 @@ +/** + * SQLite 主表与同步逻辑快照之间的确定性转换。 + * + * FTS 不进入快照;写回后由 store 从主表全量重建。所有数组按稳定主键排序, + * 既让 diff 可预测,也让不同设备序列化同一状态时得到相同结果。 + */ + +import type Database from "better-sqlite3"; + +import { parseAliases, type ContactRow, type IdentityRow } from "../rows.js"; +import { compareContactsSyncText } from "./merge.js"; +import { + membershipSyncId, + type ContactsDataSnapshot, + type ContactsSnapshotContact, + type ContactsSnapshotEvent, + type ContactsSnapshotGroup, + type ContactsSnapshotIdentity, + type ContactsSnapshotMembership, + type ContactsSnapshotRelation, +} from "./types.js"; + +function byId(a: T, b: T): number { + return compareContactsSyncText(a.id, b.id); +} + +export function readContactsSnapshot( + db: Database.Database, +): ContactsDataSnapshot { + const contacts = (db.prepare(`SELECT * FROM contacts`).all() as ContactRow[]) + .map((row) => ({ + id: row.id, + kind: row.kind as ContactsSnapshotContact["kind"], + displayName: row.display_name, + aliases: parseAliases(row.aliases), + summary: row.summary, + narrative: row.narrative, + agentNotes: row.agent_notes, + status: row.status as ContactsSnapshotContact["status"], + source: row.source as ContactsSnapshotContact["source"], + createdAt: row.created_at, + updatedAt: row.updated_at, + })) + .sort(byId); + + const identities = ( + db.prepare(`SELECT * FROM contact_identities`).all() as IdentityRow[] + ) + .map((row) => ({ + id: row.id, + contactId: row.contact_id, + platform: row.platform, + value: row.value, + normalizedValue: row.normalized_value, + label: row.label, + note: row.note, + createdAt: row.created_at, + })) + .sort(byId); + + const events = ( + db.prepare(`SELECT * FROM contact_events`).all() as Array<{ + id: string; + contact_id: string; + date: string; + text: string; + source: string; + created_at: string; + }> + ) + .map((row) => ({ + id: row.id, + contactId: row.contact_id, + date: row.date, + text: row.text, + source: row.source, + createdAt: row.created_at, + })) + .sort(byId); + + const groups = ( + db.prepare(`SELECT * FROM contact_groups`).all() as Array<{ + id: string; + name: string; + description: string; + created_at: string; + }> + ) + .map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + createdAt: row.created_at, + })) + .sort(byId); + + const memberships = ( + db.prepare(`SELECT * FROM contact_group_members`).all() as Array<{ + group_id: string; + contact_id: string; + }> + ) + .map((row) => ({ + id: membershipSyncId(row.group_id, row.contact_id), + groupId: row.group_id, + contactId: row.contact_id, + })) + .sort(byId); + + const relations = ( + db.prepare(`SELECT * FROM contact_relations`).all() as Array<{ + id: string; + from_id: string; + to_id: string; + relation: string; + note: string; + created_at: string; + }> + ) + .map((row) => ({ + id: row.id, + fromId: row.from_id, + toId: row.to_id, + relation: row.relation, + note: row.note, + createdAt: row.created_at, + })) + .sort(byId); + + return { contacts, identities, events, groups, memberships, relations }; +} + +/** + * 用逻辑快照替换通讯录主表。调用者必须放在事务中,并在成功后重建 FTS。 + */ +export function writeContactsSnapshot( + db: Database.Database, + snapshot: ContactsDataSnapshot, +): void { + db.exec(`DELETE FROM contacts; DELETE FROM contact_groups;`); + + const insertContact = db.prepare( + `INSERT INTO contacts( + id, kind, display_name, aliases, summary, narrative, agent_notes, + status, source, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const row of snapshot.contacts) { + insertContact.run( + row.id, + row.kind, + row.displayName, + JSON.stringify(row.aliases), + row.summary, + row.narrative, + row.agentNotes, + row.status, + row.source, + row.createdAt, + row.updatedAt, + ); + } + + const insertGroup = db.prepare( + `INSERT INTO contact_groups(id, name, description, created_at) VALUES (?, ?, ?, ?)`, + ); + for (const row of snapshot.groups) { + insertGroup.run(row.id, row.name, row.description, row.createdAt); + } + + const insertIdentity = db.prepare( + `INSERT INTO contact_identities( + id, contact_id, platform, value, normalized_value, label, note, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const row of snapshot.identities) { + insertIdentity.run( + row.id, + row.contactId, + row.platform, + row.value, + row.normalizedValue, + row.label, + row.note, + row.createdAt, + ); + } + + const insertEvent = db.prepare( + `INSERT INTO contact_events(id, contact_id, date, text, source, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ); + for (const row of snapshot.events) { + insertEvent.run( + row.id, + row.contactId, + row.date, + row.text, + row.source, + row.createdAt, + ); + } + + const insertMembership = db.prepare( + `INSERT INTO contact_group_members(group_id, contact_id) VALUES (?, ?)`, + ); + for (const row of snapshot.memberships) { + insertMembership.run(row.groupId, row.contactId); + } + + const insertRelation = db.prepare( + `INSERT INTO contact_relations(id, from_id, to_id, relation, note, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ); + for (const row of snapshot.relations) { + insertRelation.run( + row.id, + row.fromId, + row.toId, + row.relation, + row.note, + row.createdAt, + ); + } +} diff --git a/packages/maker-core/src/contacts/sync/types.ts b/packages/maker-core/src/contacts/sync/types.ts new file mode 100644 index 00000000000..7cd56435946 --- /dev/null +++ b/packages/maker-core/src/contacts/sync/types.ts @@ -0,0 +1,170 @@ +/** + * 智能通讯录的设备间同步契约。 + * + * 状态只包含确定性的 LWW/删除标记,不依赖模型或墙钟先后。每台设备维护单调 + * Lamport counter;并发写入用 nodeId 打破平局,因此任意数量设备、任意交换顺序 + * 都会得到同一个结果。 + */ + +import type { ContactKind, ContactSource, ContactStatus } from "../types.js"; + +export const CONTACTS_SYNC_VERSION = 1; + +export interface ContactsSyncStamp { + counter: number; + nodeId: string; +} + +export interface ContactsSyncClock { + nodeId: string; + counter: number; +} + +export interface ContactsStampedValue { + value: T; + stamp: ContactsSyncStamp; +} + +export interface ContactsSyncContact { + id: string; + kind: ContactsStampedValue; + displayName: ContactsStampedValue; + aliases: ContactsStampedValue; + summary: ContactsStampedValue; + narrative: ContactsStampedValue; + agentNotes: ContactsStampedValue; + status: ContactsStampedValue; + source: ContactsStampedValue; + createdAt: ContactsStampedValue; + updatedAt: ContactsStampedValue; + /** UUID 不复用;一旦删除,旧档案永不因离线副本重新出现。 */ + deleted?: ContactsSyncStamp; +} + +export interface ContactsSyncEntity { + id: string; + value: ContactsStampedValue; + deleted?: ContactsSyncStamp; +} + +export interface ContactsSyncIdentityValue { + contactId: string; + platform: string; + value: string; + normalizedValue: string; + label: string; + note: string; + createdAt: string; +} + +export interface ContactsSyncEventValue { + contactId: string; + date: string; + text: string; + source: string; + createdAt: string; +} + +export interface ContactsSyncGroupValue { + name: string; + description: string; + createdAt: string; +} + +export interface ContactsSyncMembershipValue { + groupId: string; + contactId: string; +} + +export interface ContactsSyncRelationValue { + fromId: string; + toId: string; + relation: string; + note: string; + createdAt: string; +} + +/** 可直接 JSON 序列化、在设备间做状态式交换的完整同步状态。 */ +export interface ContactsSyncState { + version: typeof CONTACTS_SYNC_VERSION; + clocks: ContactsSyncClock[]; + contacts: ContactsSyncContact[]; + identities: Array>; + events: Array>; + groups: Array>; + memberships: Array>; + relations: Array>; +} + +/** 当前 SQLite 主表的无时间戳逻辑快照;FTS 是派生数据,不进入同步。 */ +export interface ContactsDataSnapshot { + contacts: ContactsSnapshotContact[]; + identities: ContactsSnapshotIdentity[]; + events: ContactsSnapshotEvent[]; + groups: ContactsSnapshotGroup[]; + memberships: ContactsSnapshotMembership[]; + relations: ContactsSnapshotRelation[]; +} + +export interface ContactsSnapshotContact { + id: string; + kind: ContactKind; + displayName: string; + aliases: string[]; + summary: string; + narrative: string; + agentNotes: string; + status: ContactStatus; + source: ContactSource; + createdAt: string; + updatedAt: string; +} + +export interface ContactsSnapshotIdentity extends ContactsSyncIdentityValue { + id: string; +} + +export interface ContactsSnapshotEvent extends ContactsSyncEventValue { + id: string; +} + +export interface ContactsSnapshotGroup extends ContactsSyncGroupValue { + id: string; +} + +export interface ContactsSnapshotMembership extends ContactsSyncMembershipValue { + id: string; +} + +export interface ContactsSnapshotRelation extends ContactsSyncRelationValue { + id: string; +} + +export function createEmptyContactsSyncState(): ContactsSyncState { + return { + version: CONTACTS_SYNC_VERSION, + clocks: [], + contacts: [], + identities: [], + events: [], + groups: [], + memberships: [], + relations: [], + }; +} + +export function createEmptyContactsSnapshot(): ContactsDataSnapshot { + return { + contacts: [], + identities: [], + events: [], + groups: [], + memberships: [], + relations: [], + }; +} + +/** 复合主键只用于同步快照,不进入产品数据。 */ +export function membershipSyncId(groupId: string, contactId: string): string { + return `${groupId}\u0000${contactId}`; +} diff --git a/packages/maker-core/src/contacts/sync/validation.ts b/packages/maker-core/src/contacts/sync/validation.ts new file mode 100644 index 00000000000..5ff41d032bf --- /dev/null +++ b/packages/maker-core/src/contacts/sync/validation.ts @@ -0,0 +1,449 @@ +/** 深度校验来自设备链路或磁盘的同步状态,拒绝畸形/超量数据进入 SQLite。 */ + +import { + DEFAULT_CONTACTS_CONFIG, + MAX_NORMALIZED_IDENTITY_VALUE_LEN, + isContactKind, + isContactSource, + isContactStatus, +} from "../types.js"; +import { + CONTACTS_SYNC_VERSION, + membershipSyncId, + type ContactsDataSnapshot, + type ContactsStampedValue, + type ContactsSyncEntity, + type ContactsSyncStamp, + type ContactsSyncState, +} from "./types.js"; + +export const CONTACTS_SYNC_MAX_ROWS_PER_TABLE = 100_000; +const MAX_ID_LENGTH = 160; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isString( + value: unknown, + max: number, + allowEmpty = true, +): value is string { + return ( + typeof value === "string" && + value.length <= max && + (allowEmpty || value.length > 0) + ); +} + +function isUtf8String(value: unknown, maxBytes: number): value is string { + return ( + typeof value === "string" && Buffer.byteLength(value, "utf8") <= maxBytes + ); +} + +/** + * 这些 SQLite 文本列的本地写入契约没有长度上限;同步层只能校验类型,不能 + * 自行收紧合法域。传输层仍以整包解压上限约束来自设备的数据总量。 + */ +function isUnboundedLocalText(value: unknown): value is string { + return typeof value === "string"; +} + +function isId(value: unknown): value is string { + return isString(value, MAX_ID_LENGTH, false) && !value.includes("\u0000"); +} + +function isStamp(value: unknown): value is ContactsSyncStamp { + if (!isRecord(value)) return false; + return ( + Number.isSafeInteger(value.counter) && + (value.counter as number) > 0 && + isString(value.nodeId, 128, false) && + /^[A-Za-z0-9._:-]+$/.test(value.nodeId as string) + ); +} + +function isStamped( + value: unknown, + validate: (candidate: unknown) => candidate is T, +): value is ContactsStampedValue { + return isRecord(value) && isStamp(value.stamp) && validate(value.value); +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length <= DEFAULT_CONTACTS_CONFIG.maxAliases && + value.every((entry) => + isString(entry, DEFAULT_CONTACTS_CONFIG.maxDisplayNameLen, false), + ) + ); +} + +function isEntityArray( + value: unknown, + validate: (candidate: unknown) => candidate is T, + validateId: (candidate: unknown) => candidate is string = isId, +): value is Array> { + if (!Array.isArray(value) || value.length > CONTACTS_SYNC_MAX_ROWS_PER_TABLE) + return false; + const ids = new Set(); + for (const candidate of value) { + if ( + !isRecord(candidate) || + !validateId(candidate.id) || + ids.has(candidate.id) + ) + return false; + if (!isStamped(candidate.value, validate)) return false; + if (candidate.deleted !== undefined && !isStamp(candidate.deleted)) + return false; + ids.add(candidate.id); + } + return true; +} + +function isContact(value: unknown): boolean { + if (!isRecord(value) || !isId(value.id)) return false; + if (!isStamped(value.kind, (v): v is "person" | "org" => isContactKind(v))) + return false; + if ( + !isStamped(value.displayName, (v): v is string => + isString(v, DEFAULT_CONTACTS_CONFIG.maxDisplayNameLen, false), + ) + ) + return false; + if (!isStamped(value.aliases, isStringArray)) return false; + if ( + !isStamped(value.summary, (v): v is string => + isString(v, DEFAULT_CONTACTS_CONFIG.maxSummaryLen), + ) + ) + return false; + if ( + !isStamped(value.narrative, (v): v is string => + isUtf8String(v, DEFAULT_CONTACTS_CONFIG.maxNarrativeBytes), + ) + ) + return false; + if ( + !isStamped(value.agentNotes, (v): v is string => + isString(v, DEFAULT_CONTACTS_CONFIG.maxAgentNotesLen), + ) + ) + return false; + if ( + !isStamped(value.status, (v): v is "confirmed" | "pending" => + isContactStatus(v), + ) + ) + return false; + if ( + !isStamped(value.source, (v): v is "manual" | "agent" | "import" => + isContactSource(v), + ) + ) + return false; + if (!isStamped(value.createdAt, (v): v is string => isString(v, 64, false))) + return false; + if (!isStamped(value.updatedAt, (v): v is string => isString(v, 64, false))) + return false; + return value.deleted === undefined || isStamp(value.deleted); +} + +function isIdentity(value: unknown): value is { + contactId: string; + platform: string; + value: string; + normalizedValue: string; + label: string; + note: string; + createdAt: string; +} { + if (!isRecord(value) || !isId(value.contactId)) return false; + return ( + isString(value.platform, 32, false) && + /^[a-z0-9_-]+$/.test(value.platform) && + isString(value.value, DEFAULT_CONTACTS_CONFIG.maxIdentityValueLen, false) && + isString( + value.normalizedValue, + MAX_NORMALIZED_IDENTITY_VALUE_LEN, + false, + ) && + isUnboundedLocalText(value.label) && + isUnboundedLocalText(value.note) && + isString(value.createdAt, 64, false) + ); +} + +function isEvent(value: unknown): value is { + contactId: string; + date: string; + text: string; + source: string; + createdAt: string; +} { + if (!isRecord(value) || !isId(value.contactId)) return false; + return ( + isString(value.date, 32, false) && + isString(value.text, DEFAULT_CONTACTS_CONFIG.maxEventTextLen, false) && + isUnboundedLocalText(value.source) && + isString(value.createdAt, 64, false) + ); +} + +function isGroup(value: unknown): value is { + name: string; + description: string; + createdAt: string; +} { + return ( + isRecord(value) && + isString(value.name, DEFAULT_CONTACTS_CONFIG.maxGroupNameLen, false) && + isUnboundedLocalText(value.description) && + isString(value.createdAt, 64, false) + ); +} + +function isMembership( + value: unknown, +): value is { groupId: string; contactId: string } { + return isRecord(value) && isId(value.groupId) && isId(value.contactId); +} + +function isMembershipId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 2 && + value.length <= MAX_ID_LENGTH * 2 + 1 + ); +} + +function isRelation(value: unknown): value is { + fromId: string; + toId: string; + relation: string; + note: string; + createdAt: string; +} { + return ( + isRecord(value) && + isId(value.fromId) && + isId(value.toId) && + isString(value.relation, DEFAULT_CONTACTS_CONFIG.maxRelationLen, false) && + isUnboundedLocalText(value.note) && + isString(value.createdAt, 64, false) + ); +} + +function isSnapshotContact(value: unknown): boolean { + return ( + isRecord(value) && + isId(value.id) && + isContactKind(value.kind) && + isString( + value.displayName, + DEFAULT_CONTACTS_CONFIG.maxDisplayNameLen, + false, + ) && + isStringArray(value.aliases) && + isString(value.summary, DEFAULT_CONTACTS_CONFIG.maxSummaryLen) && + isUtf8String(value.narrative, DEFAULT_CONTACTS_CONFIG.maxNarrativeBytes) && + isString(value.agentNotes, DEFAULT_CONTACTS_CONFIG.maxAgentNotesLen) && + isContactStatus(value.status) && + isContactSource(value.source) && + isString(value.createdAt, 64, false) && + isString(value.updatedAt, 64, false) + ); +} + +function isSnapshotArray( + value: unknown, + validate: (candidate: unknown) => boolean, + validateId: (candidate: unknown) => candidate is string = isId, +): value is Array & { id: string }> { + if (!Array.isArray(value) || value.length > CONTACTS_SYNC_MAX_ROWS_PER_TABLE) + return false; + const ids = new Set(); + for (const candidate of value) { + if ( + !isRecord(candidate) || + !validateId(candidate.id) || + ids.has(candidate.id) || + !validate(candidate) + ) { + return false; + } + ids.add(candidate.id); + } + return true; +} + +/** + * projection_json 会作为下一次本地差异捕获的可信基线;必须完整 fail-closed。 + * 只校验 id 会把损坏行误判成“本机删除”,进而向 CRDT 写入永久 tombstone。 + */ +export function isValidContactsDataSnapshot( + value: unknown, +): value is ContactsDataSnapshot { + if (!isRecord(value)) return false; + if ( + !isSnapshotArray(value.contacts, isSnapshotContact) || + !isSnapshotArray( + value.identities, + (candidate) => isRecord(candidate) && isIdentity(candidate), + ) || + !isSnapshotArray( + value.events, + (candidate) => isRecord(candidate) && isEvent(candidate), + ) || + !isSnapshotArray( + value.groups, + (candidate) => isRecord(candidate) && isGroup(candidate), + ) || + !isSnapshotArray( + value.memberships, + (candidate) => + isRecord(candidate) && + isMembership(candidate) && + (candidate as unknown as { id: string }).id === + membershipSyncId(candidate.groupId, candidate.contactId), + isMembershipId, + ) || + !isSnapshotArray( + value.relations, + (candidate) => isRecord(candidate) && isRelation(candidate), + ) + ) { + return false; + } + + const snapshot = value as unknown as ContactsDataSnapshot; + const contactIds = new Set(snapshot.contacts.map((contact) => contact.id)); + const groupIds = new Set(snapshot.groups.map((group) => group.id)); + const uniqueGroups = new Set(); + for (const group of snapshot.groups) { + if (uniqueGroups.has(group.name)) return false; + uniqueGroups.add(group.name); + } + + const uniqueIdentities = new Set(); + for (const identity of snapshot.identities) { + if (!contactIds.has(identity.contactId)) return false; + const key = `${identity.platform}\u0000${identity.normalizedValue}`; + if (uniqueIdentities.has(key)) return false; + uniqueIdentities.add(key); + } + if (snapshot.events.some((event) => !contactIds.has(event.contactId))) + return false; + if ( + snapshot.memberships.some( + (membership) => + !contactIds.has(membership.contactId) || + !groupIds.has(membership.groupId), + ) + ) { + return false; + } + + const uniqueRelations = new Set(); + for (const relation of snapshot.relations) { + if ( + relation.fromId === relation.toId || + !contactIds.has(relation.fromId) || + !contactIds.has(relation.toId) + ) { + return false; + } + const key = `${relation.fromId}\u0000${relation.toId}\u0000${relation.relation}`; + if (uniqueRelations.has(key)) return false; + uniqueRelations.add(key); + } + return true; +} + +export function isValidContactsSyncState( + value: unknown, +): value is ContactsSyncState { + if (!isRecord(value) || value.version !== CONTACTS_SYNC_VERSION) return false; + if (!Array.isArray(value.clocks) || value.clocks.length > 256) return false; + const clockNodes = new Set(); + for (const clock of value.clocks) { + if ( + !isRecord(clock) || + !isStamp({ counter: clock.counter, nodeId: clock.nodeId }) + ) + return false; + if (clockNodes.has(clock.nodeId as string)) return false; + clockNodes.add(clock.nodeId as string); + } + if ( + !Array.isArray(value.contacts) || + value.contacts.length > CONTACTS_SYNC_MAX_ROWS_PER_TABLE + ) + return false; + const contactIds = new Set(); + for (const contact of value.contacts) { + if (!isContact(contact) || contactIds.has((contact as { id: string }).id)) + return false; + contactIds.add((contact as { id: string }).id); + } + if (!isEntityArray(value.identities, isIdentity)) return false; + if (!isEntityArray(value.events, isEvent)) return false; + if (!isEntityArray(value.groups, isGroup)) return false; + if (!isEntityArray(value.memberships, isMembership, isMembershipId)) + return false; + for (const membership of value.memberships) { + const entry = membership as ContactsSyncEntity<{ + groupId: string; + contactId: string; + }>; + if ( + entry.id !== + membershipSyncId(entry.value.value.groupId, entry.value.value.contactId) + ) + return false; + } + if (!isEntityArray(value.relations, isRelation)) return false; + return clocksCoverEveryStamp(value as unknown as ContactsSyncState); +} + +function clocksCoverEveryStamp(state: ContactsSyncState): boolean { + const clocks = new Map( + state.clocks.map((clock) => [clock.nodeId, clock.counter]), + ); + const covered = (stamp: ContactsSyncStamp | undefined): boolean => + !stamp || (clocks.get(stamp.nodeId) ?? 0) >= stamp.counter; + for (const contact of state.contacts) { + if ( + !covered(contact.kind.stamp) || + !covered(contact.displayName.stamp) || + !covered(contact.aliases.stamp) || + !covered(contact.summary.stamp) || + !covered(contact.narrative.stamp) || + !covered(contact.agentNotes.stamp) || + !covered(contact.status.stamp) || + !covered(contact.source.stamp) || + !covered(contact.createdAt.stamp) || + !covered(contact.updatedAt.stamp) || + !covered(contact.deleted) + ) { + return false; + } + } + for (const records of [ + state.identities, + state.events, + state.groups, + state.memberships, + state.relations, + ]) { + for (const record of records) { + if (!covered(record.value.stamp) || !covered(record.deleted)) { + return false; + } + } + } + return true; +} diff --git a/packages/maker-core/src/contacts/sync/worker-api.ts b/packages/maker-core/src/contacts/sync/worker-api.ts new file mode 100644 index 00000000000..dfbb84e44b2 --- /dev/null +++ b/packages/maker-core/src/contacts/sync/worker-api.ts @@ -0,0 +1,9 @@ +/** + * Desktop contacts-sync worker 的窄入口。 + * + * 不从 maker-core 根 barrel 引入,避免把 agent SDK 等与通讯录无关的运行时依赖 + * 拉进独立 worker bundle。 + */ +export { MakerContactsStore } from '../store.js'; +export { createContactsSyncDelta } from './merge.js'; +export type { ContactsSyncState } from './types.js'; diff --git a/packages/maker-core/src/contacts/types.ts b/packages/maker-core/src/contacts/types.ts index 0a9a0aff28d..6d794781bdd 100644 --- a/packages/maker-core/src/contacts/types.ts +++ b/packages/maker-core/src/contacts/types.ts @@ -448,6 +448,23 @@ export const DEFAULT_CONTACTS_CONFIG: ContactsConfig = { maxRelationLen: 30, }; +/** + * JavaScript 默认 Unicode 小写映射会扩展部分字符(例如 İ → i + combining dot)。 + * 设备同步以默认身份值长度为 wire contract;本地配置可以收紧但不能放宽它, + * 避免写入本地后才发现状态无法同步。 + */ +export function getMaxSyncableIdentityValueLen(maxIdentityValueLen: number): number { + return Math.min(maxIdentityValueLen, DEFAULT_CONTACTS_CONFIG.maxIdentityValueLen); +} + +export function getMaxNormalizedIdentityValueLen(maxIdentityValueLen: number): number { + return getMaxSyncableIdentityValueLen(maxIdentityValueLen) * 2; +} + +export const MAX_NORMALIZED_IDENTITY_VALUE_LEN = getMaxNormalizedIdentityValueLen( + DEFAULT_CONTACTS_CONFIG.maxIdentityValueLen, +); + export type ContactsErrorCode = | 'invalid-params' | 'not-found' diff --git a/packages/maker-core/src/index.ts b/packages/maker-core/src/index.ts index fe908600cfb..4008c597bac 100644 --- a/packages/maker-core/src/index.ts +++ b/packages/maker-core/src/index.ts @@ -68,6 +68,8 @@ export { MAKER_MEMORY_RULES } from './memory/system-prompt.js'; // maker contacts (agent-native 智能通讯录, 全局人物实体库) export * from './contacts/types.js'; +export * from './contacts/sync/types.js'; +export { createContactsSyncDelta } from './contacts/sync/merge.js'; export { CONTACTS_RULES_DISABLED, CONTACTS_RULES_ENABLED,