From 0245929d2deb0c44a25c388b3f10c2595a0b3abd Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 21 Jun 2026 23:16:24 +0800 Subject: [PATCH 01/19] Harden WebDAV chat sync before enabling push Constrain WebDAV transport to HTTPS-only fixed sync endpoints, validate decrypted snapshots, and keep sync scope limited to chat sessions while preserving local conflict safety. Constraint: Desktop IPC must not become a generic HTTP proxy and mobile/desktop sync behavior must stay consistent. Rejected: Allowing renderer-provided WebDAV targets through platform transports | preserves SSRF and redirect bypass risk. Confidence: high Scope-risk: moderate Directive: Keep future sync expansion out of settings, credentials, license, OAuth, and provider-token storage unless a new reviewed snapshot contract is added. Tested: git diff --check; vitest run src/shared/sync-settings.test.ts src/renderer/packages/sync/*.test.ts; biome lint src/shared/sync-settings.test.ts src/shared/sync-webdav.ts src/renderer/packages/sync; tsc --noEmit Not-tested: Full app packaging and live third-party WebDAV interoperability matrix --- src/main/main.ts | 8 +- src/renderer/packages/sync/crypto.test.ts | 22 ++ src/renderer/packages/sync/crypto.ts | 100 ++++++++ src/renderer/packages/sync/index.ts | 6 + src/renderer/packages/sync/local.test.ts | 81 ++++++ src/renderer/packages/sync/local.ts | 68 +++++ src/renderer/packages/sync/service.test.ts | 260 ++++++++++++++++++++ src/renderer/packages/sync/service.ts | 232 +++++++++++++++++ src/renderer/packages/sync/snapshot.test.ts | 160 ++++++++++++ src/renderer/packages/sync/snapshot.ts | 139 +++++++++++ src/renderer/packages/sync/types.ts | 37 +++ src/renderer/packages/sync/webdav.test.ts | 181 ++++++++++++++ src/renderer/packages/sync/webdav.ts | 30 +++ src/renderer/platform/desktop_platform.ts | 5 + src/renderer/platform/interfaces.ts | 4 + src/renderer/platform/mobile_platform.ts | 26 ++ src/renderer/routes/settings/general.tsx | 161 +++++++++++- src/shared/defaults.ts | 11 + src/shared/sync-settings.test.ts | 21 ++ src/shared/sync-webdav.ts | 122 +++++++++ src/shared/types/settings.ts | 31 +++ 21 files changed, 1701 insertions(+), 4 deletions(-) create mode 100644 src/renderer/packages/sync/crypto.test.ts create mode 100644 src/renderer/packages/sync/crypto.ts create mode 100644 src/renderer/packages/sync/index.ts create mode 100644 src/renderer/packages/sync/local.test.ts create mode 100644 src/renderer/packages/sync/local.ts create mode 100644 src/renderer/packages/sync/service.test.ts create mode 100644 src/renderer/packages/sync/service.ts create mode 100644 src/renderer/packages/sync/snapshot.test.ts create mode 100644 src/renderer/packages/sync/snapshot.ts create mode 100644 src/renderer/packages/sync/types.ts create mode 100644 src/renderer/packages/sync/webdav.test.ts create mode 100644 src/renderer/packages/sync/webdav.ts create mode 100644 src/shared/sync-settings.test.ts create mode 100644 src/shared/sync-webdav.ts diff --git a/src/main/main.ts b/src/main/main.ts index df93e33941..f22c4aa1e6 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -14,13 +14,14 @@ import './legacy-database-migration' */ import fs from 'node:fs' -import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, session, shell, Tray } from 'electron' +import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, net, session, shell, Tray } from 'electron' import electronDebug from 'electron-debug' import log from 'electron-log/main' import os from 'os' import path from 'path' // @ts-expect-error - source-map-support doesn't have type definitions import * as sourceMapSupport from 'source-map-support' +import { executeWebDAVRequest, type WebDAVRequest } from 'src/shared/sync-webdav' import type { ShortcutSetting } from 'src/shared/types' import * as analystic from './analystic-node' import { AppUpdater } from './app-updater' @@ -763,6 +764,11 @@ ipcMain.handle('ensureAutoLaunch', (event, enable: boolean) => { return autoLauncher.ensure(enable) }) +ipcMain.handle('webdav:request', async (_event, request: WebDAVRequest) => { + const webdavBaseUrl = getSettings().sync.webdav.url + return executeWebDAVRequest(webdavBaseUrl, request, (input, init) => net.fetch(String(input), init)) +}) + ipcMain.handle('parseFileLocally', async (event, dataJSON: string) => { const params: { filePath: string } = JSON.parse(dataJSON) try { diff --git a/src/renderer/packages/sync/crypto.test.ts b/src/renderer/packages/sync/crypto.test.ts new file mode 100644 index 0000000000..463c9428fd --- /dev/null +++ b/src/renderer/packages/sync/crypto.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' + +describe('sync crypto envelope', () => { + it('round trips JSON with the sync password', async () => { + const payload = { version: 1, sessions: [{ id: 's1', name: 'Hello' }] } + + const envelope = await encryptJsonEnvelope(payload, 'correct horse battery staple') + const decrypted = await decryptJsonEnvelope(envelope, 'correct horse battery staple') + + expect(envelope.version).toBe(1) + expect(envelope.kdf).toBe('PBKDF2-SHA256') + expect(envelope.ciphertext).not.toContain('Hello') + expect(decrypted).toEqual(payload) + }) + + it('rejects a wrong sync password', async () => { + const envelope = await encryptJsonEnvelope({ secret: 'chat history' }, 'right-password') + + await expect(decryptJsonEnvelope(envelope, 'wrong-password')).rejects.toThrow(/decrypt/i) + }) +}) diff --git a/src/renderer/packages/sync/crypto.ts b/src/renderer/packages/sync/crypto.ts new file mode 100644 index 0000000000..2c02548796 --- /dev/null +++ b/src/renderer/packages/sync/crypto.ts @@ -0,0 +1,100 @@ +import type { SyncCryptoEnvelope } from './types' + +const ENVELOPE_VERSION = 1 +const PBKDF2_ITERATIONS = 250_000 +const SALT_BYTES = 16 +const IV_BYTES = 12 + +function getCrypto(): Crypto { + const cryptoImpl = globalThis.crypto + if (!cryptoImpl?.subtle) { + throw new Error('Web Crypto is not available') + } + return cryptoImpl +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer +} + +async function deriveAesKey(password: string, salt: Uint8Array, iterations: number): Promise { + if (!password) { + throw new Error('Sync password is required') + } + const cryptoImpl = getCrypto() + const passwordKey = await cryptoImpl.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, [ + 'deriveKey', + ]) + return cryptoImpl.subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: toArrayBuffer(salt), + iterations, + }, + passwordKey, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ) +} + +export async function encryptJsonEnvelope(payload: T, password: string): Promise { + const cryptoImpl = getCrypto() + const salt = cryptoImpl.getRandomValues(new Uint8Array(SALT_BYTES)) + const iv = cryptoImpl.getRandomValues(new Uint8Array(IV_BYTES)) + const key = await deriveAesKey(password, salt, PBKDF2_ITERATIONS) + const plaintext = new TextEncoder().encode(JSON.stringify(payload)) + const ciphertext = await cryptoImpl.subtle.encrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, plaintext) + + return { + version: ENVELOPE_VERSION, + kdf: 'PBKDF2-SHA256', + cipher: 'AES-GCM', + iterations: PBKDF2_ITERATIONS, + salt: bytesToBase64(salt), + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(new Uint8Array(ciphertext)), + } +} + +export async function decryptJsonEnvelope(envelope: SyncCryptoEnvelope, password: string): Promise { + if (envelope.version !== ENVELOPE_VERSION || envelope.kdf !== 'PBKDF2-SHA256' || envelope.cipher !== 'AES-GCM') { + throw new Error('Unsupported sync encryption envelope') + } + + try { + const cryptoImpl = getCrypto() + const salt = base64ToBytes(envelope.salt) + const iv = base64ToBytes(envelope.iv) + const key = await deriveAesKey(password, salt, envelope.iterations) + const decrypted = await cryptoImpl.subtle.decrypt( + { name: 'AES-GCM', iv: toArrayBuffer(iv) }, + key, + toArrayBuffer(base64ToBytes(envelope.ciphertext)) + ) + return JSON.parse(new TextDecoder().decode(decrypted)) as T + } catch (error) { + throw new Error(`Failed to decrypt sync data: ${error instanceof Error ? error.message : String(error)}`) + } +} diff --git a/src/renderer/packages/sync/index.ts b/src/renderer/packages/sync/index.ts new file mode 100644 index 0000000000..a8603104a5 --- /dev/null +++ b/src/renderer/packages/sync/index.ts @@ -0,0 +1,6 @@ +export * from './crypto' +export * from './local' +export * from './service' +export * from './snapshot' +export * from './types' +export * from './webdav' diff --git a/src/renderer/packages/sync/local.test.ts b/src/renderer/packages/sync/local.test.ts new file mode 100644 index 0000000000..b6ba12e8a5 --- /dev/null +++ b/src/renderer/packages/sync/local.test.ts @@ -0,0 +1,81 @@ +import type { Session, SessionMetaRecord } from '@shared/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listLocalSyncMetas, listLocalSyncSessions } from './local' + +vi.mock('@/stores/chatStore', () => ({ + listAllSessionsMeta: vi.fn(), +})) + +vi.mock('@/storage', () => ({ + default: { + getItem: vi.fn(), + }, +})) + +const { listAllSessionsMeta } = await import('@/stores/chatStore') +const { default: storage } = await import('@/storage') + +function session(id: string, type?: Session['type']): Session { + return { + id, + type, + name: id, + messages: [], + } +} + +function meta(id: string, type?: SessionMetaRecord['type']): SessionMetaRecord { + return { + id, + type, + name: id, + sortOrder: 1, + createdAt: 1, + } +} + +describe('local sync data selection', () => { + beforeEach(() => { + vi.mocked(listAllSessionsMeta).mockReset() + vi.mocked(storage.getItem).mockReset() + }) + + it('lists only chat and legacy chat sessions for sync', async () => { + vi.mocked(listAllSessionsMeta).mockResolvedValue([ + meta('chat-1', 'chat'), + meta('legacy-chat'), + meta('picture-1', 'picture'), + meta('guide-1', 'guide'), + ]) + vi.mocked(storage.getItem).mockImplementation((key) => { + const id = String(key).replace('session:', '') + return Promise.resolve(session(id, id === 'legacy-chat' ? undefined : 'chat')) + }) + + const sessions = await listLocalSyncSessions() + const metas = await listLocalSyncMetas() + + expect(sessions.map((item) => item.id)).toEqual(['chat-1', 'legacy-chat']) + expect(metas.map((item) => item.id)).toEqual(['chat-1', 'legacy-chat']) + expect(storage.getItem).toHaveBeenCalledTimes(2) + }) + + it('migrates legacy message content before syncing local sessions', async () => { + vi.mocked(listAllSessionsMeta).mockResolvedValue([meta('legacy-chat')]) + vi.mocked(storage.getItem).mockResolvedValue({ + id: 'legacy-chat', + name: 'Legacy', + messages: [ + { + id: 'message-1', + role: 'user', + content: 'legacy text', + }, + ], + } as unknown as Session) + + const sessions = await listLocalSyncSessions() + + expect(sessions[0].messages[0].contentParts).toEqual([{ type: 'text', text: 'legacy text' }]) + }) +}) diff --git a/src/renderer/packages/sync/local.ts b/src/renderer/packages/sync/local.ts new file mode 100644 index 0000000000..c42af11f55 --- /dev/null +++ b/src/renderer/packages/sync/local.ts @@ -0,0 +1,68 @@ +import type { Session, SessionMetaRecord } from '@shared/types' +import { v4 as uuidv4 } from 'uuid' +import platform from '@/platform' +import storage from '@/storage' +import { StorageKeyGenerator } from '@/storage/StoreStorage' +import { getMetaStorage, listAllSessionsMeta, refreshSessionListCache } from '@/stores/chatStore' +import { settingsStore } from '@/stores/settingsStore' +import { migrateSession } from '@/utils/session-utils' +import type { WebDAVSyncDeps } from './service' + +function isChatSessionLike(item: Pick | Pick): boolean { + return item.type === 'chat' || !item.type +} + +export async function listLocalSyncSessions(): Promise { + const metas = (await listAllSessionsMeta()).filter(isChatSessionLike) + const sessions = await Promise.all( + metas.map((meta) => storage.getItem(StorageKeyGenerator.session(meta.id), null)) + ) + return sessions + .filter((session): session is Session => { + if (!session) { + return false + } + return isChatSessionLike(session) + }) + .map((session) => migrateSession(session)) +} + +export async function listLocalSyncMetas(): Promise { + return (await listAllSessionsMeta()).filter(isChatSessionLike) +} + +export async function saveSyncSession(session: Session): Promise { + await storage.setItemNow(StorageKeyGenerator.session(session.id), session) +} + +export async function deleteSyncSession(sessionId: string): Promise { + await storage.removeItem(StorageKeyGenerator.session(sessionId)) +} + +export async function saveSyncMetas(metas: SessionMetaRecord[]): Promise { + if (metas.length === 0) { + return + } + const metaStorage = await getMetaStorage() + await metaStorage.createMany(metas) + await refreshSessionListCache() +} + +export function updateSyncLastSyncedAt(isoDate: string) { + settingsStore.getState().setSettings((settings) => { + settings.sync.lastSyncedAt = isoDate + }) +} + +export function createDefaultWebDAVSyncDeps(): WebDAVSyncDeps { + return { + platform, + listLocalSessions: listLocalSyncSessions, + listLocalMetas: listLocalSyncMetas, + saveSession: saveSyncSession, + deleteSession: deleteSyncSession, + saveMetas: saveSyncMetas, + updateLastSyncedAt: updateSyncLastSyncedAt, + createId: uuidv4, + } +} diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts new file mode 100644 index 0000000000..9e45e6eac3 --- /dev/null +++ b/src/renderer/packages/sync/service.test.ts @@ -0,0 +1,260 @@ +import type { Settings } from '@shared/types' +import { describe, expect, it, vi } from 'vitest' +import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' +import { downloadAndMergeWebDAVSnapshot, uploadWebDAVSnapshot } from './service' +import type { SyncSnapshot, WebDAVRequest } from './types' + +const baseSettings = { + sync: { + enabled: true, + provider: 'webdav', + webdav: { + url: 'https://dav.example.com/files/me/', + username: 'alice', + password: 'app-password', + syncPassword: 'sync-secret', + }, + }, +} as Settings + +function session(id: string, name: string, text: string) { + return { + id, + type: 'chat' as const, + name, + messages: [ + { + id: `${id}-m1`, + role: 'user' as const, + contentParts: [{ type: 'text' as const, text }], + }, + ], + } +} + +function meta(id: string, name: string, sortOrder = 1) { + return { + id, + name, + type: 'chat' as const, + sortOrder, + createdAt: sortOrder, + } +} + +describe('WebDAV sync service', () => { + it('uploads an encrypted snapshot to the fixed WebDAV path', async () => { + const requests: WebDAVRequest[] = [] + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn((request: WebDAVRequest) => { + requests.push(request) + return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) + }), + }, + listLocalSessions: vi.fn(async () => [session('s1', 'Local', 'hello')]), + listLocalMetas: vi.fn(async () => [meta('s1', 'Local')]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(), + now: () => 1000, + } + + const result = await uploadWebDAVSnapshot(baseSettings, deps) + const put = requests.find((request) => request.method === 'PUT') + + expect(result.uploaded).toBe(1) + expect(put?.url).toBe('https://dav.example.com/files/me/ChatboxSync/v1/snapshot.json.enc') + expect(put?.headers?.Authorization).toBe(`Basic ${btoa('alice:app-password')}`) + expect(put?.body).not.toContain('hello') + + const envelope = JSON.parse(put?.body ?? '{}') + const decrypted = await decryptJsonEnvelope(envelope, 'sync-secret') + expect(decrypted.sessions.map((item) => item.id)).toEqual(['s1']) + expect(deps.updateLastSyncedAt).toHaveBeenCalledWith('1970-01-01T00:00:01.000Z') + }) + + it('downloads, decrypts, and saves missing remote sessions', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'hi')], + metas: [meta('remote-1', 'Remote')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => []), + listLocalMetas: vi.fn(async () => []), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 2000, + } + + const result = await downloadAndMergeWebDAVSnapshot(baseSettings, deps) + + expect(result.imported).toBe(1) + expect(result.conflicts).toBe(0) + expect(deps.saveSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'remote-1', + messages: remote.sessions[0].messages, + name: 'Remote', + type: 'chat', + }) + ) + expect(deps.saveMetas).toHaveBeenCalledWith(remote.metas) + expect(deps.updateLastSyncedAt).toHaveBeenCalledWith('1970-01-01T00:00:02.000Z') + }) + + it('rejects plaintext HTTP WebDAV URLs before sending credentials', async () => { + const deps = { + platform: { + webdavRequest: vi.fn(), + }, + listLocalSessions: vi.fn(async () => [session('s1', 'Local', 'hello')]), + listLocalMetas: vi.fn(async () => [meta('s1', 'Local')]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(), + } + const settings = { + ...baseSettings, + sync: { + ...baseSettings.sync, + webdav: { + ...baseSettings.sync.webdav, + url: 'http://dav.example.com/files/me/', + }, + }, + } as Settings + + await expect(uploadWebDAVSnapshot(settings, deps)).rejects.toThrow(/https/i) + expect(deps.platform.webdavRequest).not.toHaveBeenCalled() + }) + + it('rejects malformed decrypted remote snapshots', async () => { + const envelope = await encryptJsonEnvelope({ version: 1, sessions: 'not-array', metas: [] }, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async () => ({ + status: 200, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => []), + listLocalMetas: vi.fn(async () => []), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + } + + await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/invalid sync snapshot/i) + expect(deps.saveSession).not.toHaveBeenCalled() + expect(deps.saveMetas).not.toHaveBeenCalled() + expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() + }) + + it('migrates legacy remote messages before saving downloaded sessions', async () => { + const remote = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [ + { + id: 'legacy-remote', + type: 'chat', + name: 'Legacy Remote', + messages: [ + { + id: 'message-1', + role: 'user', + content: 'legacy remote text', + }, + ], + }, + ], + metas: [meta('legacy-remote', 'Legacy Remote')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => []), + listLocalMetas: vi.fn(async () => []), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 2000, + } + + await downloadAndMergeWebDAVSnapshot(baseSettings, deps) + + expect(deps.saveSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'legacy-remote', + messages: [expect.objectContaining({ contentParts: [{ type: 'text', text: 'legacy remote text' }] })], + }) + ) + }) + + it('rolls back saved sessions when metadata import fails', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote 1', 'hi'), session('remote-2', 'Remote 2', 'hello')], + metas: [meta('remote-1', 'Remote 1'), meta('remote-2', 'Remote 2')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => []), + listLocalMetas: vi.fn(async () => []), + saveSession: vi.fn(), + saveMetas: vi.fn(() => Promise.reject(new Error('meta write failed'))), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 2000, + } + + await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/meta write failed/) + + expect(deps.saveSession).toHaveBeenCalledTimes(2) + expect(deps.deleteSession).toHaveBeenCalledWith('remote-1') + expect(deps.deleteSession).toHaveBeenCalledWith('remote-2') + expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts new file mode 100644 index 0000000000..7ce7132ff4 --- /dev/null +++ b/src/renderer/packages/sync/service.ts @@ -0,0 +1,232 @@ +import type { Session, SessionMetaRecord, Settings } from '@shared/types' +import { SessionMetaRecordSchema, SessionSchema } from '@shared/types/session' +import { z } from 'zod' +import { migrateSession } from '@/utils/session-utils' +import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' +import { createSyncSnapshot, mergeRemoteSnapshot } from './snapshot' +import type { SyncCryptoEnvelope, SyncSnapshot, WebDAVRequest, WebDAVResponse } from './types' +import { buildBasicAuthHeader, joinWebDAVUrl, requestWebDAV, SYNC_COLLECTION_PATH, SYNC_SNAPSHOT_PATH } from './webdav' + +const RawSyncSnapshotSchema = z.object({ + version: z.literal(1), + exportedAt: z.string(), + deviceName: z.string(), + sessions: z.array(z.unknown()), + metas: z.array(SessionMetaRecordSchema), +}) + +type SyncPlatform = { + getDeviceName?: () => Promise + webdavRequest?: (request: WebDAVRequest, baseUrl: string) => Promise +} + +export type WebDAVSyncDeps = { + platform: SyncPlatform + listLocalSessions: () => Promise + listLocalMetas: () => Promise + saveSession: (session: Session) => Promise + deleteSession?: (sessionId: string) => Promise + saveMetas: (metas: SessionMetaRecord[]) => Promise + updateLastSyncedAt: (isoDate: string) => Promise | void + createId: () => string + now?: () => number +} + +export type UploadWebDAVSnapshotResult = { + uploaded: number + lastSyncedAt: string +} + +export type DownloadWebDAVSnapshotResult = { + imported: number + conflicts: number + saved: number + lastSyncedAt?: string + remoteMissing?: boolean +} + +function getWebDAVSettings(settings: Settings) { + const sync = settings.sync + if (!sync || sync.provider !== 'webdav') { + throw new Error('WebDAV sync is not configured') + } + const { url, username, password, syncPassword } = sync.webdav + if (!url.trim()) { + throw new Error('WebDAV URL is required') + } + if (new URL(url).protocol !== 'https:') { + throw new Error('WebDAV URL must use HTTPS') + } + if (!username.trim()) { + throw new Error('WebDAV username is required') + } + if (!password) { + throw new Error('WebDAV password is required') + } + if (!syncPassword) { + throw new Error('Sync encryption password is required') + } + return sync.webdav +} + +function authHeaders(settings: Settings): Record { + const webdav = getWebDAVSettings(settings) + return { + Authorization: buildBasicAuthHeader(webdav.username, webdav.password), + } +} + +function parseSyncSnapshot(value: unknown): SyncSnapshot { + const result = RawSyncSnapshotSchema.safeParse(value) + if (!result.success) { + throw new Error('Invalid sync snapshot') + } + const sessions = z + .array(SessionSchema) + .safeParse(result.data.sessions.map((session) => migrateSession(session as Session))) + if (!sessions.success) { + throw new Error('Invalid sync snapshot') + } + return { + ...result.data, + sessions: sessions.data, + } +} + +function snapshotUrl(settings: Settings): string { + return joinWebDAVUrl(getWebDAVSettings(settings).url, SYNC_SNAPSHOT_PATH) +} + +function collectionUrls(settings: Settings): string[] { + const webdav = getWebDAVSettings(settings) + return ['ChatboxSync/', SYNC_COLLECTION_PATH].map((path) => joinWebDAVUrl(webdav.url, path)) +} + +function assertSuccess(response: WebDAVResponse, action: string, okStatuses: number[]) { + if (!okStatuses.includes(response.status)) { + throw new Error(`${action} failed with HTTP ${response.status}${response.body ? `: ${response.body}` : ''}`) + } +} + +async function ensureWebDAVCollections(settings: Settings, platform: SyncPlatform) { + const webdav = getWebDAVSettings(settings) + const headers = authHeaders(settings) + for (const url of collectionUrls(settings)) { + const response = await requestWebDAV(platform, webdav.url, { + url, + method: 'MKCOL', + headers, + }) + assertSuccess(response, 'Create WebDAV sync directory', [200, 201, 405]) + } +} + +export async function testWebDAVConnection(settings: Settings, deps: Pick): Promise { + const webdav = getWebDAVSettings(settings) + await ensureWebDAVCollections(settings, deps.platform) + const response = await requestWebDAV(deps.platform, webdav.url, { + url: snapshotUrl(settings), + method: 'PROPFIND', + headers: { + ...authHeaders(settings), + Depth: '0', + }, + }) + assertSuccess(response, 'Check WebDAV snapshot', [200, 207, 404]) +} + +export async function uploadWebDAVSnapshot( + settings: Settings, + deps: WebDAVSyncDeps +): Promise { + const webdav = getWebDAVSettings(settings) + await ensureWebDAVCollections(settings, deps.platform) + + const [sessions, metas, deviceName] = await Promise.all([ + deps.listLocalSessions(), + deps.listLocalMetas(), + deps.platform.getDeviceName?.() ?? Promise.resolve('Unknown device'), + ]) + const lastSyncedAt = new Date((deps.now ?? Date.now)()).toISOString() + const snapshot = createSyncSnapshot({ + sessions, + metas, + deviceName, + exportedAt: lastSyncedAt, + }) + const envelope = await encryptJsonEnvelope(snapshot, webdav.syncPassword) + const response = await requestWebDAV(deps.platform, webdav.url, { + url: snapshotUrl(settings), + method: 'PUT', + headers: { + ...authHeaders(settings), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(envelope), + }) + assertSuccess(response, 'Upload WebDAV sync snapshot', [200, 201, 204]) + await deps.updateLastSyncedAt(lastSyncedAt) + return { + uploaded: snapshot.sessions.length, + lastSyncedAt, + } +} + +export async function downloadAndMergeWebDAVSnapshot( + settings: Settings, + deps: WebDAVSyncDeps +): Promise { + const webdav = getWebDAVSettings(settings) + const response = await requestWebDAV(deps.platform, webdav.url, { + url: snapshotUrl(settings), + method: 'GET', + headers: authHeaders(settings), + }) + + if (response.status === 404) { + return { + imported: 0, + conflicts: 0, + saved: 0, + remoteMissing: true, + } + } + assertSuccess(response, 'Download WebDAV sync snapshot', [200]) + + const envelope = JSON.parse(response.body) as SyncCryptoEnvelope + const remote = parseSyncSnapshot(await decryptJsonEnvelope(envelope, webdav.syncPassword)) + const [localSessions, localMetas] = await Promise.all([deps.listLocalSessions(), deps.listLocalMetas()]) + const result = mergeRemoteSnapshot({ + localSessions, + localMetas, + remote, + now: (deps.now ?? Date.now)(), + createId: deps.createId, + }) + + const savedSessionIds: string[] = [] + try { + for (const session of result.sessionsToSave) { + await deps.saveSession(session) + savedSessionIds.push(session.id) + } + if (result.metasToSave.length > 0) { + await deps.saveMetas(result.metasToSave) + } + } catch (error) { + if (deps.deleteSession) { + await Promise.allSettled(savedSessionIds.map((id) => deps.deleteSession?.(id))) + } + throw error + } + + const lastSyncedAt = new Date((deps.now ?? Date.now)()).toISOString() + await deps.updateLastSyncedAt(lastSyncedAt) + + return { + imported: result.imported, + conflicts: result.conflicts, + saved: result.sessionsToSave.length, + lastSyncedAt, + } +} diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts new file mode 100644 index 0000000000..cf278fb47e --- /dev/null +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -0,0 +1,160 @@ +import type { Session, SessionMetaRecord } from '@shared/types' +import { describe, expect, it } from 'vitest' +import { createSyncSnapshot, mergeRemoteSnapshot } from './snapshot' +import type { SyncSnapshot } from './types' + +function session(id: string, name: string, text: string, type: Session['type'] = 'chat'): Session { + return { + id, + type, + name, + messages: [ + { + id: `${id}-m1`, + role: 'user', + contentParts: [{ type: 'text', text }], + }, + ], + } +} + +function legacySession(id: string, name: string, text: string): Session { + const value = session(id, name, text) + delete value.type + return value +} + +function meta( + id: string, + name: string, + sortOrder = 1, + type: SessionMetaRecord['type'] = 'chat' +): SessionMetaRecord { + return { + id, + name, + type, + sortOrder, + createdAt: sortOrder, + } +} + +function legacyMeta(id: string, name: string, sortOrder = 1): SessionMetaRecord { + const value = meta(id, name, sortOrder) + delete value.type + return value +} + +describe('sync snapshot merge', () => { + it('creates snapshots from only chat and legacy sessions with matching chat metas', () => { + const snapshot = createSyncSnapshot({ + sessions: [ + session('chat-1', 'Chat', 'hello'), + legacySession('legacy-1', 'Legacy', 'hello'), + session('picture-1', 'Picture', 'image prompt', 'picture'), + session('guide-1', 'Guide', 'setup', 'guide'), + ], + metas: [ + meta('chat-1', 'Chat'), + legacyMeta('legacy-1', 'Legacy'), + meta('picture-1', 'Picture', 1, 'picture'), + meta('guide-1', 'Guide', 1, 'guide'), + meta('orphan-1', 'Orphan'), + ], + deviceName: 'Mac', + exportedAt: '2026-06-21T00:00:00.000Z', + }) + + expect(snapshot.sessions.map((item) => item.id)).toEqual(['chat-1', 'legacy-1']) + expect(snapshot.metas.map((item) => item.id)).toEqual(['chat-1', 'legacy-1']) + }) + + it('imports missing remote sessions and metadata', () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Mac', + sessions: [session('remote-1', 'Remote', 'hello')], + metas: [meta('remote-1', 'Remote')], + } + + const result = mergeRemoteSnapshot({ + localSessions: [], + localMetas: [], + remote, + now: 1000, + createId: () => 'unused', + }) + + expect(result.sessionsToSave.map((s) => s.id)).toEqual(['remote-1']) + expect(result.metasToSave.map((m) => m.id)).toEqual(['remote-1']) + expect(result.imported).toBe(1) + expect(result.conflicts).toBe(0) + }) + + it('ignores non-chat remote sessions and repairs non-chat metas for imported chat sessions', () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [ + session('chat-1', 'Chat', 'hello'), + legacySession('legacy-1', 'Legacy', 'hello'), + session('picture-1', 'Picture', 'image prompt', 'picture'), + session('guide-1', 'Guide', 'setup', 'guide'), + ], + metas: [ + meta('chat-1', 'Wrong type meta', 5, 'picture'), + legacyMeta('legacy-1', 'Legacy', 4), + meta('picture-1', 'Picture', 3, 'picture'), + meta('guide-1', 'Guide', 2, 'guide'), + ], + } + + const result = mergeRemoteSnapshot({ + localSessions: [], + localMetas: [], + remote, + now: 1000, + createId: () => 'unused', + }) + + expect(result.sessionsToSave.map((item) => item.id)).toEqual(['chat-1', 'legacy-1']) + expect(result.metasToSave.map((item) => [item.id, item.type])).toEqual([ + ['chat-1', 'chat'], + ['legacy-1', undefined], + ]) + }) + + it('preserves local data by importing changed remote sessions as synced copies', () => { + const local = session('same-id', 'Project', 'local text') + const remoteSession = session('same-id', 'Project', 'remote text') + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSession], + metas: [meta('same-id', 'Project')], + } + + const result = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project')], + remote, + now: 2000, + createId: () => 'copy-id', + }) + + expect(result.sessionsToSave).toHaveLength(1) + expect(result.sessionsToSave[0].id).toBe('copy-id') + expect(result.sessionsToSave[0].name).toBe('Project (Synced copy)') + expect(result.metasToSave[0]).toMatchObject({ + id: 'copy-id', + name: 'Project (Synced copy)', + sortOrder: 2000, + createdAt: 2000, + }) + expect(result.imported).toBe(0) + expect(result.conflicts).toBe(1) + }) +}) diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts new file mode 100644 index 0000000000..4d37a507ae --- /dev/null +++ b/src/renderer/packages/sync/snapshot.ts @@ -0,0 +1,139 @@ +import { isChatSession, type Session, type SessionMetaRecord } from '@shared/types' +import type { MergeRemoteSnapshotInput, MergeRemoteSnapshotResult, SyncSnapshot } from './types' + +function isChatSessionMetaLike(item: Pick): boolean { + return item.type === 'chat' || !item.type +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]` + } + if (value && typeof value === 'object') { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`) + .join(',')}}` + } + return JSON.stringify(value) +} + +function sessionsEqual(left: Session, right: Session): boolean { + return stableStringify(left) === stableStringify(right) +} + +function copyName(name: string): string { + return `${name} (Synced copy)` +} + +function metaForSession( + session: Session, + remoteMeta: SessionMetaRecord | undefined, + now: number +): SessionMetaRecord { + return { + id: session.id, + name: session.name, + type: session.type, + starred: session.starred ?? remoteMeta?.starred, + hidden: session.hidden ?? remoteMeta?.hidden, + assistantAvatarKey: session.assistantAvatarKey ?? remoteMeta?.assistantAvatarKey, + picUrl: session.picUrl ?? remoteMeta?.picUrl, + backgroundImage: session.backgroundImage ?? remoteMeta?.backgroundImage, + sortOrder: remoteMeta?.sortOrder ?? now, + createdAt: remoteMeta?.createdAt ?? now, + } +} + +function metaForCopiedSession( + session: Session, + remoteMeta: SessionMetaRecord | undefined, + now: number +): SessionMetaRecord { + return { + id: session.id, + name: session.name, + type: session.type, + starred: session.starred ?? remoteMeta?.starred, + hidden: session.hidden ?? remoteMeta?.hidden, + assistantAvatarKey: session.assistantAvatarKey ?? remoteMeta?.assistantAvatarKey, + picUrl: session.picUrl ?? remoteMeta?.picUrl, + backgroundImage: session.backgroundImage ?? remoteMeta?.backgroundImage, + sortOrder: now, + createdAt: now, + } +} + +export function createSyncSnapshot(input: { + sessions: Session[] + metas: SessionMetaRecord[] + deviceName: string + exportedAt?: string +}): SyncSnapshot { + const sessions = input.sessions.filter(isChatSession) + const sessionIds = new Set(sessions.map((session) => session.id)) + + return { + version: 1, + exportedAt: input.exportedAt ?? new Date().toISOString(), + deviceName: input.deviceName, + sessions, + metas: input.metas.filter((meta) => sessionIds.has(meta.id) && isChatSessionMetaLike(meta)), + } +} + +export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemoteSnapshotResult { + const localSessionById = new Map(input.localSessions.filter(isChatSession).map((session) => [session.id, session])) + const localMetaById = new Map(input.localMetas.filter(isChatSessionMetaLike).map((meta) => [meta.id, meta])) + const remoteMetaById = new Map(input.remote.metas.map((meta) => [meta.id, meta])) + const seenRemoteSessionIds = new Set() + + const sessionsToSave: Session[] = [] + const metasToSave: SessionMetaRecord[] = [] + let imported = 0 + let conflicts = 0 + + for (const remoteSession of input.remote.sessions) { + if (!isChatSession(remoteSession) || seenRemoteSessionIds.has(remoteSession.id)) { + continue + } + seenRemoteSessionIds.add(remoteSession.id) + + const localSession = localSessionById.get(remoteSession.id) + const remoteMeta = remoteMetaById.get(remoteSession.id) + + if (!localSession) { + sessionsToSave.push(remoteSession) + metasToSave.push(metaForSession(remoteSession, remoteMeta, input.now)) + imported += 1 + continue + } + + if (sessionsEqual(localSession, remoteSession)) { + if (!localMetaById.has(remoteSession.id) && remoteMeta) { + metasToSave.push(metaForSession(remoteSession, remoteMeta, input.now)) + } + continue + } + + const copiedName = copyName(remoteSession.name) + const copiedSession: Session = { + ...remoteSession, + id: input.createId(), + name: copiedName, + } + sessionsToSave.push(copiedSession) + metasToSave.push({ + ...metaForCopiedSession(copiedSession, remoteMeta, input.now), + name: copiedName, + }) + conflicts += 1 + } + + return { + sessionsToSave, + metasToSave, + imported, + conflicts, + } +} diff --git a/src/renderer/packages/sync/types.ts b/src/renderer/packages/sync/types.ts new file mode 100644 index 0000000000..8ac4098a0d --- /dev/null +++ b/src/renderer/packages/sync/types.ts @@ -0,0 +1,37 @@ +import type { Session, SessionMetaRecord } from '@shared/types' +export type { WebDAVMethod, WebDAVRequest, WebDAVResponse } from '@shared/sync-webdav' + +export type SyncProvider = 'webdav' + +export type SyncCryptoEnvelope = { + version: 1 + kdf: 'PBKDF2-SHA256' + cipher: 'AES-GCM' + iterations: number + salt: string + iv: string + ciphertext: string +} + +export type SyncSnapshot = { + version: 1 + exportedAt: string + deviceName: string + sessions: Session[] + metas: SessionMetaRecord[] +} + +export type MergeRemoteSnapshotInput = { + localSessions: Session[] + localMetas: SessionMetaRecord[] + remote: SyncSnapshot + now: number + createId: () => string +} + +export type MergeRemoteSnapshotResult = { + sessionsToSave: Session[] + metasToSave: SessionMetaRecord[] + imported: number + conflicts: number +} diff --git a/src/renderer/packages/sync/webdav.test.ts b/src/renderer/packages/sync/webdav.test.ts new file mode 100644 index 0000000000..141c842d74 --- /dev/null +++ b/src/renderer/packages/sync/webdav.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildBasicAuthHeader, + executeWebDAVRequest, + joinWebDAVUrl, + requestWebDAV, + validateWebDAVRequestTarget, +} from './webdav' + +describe('WebDAV helpers', () => { + it('joins a base collection URL and sync-relative path without duplicate slashes', () => { + expect(joinWebDAVUrl('https://dav.example.com/remote.php/dav/files/me/', '/ChatboxSync/v1/snapshot.json.enc')).toBe( + 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc' + ) + }) + + it('builds a basic auth header from username and password', () => { + expect(buildBasicAuthHeader('alice', 'app-password')).toBe(`Basic ${btoa('alice:app-password')}`) + }) + + it('routes requests through the platform WebDAV request method', async () => { + const webdavRequest = vi.fn(async () => ({ + status: 200, + headers: { etag: '"abc"' }, + body: 'ok', + })) + + const result = await requestWebDAV( + { + webdavRequest, + }, + 'https://dav.example.com/', + { + url: 'https://dav.example.com/ChatboxSync/v1/snapshot.json.enc', + method: 'PUT', + headers: { Authorization: 'Basic abc' }, + body: 'payload', + } + ) + + expect(webdavRequest).toHaveBeenCalledWith({ + url: 'https://dav.example.com/ChatboxSync/v1/snapshot.json.enc', + method: 'PUT', + headers: { Authorization: 'Basic abc' }, + body: 'payload', + }, 'https://dav.example.com/') + expect(result.body).toBe('ok') + }) + + it('rejects WebDAV requests outside the fixed sync paths and methods', () => { + const baseUrl = 'https://dav.example.com/remote.php/dav/files/me/' + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'GET', + }) + ).not.toThrow() + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/other.json', + method: 'GET', + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'DELETE', + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc?target=other', + method: 'GET', + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'GET', + headers: { 'X-Forwarded-Host': 'internal' }, + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'GET', + body: 'unexpected', + }) + ).toThrow(/not allowed/i) + }) + + it('rejects malformed runtime request payloads before transport', () => { + const baseUrl = 'https://dav.example.com/remote.php/dav/files/me/' + const url = 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc' + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: new URL(url) as unknown as string, + method: 'GET', + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url, + method: 'GET', + headers: { Authorization: 123 as unknown as string }, + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url, + method: 'PUT', + body: { payload: true } as unknown as string, + }) + ).toThrow(/not allowed/i) + + expect(() => + validateWebDAVRequestTarget(`${baseUrl}?token=secret`, { + url, + method: 'GET', + }) + ).toThrow(/not allowed/i) + }) + + it('executes WebDAV requests with redirect rejection through the provided transport', async () => { + const fetchImpl = vi.fn(async () => new Response('ok', { status: 200, headers: { ETag: '"abc"' } })) + + const result = await executeWebDAVRequest( + 'https://dav.example.com/remote.php/dav/files/me/', + { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'GET', + headers: { Authorization: 'Basic abc' }, + }, + fetchImpl + ) + + expect(fetchImpl).toHaveBeenCalledWith( + new URL('https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc'), + { + method: 'GET', + headers: { Authorization: 'Basic abc' }, + body: undefined, + redirect: 'error', + } + ) + expect(result.status).toBe(200) + expect(result.headers.etag).toBe('"abc"') + expect(result.body).toBe('ok') + }) + + it('rejects WebDAV redirects even if the transport returns a redirect response', async () => { + const fetchImpl = vi.fn( + async () => + new Response('', { + status: 302, + headers: { Location: 'https://internal.example.test/' }, + }) + ) + + await expect( + executeWebDAVRequest( + 'https://dav.example.com/remote.php/dav/files/me/', + { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'GET', + headers: { Authorization: 'Basic abc' }, + }, + fetchImpl + ) + ).rejects.toThrow(/redirect/i) + }) +}) diff --git a/src/renderer/packages/sync/webdav.ts b/src/renderer/packages/sync/webdav.ts new file mode 100644 index 0000000000..580e316f6d --- /dev/null +++ b/src/renderer/packages/sync/webdav.ts @@ -0,0 +1,30 @@ +import { + executeWebDAVRequest, + joinWebDAVUrl, + SYNC_COLLECTION_PATH, + SYNC_SNAPSHOT_PATH, + validateWebDAVRequestTarget, +} from '@shared/sync-webdav' +import type { WebDAVRequest, WebDAVResponse } from './types' + +export { executeWebDAVRequest, joinWebDAVUrl, SYNC_COLLECTION_PATH, SYNC_SNAPSHOT_PATH, validateWebDAVRequestTarget } + +type WebDAVCapablePlatform = { + webdavRequest?: (request: WebDAVRequest, baseUrl: string) => Promise +} + +export function buildBasicAuthHeader(username: string, password: string): string { + return `Basic ${btoa(`${username}:${password}`)}` +} + +export function requestWebDAV( + platform: WebDAVCapablePlatform, + baseUrl: string, + request: WebDAVRequest +): Promise { + validateWebDAVRequestTarget(baseUrl, request) + if (!platform.webdavRequest) { + throw new Error('WebDAV sync is not supported on this platform') + } + return platform.webdavRequest(request, baseUrl) +} diff --git a/src/renderer/platform/desktop_platform.ts b/src/renderer/platform/desktop_platform.ts index 96b51c3641..2e5f9882c7 100644 --- a/src/renderer/platform/desktop_platform.ts +++ b/src/renderer/platform/desktop_platform.ts @@ -12,6 +12,7 @@ import { IndexedDBSessionMetaStorage, type SessionMetaStorage } from '@/storage/ import { IndexedDBTaskSessionStorage, type TaskSessionStorage } from '@/storage/TaskSessionStorage' import { rememberFileNativePath } from '@/utils/file-native-path' import { getOS } from '../packages/navigator' +import type { WebDAVRequest, WebDAVResponse } from '../packages/sync/types' import type { Platform, PlatformType } from './interfaces' import DesktopKnowledgeBaseController from './knowledge-base/desktop-controller' import DesktopSessionAttachmentRagController from './session-attachment-rag/desktop-controller' @@ -237,6 +238,10 @@ export default class DesktopPlatform implements Platform { return this.ipc.invoke('ensureAutoLaunch', enable) } + public async webdavRequest(request: WebDAVRequest, _baseUrl: string): Promise { + return this.ipc.invoke('webdav:request', request) + } + async parseFileLocally(file: File): Promise<{ key?: string; isSupported: boolean }> { let result: { text: string; isSupported: boolean } const filePath = this.getLocalFilePath(file) diff --git a/src/renderer/platform/interfaces.ts b/src/renderer/platform/interfaces.ts index 7df39290e3..fd01878b31 100644 --- a/src/renderer/platform/interfaces.ts +++ b/src/renderer/platform/interfaces.ts @@ -3,6 +3,7 @@ import type { Config, Language, Settings, ShortcutSetting } from '@shared/types' import type { ImageGenerationStorage } from '@/storage/ImageGenerationStorage' import type { SessionMetaStorage } from '@/storage/SessionMetaStorage' import type { TaskSessionStorage } from '@/storage/TaskSessionStorage' +import type { WebDAVRequest, WebDAVResponse } from '../packages/sync/types' import type { KnowledgeBaseController } from './knowledge-base/interface' import type { SessionAttachmentRagController } from './session-attachment-rag/interface' @@ -79,6 +80,9 @@ export interface Platform extends Storage { ensureAutoLaunch(enable: boolean): Promise + // WebDAV sync transport (Desktop/Mobile only) + webdavRequest?(request: WebDAVRequest, baseUrl: string): Promise + parseFileLocally(file: File): Promise<{ key?: string; isSupported: boolean }> getLocalFilePath(file: File): string readLocalFileContent?(filePath: string): Promise diff --git a/src/renderer/platform/mobile_platform.ts b/src/renderer/platform/mobile_platform.ts index 58fb209156..9f393728a5 100644 --- a/src/renderer/platform/mobile_platform.ts +++ b/src/renderer/platform/mobile_platform.ts @@ -1,7 +1,9 @@ import { App } from '@capacitor/app' import { Browser } from '@capacitor/browser' +import { CapacitorHttp } from '@capacitor/core' import { Device } from '@capacitor/device' import * as defaults from '@shared/defaults' +import { validateWebDAVRequestTarget } from '@shared/sync-webdav' import type { Config, Settings, ShortcutSetting } from '@shared/types' import localforage from 'localforage' import { v4 as uuidv4 } from 'uuid' @@ -13,6 +15,7 @@ import { SQLiteSessionMetaStorage } from '@/storage/SQLiteSessionMetaStorage' import { IndexedDBTaskSessionStorage, type TaskSessionStorage } from '@/storage/TaskSessionStorage' import { CHATBOX_BUILD_PLATFORM } from '@/variables' import { getBrowser, getOS } from '../packages/navigator' +import type { WebDAVRequest, WebDAVResponse } from '../packages/sync/types' import type { Platform, PlatformType } from './interfaces' import type { KnowledgeBaseController } from './knowledge-base/interface' import MobileExporter from './mobile_exporter' @@ -243,6 +246,29 @@ export default class MobilePlatform extends MobileSQLiteStorage implements Platf return } + public async webdavRequest(request: WebDAVRequest, baseUrl: string): Promise { + validateWebDAVRequestTarget(baseUrl, request) + const response = await CapacitorHttp.request({ + url: request.url, + method: request.method, + headers: request.headers, + data: request.body, + disableRedirects: true, + webFetchExtra: { + redirect: 'error', + }, + responseType: 'text', + }) + if (response.status >= 300 && response.status <= 399) { + throw new Error('WebDAV redirects are not allowed') + } + return { + status: response.status, + headers: response.headers ?? {}, + body: typeof response.data === 'string' ? response.data : JSON.stringify(response.data ?? ''), + } + } + async parseFileLocally(file: File): Promise<{ key?: string; isSupported: boolean }> { const result = await parseTextFileLocally(file) if (!result.isSupported) { diff --git a/src/renderer/routes/settings/general.tsx b/src/renderer/routes/settings/general.tsx index 74d844630c..276b86aa92 100644 --- a/src/renderer/routes/settings/general.tsx +++ b/src/renderer/routes/settings/general.tsx @@ -5,8 +5,8 @@ import { Divider, FileButton, Flex, + PasswordInput, Radio, - Select, Stack, Switch, Text, @@ -18,17 +18,21 @@ import { formatFileSize } from '@shared/utils' import { IconInfoCircle } from '@tabler/icons-react' import { createFileRoute } from '@tanstack/react-router' import dayjs from 'dayjs' -import { mapValues, uniqBy } from 'lodash' +import { mapValues } from 'lodash' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import { AdaptiveSelect } from '@/components/AdaptiveSelect' import LazySlider from '@/components/common/LazySlider' import { languageNameMap, languages } from '@/i18n/locales' +import { createDefaultWebDAVSyncDeps } from '@/packages/sync/local' +import { downloadAndMergeWebDAVSnapshot, testWebDAVConnection, uploadWebDAVSnapshot } from '@/packages/sync/service' +import { toastError } from '@/packages/toast' import platform from '@/platform' import storage, { StorageKey } from '@/storage' import { getMetaStorage, recoverSessionList } from '@/stores/chatStore' import { migrateOnData } from '@/stores/migration' -import { useSettingsStore } from '@/stores/settingsStore' +import { settingsStore, useSettingsStore } from '@/stores/settingsStore' export const Route = createFileRoute('/settings/general')({ component: RouteComponent, @@ -156,6 +160,14 @@ export function RouteComponent() { {/* Data Recovery */} + {platform.type !== 'web' && ( + <> + + + + + )} + {/* import and export data */} @@ -226,6 +238,149 @@ export function RouteComponent() { ) } +const WebDAVSyncSection = () => { + const { t } = useTranslation() + const { setSettings, sync } = useSettingsStore((state) => ({ + setSettings: state.setSettings, + sync: state.sync, + })) + const [runningAction, setRunningAction] = useState<'test' | 'upload' | 'download' | null>(null) + + const updateWebDAVSettings = (patch: Partial) => { + setSettings((settings) => { + settings.sync.webdav = { + ...settings.sync.webdav, + ...patch, + } + }) + } + + const runSyncAction = async (action: 'test' | 'upload' | 'download', task: () => Promise) => { + if (runningAction) return + setRunningAction(action) + try { + const message = await task() + if (message) { + toast.success(message) + } + } catch (error) { + toastError(error instanceof Error ? error.message : String(error)) + } finally { + setRunningAction(null) + } + } + + const currentSettings = () => settingsStore.getState().getSettings() + + return ( + + + {t('WebDAV Sync')} + + {t( + 'Sync chat history through your own WebDAV storage. API keys, licenses, and provider credentials are not synced.' + )} + + + + + setSettings((settings) => { + settings.sync.enabled = event.currentTarget.checked + }) + } + /> + + updateWebDAVSettings({ url: event.currentTarget.value })} + /> + + updateWebDAVSettings({ username: event.currentTarget.value })} + /> + + updateWebDAVSettings({ password: event.currentTarget.value })} + /> + + updateWebDAVSettings({ syncPassword: event.currentTarget.value })} + /> + + {sync.lastSyncedAt && ( + + {t('Last synced at {{time}}', { time: dayjs(sync.lastSyncedAt).format('YYYY-MM-DD HH:mm') })} + + )} + + + + + + + + ) +} + const DataRecoverySection = () => { const { t } = useTranslation() const [isRecovering, setIsRecovering] = useState(false) diff --git a/src/shared/defaults.ts b/src/shared/defaults.ts index 69ffa004e7..d890d967b8 100644 --- a/src/shared/defaults.ts +++ b/src/shared/defaults.ts @@ -151,6 +151,17 @@ export function settings(): Settings { enabledSkillNames: [], translationEnabled: true, }, + sync: { + enabled: false, + provider: 'webdav', + webdav: { + url: '', + username: '', + password: '', + syncPassword: '', + }, + lastSyncedAt: undefined, + }, } } diff --git a/src/shared/sync-settings.test.ts b/src/shared/sync-settings.test.ts new file mode 100644 index 0000000000..b51fd42f82 --- /dev/null +++ b/src/shared/sync-settings.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import * as defaults from './defaults' +import { SettingsSchema } from './types' + +describe('sync settings defaults', () => { + it('defaults WebDAV sync to disabled and keeps credentials empty', () => { + const settings = SettingsSchema.parse(defaults.settings()) + + expect(settings.sync).toEqual({ + enabled: false, + provider: 'webdav', + webdav: { + url: '', + username: '', + password: '', + syncPassword: '', + }, + lastSyncedAt: undefined, + }) + }) +}) diff --git a/src/shared/sync-webdav.ts b/src/shared/sync-webdav.ts new file mode 100644 index 0000000000..50a80b61d8 --- /dev/null +++ b/src/shared/sync-webdav.ts @@ -0,0 +1,122 @@ +export const SYNC_COLLECTION_PATH = 'ChatboxSync/v1/' +export const SYNC_SNAPSHOT_PATH = `${SYNC_COLLECTION_PATH}snapshot.json.enc` + +export type WebDAVMethod = 'GET' | 'PUT' | 'MKCOL' | 'PROPFIND' | 'DELETE' + +export type WebDAVRequest = { + url: string + method: WebDAVMethod + headers?: Record + body?: string +} + +export type WebDAVResponse = { + status: number + headers: Record + body: string +} + +type WebDAVFetch = ( + input: string | URL, + init: { + method: WebDAVMethod + headers?: Record + body?: string + redirect: 'error' + } +) => Promise + +const ALLOWED_WEBDAV_TARGETS = new Map>([ + ['ChatboxSync/', new Set(['MKCOL'])], + [SYNC_COLLECTION_PATH, new Set(['MKCOL'])], + [SYNC_SNAPSHOT_PATH, new Set(['GET', 'PUT', 'PROPFIND'])], +]) +const ALLOWED_WEBDAV_HEADERS = new Set(['authorization', 'content-type', 'depth']) +const REDIRECT_STATUS_MIN = 300 +const REDIRECT_STATUS_MAX = 399 + +function rejectWebDAVRequest(): never { + throw new Error('WebDAV request target is not allowed') +} + +export function joinWebDAVUrl(base: string, path: string): string { + return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}` +} + +function normalizeRelativePath(pathname: string): string { + return pathname.replace(/^\/+/, '') +} + +export function validateWebDAVRequestTarget(baseUrl: string, request: WebDAVRequest): void { + if (typeof baseUrl !== 'string' || typeof request !== 'object' || request === null || Array.isArray(request)) { + rejectWebDAVRequest() + } + if (typeof request.url !== 'string' || typeof request.method !== 'string') { + rejectWebDAVRequest() + } + if (request.headers !== undefined && (typeof request.headers !== 'object' || request.headers === null || Array.isArray(request.headers))) { + rejectWebDAVRequest() + } + if (request.body !== undefined && typeof request.body !== 'string') { + rejectWebDAVRequest() + } + + const base = new URL(baseUrl) + const url = new URL(request.url) + if (base.protocol !== 'https:' || url.protocol !== 'https:') { + throw new Error('WebDAV URL must use HTTPS') + } + if (base.search || base.hash) { + rejectWebDAVRequest() + } + const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/` + if (url.origin !== base.origin || !url.pathname.startsWith(basePath)) { + rejectWebDAVRequest() + } + if (url.search || url.hash) { + rejectWebDAVRequest() + } + + const relativePath = normalizeRelativePath(url.pathname.slice(basePath.length)) + const allowedMethods = ALLOWED_WEBDAV_TARGETS.get(relativePath) + if (!allowedMethods?.has(request.method)) { + rejectWebDAVRequest() + } + for (const [headerName, headerValue] of Object.entries(request.headers ?? {})) { + if (typeof headerValue !== 'string') { + rejectWebDAVRequest() + } + if (!ALLOWED_WEBDAV_HEADERS.has(headerName.toLowerCase())) { + rejectWebDAVRequest() + } + } + if (request.body !== undefined && request.method !== 'PUT') { + rejectWebDAVRequest() + } +} + +export async function executeWebDAVRequest( + baseUrl: string, + request: WebDAVRequest, + fetchImpl: WebDAVFetch +): Promise { + validateWebDAVRequestTarget(baseUrl, request) + const response = await fetchImpl(new URL(request.url), { + method: request.method, + headers: request.headers, + body: request.body, + redirect: 'error', + }) + if (response.status >= REDIRECT_STATUS_MIN && response.status <= REDIRECT_STATUS_MAX) { + throw new Error('WebDAV redirects are not allowed') + } + const headers: Record = {} + response.headers.forEach((value, key) => { + headers[key] = value + }) + return { + status: response.status, + headers, + body: await response.text(), + } +} diff --git a/src/shared/types/settings.ts b/src/shared/types/settings.ts index 5b809c9973..241dbbffad 100644 --- a/src/shared/types/settings.ts +++ b/src/shared/types/settings.ts @@ -282,6 +282,25 @@ const MCPSettingsSchema = z.object({ enabledBuiltinServers: z.array(z.string()), }) +export const SyncSettingsSchema = z.object({ + enabled: z.boolean().catch(false), + provider: z.literal('webdav').catch('webdav'), + webdav: z + .object({ + url: z.string().catch(''), + username: z.string().catch(''), + password: z.string().catch(''), + syncPassword: z.string().catch(''), + }) + .catch({ + url: '', + username: '', + password: '', + syncPassword: '', + }), + lastSyncedAt: z.string().optional().catch(undefined), +}) + export enum Theme { Dark, Light, @@ -414,6 +433,17 @@ export const SettingsSchema = GlobalSessionSettingsSchema.extend({ enabledSkillNames: [], translationEnabled: true, }), + sync: SyncSettingsSchema.catch({ + enabled: false, + provider: 'webdav', + webdav: { + url: '', + username: '', + password: '', + syncPassword: '', + }, + lastSyncedAt: undefined, + }), }) // TODO: provider的 base info 和 settings混在一起了,可以考虑像 session settings 和 global settings一样拆开 @@ -441,6 +471,7 @@ export type ExtensionSettings = z.infer export type MCPTransportConfig = z.infer export type MCPServerConfig = z.infer export type MCPSettings = z.infer +export type SyncSettings = z.infer // Re-export SkillSettings for convenience export type { SkillSettings } from './skills' From 0279b3baa6b1b0c9afa1422f15fbde14f1bb4d74 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 5 Jul 2026 09:47:12 +0800 Subject: [PATCH 02/19] Address WebDAV sync review feedback --- src/renderer/packages/sync/service.test.ts | 49 ++++++- src/renderer/packages/sync/service.ts | 50 +++++-- src/renderer/packages/sync/snapshot.test.ts | 139 +++++++++++++++++++- src/renderer/packages/sync/snapshot.ts | 134 +++++++++++++++++-- src/renderer/packages/sync/webdav.test.ts | 19 ++- src/renderer/packages/sync/webdav.ts | 7 +- 6 files changed, 360 insertions(+), 38 deletions(-) diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index 9e45e6eac3..43ab56ceff 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -50,7 +50,11 @@ describe('WebDAV sync service', () => { getDeviceName: vi.fn(async () => 'Mac'), webdavRequest: vi.fn((request: WebDAVRequest) => { requests.push(request) - return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) + return Promise.resolve({ + status: request.method === 'PUT' ? 201 : request.method === 'GET' ? 404 : 405, + headers: {}, + body: '', + }) }), }, listLocalSessions: vi.fn(async () => [session('s1', 'Local', 'hello')]), @@ -77,6 +81,49 @@ describe('WebDAV sync service', () => { expect(deps.updateLastSyncedAt).toHaveBeenCalledWith('1970-01-01T00:00:01.000Z') }) + it('merges the existing remote snapshot before uploading local sessions', async () => { + const requests: WebDAVRequest[] = [] + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'remote text')], + metas: [meta('remote-1', 'Remote')], + } + const remoteEnvelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn((request: WebDAVRequest) => { + requests.push(request) + if (request.method === 'GET') { + return Promise.resolve({ status: 200, headers: {}, body: JSON.stringify(remoteEnvelope) }) + } + return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) + }), + }, + listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), + listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 1000, + } + + const result = await uploadWebDAVSnapshot(baseSettings, deps) + const put = requests.find((request) => request.method === 'PUT') + const envelope = JSON.parse(put?.body ?? '{}') + const decrypted = await decryptJsonEnvelope(envelope, 'sync-secret') + + expect(result.uploaded).toBe(2) + expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) + expect(decrypted.metas.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) + expect(deps.saveSession).not.toHaveBeenCalled() + expect(deps.saveMetas).not.toHaveBeenCalled() + }) + it('downloads, decrypts, and saves missing remote sessions', async () => { const remote: SyncSnapshot = { version: 1, diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 7ce7132ff4..7cc40626f7 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -121,6 +121,23 @@ async function ensureWebDAVCollections(settings: Settings, platform: SyncPlatfor } } +async function downloadWebDAVSnapshot(settings: Settings, platform: SyncPlatform): Promise { + const webdav = getWebDAVSettings(settings) + const response = await requestWebDAV(platform, webdav.url, { + url: snapshotUrl(settings), + method: 'GET', + headers: authHeaders(settings), + }) + + if (response.status === 404) { + return undefined + } + assertSuccess(response, 'Download WebDAV sync snapshot', [200]) + + const envelope = JSON.parse(response.body) as SyncCryptoEnvelope + return parseSyncSnapshot(await decryptJsonEnvelope(envelope, webdav.syncPassword)) +} + export async function testWebDAVConnection(settings: Settings, deps: Pick): Promise { const webdav = getWebDAVSettings(settings) await ensureWebDAVCollections(settings, deps.platform) @@ -148,12 +165,30 @@ export async function uploadWebDAVSnapshot( deps.platform.getDeviceName?.() ?? Promise.resolve('Unknown device'), ]) const lastSyncedAt = new Date((deps.now ?? Date.now)()).toISOString() - const snapshot = createSyncSnapshot({ + const localSnapshot = createSyncSnapshot({ sessions, metas, deviceName, exportedAt: lastSyncedAt, }) + const remoteSnapshot = await downloadWebDAVSnapshot(settings, deps.platform) + const mergeResult = remoteSnapshot + ? mergeRemoteSnapshot({ + localSessions: localSnapshot.sessions, + localMetas: localSnapshot.metas, + remote: remoteSnapshot, + now: (deps.now ?? Date.now)(), + createId: deps.createId, + }) + : undefined + const snapshot = mergeResult + ? createSyncSnapshot({ + sessions: [...localSnapshot.sessions, ...mergeResult.sessionsToSave], + metas: [...localSnapshot.metas, ...mergeResult.metasToSave], + deviceName, + exportedAt: lastSyncedAt, + }) + : localSnapshot const envelope = await encryptJsonEnvelope(snapshot, webdav.syncPassword) const response = await requestWebDAV(deps.platform, webdav.url, { url: snapshotUrl(settings), @@ -176,14 +211,8 @@ export async function downloadAndMergeWebDAVSnapshot( settings: Settings, deps: WebDAVSyncDeps ): Promise { - const webdav = getWebDAVSettings(settings) - const response = await requestWebDAV(deps.platform, webdav.url, { - url: snapshotUrl(settings), - method: 'GET', - headers: authHeaders(settings), - }) - - if (response.status === 404) { + const remote = await downloadWebDAVSnapshot(settings, deps.platform) + if (!remote) { return { imported: 0, conflicts: 0, @@ -191,10 +220,7 @@ export async function downloadAndMergeWebDAVSnapshot( remoteMissing: true, } } - assertSuccess(response, 'Download WebDAV sync snapshot', [200]) - const envelope = JSON.parse(response.body) as SyncCryptoEnvelope - const remote = parseSyncSnapshot(await decryptJsonEnvelope(envelope, webdav.syncPassword)) const [localSessions, localMetas] = await Promise.all([deps.listLocalSessions(), deps.listLocalMetas()]) const result = mergeRemoteSnapshot({ localSessions, diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts index cf278fb47e..9de84e7e88 100644 --- a/src/renderer/packages/sync/snapshot.test.ts +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -24,12 +24,7 @@ function legacySession(id: string, name: string, text: string): Session { return value } -function meta( - id: string, - name: string, - sortOrder = 1, - type: SessionMetaRecord['type'] = 'chat' -): SessionMetaRecord { +function meta(id: string, name: string, sortOrder = 1, type: SessionMetaRecord['type'] = 'chat'): SessionMetaRecord { return { id, name, @@ -69,6 +64,100 @@ describe('sync snapshot merge', () => { expect(snapshot.metas.map((item) => item.id)).toEqual(['chat-1', 'legacy-1']) }) + it('strips local-only blob references when creating snapshots', () => { + const local = session('chat-1', 'Chat', 'hello') + local.assistantAvatarKey = 'avatar-key' + local.backgroundImage = { type: 'storage-key', storageKey: 'background-key' } + local.messages[0].contentParts.push({ type: 'image', storageKey: 'image-key' }) + local.messages[0].files = [ + { + id: 'file-1', + name: 'doc.txt', + fileType: 'text/plain', + storageKey: 'file-key', + localPath: '/tmp/doc.txt', + ragMode: 'session-retrieval', + sessionAttachmentId: 12, + sessionAttachmentAvailability: 'allowed', + tokenCountMap: { default: 10 }, + lineCount: 5, + byteLength: 123, + }, + ] + local.messages[0].links = [ + { + id: 'link-1', + title: 'Example', + url: 'https://example.com', + storageKey: 'link-key', + tokenCountMap: { default: 20 }, + lineCount: 10, + byteLength: 456, + }, + ] + ;(local.messages[0] as unknown as { pictures: Array<{ storageKey: string }> }).pictures = [ + { storageKey: 'legacy-picture-key' }, + ] + local.threads = [ + { + id: 'thread-1', + name: 'Thread', + createdAt: 1, + messages: [ + { + id: 'thread-message-1', + role: 'user', + contentParts: [{ type: 'image', storageKey: 'thread-image-key' }], + }, + ], + }, + ] + local.messageForksHash = { + fork: { + position: 0, + createdAt: 1, + lists: [ + { + id: 'fork-list', + messages: [ + { + id: 'fork-message-1', + role: 'user', + contentParts: [{ type: 'image', storageKey: 'fork-image-key' }], + }, + ], + }, + ], + }, + } + + const snapshot = createSyncSnapshot({ + sessions: [local], + metas: [ + { + ...meta('chat-1', 'Chat'), + assistantAvatarKey: 'avatar-key', + backgroundImage: { type: 'storage-key', storageKey: 'background-key' }, + }, + ], + deviceName: 'Mac', + exportedAt: '2026-06-21T00:00:00.000Z', + }) + const synced = snapshot.sessions[0] + const message = synced.messages[0] + + expect(synced.assistantAvatarKey).toBeUndefined() + expect(synced.backgroundImage).toBeUndefined() + expect(message.contentParts).toEqual([{ type: 'text', text: 'hello' }]) + expect(message.files).toEqual([{ id: 'file-1', name: 'doc.txt', fileType: 'text/plain' }]) + expect(message.links).toEqual([{ id: 'link-1', title: 'Example', url: 'https://example.com' }]) + expect(message).not.toHaveProperty('pictures') + expect(synced.threads?.[0].messages[0].contentParts).toEqual([]) + expect(synced.messageForksHash?.fork.lists[0].messages[0].contentParts).toEqual([]) + expect(snapshot.metas[0].assistantAvatarKey).toBeUndefined() + expect(snapshot.metas[0].backgroundImage).toBeUndefined() + }) + it('imports missing remote sessions and metadata', () => { const remote: SyncSnapshot = { version: 1, @@ -92,6 +181,44 @@ describe('sync snapshot merge', () => { expect(result.conflicts).toBe(0) }) + it('strips local-only blob references when importing remote sessions', () => { + const remoteSession = session('remote-1', 'Remote', 'hello') + remoteSession.assistantAvatarKey = 'avatar-key' + remoteSession.messages[0].contentParts.push({ type: 'image', storageKey: 'image-key' }) + remoteSession.messages[0].files = [ + { id: 'file-1', name: 'doc.txt', fileType: 'text/plain', storageKey: 'file-key', localPath: '/tmp/doc.txt' }, + ] + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSession], + metas: [ + { + ...meta('remote-1', 'Remote'), + assistantAvatarKey: 'avatar-key', + backgroundImage: { type: 'storage-key', storageKey: 'background-key' }, + }, + ], + } + + const result = mergeRemoteSnapshot({ + localSessions: [], + localMetas: [], + remote, + now: 1000, + createId: () => 'unused', + }) + + expect(result.sessionsToSave[0].assistantAvatarKey).toBeUndefined() + expect(result.sessionsToSave[0].messages[0].contentParts).toEqual([{ type: 'text', text: 'hello' }]) + expect(result.sessionsToSave[0].messages[0].files).toEqual([ + { id: 'file-1', name: 'doc.txt', fileType: 'text/plain' }, + ]) + expect(result.metasToSave[0].assistantAvatarKey).toBeUndefined() + expect(result.metasToSave[0].backgroundImage).toBeUndefined() + }) + it('ignores non-chat remote sessions and repairs non-chat metas for imported chat sessions', () => { const remote: SyncSnapshot = { version: 1, diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index 4d37a507ae..95f1fac293 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -1,4 +1,11 @@ -import { isChatSession, type Session, type SessionMetaRecord } from '@shared/types' +import { + isChatSession, + type Message, + type MessageFile, + type MessageLink, + type Session, + type SessionMetaRecord, +} from '@shared/types' import type { MergeRemoteSnapshotInput, MergeRemoteSnapshotResult, SyncSnapshot } from './types' function isChatSessionMetaLike(item: Pick): boolean { @@ -22,15 +29,105 @@ function sessionsEqual(left: Session, right: Session): boolean { return stableStringify(left) === stableStringify(right) } +function stripLocalFileReferences(file: MessageFile): MessageFile { + const { + storageKey: _storageKey, + localPath: _localPath, + ragMode: _ragMode, + sessionAttachmentId: _sessionAttachmentId, + sessionAttachmentAvailability: _sessionAttachmentAvailability, + sessionAttachmentIndexStatus: _sessionAttachmentIndexStatus, + sessionAttachmentBlockedReason: _sessionAttachmentBlockedReason, + sessionAttachmentWarningReason: _sessionAttachmentWarningReason, + sessionAttachmentStatus: _sessionAttachmentStatus, + sessionAttachmentChunkCount: _sessionAttachmentChunkCount, + sessionAttachmentIndexingStage: _sessionAttachmentIndexingStage, + sessionAttachmentTotalChunks: _sessionAttachmentTotalChunks, + sessionAttachmentEmbeddedChunks: _sessionAttachmentEmbeddedChunks, + tokenCountMap: _tokenCountMap, + tokenCalculatedAt: _tokenCalculatedAt, + lineCount: _lineCount, + byteLength: _byteLength, + ...rest + } = file + return rest +} + +function stripLocalLinkReferences(link: MessageLink): MessageLink { + const { + storageKey: _storageKey, + tokenCountMap: _tokenCountMap, + tokenCalculatedAt: _tokenCalculatedAt, + lineCount: _lineCount, + byteLength: _byteLength, + ...rest + } = link + return rest +} + +function stripLocalMessageReferences(message: Message): Message { + const { pictures: _pictures, ...messageWithoutLegacyPictures } = message as Message & { pictures?: unknown } + const result: Message = { + ...messageWithoutLegacyPictures, + contentParts: message.contentParts.filter((part) => part.type !== 'image'), + } + + if (message.files) { + result.files = message.files.map(stripLocalFileReferences) + } + if (message.links) { + result.links = message.links.map(stripLocalLinkReferences) + } + + return result +} + +function stripLocalSessionReferences(session: Session): Session { + const { + assistantAvatarKey: _assistantAvatarKey, + backgroundImage: _backgroundImage, + ...sessionWithoutLocalImages + } = session + + return { + ...sessionWithoutLocalImages, + backgroundImage: session.backgroundImage?.type === 'url' ? session.backgroundImage : undefined, + messages: session.messages.map(stripLocalMessageReferences), + threads: session.threads?.map((thread) => ({ + ...thread, + messages: thread.messages.map(stripLocalMessageReferences), + })), + messageForksHash: session.messageForksHash + ? Object.fromEntries( + Object.entries(session.messageForksHash).map(([key, fork]) => [ + key, + { + ...fork, + lists: fork.lists.map((list) => ({ + ...list, + messages: list.messages.map(stripLocalMessageReferences), + })), + }, + ]) + ) + : undefined, + } +} + +function stripLocalMetaReferences(meta: SessionMetaRecord): SessionMetaRecord { + const { assistantAvatarKey: _assistantAvatarKey, backgroundImage: _backgroundImage, ...metaWithoutLocalImages } = meta + + return { + ...metaWithoutLocalImages, + backgroundImage: meta.backgroundImage?.type === 'url' ? meta.backgroundImage : undefined, + } +} + function copyName(name: string): string { return `${name} (Synced copy)` } -function metaForSession( - session: Session, - remoteMeta: SessionMetaRecord | undefined, - now: number -): SessionMetaRecord { +function metaForSession(session: Session, remoteMeta: SessionMetaRecord | undefined, now: number): SessionMetaRecord { return { id: session.id, name: session.name, @@ -70,7 +167,7 @@ export function createSyncSnapshot(input: { deviceName: string exportedAt?: string }): SyncSnapshot { - const sessions = input.sessions.filter(isChatSession) + const sessions = input.sessions.filter(isChatSession).map(stripLocalSessionReferences) const sessionIds = new Set(sessions.map((session) => session.id)) return { @@ -78,14 +175,27 @@ export function createSyncSnapshot(input: { exportedAt: input.exportedAt ?? new Date().toISOString(), deviceName: input.deviceName, sessions, - metas: input.metas.filter((meta) => sessionIds.has(meta.id) && isChatSessionMetaLike(meta)), + metas: input.metas + .filter((meta) => sessionIds.has(meta.id) && isChatSessionMetaLike(meta)) + .map(stripLocalMetaReferences), } } export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemoteSnapshotResult { - const localSessionById = new Map(input.localSessions.filter(isChatSession).map((session) => [session.id, session])) - const localMetaById = new Map(input.localMetas.filter(isChatSessionMetaLike).map((meta) => [meta.id, meta])) - const remoteMetaById = new Map(input.remote.metas.map((meta) => [meta.id, meta])) + const localSessionById = new Map( + input.localSessions.filter(isChatSession).map((session) => { + const sanitizedSession = stripLocalSessionReferences(session) + return [sanitizedSession.id, sanitizedSession] + }) + ) + const localMetaById = new Map( + input.localMetas.filter(isChatSessionMetaLike).map((meta) => { + const sanitizedMeta = stripLocalMetaReferences(meta) + return [sanitizedMeta.id, sanitizedMeta] + }) + ) + const remoteMetas = input.remote.metas.map(stripLocalMetaReferences) + const remoteMetaById = new Map(remoteMetas.map((meta) => [meta.id, meta])) const seenRemoteSessionIds = new Set() const sessionsToSave: Session[] = [] @@ -93,7 +203,7 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot let imported = 0 let conflicts = 0 - for (const remoteSession of input.remote.sessions) { + for (const remoteSession of input.remote.sessions.map(stripLocalSessionReferences)) { if (!isChatSession(remoteSession) || seenRemoteSessionIds.has(remoteSession.id)) { continue } diff --git a/src/renderer/packages/sync/webdav.test.ts b/src/renderer/packages/sync/webdav.test.ts index 141c842d74..b81f95d8fe 100644 --- a/src/renderer/packages/sync/webdav.test.ts +++ b/src/renderer/packages/sync/webdav.test.ts @@ -18,6 +18,10 @@ describe('WebDAV helpers', () => { expect(buildBasicAuthHeader('alice', 'app-password')).toBe(`Basic ${btoa('alice:app-password')}`) }) + it('encodes basic auth credentials as UTF-8 before base64 encoding', () => { + expect(buildBasicAuthHeader('用户', '密钥')).toBe(`Basic ${Buffer.from('用户:密钥', 'utf8').toString('base64')}`) + }) + it('routes requests through the platform WebDAV request method', async () => { const webdavRequest = vi.fn(async () => ({ status: 200, @@ -38,12 +42,15 @@ describe('WebDAV helpers', () => { } ) - expect(webdavRequest).toHaveBeenCalledWith({ - url: 'https://dav.example.com/ChatboxSync/v1/snapshot.json.enc', - method: 'PUT', - headers: { Authorization: 'Basic abc' }, - body: 'payload', - }, 'https://dav.example.com/') + expect(webdavRequest).toHaveBeenCalledWith( + { + url: 'https://dav.example.com/ChatboxSync/v1/snapshot.json.enc', + method: 'PUT', + headers: { Authorization: 'Basic abc' }, + body: 'payload', + }, + 'https://dav.example.com/' + ) expect(result.body).toBe('ok') }) diff --git a/src/renderer/packages/sync/webdav.ts b/src/renderer/packages/sync/webdav.ts index 580e316f6d..8a8ad8e20d 100644 --- a/src/renderer/packages/sync/webdav.ts +++ b/src/renderer/packages/sync/webdav.ts @@ -14,7 +14,12 @@ type WebDAVCapablePlatform = { } export function buildBasicAuthHeader(username: string, password: string): string { - return `Basic ${btoa(`${username}:${password}`)}` + const bytes = new TextEncoder().encode(`${username}:${password}`) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return `Basic ${btoa(binary)}` } export function requestWebDAV( From d581e2b49a123f01e3101dca30e02d872fdbc6e4 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 5 Jul 2026 10:14:41 +0800 Subject: [PATCH 03/19] Strip local file keys from synced attachment ids --- src/renderer/packages/sync/snapshot.test.ts | 25 ++++++++++++++++----- src/renderer/packages/sync/snapshot.ts | 12 ++++++++-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts index 9de84e7e88..b1de1ce25e 100644 --- a/src/renderer/packages/sync/snapshot.test.ts +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -71,10 +71,10 @@ describe('sync snapshot merge', () => { local.messages[0].contentParts.push({ type: 'image', storageKey: 'image-key' }) local.messages[0].files = [ { - id: 'file-1', + id: 'file:/tmp/doc.txt-123-456', name: 'doc.txt', fileType: 'text/plain', - storageKey: 'file-key', + storageKey: 'file:/tmp/doc.txt-123-456', localPath: '/tmp/doc.txt', ragMode: 'session-retrieval', sessionAttachmentId: 12, @@ -83,6 +83,12 @@ describe('sync snapshot merge', () => { lineCount: 5, byteLength: 123, }, + { + id: 'custom-file-id', + name: 'custom.txt', + fileType: 'text/plain', + storageKey: 'file:/tmp/custom.txt-789-123', + }, ] local.messages[0].links = [ { @@ -149,7 +155,10 @@ describe('sync snapshot merge', () => { expect(synced.assistantAvatarKey).toBeUndefined() expect(synced.backgroundImage).toBeUndefined() expect(message.contentParts).toEqual([{ type: 'text', text: 'hello' }]) - expect(message.files).toEqual([{ id: 'file-1', name: 'doc.txt', fileType: 'text/plain' }]) + expect(message.files).toEqual([ + { id: 'synced-file:0:doc.txt', name: 'doc.txt', fileType: 'text/plain' }, + { id: 'custom-file-id', name: 'custom.txt', fileType: 'text/plain' }, + ]) expect(message.links).toEqual([{ id: 'link-1', title: 'Example', url: 'https://example.com' }]) expect(message).not.toHaveProperty('pictures') expect(synced.threads?.[0].messages[0].contentParts).toEqual([]) @@ -186,7 +195,13 @@ describe('sync snapshot merge', () => { remoteSession.assistantAvatarKey = 'avatar-key' remoteSession.messages[0].contentParts.push({ type: 'image', storageKey: 'image-key' }) remoteSession.messages[0].files = [ - { id: 'file-1', name: 'doc.txt', fileType: 'text/plain', storageKey: 'file-key', localPath: '/tmp/doc.txt' }, + { + id: 'file:/tmp/doc.txt-123-456', + name: 'doc.txt', + fileType: 'text/plain', + storageKey: 'file:/tmp/doc.txt-123-456', + localPath: '/tmp/doc.txt', + }, ] const remote: SyncSnapshot = { version: 1, @@ -213,7 +228,7 @@ describe('sync snapshot merge', () => { expect(result.sessionsToSave[0].assistantAvatarKey).toBeUndefined() expect(result.sessionsToSave[0].messages[0].contentParts).toEqual([{ type: 'text', text: 'hello' }]) expect(result.sessionsToSave[0].messages[0].files).toEqual([ - { id: 'file-1', name: 'doc.txt', fileType: 'text/plain' }, + { id: 'synced-file:0:doc.txt', name: 'doc.txt', fileType: 'text/plain' }, ]) expect(result.metasToSave[0].assistantAvatarKey).toBeUndefined() expect(result.metasToSave[0].backgroundImage).toBeUndefined() diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index 95f1fac293..f915df974e 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -29,8 +29,13 @@ function sessionsEqual(left: Session, right: Session): boolean { return stableStringify(left) === stableStringify(right) } -function stripLocalFileReferences(file: MessageFile): MessageFile { +function shouldNormalizeFileAttachmentId(file: MessageFile): boolean { + return file.id === file.storageKey || file.id === file.localPath || file.id.startsWith('file:') +} + +function stripLocalFileReferences(file: MessageFile, index: number): MessageFile { const { + id, storageKey: _storageKey, localPath: _localPath, ragMode: _ragMode, @@ -50,7 +55,10 @@ function stripLocalFileReferences(file: MessageFile): MessageFile { byteLength: _byteLength, ...rest } = file - return rest + return { + id: shouldNormalizeFileAttachmentId(file) ? `synced-file:${index}:${file.name}` : id, + ...rest, + } } function stripLocalLinkReferences(link: MessageLink): MessageLink { From 49ebca2ebfaa1b2c719549f283ea4d79fe9414c1 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 5 Jul 2026 10:39:56 +0800 Subject: [PATCH 04/19] Redact WebDAV sync secrets from settings export --- src/renderer/packages/settings-export.test.ts | 66 +++++++++++++++++++ src/renderer/packages/settings-export.ts | 38 +++++++++++ .../SettingDialog/AdvancedSettingTab.tsx | 12 ++-- src/renderer/routes/settings/general.tsx | 25 ++----- 4 files changed, 118 insertions(+), 23 deletions(-) create mode 100644 src/renderer/packages/settings-export.test.ts create mode 100644 src/renderer/packages/settings-export.ts diff --git a/src/renderer/packages/settings-export.test.ts b/src/renderer/packages/settings-export.test.ts new file mode 100644 index 0000000000..ed5464ad9b --- /dev/null +++ b/src/renderer/packages/settings-export.test.ts @@ -0,0 +1,66 @@ +import * as defaults from '@shared/defaults' +import type { Settings } from '@shared/types' +import { describe, expect, it } from 'vitest' +import { sanitizeSettingsForExport } from './settings-export' + +function settingsWithSecrets(): Settings { + return { + ...defaults.settings(), + licenseKey: 'license-secret', + licenseDetail: { plan: 'pro' } as unknown as Settings['licenseDetail'], + licenseInstances: { 'license-secret': 'device-1' }, + providers: { + openai: { + apiKey: 'sk-secret', + accessKey: 'access-secret', + secretKey: 'secret-key', + sessionToken: 'session-token', + apiHost: 'https://api.example.com', + }, + }, + sync: { + enabled: true, + provider: 'webdav', + webdav: { + url: 'https://dav.example.com/files/me/', + username: 'alice', + password: 'dav-secret', + syncPassword: 'sync-secret', + }, + lastSyncedAt: '2026-07-05T00:00:00.000Z', + }, + } +} + +describe('sanitizeSettingsForExport', () => { + it('removes WebDAV and provider secrets when key export is not selected', () => { + const settings = settingsWithSecrets() + const sanitized = sanitizeSettingsForExport(settings, false) + + expect(sanitized.licenseKey).toBeUndefined() + expect(sanitized.licenseDetail).toBeUndefined() + expect(sanitized.licenseInstances).toBeUndefined() + expect(sanitized.providers?.openai).toEqual({ + apiHost: 'https://api.example.com', + }) + expect(sanitized.sync.webdav).toEqual({ + url: 'https://dav.example.com/files/me/', + username: 'alice', + password: '', + syncPassword: '', + }) + expect(settings.sync.webdav.password).toBe('dav-secret') + expect(settings.sync.webdav.syncPassword).toBe('sync-secret') + }) + + it('keeps WebDAV and provider secrets when key export is selected', () => { + const sanitized = sanitizeSettingsForExport(settingsWithSecrets(), true) + + expect(sanitized.licenseKey).toBe('license-secret') + expect(sanitized.providers?.openai?.apiKey).toBe('sk-secret') + expect(sanitized.sync.webdav.password).toBe('dav-secret') + expect(sanitized.sync.webdav.syncPassword).toBe('sync-secret') + expect(sanitized.licenseDetail).toBeUndefined() + expect(sanitized.licenseInstances).toBeUndefined() + }) +}) diff --git a/src/renderer/packages/settings-export.ts b/src/renderer/packages/settings-export.ts new file mode 100644 index 0000000000..94fc80ab08 --- /dev/null +++ b/src/renderer/packages/settings-export.ts @@ -0,0 +1,38 @@ +import type { ProviderSettings, Settings } from '@shared/types' + +function sanitizeProviderForExport(provider: ProviderSettings): ProviderSettings { + const cleanedProvider = { ...provider } + delete cleanedProvider.apiKey + delete cleanedProvider.accessKey + delete cleanedProvider.secretKey + delete cleanedProvider.sessionToken + return cleanedProvider +} + +export function sanitizeSettingsForExport(settings: Settings, includeSecrets: boolean): Settings { + const cleanedSettings: Settings = { + ...settings, + licenseDetail: undefined, + licenseInstances: undefined, + providers: settings.providers ? { ...settings.providers } : settings.providers, + sync: { + ...settings.sync, + webdav: { + ...settings.sync.webdav, + }, + }, + } + + if (!includeSecrets) { + delete cleanedSettings.licenseKey + if (cleanedSettings.providers) { + cleanedSettings.providers = Object.fromEntries( + Object.entries(cleanedSettings.providers).map(([id, provider]) => [id, sanitizeProviderForExport(provider)]) + ) as Settings['providers'] + } + cleanedSettings.sync.webdav.password = '' + cleanedSettings.sync.webdav.syncPassword = '' + } + + return cleanedSettings +} diff --git a/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx b/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx index 639bf9898b..223585c2d9 100644 --- a/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx +++ b/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx @@ -18,6 +18,7 @@ import { Accordion, AccordionDetails, AccordionSummary } from '@/components/Acco import TextFieldReset from '@/components/common/TextFieldReset' import { ShortcutConfig } from '@/components/Shortcut' import { useIsSmallScreen } from '@/hooks/useScreenChange' +import { sanitizeSettingsForExport } from '@/packages/settings-export' import platform from '@/platform' import storage, { StorageKey } from '@/storage' import { migrateOnData } from '@/stores/migration' @@ -163,10 +164,13 @@ function ExportAndImport(props: { onCancel: () => void }) { const onExport = async () => { const data = await storage.getAll() delete data[StorageKey.Configs] // 不导出 uuid - ;(data[StorageKey.Settings] as Settings).licenseDetail = undefined // 不导出license认证数据 - ;(data[StorageKey.Settings] as Settings).licenseInstances = undefined // 不导出license设备数据,导入数据的新设备也应该计入设备数 - if (!exportItems.includes(ExportDataItem.Key)) { - delete (data[StorageKey.Settings] as Settings).licenseKey + if (data[StorageKey.Settings]) { + data[StorageKey.Settings] = sanitizeSettingsForExport( + data[StorageKey.Settings] as Settings, + exportItems.includes(ExportDataItem.Key) + ) + } + if (!exportItems.includes(ExportDataItem.Key) && data[StorageKey.Settings]) { delete (data[StorageKey.Settings] as Settings).providers } if (!exportItems.includes(ExportDataItem.Setting)) { diff --git a/src/renderer/routes/settings/general.tsx b/src/renderer/routes/settings/general.tsx index 276b86aa92..aafb7d5fa5 100644 --- a/src/renderer/routes/settings/general.tsx +++ b/src/renderer/routes/settings/general.tsx @@ -13,18 +13,18 @@ import { TextInput, Title, } from '@mantine/core' -import { type Language, type ProviderInfo, type Settings, Theme } from '@shared/types' +import { type Language, type Settings, Theme } from '@shared/types' import { formatFileSize } from '@shared/utils' import { IconInfoCircle } from '@tabler/icons-react' import { createFileRoute } from '@tanstack/react-router' import dayjs from 'dayjs' -import { mapValues } from 'lodash' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { AdaptiveSelect } from '@/components/AdaptiveSelect' import LazySlider from '@/components/common/LazySlider' import { languageNameMap, languages } from '@/i18n/locales' +import { sanitizeSettingsForExport } from '@/packages/settings-export' import { createDefaultWebDAVSyncDeps } from '@/packages/sync/local' import { downloadAndMergeWebDAVSnapshot, testWebDAVConnection, uploadWebDAVSnapshot } from '@/packages/sync/service' import { toastError } from '@/packages/toast' @@ -524,23 +524,10 @@ const ImportExportDataSection = () => { if (value !== null) { // 对settings进行特殊处理,清理敏感数据 if (key === StorageKey.Settings) { - const cleanedSettings = { ...(value as Settings) } - cleanedSettings.licenseDetail = undefined - cleanedSettings.licenseInstances = undefined - - if (!exportItems.includes(ExportDataItem.Key)) { - delete cleanedSettings.licenseKey - if (cleanedSettings.providers) { - cleanedSettings.providers = mapValues(cleanedSettings.providers, (provider: ProviderInfo) => { - const cleanedProvider = { ...provider } - delete cleanedProvider.apiKey - delete cleanedProvider.accessKey - delete cleanedProvider.secretKey - delete cleanedProvider.sessionToken - return cleanedProvider - }) as unknown as { [key: string]: ProviderInfo } - } - } + const cleanedSettings = sanitizeSettingsForExport( + value as Settings, + exportItems.includes(ExportDataItem.Key) + ) yield ',' yield `"${key}":${JSON.stringify(cleanedSettings)}` From d2a0920aa2982c29add79b1564b3be7f18a695d3 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 5 Jul 2026 11:00:43 +0800 Subject: [PATCH 05/19] Handle metadata-only WebDAV sync updates --- src/renderer/packages/sync/service.test.ts | 50 ++++++++++- src/renderer/packages/sync/service.ts | 1 + src/renderer/packages/sync/snapshot.test.ts | 61 ++++++++++++++ src/renderer/packages/sync/snapshot.ts | 92 ++++++++++++++++----- src/renderer/packages/sync/types.ts | 2 + 5 files changed, 185 insertions(+), 21 deletions(-) diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index 43ab56ceff..99f5aa753a 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -1,4 +1,4 @@ -import type { Settings } from '@shared/types' +import type { Session, Settings } from '@shared/types' import { describe, expect, it, vi } from 'vitest' import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' import { downloadAndMergeWebDAVSnapshot, uploadWebDAVSnapshot } from './service' @@ -17,7 +17,7 @@ const baseSettings = { }, } as Settings -function session(id: string, name: string, text: string) { +function session(id: string, name: string, text: string): Session { return { id, type: 'chat' as const, @@ -167,6 +167,52 @@ describe('WebDAV sync service', () => { expect(deps.updateLastSyncedAt).toHaveBeenCalledWith('1970-01-01T00:00:02.000Z') }) + it('downloads metadata-only changes without creating synced copies', async () => { + const local = session('same-id', 'Local Name', 'same text') + local.settings = { temperature: undefined } + const remoteSession = { + ...session('same-id', 'Remote Name', 'same text'), + starred: true, + } + const remoteMeta = { + ...meta('same-id', 'Remote Name', 99), + starred: true, + } + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSession], + metas: [remoteMeta], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => [local]), + listLocalMetas: vi.fn(async () => [meta('same-id', 'Local Name', 1)]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 2000, + } + + const result = await downloadAndMergeWebDAVSnapshot(baseSettings, deps) + + expect(result.imported).toBe(0) + expect(result.conflicts).toBe(0) + expect(deps.createId).not.toHaveBeenCalled() + expect(deps.saveSession).toHaveBeenCalledWith(expect.objectContaining({ id: 'same-id', name: 'Remote Name' })) + expect(deps.saveMetas).toHaveBeenCalledWith([expect.objectContaining(remoteMeta)]) + }) + it('rejects plaintext HTTP WebDAV URLs before sending credentials', async () => { const deps = { platform: { diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 7cc40626f7..3336f1d7e8 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -228,6 +228,7 @@ export async function downloadAndMergeWebDAVSnapshot( remote, now: (deps.now ?? Date.now)(), createId: deps.createId, + preferRemoteMetadata: true, }) const savedSessionIds: string[] = [] diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts index b1de1ce25e..bcc2b0c0a1 100644 --- a/src/renderer/packages/sync/snapshot.test.ts +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -299,4 +299,65 @@ describe('sync snapshot merge', () => { expect(result.imported).toBe(0) expect(result.conflicts).toBe(1) }) + + it('updates remote metadata for same-content sessions without creating synced copies when requested', () => { + const local = session('same-id', 'Project', 'same text') + const remoteSession = { + ...session('same-id', 'Project Remote', 'same text'), + starred: true, + } + const remoteMeta = { + ...meta('same-id', 'Project Remote', 99), + starred: true, + } + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSession], + metas: [remoteMeta], + } + + const result = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project', 1)], + remote, + now: 2000, + createId: () => 'copy-id', + preferRemoteMetadata: true, + }) + + expect(result.sessionsToSave).toEqual([expect.objectContaining({ id: 'same-id', name: 'Project Remote' })]) + expect(result.metasToSave).toEqual([remoteMeta]) + expect(result.imported).toBe(0) + expect(result.conflicts).toBe(0) + }) + + it('keeps local metadata for same-content sessions during upload merges', () => { + const local = session('same-id', 'Project', 'same text') + const remoteSession = { + ...session('same-id', 'Project Remote', 'same text'), + starred: true, + } + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSession], + metas: [{ ...meta('same-id', 'Project Remote', 99), starred: true }], + } + + const result = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project', 1)], + remote, + now: 2000, + createId: () => 'copy-id', + }) + + expect(result.sessionsToSave).toEqual([]) + expect(result.metasToSave).toEqual([]) + expect(result.imported).toBe(0) + expect(result.conflicts).toBe(0) + }) }) diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index f915df974e..2c269c193a 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -29,6 +29,32 @@ function sessionsEqual(left: Session, right: Session): boolean { return stableStringify(left) === stableStringify(right) } +function sessionContentEqual(left: Session, right: Session): boolean { + const { + name: _leftName, + starred: _leftStarred, + hidden: _leftHidden, + assistantAvatarKey: _leftAssistantAvatarKey, + picUrl: _leftPicUrl, + backgroundImage: _leftBackgroundImage, + ...leftContent + } = left + const { + name: _rightName, + starred: _rightStarred, + hidden: _rightHidden, + assistantAvatarKey: _rightAssistantAvatarKey, + picUrl: _rightPicUrl, + backgroundImage: _rightBackgroundImage, + ...rightContent + } = right + return stableStringify(leftContent) === stableStringify(rightContent) +} + +function metasEqual(left: SessionMetaRecord, right: SessionMetaRecord): boolean { + return stableStringify(left) === stableStringify(right) +} + function shouldNormalizeFileAttachmentId(file: MessageFile): boolean { return file.id === file.storageKey || file.id === file.localPath || file.id.startsWith('file:') } @@ -96,18 +122,11 @@ function stripLocalSessionReferences(session: Session): Session { backgroundImage: _backgroundImage, ...sessionWithoutLocalImages } = session - - return { - ...sessionWithoutLocalImages, - backgroundImage: session.backgroundImage?.type === 'url' ? session.backgroundImage : undefined, - messages: session.messages.map(stripLocalMessageReferences), - threads: session.threads?.map((thread) => ({ - ...thread, - messages: thread.messages.map(stripLocalMessageReferences), - })), - messageForksHash: session.messageForksHash + const messageForkEntries = Object.entries(session.messageForksHash ?? {}) + const messageForksHash = + messageForkEntries.length > 0 ? Object.fromEntries( - Object.entries(session.messageForksHash).map(([key, fork]) => [ + messageForkEntries.map(([key, fork]) => [ key, { ...fork, @@ -118,7 +137,17 @@ function stripLocalSessionReferences(session: Session): Session { }, ]) ) - : undefined, + : undefined + + return { + ...sessionWithoutLocalImages, + backgroundImage: session.backgroundImage?.type === 'url' ? session.backgroundImage : undefined, + messages: session.messages.map(stripLocalMessageReferences), + threads: session.threads?.map((thread) => ({ + ...thread, + messages: thread.messages.map(stripLocalMessageReferences), + })), + messageForksHash, } } @@ -169,6 +198,22 @@ function metaForCopiedSession( } } +function applyMetaToSession(session: Session, meta: SessionMetaRecord | undefined): Session { + if (!meta) { + return session + } + return { + ...session, + name: meta.name, + type: session.type, + starred: meta.starred, + hidden: meta.hidden, + assistantAvatarKey: meta.assistantAvatarKey, + picUrl: meta.picUrl, + backgroundImage: meta.backgroundImage, + } +} + export function createSyncSnapshot(input: { sessions: Session[] metas: SessionMetaRecord[] @@ -219,24 +264,33 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot const localSession = localSessionById.get(remoteSession.id) const remoteMeta = remoteMetaById.get(remoteSession.id) + const remoteSessionWithMeta = applyMetaToSession(remoteSession, remoteMeta) if (!localSession) { - sessionsToSave.push(remoteSession) - metasToSave.push(metaForSession(remoteSession, remoteMeta, input.now)) + sessionsToSave.push(remoteSessionWithMeta) + metasToSave.push(metaForSession(remoteSessionWithMeta, remoteMeta, input.now)) imported += 1 continue } - if (sessionsEqual(localSession, remoteSession)) { - if (!localMetaById.has(remoteSession.id) && remoteMeta) { - metasToSave.push(metaForSession(remoteSession, remoteMeta, input.now)) + if (sessionContentEqual(localSession, remoteSession)) { + if (input.preferRemoteMetadata) { + if (!sessionsEqual(localSession, remoteSessionWithMeta)) { + sessionsToSave.push(remoteSessionWithMeta) + } + const localMeta = localMetaById.get(remoteSession.id) + if (remoteMeta && (!localMeta || !metasEqual(localMeta, remoteMeta))) { + metasToSave.push(metaForSession(remoteSessionWithMeta, remoteMeta, input.now)) + } + } else if (!localMetaById.has(remoteSession.id) && remoteMeta) { + metasToSave.push(metaForSession(remoteSessionWithMeta, remoteMeta, input.now)) } continue } - const copiedName = copyName(remoteSession.name) + const copiedName = copyName(remoteSessionWithMeta.name) const copiedSession: Session = { - ...remoteSession, + ...remoteSessionWithMeta, id: input.createId(), name: copiedName, } diff --git a/src/renderer/packages/sync/types.ts b/src/renderer/packages/sync/types.ts index 8ac4098a0d..e433d38b9b 100644 --- a/src/renderer/packages/sync/types.ts +++ b/src/renderer/packages/sync/types.ts @@ -1,4 +1,5 @@ import type { Session, SessionMetaRecord } from '@shared/types' + export type { WebDAVMethod, WebDAVRequest, WebDAVResponse } from '@shared/sync-webdav' export type SyncProvider = 'webdav' @@ -27,6 +28,7 @@ export type MergeRemoteSnapshotInput = { remote: SyncSnapshot now: number createId: () => string + preferRemoteMetadata?: boolean } export type MergeRemoteSnapshotResult = { From 700ef0a500d953baa62482dad227e192f90e77cd Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Sun, 5 Jul 2026 12:14:03 +0800 Subject: [PATCH 06/19] Use conditional WebDAV snapshot uploads --- src/renderer/packages/sync/service.test.ts | 111 +++++++++++++++++- src/renderer/packages/sync/service.ts | 124 ++++++++++++++------- src/renderer/packages/sync/webdav.test.ts | 9 ++ src/shared/sync-webdav.ts | 7 +- 4 files changed, 207 insertions(+), 44 deletions(-) diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index 99f5aa753a..42e0239c59 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -2,7 +2,7 @@ import type { Session, Settings } from '@shared/types' import { describe, expect, it, vi } from 'vitest' import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' import { downloadAndMergeWebDAVSnapshot, uploadWebDAVSnapshot } from './service' -import type { SyncSnapshot, WebDAVRequest } from './types' +import type { SyncSnapshot, WebDAVRequest, WebDAVResponse } from './types' const baseSettings = { sync: { @@ -73,6 +73,8 @@ describe('WebDAV sync service', () => { expect(result.uploaded).toBe(1) expect(put?.url).toBe('https://dav.example.com/files/me/ChatboxSync/v1/snapshot.json.enc') expect(put?.headers?.Authorization).toBe(`Basic ${btoa('alice:app-password')}`) + expect(put?.headers?.['If-None-Match']).toBe('*') + expect(put?.headers?.['If-Match']).toBeUndefined() expect(put?.body).not.toContain('hello') const envelope = JSON.parse(put?.body ?? '{}') @@ -97,7 +99,11 @@ describe('WebDAV sync service', () => { webdavRequest: vi.fn((request: WebDAVRequest) => { requests.push(request) if (request.method === 'GET') { - return Promise.resolve({ status: 200, headers: {}, body: JSON.stringify(remoteEnvelope) }) + return Promise.resolve({ + status: 200, + headers: { ETag: '"remote-etag"' }, + body: JSON.stringify(remoteEnvelope), + }) } return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) }), @@ -118,12 +124,113 @@ describe('WebDAV sync service', () => { const decrypted = await decryptJsonEnvelope(envelope, 'sync-secret') expect(result.uploaded).toBe(2) + expect(put?.headers?.['If-Match']).toBe('"remote-etag"') + expect(put?.headers?.['If-None-Match']).toBeUndefined() expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) expect(decrypted.metas.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) expect(deps.saveSession).not.toHaveBeenCalled() expect(deps.saveMetas).not.toHaveBeenCalled() }) + it('re-downloads and merges again when a conditional upload detects a changed remote snapshot', async () => { + const requests: WebDAVRequest[] = [] + const remoteBeforeRace: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote 1', 'remote text 1')], + metas: [meta('remote-1', 'Remote 1')], + } + const remoteAfterRace: SyncSnapshot = { + ...remoteBeforeRace, + sessions: [...remoteBeforeRace.sessions, session('remote-2', 'Remote 2', 'remote text 2')], + metas: [...remoteBeforeRace.metas, meta('remote-2', 'Remote 2', 2)], + } + let getCount = 0 + let putCount = 0 + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn(async (request: WebDAVRequest): Promise => { + requests.push(request) + if (request.method === 'GET') { + getCount += 1 + const remote = getCount === 1 ? remoteBeforeRace : remoteAfterRace + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + return { + status: 200, + headers: { etag: getCount === 1 ? '"old-etag"' : '"new-etag"' }, + body: JSON.stringify(envelope), + } + } + if (request.method === 'PUT') { + putCount += 1 + return { status: putCount === 1 ? 412 : 201, headers: {}, body: '' } + } + return { status: 405, headers: {}, body: '' } + }), + }, + listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), + listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 1000, + } + + const result = await uploadWebDAVSnapshot(baseSettings, deps) + const puts = requests.filter((request) => request.method === 'PUT') + const finalEnvelope = JSON.parse(puts[1]?.body ?? '{}') + const decrypted = await decryptJsonEnvelope(finalEnvelope, 'sync-secret') + + expect(result.uploaded).toBe(3) + expect(requests.filter((request) => request.method === 'GET')).toHaveLength(2) + expect(puts).toHaveLength(2) + expect(puts[0].headers?.['If-Match']).toBe('"old-etag"') + expect(puts[1].headers?.['If-Match']).toBe('"new-etag"') + expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1', 'remote-2']) + expect(deps.updateLastSyncedAt).toHaveBeenCalledTimes(1) + }) + + it('refuses to overwrite an existing remote snapshot when the server does not return an ETag', async () => { + const requests: WebDAVRequest[] = [] + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'remote text')], + metas: [meta('remote-1', 'Remote')], + } + const remoteEnvelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn((request: WebDAVRequest) => { + requests.push(request) + if (request.method === 'GET') { + return Promise.resolve({ status: 200, headers: {}, body: JSON.stringify(remoteEnvelope) }) + } + return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) + }), + }, + listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), + listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), + saveSession: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createId: vi.fn(() => 'copy-id'), + now: () => 1000, + } + + await expect(uploadWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/etag/i) + + expect(requests.filter((request) => request.method === 'PUT')).toHaveLength(0) + expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() + }) + it('downloads, decrypts, and saves missing remote sessions', async () => { const remote: SyncSnapshot = { version: 1, diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 3336f1d7e8..fdf67c57e8 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -15,11 +15,20 @@ const RawSyncSnapshotSchema = z.object({ metas: z.array(SessionMetaRecordSchema), }) +const MAX_UPLOAD_ATTEMPTS = 3 +const HTTP_NOT_FOUND = 404 +const HTTP_PRECONDITION_FAILED = 412 + type SyncPlatform = { getDeviceName?: () => Promise webdavRequest?: (request: WebDAVRequest, baseUrl: string) => Promise } +type DownloadedWebDAVSnapshot = { + snapshot?: SyncSnapshot + etag?: string +} + export type WebDAVSyncDeps = { platform: SyncPlatform listLocalSessions: () => Promise @@ -108,6 +117,20 @@ function assertSuccess(response: WebDAVResponse, action: string, okStatuses: num } } +function responseHeader(headers: Record, name: string): string | undefined { + const normalizedName = name.toLowerCase() + const entry = Object.entries(headers).find(([headerName]) => headerName.toLowerCase() === normalizedName) + const value = entry?.[1].trim() + return value ? value : undefined +} + +function requireWebDAVSnapshotETag(remote: DownloadedWebDAVSnapshot): string { + if (!remote.etag) { + throw new Error('WebDAV server did not return an ETag for the sync snapshot; refusing to overwrite it') + } + return remote.etag +} + async function ensureWebDAVCollections(settings: Settings, platform: SyncPlatform) { const webdav = getWebDAVSettings(settings) const headers = authHeaders(settings) @@ -121,7 +144,7 @@ async function ensureWebDAVCollections(settings: Settings, platform: SyncPlatfor } } -async function downloadWebDAVSnapshot(settings: Settings, platform: SyncPlatform): Promise { +async function downloadWebDAVSnapshot(settings: Settings, platform: SyncPlatform): Promise { const webdav = getWebDAVSettings(settings) const response = await requestWebDAV(platform, webdav.url, { url: snapshotUrl(settings), @@ -129,13 +152,16 @@ async function downloadWebDAVSnapshot(settings: Settings, platform: SyncPlatform headers: authHeaders(settings), }) - if (response.status === 404) { - return undefined + if (response.status === HTTP_NOT_FOUND) { + return {} } assertSuccess(response, 'Download WebDAV sync snapshot', [200]) const envelope = JSON.parse(response.body) as SyncCryptoEnvelope - return parseSyncSnapshot(await decryptJsonEnvelope(envelope, webdav.syncPassword)) + return { + snapshot: parseSyncSnapshot(await decryptJsonEnvelope(envelope, webdav.syncPassword)), + etag: responseHeader(response.headers, 'etag'), + } } export async function testWebDAVConnection(settings: Settings, deps: Pick): Promise { @@ -157,6 +183,7 @@ export async function uploadWebDAVSnapshot( deps: WebDAVSyncDeps ): Promise { const webdav = getWebDAVSettings(settings) + const now = deps.now ?? Date.now await ensureWebDAVCollections(settings, deps.platform) const [sessions, metas, deviceName] = await Promise.all([ @@ -164,47 +191,64 @@ export async function uploadWebDAVSnapshot( deps.listLocalMetas(), deps.platform.getDeviceName?.() ?? Promise.resolve('Unknown device'), ]) - const lastSyncedAt = new Date((deps.now ?? Date.now)()).toISOString() + const lastSyncedAt = new Date(now()).toISOString() const localSnapshot = createSyncSnapshot({ sessions, metas, deviceName, exportedAt: lastSyncedAt, }) - const remoteSnapshot = await downloadWebDAVSnapshot(settings, deps.platform) - const mergeResult = remoteSnapshot - ? mergeRemoteSnapshot({ - localSessions: localSnapshot.sessions, - localMetas: localSnapshot.metas, - remote: remoteSnapshot, - now: (deps.now ?? Date.now)(), - createId: deps.createId, - }) - : undefined - const snapshot = mergeResult - ? createSyncSnapshot({ - sessions: [...localSnapshot.sessions, ...mergeResult.sessionsToSave], - metas: [...localSnapshot.metas, ...mergeResult.metasToSave], - deviceName, - exportedAt: lastSyncedAt, - }) - : localSnapshot - const envelope = await encryptJsonEnvelope(snapshot, webdav.syncPassword) - const response = await requestWebDAV(deps.platform, webdav.url, { - url: snapshotUrl(settings), - method: 'PUT', - headers: { - ...authHeaders(settings), - 'Content-Type': 'application/json', - }, - body: JSON.stringify(envelope), - }) - assertSuccess(response, 'Upload WebDAV sync snapshot', [200, 201, 204]) - await deps.updateLastSyncedAt(lastSyncedAt) - return { - uploaded: snapshot.sessions.length, - lastSyncedAt, + + for (let attempt = 0; attempt < MAX_UPLOAD_ATTEMPTS; attempt += 1) { + const remote = await downloadWebDAVSnapshot(settings, deps.platform) + const mergeResult = remote.snapshot + ? mergeRemoteSnapshot({ + localSessions: localSnapshot.sessions, + localMetas: localSnapshot.metas, + remote: remote.snapshot, + now: now(), + createId: deps.createId, + }) + : undefined + const snapshot = mergeResult + ? createSyncSnapshot({ + sessions: [...localSnapshot.sessions, ...mergeResult.sessionsToSave], + metas: [...localSnapshot.metas, ...mergeResult.metasToSave], + deviceName, + exportedAt: lastSyncedAt, + }) + : localSnapshot + const preconditionHeaders: Record = remote.snapshot + ? { 'If-Match': requireWebDAVSnapshotETag(remote) } + : { 'If-None-Match': '*' } + const envelope = await encryptJsonEnvelope(snapshot, webdav.syncPassword) + const response = await requestWebDAV(deps.platform, webdav.url, { + url: snapshotUrl(settings), + method: 'PUT', + headers: { + ...authHeaders(settings), + 'Content-Type': 'application/json', + ...preconditionHeaders, + }, + body: JSON.stringify(envelope), + }) + + if (response.status === HTTP_PRECONDITION_FAILED) { + if (attempt < MAX_UPLOAD_ATTEMPTS - 1) { + continue + } + throw new Error('Upload WebDAV sync snapshot failed because the remote snapshot changed during upload') + } + + assertSuccess(response, 'Upload WebDAV sync snapshot', [200, 201, 204]) + await deps.updateLastSyncedAt(lastSyncedAt) + return { + uploaded: snapshot.sessions.length, + lastSyncedAt, + } } + + throw new Error('Upload WebDAV sync snapshot failed because the remote snapshot changed during upload') } export async function downloadAndMergeWebDAVSnapshot( @@ -212,7 +256,7 @@ export async function downloadAndMergeWebDAVSnapshot( deps: WebDAVSyncDeps ): Promise { const remote = await downloadWebDAVSnapshot(settings, deps.platform) - if (!remote) { + if (!remote.snapshot) { return { imported: 0, conflicts: 0, @@ -225,7 +269,7 @@ export async function downloadAndMergeWebDAVSnapshot( const result = mergeRemoteSnapshot({ localSessions, localMetas, - remote, + remote: remote.snapshot, now: (deps.now ?? Date.now)(), createId: deps.createId, preferRemoteMetadata: true, diff --git a/src/renderer/packages/sync/webdav.test.ts b/src/renderer/packages/sync/webdav.test.ts index b81f95d8fe..b6c77aef4e 100644 --- a/src/renderer/packages/sync/webdav.test.ts +++ b/src/renderer/packages/sync/webdav.test.ts @@ -64,6 +64,15 @@ describe('WebDAV helpers', () => { }) ).not.toThrow() + expect(() => + validateWebDAVRequestTarget(baseUrl, { + url: 'https://dav.example.com/remote.php/dav/files/me/ChatboxSync/v1/snapshot.json.enc', + method: 'PUT', + headers: { 'If-Match': '"abc"', 'If-None-Match': '*' }, + body: 'payload', + }) + ).not.toThrow() + expect(() => validateWebDAVRequestTarget(baseUrl, { url: 'https://dav.example.com/remote.php/dav/files/me/other.json', diff --git a/src/shared/sync-webdav.ts b/src/shared/sync-webdav.ts index 50a80b61d8..3918b6c987 100644 --- a/src/shared/sync-webdav.ts +++ b/src/shared/sync-webdav.ts @@ -31,7 +31,7 @@ const ALLOWED_WEBDAV_TARGETS = new Map>([ [SYNC_COLLECTION_PATH, new Set(['MKCOL'])], [SYNC_SNAPSHOT_PATH, new Set(['GET', 'PUT', 'PROPFIND'])], ]) -const ALLOWED_WEBDAV_HEADERS = new Set(['authorization', 'content-type', 'depth']) +const ALLOWED_WEBDAV_HEADERS = new Set(['authorization', 'content-type', 'depth', 'if-match', 'if-none-match']) const REDIRECT_STATUS_MIN = 300 const REDIRECT_STATUS_MAX = 399 @@ -54,7 +54,10 @@ export function validateWebDAVRequestTarget(baseUrl: string, request: WebDAVRequ if (typeof request.url !== 'string' || typeof request.method !== 'string') { rejectWebDAVRequest() } - if (request.headers !== undefined && (typeof request.headers !== 'object' || request.headers === null || Array.isArray(request.headers))) { + if ( + request.headers !== undefined && + (typeof request.headers !== 'object' || request.headers === null || Array.isArray(request.headers)) + ) { rejectWebDAVRequest() } if (request.body !== undefined && typeof request.body !== 'string') { From 5b28d21a95deca693a3c2745dce6fb0ab2a0cc9b Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Tue, 21 Jul 2026 17:14:56 +0800 Subject: [PATCH 07/19] fix(webdav-sync): transactional import, serialized writes, and stable conflict IDs - Restore overwritten sessions on failure instead of deleting everything (P1) - Route existing-session updates through chatStore's UpdateQueue and React Query cache; new sessions use atomic createSessionWithId (P2) - Use deterministic UUIDv5 for conflict copies to prevent duplicate imports across repeated downloads (P3) - Track session changes as create/update-metadata kinds for precise rollback in downloadAndMergeWebDAVSnapshot --- src/renderer/packages/sync/local.test.ts | 112 +++++++++++- src/renderer/packages/sync/local.ts | 88 +++++++-- src/renderer/packages/sync/service.test.ts | 139 ++++++++++---- src/renderer/packages/sync/service.ts | 70 ++++++-- src/renderer/packages/sync/snapshot.test.ts | 189 ++++++++++++++++++-- src/renderer/packages/sync/snapshot.ts | 150 ++++++++++++---- src/renderer/packages/sync/types.ts | 17 +- src/renderer/stores/chatStore.sync.test.ts | 126 +++++++++++++ src/renderer/stores/chatStore.ts | 54 ++++++ 9 files changed, 828 insertions(+), 117 deletions(-) create mode 100644 src/renderer/stores/chatStore.sync.test.ts diff --git a/src/renderer/packages/sync/local.test.ts b/src/renderer/packages/sync/local.test.ts index b6ba12e8a5..21f65676a3 100644 --- a/src/renderer/packages/sync/local.test.ts +++ b/src/renderer/packages/sync/local.test.ts @@ -1,9 +1,21 @@ import type { Session, SessionMetaRecord } from '@shared/types' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { listLocalSyncMetas, listLocalSyncSessions } from './local' +import { + createSyncSession, + deleteSyncSession, + listLocalSyncMetas, + listLocalSyncSessions, + saveSyncMetas, + updateSyncSessionMetadata, +} from './local' vi.mock('@/stores/chatStore', () => ({ + createSessionWithId: vi.fn(), + deleteSession: vi.fn(), + getMetaStorage: vi.fn(), listAllSessionsMeta: vi.fn(), + refreshSessionListCache: vi.fn(), + updateSession: vi.fn(), })) vi.mock('@/storage', () => ({ @@ -12,7 +24,14 @@ vi.mock('@/storage', () => ({ }, })) -const { listAllSessionsMeta } = await import('@/stores/chatStore') +const { + createSessionWithId, + deleteSession, + getMetaStorage, + listAllSessionsMeta, + refreshSessionListCache, + updateSession, +} = await import('@/stores/chatStore') const { default: storage } = await import('@/storage') function session(id: string, type?: Session['type']): Session { @@ -38,6 +57,11 @@ describe('local sync data selection', () => { beforeEach(() => { vi.mocked(listAllSessionsMeta).mockReset() vi.mocked(storage.getItem).mockReset() + vi.mocked(createSessionWithId).mockReset() + vi.mocked(deleteSession).mockReset() + vi.mocked(updateSession).mockReset() + vi.mocked(getMetaStorage).mockReset() + vi.mocked(refreshSessionListCache).mockReset() }) it('lists only chat and legacy chat sessions for sync', async () => { @@ -78,4 +102,88 @@ describe('local sync data selection', () => { expect(sessions[0].messages[0].contentParts).toEqual([{ type: 'text', text: 'legacy text' }]) }) + + it('creates new synced sessions through the fixed-ID chatStore path', async () => { + const syncedSession = session('remote-1', 'chat') + const syncedMeta = meta('remote-1', 'chat') + + await createSyncSession(syncedSession, syncedMeta) + + expect(createSessionWithId).toHaveBeenCalledWith(syncedSession, syncedMeta) + }) + + it('updates only metadata through the serialized chatStore path and returns the previous patch', async () => { + const current = session('same-id', 'chat') + current.name = 'Local Name' + current.messages = [ + { + id: 'latest-message', + role: 'user', + contentParts: [{ type: 'text', text: 'keep me' }], + }, + ] + vi.mocked(updateSession).mockImplementation((_id, updater) => { + const patch = typeof updater === 'function' ? updater(current) : updater + expect(patch).toMatchObject({ messages: current.messages, name: 'Remote Name', starred: true }) + return Promise.resolve({ ...current, ...patch }) + }) + + const previous = await updateSyncSessionMetadata('same-id', { + name: 'Remote Name', + type: 'chat', + starred: true, + }) + + expect(previous).toMatchObject({ name: 'Local Name', type: 'chat' }) + expect(updateSession).toHaveBeenCalledTimes(1) + }) + + it('restores metadata through chatStore when an update fails after entering the queue', async () => { + const current = session('same-id', 'chat') + current.name = 'Local Name' + vi.mocked(updateSession) + .mockImplementationOnce((_id, updater) => { + if (typeof updater === 'function') updater(current) + return Promise.reject(new Error('meta update failed')) + }) + .mockResolvedValueOnce(current) + + await expect(updateSyncSessionMetadata('same-id', { name: 'Remote Name', type: 'chat' })).rejects.toThrow( + /meta update failed/ + ) + + expect(updateSession).toHaveBeenNthCalledWith( + 2, + 'same-id', + expect.objectContaining({ name: 'Local Name', type: 'chat' }) + ) + }) + + it('deletes rolled-back sessions through chatStore', async () => { + await deleteSyncSession('remote-1') + + expect(deleteSession).toHaveBeenCalledWith('remote-1') + }) + + it('restores exact metadata when the batch is written but refreshing the cache fails', async () => { + const previous = meta('same-id', 'chat') + previous.name = 'Local Name' + const remote = { ...previous, name: 'Remote Name', sortOrder: 99 } + const metaStorage = { + getById: vi.fn(async () => previous), + createMany: vi.fn(async () => undefined), + deleteMany: vi.fn(async () => undefined), + } + vi.mocked(getMetaStorage).mockResolvedValue(metaStorage as never) + vi.mocked(refreshSessionListCache) + .mockRejectedValueOnce(new Error('cache refresh failed')) + .mockResolvedValueOnce(undefined) + + await expect(saveSyncMetas([remote])).rejects.toThrow(/cache refresh failed/) + + expect(metaStorage.createMany).toHaveBeenNthCalledWith(1, [remote]) + expect(metaStorage.createMany).toHaveBeenNthCalledWith(2, [previous]) + expect(metaStorage.deleteMany).toHaveBeenCalledWith([]) + expect(refreshSessionListCache).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/renderer/packages/sync/local.ts b/src/renderer/packages/sync/local.ts index c42af11f55..72ccf7a392 100644 --- a/src/renderer/packages/sync/local.ts +++ b/src/renderer/packages/sync/local.ts @@ -1,9 +1,15 @@ -import type { Session, SessionMetaRecord } from '@shared/types' -import { v4 as uuidv4 } from 'uuid' +import type { Session, SessionMeta, SessionMetaRecord } from '@shared/types' import platform from '@/platform' import storage from '@/storage' import { StorageKeyGenerator } from '@/storage/StoreStorage' -import { getMetaStorage, listAllSessionsMeta, refreshSessionListCache } from '@/stores/chatStore' +import { + createSessionWithId, + deleteSession, + getMetaStorage, + listAllSessionsMeta, + refreshSessionListCache, + updateSession, +} from '@/stores/chatStore' import { settingsStore } from '@/stores/settingsStore' import { migrateSession } from '@/utils/session-utils' import type { WebDAVSyncDeps } from './service' @@ -31,12 +37,58 @@ export async function listLocalSyncMetas(): Promise { return (await listAllSessionsMeta()).filter(isChatSessionLike) } -export async function saveSyncSession(session: Session): Promise { - await storage.setItemNow(StorageKeyGenerator.session(session.id), session) +export async function createSyncSession(session: Session, meta: SessionMetaRecord): Promise { + await createSessionWithId(session, meta) } export async function deleteSyncSession(sessionId: string): Promise { - await storage.removeItem(StorageKeyGenerator.session(sessionId)) + await deleteSession(sessionId) +} + +function sessionMetadataPatch(session: SessionMeta): Omit { + return { + name: session.name, + type: session.type, + starred: session.starred, + hidden: session.hidden, + assistantAvatarKey: session.assistantAvatarKey, + picUrl: session.picUrl, + backgroundImage: session.backgroundImage, + } +} + +export async function updateSyncSessionMetadata( + sessionId: string, + patch: Omit +): Promise> { + let previous: Omit | undefined + try { + await updateSession(sessionId, (current) => { + if (!current) { + throw new Error(`Session ${sessionId} not found`) + } + previous = sessionMetadataPatch(current) + return { ...current, ...patch } + }) + } catch (error) { + if (previous) { + try { + await updateSession(sessionId, previous) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `Failed to update synced session ${sessionId} and restore its metadata`, + { cause: error } + ) + } + } + throw error + } + + if (!previous) { + throw new Error(`Session ${sessionId} not found`) + } + return previous } export async function saveSyncMetas(metas: SessionMetaRecord[]): Promise { @@ -44,8 +96,24 @@ export async function saveSyncMetas(metas: SessionMetaRecord[]): Promise { return } const metaStorage = await getMetaStorage() - await metaStorage.createMany(metas) - await refreshSessionListCache() + const previous = await Promise.all(metas.map((meta) => metaStorage.getById(meta.id))) + try { + await metaStorage.createMany(metas) + await refreshSessionListCache() + } catch (error) { + const previousRecords = previous.filter((meta): meta is SessionMetaRecord => meta !== null) + const newIds = metas.filter((_, index) => previous[index] === null).map((meta) => meta.id) + try { + await metaStorage.createMany(previousRecords) + await metaStorage.deleteMany(newIds) + await refreshSessionListCache() + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], 'Failed to save synced metadata and restore previous records', { + cause: error, + }) + } + throw error + } } export function updateSyncLastSyncedAt(isoDate: string) { @@ -59,10 +127,10 @@ export function createDefaultWebDAVSyncDeps(): WebDAVSyncDeps { platform, listLocalSessions: listLocalSyncSessions, listLocalMetas: listLocalSyncMetas, - saveSession: saveSyncSession, + createSession: createSyncSession, + updateSessionMetadata: updateSyncSessionMetadata, deleteSession: deleteSyncSession, saveMetas: saveSyncMetas, updateLastSyncedAt: updateSyncLastSyncedAt, - createId: uuidv4, } } diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index 42e0239c59..722160ae0d 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -59,11 +59,12 @@ describe('WebDAV sync service', () => { }, listLocalSessions: vi.fn(async () => [session('s1', 'Local', 'hello')]), listLocalMetas: vi.fn(async () => [meta('s1', 'Local')]), - saveSession: vi.fn(), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - createId: vi.fn(), + createConflictId: vi.fn(), now: () => 1000, } @@ -110,11 +111,12 @@ describe('WebDAV sync service', () => { }, listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), - saveSession: vi.fn(), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - createId: vi.fn(() => 'copy-id'), + createConflictId: vi.fn(() => 'copy-id'), now: () => 1000, } @@ -128,7 +130,7 @@ describe('WebDAV sync service', () => { expect(put?.headers?.['If-None-Match']).toBeUndefined() expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) expect(decrypted.metas.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) - expect(deps.saveSession).not.toHaveBeenCalled() + expect(deps.createSession).not.toHaveBeenCalled() expect(deps.saveMetas).not.toHaveBeenCalled() }) @@ -172,11 +174,12 @@ describe('WebDAV sync service', () => { }, listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), - saveSession: vi.fn(), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - createId: vi.fn(() => 'copy-id'), + createConflictId: vi.fn(() => 'copy-id'), now: () => 1000, } @@ -217,11 +220,12 @@ describe('WebDAV sync service', () => { }, listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), - saveSession: vi.fn(), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - createId: vi.fn(() => 'copy-id'), + createConflictId: vi.fn(() => 'copy-id'), now: () => 1000, } @@ -250,11 +254,12 @@ describe('WebDAV sync service', () => { }, listLocalSessions: vi.fn(async () => []), listLocalMetas: vi.fn(async () => []), - saveSession: vi.fn(), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - createId: vi.fn(() => 'copy-id'), + createConflictId: vi.fn(() => 'copy-id'), now: () => 2000, } @@ -262,15 +267,16 @@ describe('WebDAV sync service', () => { expect(result.imported).toBe(1) expect(result.conflicts).toBe(0) - expect(deps.saveSession).toHaveBeenCalledWith( + expect(deps.createSession).toHaveBeenCalledWith( expect.objectContaining({ id: 'remote-1', messages: remote.sessions[0].messages, name: 'Remote', type: 'chat', - }) + }), + remote.metas[0] ) - expect(deps.saveMetas).toHaveBeenCalledWith(remote.metas) + expect(deps.saveMetas).not.toHaveBeenCalled() expect(deps.updateLastSyncedAt).toHaveBeenCalledWith('1970-01-01T00:00:02.000Z') }) @@ -303,11 +309,12 @@ describe('WebDAV sync service', () => { }, listLocalSessions: vi.fn(async () => [local]), listLocalMetas: vi.fn(async () => [meta('same-id', 'Local Name', 1)]), - saveSession: vi.fn(), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(async () => ({ name: 'Local Name', type: 'chat' as const })), saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - createId: vi.fn(() => 'copy-id'), + createConflictId: vi.fn(() => 'copy-id'), now: () => 2000, } @@ -315,8 +322,11 @@ describe('WebDAV sync service', () => { expect(result.imported).toBe(0) expect(result.conflicts).toBe(0) - expect(deps.createId).not.toHaveBeenCalled() - expect(deps.saveSession).toHaveBeenCalledWith(expect.objectContaining({ id: 'same-id', name: 'Remote Name' })) + expect(deps.createConflictId).not.toHaveBeenCalled() + expect(deps.updateSessionMetadata).toHaveBeenCalledWith( + 'same-id', + expect.objectContaining({ name: 'Remote Name', starred: true }) + ) expect(deps.saveMetas).toHaveBeenCalledWith([expect.objectContaining(remoteMeta)]) }) @@ -327,11 +337,12 @@ describe('WebDAV sync service', () => { }, listLocalSessions: vi.fn(async () => [session('s1', 'Local', 'hello')]), listLocalMetas: vi.fn(async () => [meta('s1', 'Local')]), - saveSession: vi.fn(), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - createId: vi.fn(), + createConflictId: vi.fn(), } const settings = { ...baseSettings, @@ -360,15 +371,16 @@ describe('WebDAV sync service', () => { }, listLocalSessions: vi.fn(async () => []), listLocalMetas: vi.fn(async () => []), - saveSession: vi.fn(), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - createId: vi.fn(() => 'copy-id'), + createConflictId: vi.fn(() => 'copy-id'), } await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/invalid sync snapshot/i) - expect(deps.saveSession).not.toHaveBeenCalled() + expect(deps.createSession).not.toHaveBeenCalled() expect(deps.saveMetas).not.toHaveBeenCalled() expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() }) @@ -405,25 +417,81 @@ describe('WebDAV sync service', () => { }, listLocalSessions: vi.fn(async () => []), listLocalMetas: vi.fn(async () => []), - saveSession: vi.fn(), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - createId: vi.fn(() => 'copy-id'), + createConflictId: vi.fn(() => 'copy-id'), now: () => 2000, } await downloadAndMergeWebDAVSnapshot(baseSettings, deps) - expect(deps.saveSession).toHaveBeenCalledWith( + expect(deps.createSession).toHaveBeenCalledWith( expect.objectContaining({ id: 'legacy-remote', messages: [expect.objectContaining({ contentParts: [{ type: 'text', text: 'legacy remote text' }] })], - }) + }), + remote.metas[0] + ) + }) + + it('restores updated sessions and deletes only new sessions when metadata import fails', async () => { + const local = session('same-id', 'Local Name', 'same text') + local.settings = { temperature: undefined } + const remoteExisting = session('same-id', 'Remote Name', 'same text') + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteExisting, session('remote-2', 'Remote 2', 'hello')], + metas: [meta('same-id', 'Remote Name', 99), meta('remote-2', 'Remote 2')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const previousPatch = { + name: 'Local Name', + type: 'chat' as const, + starred: undefined, + hidden: undefined, + assistantAvatarKey: undefined, + picUrl: undefined, + backgroundImage: undefined, + } + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => [local]), + listLocalMetas: vi.fn(async () => [meta('same-id', 'Local Name')]), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(async () => previousPatch), + saveMetas: vi.fn(() => Promise.reject(new Error('meta write failed'))), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createConflictId: vi.fn(() => 'copy-id'), + now: () => 2000, + } + + await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/meta write failed/) + + expect(deps.createSession).toHaveBeenCalledTimes(1) + expect(deps.deleteSession).toHaveBeenCalledWith('remote-2') + expect(deps.deleteSession).not.toHaveBeenCalledWith('same-id') + expect(deps.updateSessionMetadata).toHaveBeenNthCalledWith( + 1, + 'same-id', + expect.objectContaining({ name: 'Remote Name' }) ) + expect(deps.updateSessionMetadata).toHaveBeenNthCalledWith(2, 'same-id', previousPatch) + expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() }) - it('rolls back saved sessions when metadata import fails', async () => { + it('removes earlier new sessions when a later create fails', async () => { const remote: SyncSnapshot = { version: 1, exportedAt: '2026-06-21T00:00:00.000Z', @@ -442,19 +510,22 @@ describe('WebDAV sync service', () => { }, listLocalSessions: vi.fn(async () => []), listLocalMetas: vi.fn(async () => []), - saveSession: vi.fn(), - saveMetas: vi.fn(() => Promise.reject(new Error('meta write failed'))), + createSession: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('second session write failed')), + updateSessionMetadata: vi.fn(), + saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - createId: vi.fn(() => 'copy-id'), now: () => 2000, } - await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/meta write failed/) + await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/second session write failed/) - expect(deps.saveSession).toHaveBeenCalledTimes(2) + expect(deps.deleteSession).toHaveBeenCalledTimes(1) expect(deps.deleteSession).toHaveBeenCalledWith('remote-1') - expect(deps.deleteSession).toHaveBeenCalledWith('remote-2') + expect(deps.saveMetas).not.toHaveBeenCalled() expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index fdf67c57e8..7f7991ab97 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -1,4 +1,4 @@ -import type { Session, SessionMetaRecord, Settings } from '@shared/types' +import type { Session, SessionMeta, SessionMetaRecord, Settings } from '@shared/types' import { SessionMetaRecordSchema, SessionSchema } from '@shared/types/session' import { z } from 'zod' import { migrateSession } from '@/utils/session-utils' @@ -33,11 +33,12 @@ export type WebDAVSyncDeps = { platform: SyncPlatform listLocalSessions: () => Promise listLocalMetas: () => Promise - saveSession: (session: Session) => Promise - deleteSession?: (sessionId: string) => Promise + createSession: (session: Session, meta: SessionMetaRecord) => Promise + updateSessionMetadata: (sessionId: string, patch: Omit) => Promise> + deleteSession: (sessionId: string) => Promise saveMetas: (metas: SessionMetaRecord[]) => Promise updateLastSyncedAt: (isoDate: string) => Promise | void - createId: () => string + createConflictId?: (sourceSessionId: string, contentFingerprint: string) => string now?: () => number } @@ -207,12 +208,15 @@ export async function uploadWebDAVSnapshot( localMetas: localSnapshot.metas, remote: remote.snapshot, now: now(), - createId: deps.createId, + createConflictId: deps.createConflictId, }) : undefined const snapshot = mergeResult ? createSyncSnapshot({ - sessions: [...localSnapshot.sessions, ...mergeResult.sessionsToSave], + sessions: [ + ...localSnapshot.sessions, + ...mergeResult.sessionChanges.filter((change) => change.kind === 'create').map((change) => change.session), + ], metas: [...localSnapshot.metas, ...mergeResult.metasToSave], deviceName, exportedAt: lastSyncedAt, @@ -271,22 +275,56 @@ export async function downloadAndMergeWebDAVSnapshot( localMetas, remote: remote.snapshot, now: (deps.now ?? Date.now)(), - createId: deps.createId, + createConflictId: deps.createConflictId, preferRemoteMetadata: true, }) - const savedSessionIds: string[] = [] + const metaById = new Map(result.metasToSave.map((meta) => [meta.id, meta])) + const createdSessionIds = new Set() + const undoOperations: Array< + | { kind: 'delete-created'; sessionId: string } + | { kind: 'restore-metadata'; sessionId: string; patch: Omit } + > = [] try { - for (const session of result.sessionsToSave) { - await deps.saveSession(session) - savedSessionIds.push(session.id) + for (const change of result.sessionChanges) { + if (change.kind === 'create') { + const meta = metaById.get(change.session.id) + if (!meta) { + throw new Error(`Missing metadata for synced session ${change.session.id}`) + } + await deps.createSession(change.session, meta) + createdSessionIds.add(change.session.id) + undoOperations.push({ kind: 'delete-created', sessionId: change.session.id }) + } else { + const previous = await deps.updateSessionMetadata(change.sessionId, change.patch) + undoOperations.push({ kind: 'restore-metadata', sessionId: change.sessionId, patch: previous }) + } } - if (result.metasToSave.length > 0) { - await deps.saveMetas(result.metasToSave) + + const existingMetasToSave = result.metasToSave.filter((meta) => !createdSessionIds.has(meta.id)) + if (existingMetasToSave.length > 0) { + await deps.saveMetas(existingMetasToSave) } } catch (error) { - if (deps.deleteSession) { - await Promise.allSettled(savedSessionIds.map((id) => deps.deleteSession?.(id))) + const rollbackErrors: unknown[] = [] + for (const undo of undoOperations.reverse()) { + try { + if (undo.kind === 'delete-created') { + await deps.deleteSession(undo.sessionId) + } else { + await deps.updateSessionMetadata(undo.sessionId, undo.patch) + } + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + if (rollbackErrors.length > 0) { + const message = error instanceof Error ? error.message : String(error) + throw new AggregateError( + [error, ...rollbackErrors], + `WebDAV sync import failed: ${message}; restoring local data also failed`, + { cause: error } + ) } throw error } @@ -297,7 +335,7 @@ export async function downloadAndMergeWebDAVSnapshot( return { imported: result.imported, conflicts: result.conflicts, - saved: result.sessionsToSave.length, + saved: result.sessionChanges.length, lastSyncedAt, } } diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts index bcc2b0c0a1..09d5dc566c 100644 --- a/src/renderer/packages/sync/snapshot.test.ts +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -181,10 +181,11 @@ describe('sync snapshot merge', () => { localMetas: [], remote, now: 1000, - createId: () => 'unused', }) - expect(result.sessionsToSave.map((s) => s.id)).toEqual(['remote-1']) + expect(result.sessionChanges).toEqual([ + expect.objectContaining({ kind: 'create', session: expect.objectContaining({ id: 'remote-1' }) }), + ]) expect(result.metasToSave.map((m) => m.id)).toEqual(['remote-1']) expect(result.imported).toBe(1) expect(result.conflicts).toBe(0) @@ -222,12 +223,14 @@ describe('sync snapshot merge', () => { localMetas: [], remote, now: 1000, - createId: () => 'unused', }) - expect(result.sessionsToSave[0].assistantAvatarKey).toBeUndefined() - expect(result.sessionsToSave[0].messages[0].contentParts).toEqual([{ type: 'text', text: 'hello' }]) - expect(result.sessionsToSave[0].messages[0].files).toEqual([ + const created = result.sessionChanges[0] + expect(created.kind).toBe('create') + if (created.kind !== 'create') throw new Error('Expected a created session') + expect(created.session.assistantAvatarKey).toBeUndefined() + expect(created.session.messages[0].contentParts).toEqual([{ type: 'text', text: 'hello' }]) + expect(created.session.messages[0].files).toEqual([ { id: 'synced-file:0:doc.txt', name: 'doc.txt', fileType: 'text/plain' }, ]) expect(result.metasToSave[0].assistantAvatarKey).toBeUndefined() @@ -258,10 +261,11 @@ describe('sync snapshot merge', () => { localMetas: [], remote, now: 1000, - createId: () => 'unused', }) - expect(result.sessionsToSave.map((item) => item.id)).toEqual(['chat-1', 'legacy-1']) + expect( + result.sessionChanges.filter((change) => change.kind === 'create').map((change) => change.session.id) + ).toEqual(['chat-1', 'legacy-1']) expect(result.metasToSave.map((item) => [item.id, item.type])).toEqual([ ['chat-1', 'chat'], ['legacy-1', undefined], @@ -284,12 +288,16 @@ describe('sync snapshot merge', () => { localMetas: [meta('same-id', 'Project')], remote, now: 2000, - createId: () => 'copy-id', + createConflictId: () => 'copy-id', }) - expect(result.sessionsToSave).toHaveLength(1) - expect(result.sessionsToSave[0].id).toBe('copy-id') - expect(result.sessionsToSave[0].name).toBe('Project (Synced copy)') + expect(result.sessionChanges).toHaveLength(1) + expect(result.sessionChanges[0]).toEqual( + expect.objectContaining({ + kind: 'create', + session: expect.objectContaining({ id: 'copy-id', name: 'Project (Synced copy)' }), + }) + ) expect(result.metasToSave[0]).toMatchObject({ id: 'copy-id', name: 'Project (Synced copy)', @@ -300,6 +308,149 @@ describe('sync snapshot merge', () => { expect(result.conflicts).toBe(1) }) + it('does not create another copy when the same conflict is merged again', () => { + const local = session('same-id', 'Project', 'local text') + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('same-id', 'Project', 'remote text')], + metas: [meta('same-id', 'Project')], + } + const createConflictId = () => 'stable-copy-id' + const first = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project')], + remote, + now: 2000, + createConflictId, + preferRemoteMetadata: true, + }) + const created = first.sessionChanges[0] + if (created.kind !== 'create') throw new Error('Expected a created conflict copy') + + const second = mergeRemoteSnapshot({ + localSessions: [local, created.session], + localMetas: [meta('same-id', 'Project'), first.metasToSave[0]], + remote, + now: 3000, + createConflictId, + preferRemoteMetadata: true, + }) + + expect(second.sessionChanges).toEqual([]) + expect(second.metasToSave).toEqual([]) + expect(second.conflicts).toBe(0) + }) + + it('deduplicates a source conflict when the remote snapshot already contains its stable copy', () => { + const local = session('same-id', 'Project', 'local text') + const remoteSource = session('same-id', 'Project', 'remote text') + const sourceSnapshot: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSource], + metas: [meta('same-id', 'Project')], + } + const initial = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project')], + remote: sourceSnapshot, + now: 2000, + }) + const stableCopy = initial.sessionChanges[0] + if (stableCopy.kind !== 'create') throw new Error('Expected a created conflict copy') + const remoteWithCopy: SyncSnapshot = { + ...sourceSnapshot, + sessions: [remoteSource, stableCopy.session], + metas: [sourceSnapshot.metas[0], initial.metasToSave[0]], + } + + const result = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project')], + remote: remoteWithCopy, + now: 2000, + }) + + expect(result.sessionChanges).toHaveLength(1) + expect(result.sessionChanges[0]).toEqual( + expect.objectContaining({ + kind: 'create', + session: expect.objectContaining({ id: stableCopy.session.id }), + }) + ) + expect(result.metasToSave).toHaveLength(1) + expect(result.conflicts).toBe(1) + }) + + it('uses different stable conflict IDs when remote content changes', () => { + const local = session('same-id', 'Project', 'local text') + const merge = (text: string) => + mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project')], + remote: { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('same-id', 'Project', text)], + metas: [meta('same-id', 'Project')], + }, + now: 2000, + }) + const first = merge('remote text 1').sessionChanges[0] + const second = merge('remote text 2').sessionChanges[0] + if (first.kind !== 'create' || second.kind !== 'create') throw new Error('Expected conflict copies') + + expect(first.session.id).not.toBe(second.session.id) + }) + + it('keeps the stable conflict ID when only remote metadata changes', () => { + const local = session('same-id', 'Local', 'local text') + const merge = (name: string, starred: boolean) => + mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Local')], + remote: { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [{ ...session('same-id', name, 'remote text'), starred }], + metas: [{ ...meta('same-id', name), starred }], + }, + now: 2000, + }) + const first = merge('Remote Name', false).sessionChanges[0] + const second = merge('Renamed Remote', true).sessionChanges[0] + if (first.kind !== 'create' || second.kind !== 'create') throw new Error('Expected conflict copies') + + expect(first.session.id).toBe(second.session.id) + }) + + it('refuses to overwrite different content occupying a stable conflict ID', () => { + const local = session('same-id', 'Local', 'local text') + const occupied = session('stable-copy-id', 'Unrelated', 'different content') + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('same-id', 'Remote', 'remote text')], + metas: [meta('same-id', 'Remote')], + } + + expect(() => + mergeRemoteSnapshot({ + localSessions: [local, occupied], + localMetas: [meta('same-id', 'Local'), meta('stable-copy-id', 'Unrelated')], + remote, + now: 2000, + createConflictId: () => 'stable-copy-id', + }) + ).toThrow(/already used by different content/) + }) + it('updates remote metadata for same-content sessions without creating synced copies when requested', () => { const local = session('same-id', 'Project', 'same text') const remoteSession = { @@ -323,11 +474,17 @@ describe('sync snapshot merge', () => { localMetas: [meta('same-id', 'Project', 1)], remote, now: 2000, - createId: () => 'copy-id', + createConflictId: () => 'copy-id', preferRemoteMetadata: true, }) - expect(result.sessionsToSave).toEqual([expect.objectContaining({ id: 'same-id', name: 'Project Remote' })]) + expect(result.sessionChanges).toEqual([ + expect.objectContaining({ + kind: 'update-metadata', + sessionId: 'same-id', + patch: expect.objectContaining({ name: 'Project Remote', starred: true }), + }), + ]) expect(result.metasToSave).toEqual([remoteMeta]) expect(result.imported).toBe(0) expect(result.conflicts).toBe(0) @@ -352,10 +509,10 @@ describe('sync snapshot merge', () => { localMetas: [meta('same-id', 'Project', 1)], remote, now: 2000, - createId: () => 'copy-id', + createConflictId: () => 'copy-id', }) - expect(result.sessionsToSave).toEqual([]) + expect(result.sessionChanges).toEqual([]) expect(result.metasToSave).toEqual([]) expect(result.imported).toBe(0) expect(result.conflicts).toBe(0) diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index 2c269c193a..5d8228c7ff 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -4,8 +4,10 @@ import { type MessageFile, type MessageLink, type Session, + type SessionMeta, type SessionMetaRecord, } from '@shared/types' +import { v5 as uuidv5 } from 'uuid' import type { MergeRemoteSnapshotInput, MergeRemoteSnapshotResult, SyncSnapshot } from './types' function isChatSessionMetaLike(item: Pick): boolean { @@ -25,34 +27,38 @@ function stableStringify(value: unknown): string { return JSON.stringify(value) } -function sessionsEqual(left: Session, right: Session): boolean { - return stableStringify(left) === stableStringify(right) +function sessionContentFingerprint(session: Session): string { + const { + id: _id, + name: _name, + starred: _starred, + hidden: _hidden, + assistantAvatarKey: _assistantAvatarKey, + picUrl: _picUrl, + backgroundImage: _backgroundImage, + ...content + } = session + return stableStringify(content) } function sessionContentEqual(left: Session, right: Session): boolean { - const { - name: _leftName, - starred: _leftStarred, - hidden: _leftHidden, - assistantAvatarKey: _leftAssistantAvatarKey, - picUrl: _leftPicUrl, - backgroundImage: _leftBackgroundImage, - ...leftContent - } = left - const { - name: _rightName, - starred: _rightStarred, - hidden: _rightHidden, - assistantAvatarKey: _rightAssistantAvatarKey, - picUrl: _rightPicUrl, - backgroundImage: _rightBackgroundImage, - ...rightContent - } = right - return stableStringify(leftContent) === stableStringify(rightContent) + return sessionContentFingerprint(left) === sessionContentFingerprint(right) } function metasEqual(left: SessionMetaRecord, right: SessionMetaRecord): boolean { - return stableStringify(left) === stableStringify(right) + const normalize = (meta: SessionMetaRecord) => ({ + id: meta.id, + name: meta.name, + type: meta.type, + starred: meta.starred, + hidden: meta.hidden, + assistantAvatarKey: meta.assistantAvatarKey, + picUrl: meta.picUrl, + backgroundImage: meta.backgroundImage, + sortOrder: meta.sortOrder, + createdAt: meta.createdAt, + }) + return stableStringify(normalize(left)) === stableStringify(normalize(right)) } function shouldNormalizeFileAttachmentId(file: MessageFile): boolean { @@ -214,6 +220,26 @@ function applyMetaToSession(session: Session, meta: SessionMetaRecord | undefine } } +function metadataPatch(session: Session): Omit { + return { + name: session.name, + type: session.type, + starred: session.starred, + hidden: session.hidden, + assistantAvatarKey: session.assistantAvatarKey, + picUrl: session.picUrl, + backgroundImage: session.backgroundImage, + } +} + +function sessionMetadataEqual(left: Session, right: Session): boolean { + return stableStringify(metadataPatch(left)) === stableStringify(metadataPatch(right)) +} + +function defaultCreateConflictId(sourceSessionId: string, contentFingerprint: string): string { + return uuidv5(`chatbox:webdav-sync:v1:${sourceSessionId}:${contentFingerprint}`, uuidv5.URL) +} + export function createSyncSnapshot(input: { sessions: Session[] metas: SessionMetaRecord[] @@ -251,8 +277,8 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot const remoteMetaById = new Map(remoteMetas.map((meta) => [meta.id, meta])) const seenRemoteSessionIds = new Set() - const sessionsToSave: Session[] = [] - const metasToSave: SessionMetaRecord[] = [] + const sessionChanges: MergeRemoteSnapshotResult['sessionChanges'] = [] + const metasToSaveById = new Map() let imported = 0 let conflicts = 0 @@ -267,44 +293,96 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot const remoteSessionWithMeta = applyMetaToSession(remoteSession, remoteMeta) if (!localSession) { - sessionsToSave.push(remoteSessionWithMeta) - metasToSave.push(metaForSession(remoteSessionWithMeta, remoteMeta, input.now)) + const meta = metaForSession(remoteSessionWithMeta, remoteMeta, input.now) + sessionChanges.push({ kind: 'create', session: remoteSessionWithMeta }) + metasToSaveById.set(meta.id, meta) + localSessionById.set(remoteSessionWithMeta.id, remoteSessionWithMeta) + localMetaById.set(meta.id, meta) imported += 1 continue } if (sessionContentEqual(localSession, remoteSession)) { if (input.preferRemoteMetadata) { - if (!sessionsEqual(localSession, remoteSessionWithMeta)) { - sessionsToSave.push(remoteSessionWithMeta) + if (!sessionMetadataEqual(localSession, remoteSessionWithMeta)) { + sessionChanges.push({ + kind: 'update-metadata', + sessionId: remoteSession.id, + patch: metadataPatch(remoteSessionWithMeta), + }) + localSessionById.set(remoteSession.id, { + ...localSession, + ...metadataPatch(remoteSessionWithMeta), + }) } const localMeta = localMetaById.get(remoteSession.id) if (remoteMeta && (!localMeta || !metasEqual(localMeta, remoteMeta))) { - metasToSave.push(metaForSession(remoteSessionWithMeta, remoteMeta, input.now)) + const meta = metaForSession(remoteSessionWithMeta, remoteMeta, input.now) + metasToSaveById.set(meta.id, meta) + localMetaById.set(meta.id, meta) } } else if (!localMetaById.has(remoteSession.id) && remoteMeta) { - metasToSave.push(metaForSession(remoteSessionWithMeta, remoteMeta, input.now)) + const meta = metaForSession(remoteSessionWithMeta, remoteMeta, input.now) + metasToSaveById.set(meta.id, meta) + localMetaById.set(meta.id, meta) } continue } + const contentFingerprint = sessionContentFingerprint(remoteSessionWithMeta) + const conflictId = (input.createConflictId ?? defaultCreateConflictId)(remoteSession.id, contentFingerprint) const copiedName = copyName(remoteSessionWithMeta.name) const copiedSession: Session = { ...remoteSessionWithMeta, - id: input.createId(), + id: conflictId, name: copiedName, } - sessionsToSave.push(copiedSession) - metasToSave.push({ + const copiedMeta = { ...metaForCopiedSession(copiedSession, remoteMeta, input.now), name: copiedName, - }) + } + const existingCopy = localSessionById.get(conflictId) + + if (existingCopy) { + if (!sessionContentEqual(existingCopy, copiedSession)) { + throw new Error(`Stable sync conflict ID ${conflictId} is already used by different content`) + } + if (input.preferRemoteMetadata && !sessionMetadataEqual(existingCopy, copiedSession)) { + sessionChanges.push({ + kind: 'update-metadata', + sessionId: conflictId, + patch: metadataPatch(copiedSession), + }) + localSessionById.set(conflictId, { + ...existingCopy, + ...metadataPatch(copiedSession), + }) + } + const existingCopyMeta = localMetaById.get(conflictId) + const updatedCopyMeta = existingCopyMeta + ? { + ...copiedMeta, + sortOrder: existingCopyMeta.sortOrder, + createdAt: existingCopyMeta.createdAt, + } + : copiedMeta + if (input.preferRemoteMetadata && (!existingCopyMeta || !metasEqual(existingCopyMeta, updatedCopyMeta))) { + metasToSaveById.set(conflictId, updatedCopyMeta) + localMetaById.set(conflictId, updatedCopyMeta) + } + continue + } + + sessionChanges.push({ kind: 'create', session: copiedSession }) + metasToSaveById.set(conflictId, copiedMeta) + localSessionById.set(conflictId, copiedSession) + localMetaById.set(conflictId, copiedMeta) conflicts += 1 } return { - sessionsToSave, - metasToSave, + sessionChanges, + metasToSave: [...metasToSaveById.values()], imported, conflicts, } diff --git a/src/renderer/packages/sync/types.ts b/src/renderer/packages/sync/types.ts index e433d38b9b..bc5497a529 100644 --- a/src/renderer/packages/sync/types.ts +++ b/src/renderer/packages/sync/types.ts @@ -1,4 +1,4 @@ -import type { Session, SessionMetaRecord } from '@shared/types' +import type { Session, SessionMeta, SessionMetaRecord } from '@shared/types' export type { WebDAVMethod, WebDAVRequest, WebDAVResponse } from '@shared/sync-webdav' @@ -27,12 +27,23 @@ export type MergeRemoteSnapshotInput = { localMetas: SessionMetaRecord[] remote: SyncSnapshot now: number - createId: () => string + createConflictId?: (sourceSessionId: string, contentFingerprint: string) => string preferRemoteMetadata?: boolean } +export type SyncSessionChange = + | { + kind: 'create' + session: Session + } + | { + kind: 'update-metadata' + sessionId: string + patch: Omit + } + export type MergeRemoteSnapshotResult = { - sessionsToSave: Session[] + sessionChanges: SyncSessionChange[] metasToSave: SessionMetaRecord[] imported: number conflicts: number diff --git a/src/renderer/stores/chatStore.sync.test.ts b/src/renderer/stores/chatStore.sync.test.ts new file mode 100644 index 0000000000..b3fc171817 --- /dev/null +++ b/src/renderer/stores/chatStore.sync.test.ts @@ -0,0 +1,126 @@ +import type { Session, SessionMetaRecord } from '@shared/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + storage: { + getItem: vi.fn(), + setItemNow: vi.fn(), + removeItem: vi.fn(), + }, + metaStorage: { + initialize: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + }, + queryClient: { + setQueryData: vi.fn(), + fetchQuery: vi.fn(), + }, + getStoreValue: vi.fn(), +})) + +vi.mock('@/platform', () => ({ + default: { + type: 'web', + getSessionMetaStorage: () => mocks.metaStorage, + getStoreValue: mocks.getStoreValue, + }, +})) + +vi.mock('@/storage', () => ({ default: mocks.storage })) +vi.mock('@/components/chat/MessageList', () => ({ clearScrollPositionCache: vi.fn() })) +vi.mock('./atoms/throttleWriteSessionAtom', () => ({ cleanupSessionAtomCache: vi.fn() })) +vi.mock('./lastUsedModelStore', () => ({ + lastUsedModelStore: { getState: () => ({ chat: {}, picture: {} }) }, +})) +vi.mock('./queryClient', () => ({ default: mocks.queryClient })) +vi.mock('./sessionHelpers', () => ({ + getSessionMeta: (session: Session) => ({ + id: session.id, + name: session.name, + type: session.type, + starred: session.starred, + hidden: session.hidden, + assistantAvatarKey: session.assistantAvatarKey, + picUrl: session.picUrl, + backgroundImage: session.backgroundImage, + }), +})) +vi.mock('./settingsStore', () => ({ + settingsStore: { getState: vi.fn() }, + useSettingsStore: vi.fn(), +})) +vi.mock('./uiStore', () => ({ + uiStore: { + getState: () => ({ + clearSessionWebBrowsing: vi.fn(), + removeSessionKnowledgeBase: vi.fn(), + }), + }, +})) + +const { createSessionWithId } = await import('./chatStore') + +function session(id: string): Session { + return { + id, + type: 'chat', + name: 'Synced session', + messages: [], + } +} + +function meta(id: string): SessionMetaRecord { + return { + id, + type: 'chat', + name: 'Synced session', + sortOrder: 10, + createdAt: 5, + } +} + +describe('chatStore fixed-ID session creation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.storage.getItem.mockResolvedValue(null) + mocks.storage.setItemNow.mockResolvedValue(undefined) + mocks.storage.removeItem.mockResolvedValue(undefined) + mocks.metaStorage.initialize.mockResolvedValue(undefined) + mocks.metaStorage.getById.mockResolvedValue(null) + mocks.metaStorage.create.mockResolvedValue(undefined) + mocks.getStoreValue.mockResolvedValue(undefined) + }) + + it('writes the session and exact metadata before publishing the cache update', async () => { + const syncedSession = session('remote-1') + const syncedMeta = meta('remote-1') + + await createSessionWithId(syncedSession, syncedMeta) + + expect(mocks.storage.setItemNow).toHaveBeenCalledWith('session:remote-1', syncedSession) + expect(mocks.metaStorage.create).toHaveBeenCalledWith(syncedMeta) + expect(mocks.queryClient.setQueryData).toHaveBeenCalled() + }) + + it('removes a partially written session when metadata creation fails', async () => { + mocks.metaStorage.create.mockRejectedValueOnce(new Error('meta create failed')) + + await expect(createSessionWithId(session('remote-1'), meta('remote-1'))).rejects.toThrow(/meta create failed/) + + expect(mocks.storage.removeItem).toHaveBeenCalledWith('session:remote-1') + + await expect(createSessionWithId(session('remote-1'), meta('remote-1'))).resolves.toMatchObject({ + id: 'remote-1', + }) + }) + + it('refuses to overwrite an existing session ID', async () => { + mocks.getStoreValue.mockResolvedValueOnce(session('remote-1')) + + await expect(createSessionWithId(session('remote-1'), meta('remote-1'))).rejects.toThrow(/already exists/) + + expect(mocks.storage.setItemNow).not.toHaveBeenCalled() + expect(mocks.metaStorage.create).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/stores/chatStore.ts b/src/renderer/stores/chatStore.ts index ed8a61cab2..6478908cf3 100644 --- a/src/renderer/stores/chatStore.ts +++ b/src/renderer/stores/chatStore.ts @@ -234,6 +234,60 @@ export async function createSession(newSession: Omit, previousId? return session } +const pendingSessionCreates = new Map>() + +async function _createSessionWithId(session: Session, record: SessionMetaRecord): Promise { + if (session.id !== record.id) { + throw new Error('Session and metadata IDs must match') + } + + const metaStorage = await getMetaStorage() + const [existingSession, existingMeta] = await Promise.all([ + platform.getStoreValue(StorageKeyGenerator.session(session.id)), + metaStorage.getById(session.id), + ]) + if (existingSession || existingMeta) { + throw new Error(`Session ${session.id} already exists`) + } + + await storage.setItemNow(StorageKeyGenerator.session(session.id), session) + try { + await metaStorage.create(record) + } catch (error) { + try { + await storage.removeItem(StorageKeyGenerator.session(session.id)) + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `Failed to create session ${session.id} and remove its partially written data`, + { cause: error } + ) + } + throw error + } + + _setSessionCache(session.id, session) + updateSessionListData((items) => sortSessionRecords([...items, record])) + return session +} + +/** Create an imported session with its existing ID and exact metadata. */ +export async function createSessionWithId(session: Session, record: SessionMetaRecord): Promise { + const pending = pendingSessionCreates.get(session.id) + if (pending) { + await pending + throw new Error(`Session ${session.id} already exists`) + } + + const creation = _createSessionWithId(session, record) + pendingSessionCreates.set(session.id, creation) + try { + return await creation + } finally { + pendingSessionCreates.delete(session.id) + } +} + const sessionUpdateQueues: Record> = {} export async function updateSessionWithMessages(sessionId: string, updater: Updater) { From 7830ed7c03ba1d63071500d3fd7eab5ac0f4de18 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Tue, 21 Jul 2026 18:18:18 +0800 Subject: [PATCH 08/19] =?UTF-8?q?fix(sync):=20add=20updatedAt=20to=20snaps?= =?UTF-8?q?hots,=20skip=20stale=20remote=20merge=20=E2=80=94=20prevents=20?= =?UTF-8?q?duplicate=20explosion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SyncSnapshot gains updatedAt (epoch ms); older snapshots fall back to exportedAt - isRemoteSnapshotStale() compares remote.updatedAt <= lastSyncedAt; when true, both upload and download skip merge to prevent re-creating (Synced copy) duplicates - upload writes precise updatedAt on every PUT; download returns remoteStale flag - WebDAVSyncDeps adds optional getLastSyncedAt; local.ts wires it from settings fix(export): unify AdvancedSettingTab export to redact provider secrets instead of delete - Remove redundant delete providers branch; sanitizeSettingsForExport already strips apiKey/accessKey/secretKey/sessionToken per-provider when !includeSecrets - Without this fix, unchecking 'API KEY & License' would drop all provider configs (model, baseURL, etc.) from export --- .gitignore | 3 +- scripts/ralph/ralph.sh | 0 src/renderer/packages/sync/local.ts | 5 + src/renderer/packages/sync/service.test.ts | 135 ++++++++++++++++++ src/renderer/packages/sync/service.ts | 51 ++++++- src/renderer/packages/sync/snapshot.ts | 17 ++- src/renderer/packages/sync/types.ts | 2 + .../SettingDialog/AdvancedSettingTab.tsx | 3 - src/renderer/routes/settings/general.tsx | 3 + 9 files changed, 210 insertions(+), 9 deletions(-) mode change 100755 => 100644 scripts/ralph/ralph.sh diff --git a/.gitignore b/.gitignore index 32d5be1f2f..adad2eaac6 100644 --- a/.gitignore +++ b/.gitignore @@ -189,4 +189,5 @@ storybook-static .worktrees/ .claude/worktrees/ -tmp-*.md \ No newline at end of file +tmp-*.md +.workbuddy/ \ No newline at end of file diff --git a/scripts/ralph/ralph.sh b/scripts/ralph/ralph.sh old mode 100755 new mode 100644 diff --git a/src/renderer/packages/sync/local.ts b/src/renderer/packages/sync/local.ts index 72ccf7a392..d1e0a9ac1e 100644 --- a/src/renderer/packages/sync/local.ts +++ b/src/renderer/packages/sync/local.ts @@ -122,6 +122,10 @@ export function updateSyncLastSyncedAt(isoDate: string) { }) } +export function getSyncLastSyncedAt(): string | undefined { + return settingsStore.getState().sync?.lastSyncedAt +} + export function createDefaultWebDAVSyncDeps(): WebDAVSyncDeps { return { platform, @@ -132,5 +136,6 @@ export function createDefaultWebDAVSyncDeps(): WebDAVSyncDeps { deleteSession: deleteSyncSession, saveMetas: saveSyncMetas, updateLastSyncedAt: updateSyncLastSyncedAt, + getLastSyncedAt: getSyncLastSyncedAt, } } diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index 722160ae0d..64f0e85511 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -235,6 +235,141 @@ describe('WebDAV sync service', () => { expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() }) + it('skips downloading when the remote snapshot is not newer than the last sync', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('same-id', 'Remote', 'remote text')], + metas: [meta('same-id', 'Remote')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => [session('same-id', 'Local', 'local text')]), + listLocalMetas: vi.fn(async () => [meta('same-id', 'Local')]), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + getLastSyncedAt: vi.fn(() => '2026-06-21T00:00:00.000Z'), + createConflictId: vi.fn(() => 'copy-id'), + now: () => 2000, + } + + const result = await downloadAndMergeWebDAVSnapshot(baseSettings, deps) + + expect(result.remoteStale).toBe(true) + expect(result.imported).toBe(0) + expect(result.conflicts).toBe(0) + expect(deps.createSession).not.toHaveBeenCalled() + expect(deps.listLocalSessions).not.toHaveBeenCalled() + }) + + it('skips re-merging an already synced remote snapshot during upload', async () => { + const requests: WebDAVRequest[] = [] + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('same-id', 'Remote', 'remote text')], + metas: [meta('same-id', 'Remote')], + } + const remoteEnvelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn((request: WebDAVRequest) => { + requests.push(request) + if (request.method === 'GET') { + return Promise.resolve({ + status: 200, + headers: { ETag: '"remote-etag"' }, + body: JSON.stringify(remoteEnvelope), + }) + } + return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) + }), + }, + listLocalSessions: vi.fn(async () => [session('same-id', 'Local', 'local text')]), + listLocalMetas: vi.fn(async () => [meta('same-id', 'Local')]), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + getLastSyncedAt: vi.fn(() => '2026-06-21T00:00:00.000Z'), + createConflictId: vi.fn(() => 'copy-id'), + now: () => 1000, + } + + const result = await uploadWebDAVSnapshot(baseSettings, deps) + const put = requests.find((request) => request.method === 'PUT') + const envelope = JSON.parse(put?.body ?? '{}') + const decrypted = await decryptJsonEnvelope(envelope, 'sync-secret') + + // The stale remote snapshot must not be merged back in, otherwise its + // divergent content would be re-uploaded as a "(Synced copy)" duplicate. + expect(result.uploaded).toBe(1) + expect(decrypted.sessions.map((item) => item.id)).toEqual(['same-id']) + expect(decrypted.sessions[0].name).toBe('Local') + expect(decrypted.updatedAt).toBe(1000) + }) + + it('merges a remote snapshot that is newer than the last sync', async () => { + const requests: WebDAVRequest[] = [] + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-22T00:00:00.000Z', + updatedAt: Date.parse('2026-06-22T00:00:00.000Z'), + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'remote text')], + metas: [meta('remote-1', 'Remote')], + } + const remoteEnvelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn((request: WebDAVRequest) => { + requests.push(request) + if (request.method === 'GET') { + return Promise.resolve({ + status: 200, + headers: { ETag: '"remote-etag"' }, + body: JSON.stringify(remoteEnvelope), + }) + } + return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) + }), + }, + listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), + listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + getLastSyncedAt: vi.fn(() => '2026-06-21T00:00:00.000Z'), + createConflictId: vi.fn(() => 'copy-id'), + now: () => 1000, + } + + const result = await uploadWebDAVSnapshot(baseSettings, deps) + const put = requests.find((request) => request.method === 'PUT') + const envelope = JSON.parse(put?.body ?? '{}') + const decrypted = await decryptJsonEnvelope(envelope, 'sync-secret') + + expect(result.uploaded).toBe(2) + expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) + }) + it('downloads, decrypts, and saves missing remote sessions', async () => { const remote: SyncSnapshot = { version: 1, diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 7f7991ab97..464507b45e 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -3,13 +3,14 @@ import { SessionMetaRecordSchema, SessionSchema } from '@shared/types/session' import { z } from 'zod' import { migrateSession } from '@/utils/session-utils' import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' -import { createSyncSnapshot, mergeRemoteSnapshot } from './snapshot' +import { createSyncSnapshot, mergeRemoteSnapshot, snapshotUpdatedAt } from './snapshot' import type { SyncCryptoEnvelope, SyncSnapshot, WebDAVRequest, WebDAVResponse } from './types' import { buildBasicAuthHeader, joinWebDAVUrl, requestWebDAV, SYNC_COLLECTION_PATH, SYNC_SNAPSHOT_PATH } from './webdav' const RawSyncSnapshotSchema = z.object({ version: z.literal(1), exportedAt: z.string(), + updatedAt: z.number().optional(), deviceName: z.string(), sessions: z.array(z.unknown()), metas: z.array(SessionMetaRecordSchema), @@ -38,6 +39,7 @@ export type WebDAVSyncDeps = { deleteSession: (sessionId: string) => Promise saveMetas: (metas: SessionMetaRecord[]) => Promise updateLastSyncedAt: (isoDate: string) => Promise | void + getLastSyncedAt?: () => string | undefined | Promise createConflictId?: (sourceSessionId: string, contentFingerprint: string) => string now?: () => number } @@ -53,6 +55,31 @@ export type DownloadWebDAVSnapshotResult = { saved: number lastSyncedAt?: string remoteMissing?: boolean + remoteStale?: boolean +} + +function parseIsoToMillis(value: string | undefined): number | undefined { + if (!value) { + return undefined + } + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +/** + * Skip merging when the remote snapshot is not newer than the last sync. + * Merging an already-seen snapshot again would only recreate "(Synced copy)" + * duplicates on every device that replays it. + */ +async function isRemoteSnapshotStale(remote: SyncSnapshot, deps: WebDAVSyncDeps): Promise { + if (!deps.getLastSyncedAt) { + return false + } + const lastSyncedAt = parseIsoToMillis(await deps.getLastSyncedAt()) + if (lastSyncedAt === undefined) { + return false + } + return snapshotUpdatedAt(remote) <= lastSyncedAt } function getWebDAVSettings(settings: Settings) { @@ -192,21 +219,27 @@ export async function uploadWebDAVSnapshot( deps.listLocalMetas(), deps.platform.getDeviceName?.() ?? Promise.resolve('Unknown device'), ]) - const lastSyncedAt = new Date(now()).toISOString() + const snapshotUpdatedAtMs = now() + const lastSyncedAt = new Date(snapshotUpdatedAtMs).toISOString() const localSnapshot = createSyncSnapshot({ sessions, metas, deviceName, exportedAt: lastSyncedAt, + updatedAt: snapshotUpdatedAtMs, }) for (let attempt = 0; attempt < MAX_UPLOAD_ATTEMPTS; attempt += 1) { const remote = await downloadWebDAVSnapshot(settings, deps.platform) - const mergeResult = remote.snapshot + // Only merge remote content that is newer than our last sync. Re-merging an + // already-synced snapshot would resurrect "(Synced copy)" duplicates that the + // local device has since folded back into its own sessions. + const shouldMergeRemote = remote.snapshot ? !(await isRemoteSnapshotStale(remote.snapshot, deps)) : false + const mergeResult = shouldMergeRemote ? mergeRemoteSnapshot({ localSessions: localSnapshot.sessions, localMetas: localSnapshot.metas, - remote: remote.snapshot, + remote: remote.snapshot!, now: now(), createConflictId: deps.createConflictId, }) @@ -220,6 +253,7 @@ export async function uploadWebDAVSnapshot( metas: [...localSnapshot.metas, ...mergeResult.metasToSave], deviceName, exportedAt: lastSyncedAt, + updatedAt: snapshotUpdatedAtMs, }) : localSnapshot const preconditionHeaders: Record = remote.snapshot @@ -269,6 +303,15 @@ export async function downloadAndMergeWebDAVSnapshot( } } + if (await isRemoteSnapshotStale(remote.snapshot, deps)) { + return { + imported: 0, + conflicts: 0, + saved: 0, + remoteStale: true, + } + } + const [localSessions, localMetas] = await Promise.all([deps.listLocalSessions(), deps.listLocalMetas()]) const result = mergeRemoteSnapshot({ localSessions, diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index 5d8228c7ff..0126ac775c 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -245,13 +245,16 @@ export function createSyncSnapshot(input: { metas: SessionMetaRecord[] deviceName: string exportedAt?: string + updatedAt?: number }): SyncSnapshot { const sessions = input.sessions.filter(isChatSession).map(stripLocalSessionReferences) const sessionIds = new Set(sessions.map((session) => session.id)) + const exportedAt = input.exportedAt ?? new Date().toISOString() return { version: 1, - exportedAt: input.exportedAt ?? new Date().toISOString(), + exportedAt, + updatedAt: input.updatedAt ?? Date.parse(exportedAt), deviceName: input.deviceName, sessions, metas: input.metas @@ -260,6 +263,18 @@ export function createSyncSnapshot(input: { } } +/** + * Last-write time of a snapshot in epoch milliseconds. + * Snapshots written before `updatedAt` existed fall back to `exportedAt`. + */ +export function snapshotUpdatedAt(snapshot: Pick): number { + if (typeof snapshot.updatedAt === 'number' && Number.isFinite(snapshot.updatedAt)) { + return snapshot.updatedAt + } + const parsed = Date.parse(snapshot.exportedAt) + return Number.isFinite(parsed) ? parsed : 0 +} + export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemoteSnapshotResult { const localSessionById = new Map( input.localSessions.filter(isChatSession).map((session) => { diff --git a/src/renderer/packages/sync/types.ts b/src/renderer/packages/sync/types.ts index bc5497a529..748ad352b3 100644 --- a/src/renderer/packages/sync/types.ts +++ b/src/renderer/packages/sync/types.ts @@ -17,6 +17,8 @@ export type SyncCryptoEnvelope = { export type SyncSnapshot = { version: 1 exportedAt: string + /** Snapshot write time in epoch milliseconds; older snapshots may omit it (fall back to exportedAt). */ + updatedAt?: number deviceName: string sessions: Session[] metas: SessionMetaRecord[] diff --git a/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx b/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx index 223585c2d9..d42ed9fad4 100644 --- a/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx +++ b/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx @@ -170,9 +170,6 @@ function ExportAndImport(props: { onCancel: () => void }) { exportItems.includes(ExportDataItem.Key) ) } - if (!exportItems.includes(ExportDataItem.Key) && data[StorageKey.Settings]) { - delete (data[StorageKey.Settings] as Settings).providers - } if (!exportItems.includes(ExportDataItem.Setting)) { delete data[StorageKey.Settings] } diff --git a/src/renderer/routes/settings/general.tsx b/src/renderer/routes/settings/general.tsx index aafb7d5fa5..70b014738c 100644 --- a/src/renderer/routes/settings/general.tsx +++ b/src/renderer/routes/settings/general.tsx @@ -365,6 +365,9 @@ const WebDAVSyncSection = () => { if (result.remoteMissing) { return String(t('No remote sync snapshot found')) } + if (result.remoteStale) { + return String(t('Remote snapshot is not newer than local data, merge skipped')) + } return String( t('Imported {{imported}} conversations, created {{conflicts}} synced copies', { imported: result.imported, From 1e62243838e186e67d8e20ecf6f60b083514fa0e Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Wed, 22 Jul 2026 08:53:41 +0800 Subject: [PATCH 09/19] chore: revert unrelated .gitignore/ralph.sh noise - Remove .workbuddy/ entry from .gitignore (local preference, not upstream material) - Restore scripts/ralph/ralph.sh executable bit (0755) in git index --- .gitignore | 3 +-- scripts/ralph/ralph.sh | 0 2 files changed, 1 insertion(+), 2 deletions(-) mode change 100644 => 100755 scripts/ralph/ralph.sh diff --git a/.gitignore b/.gitignore index adad2eaac6..32d5be1f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -189,5 +189,4 @@ storybook-static .worktrees/ .claude/worktrees/ -tmp-*.md -.workbuddy/ \ No newline at end of file +tmp-*.md \ No newline at end of file diff --git a/scripts/ralph/ralph.sh b/scripts/ralph/ralph.sh old mode 100644 new mode 100755 From 842ada9371e08b2dd6cd21b18cfb8b8da7a1018a Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Wed, 22 Jul 2026 08:53:47 +0800 Subject: [PATCH 10/19] fix(export): sanitize nested OAuth, memorized license, and webSearch API keys from settings export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sanitizeProviderForExport now also deletes the entire oauth object (accessToken/refreshToken/extra) alongside apiKey/accessKey/secretKey - Top-level sanitize deletes memorizedManualLicenseKey and lastSelectedLicenseByUser (email-to-license-key map) - Deep-clone extension.webSearch then delete tavilyApiKey, bochaApiKey, queritApiKey - Tests expanded from 2 to 5: nested OAuth, remembered license key, web search keys — both !includeSecrets and includeSecrets directions --- src/renderer/packages/settings-export.test.ts | 57 +++++++++++++++++++ src/renderer/packages/settings-export.ts | 16 ++++++ 2 files changed, 73 insertions(+) diff --git a/src/renderer/packages/settings-export.test.ts b/src/renderer/packages/settings-export.test.ts index ed5464ad9b..7314769281 100644 --- a/src/renderer/packages/settings-export.test.ts +++ b/src/renderer/packages/settings-export.test.ts @@ -9,6 +9,8 @@ function settingsWithSecrets(): Settings { licenseKey: 'license-secret', licenseDetail: { plan: 'pro' } as unknown as Settings['licenseDetail'], licenseInstances: { 'license-secret': 'device-1' }, + memorizedManualLicenseKey: 'memorized-license-secret', + lastSelectedLicenseByUser: { 'alice@example.com': 'selected-license-secret' }, providers: { openai: { apiKey: 'sk-secret', @@ -16,6 +18,12 @@ function settingsWithSecrets(): Settings { secretKey: 'secret-key', sessionToken: 'session-token', apiHost: 'https://api.example.com', + oauth: { + accessToken: 'oauth-access-secret', + refreshToken: 'oauth-refresh-secret', + expiresAt: 1893456000000, + }, + activeAuthMode: 'oauth', }, }, sync: { @@ -29,6 +37,16 @@ function settingsWithSecrets(): Settings { }, lastSyncedAt: '2026-07-05T00:00:00.000Z', }, + extension: { + ...defaults.settings().extension, + webSearch: { + ...defaults.settings().extension.webSearch, + provider: 'tavily', + tavilyApiKey: 'tavily-secret', + bochaApiKey: 'bocha-secret', + queritApiKey: 'querit-secret', + }, + }, } } @@ -42,6 +60,7 @@ describe('sanitizeSettingsForExport', () => { expect(sanitized.licenseInstances).toBeUndefined() expect(sanitized.providers?.openai).toEqual({ apiHost: 'https://api.example.com', + activeAuthMode: 'oauth', }) expect(sanitized.sync.webdav).toEqual({ url: 'https://dav.example.com/files/me/', @@ -53,11 +72,49 @@ describe('sanitizeSettingsForExport', () => { expect(settings.sync.webdav.syncPassword).toBe('sync-secret') }) + it('removes nested OAuth credentials when key export is not selected', () => { + const settings = settingsWithSecrets() + const sanitized = sanitizeSettingsForExport(settings, false) + + expect(sanitized.providers?.openai?.oauth).toBeUndefined() + // The original settings object must keep its credentials untouched. + expect(settings.providers?.openai?.oauth?.accessToken).toBe('oauth-access-secret') + expect(settings.providers?.openai?.oauth?.refreshToken).toBe('oauth-refresh-secret') + }) + + it('removes remembered and per-account license keys when key export is not selected', () => { + const sanitized = sanitizeSettingsForExport(settingsWithSecrets(), false) + + expect(sanitized.memorizedManualLicenseKey).toBeUndefined() + expect(sanitized.lastSelectedLicenseByUser).toBeUndefined() + }) + + it('removes web search API keys when key export is not selected', () => { + const settings = settingsWithSecrets() + const sanitized = sanitizeSettingsForExport(settings, false) + + expect(sanitized.extension.webSearch.provider).toBe('tavily') + expect(sanitized.extension.webSearch.tavilyApiKey).toBeUndefined() + expect(sanitized.extension.webSearch.bochaApiKey).toBeUndefined() + expect(sanitized.extension.webSearch.queritApiKey).toBeUndefined() + // The original settings object must keep its keys untouched. + expect(settings.extension.webSearch.tavilyApiKey).toBe('tavily-secret') + expect(settings.extension.webSearch.bochaApiKey).toBe('bocha-secret') + expect(settings.extension.webSearch.queritApiKey).toBe('querit-secret') + }) + it('keeps WebDAV and provider secrets when key export is selected', () => { const sanitized = sanitizeSettingsForExport(settingsWithSecrets(), true) expect(sanitized.licenseKey).toBe('license-secret') + expect(sanitized.memorizedManualLicenseKey).toBe('memorized-license-secret') + expect(sanitized.lastSelectedLicenseByUser).toEqual({ 'alice@example.com': 'selected-license-secret' }) expect(sanitized.providers?.openai?.apiKey).toBe('sk-secret') + expect(sanitized.providers?.openai?.oauth?.accessToken).toBe('oauth-access-secret') + expect(sanitized.providers?.openai?.oauth?.refreshToken).toBe('oauth-refresh-secret') + expect(sanitized.extension.webSearch.tavilyApiKey).toBe('tavily-secret') + expect(sanitized.extension.webSearch.bochaApiKey).toBe('bocha-secret') + expect(sanitized.extension.webSearch.queritApiKey).toBe('querit-secret') expect(sanitized.sync.webdav.password).toBe('dav-secret') expect(sanitized.sync.webdav.syncPassword).toBe('sync-secret') expect(sanitized.licenseDetail).toBeUndefined() diff --git a/src/renderer/packages/settings-export.ts b/src/renderer/packages/settings-export.ts index 94fc80ab08..5317b2ca76 100644 --- a/src/renderer/packages/settings-export.ts +++ b/src/renderer/packages/settings-export.ts @@ -6,6 +6,9 @@ function sanitizeProviderForExport(provider: ProviderSettings): ProviderSettings delete cleanedProvider.accessKey delete cleanedProvider.secretKey delete cleanedProvider.sessionToken + // Nested OAuth credentials (accessToken / refreshToken / extra tokens) are + // just as sensitive as API keys; the whole object must not be exported. + delete cleanedProvider.oauth return cleanedProvider } @@ -15,6 +18,12 @@ export function sanitizeSettingsForExport(settings: Settings, includeSecrets: bo licenseDetail: undefined, licenseInstances: undefined, providers: settings.providers ? { ...settings.providers } : settings.providers, + extension: { + ...settings.extension, + webSearch: { + ...settings.extension.webSearch, + }, + }, sync: { ...settings.sync, webdav: { @@ -25,11 +34,18 @@ export function sanitizeSettingsForExport(settings: Settings, includeSecrets: bo if (!includeSecrets) { delete cleanedSettings.licenseKey + // License keys remembered for the UI or selected per account are still + // credentials even though the active licenseKey lives elsewhere. + delete cleanedSettings.memorizedManualLicenseKey + delete cleanedSettings.lastSelectedLicenseByUser if (cleanedSettings.providers) { cleanedSettings.providers = Object.fromEntries( Object.entries(cleanedSettings.providers).map(([id, provider]) => [id, sanitizeProviderForExport(provider)]) ) as Settings['providers'] } + delete cleanedSettings.extension.webSearch.tavilyApiKey + delete cleanedSettings.extension.webSearch.bochaApiKey + delete cleanedSettings.extension.webSearch.queritApiKey cleanedSettings.sync.webdav.password = '' cleanedSettings.sync.webdav.syncPassword = '' } From efdc1c93238f85fe91a4e9d2b3dbcddf66c34211 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Wed, 22 Jul 2026 08:53:55 +0800 Subject: [PATCH 11/19] fix(sync): replace wall-clock stale gate with endpoint-scoped ETag dedup; preserve edited sync copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1 (ETag dedup instead of clock comparison): - Remove SyncSnapshot.updatedAt, snapshotUpdatedAt(), and the entire isRemoteSnapshotStale function that compared device clocks - New isRemoteSnapshotAlreadySeen: exact byte-identity check scoped to the WebDAV endpoint (url+username) + ETag. No ETag / no record / endpoint mismatch → always merge (safe default) - New rememberRemoteSnapshot: records PUT response ETag after upload, GET ETag after download; stored as sync.lastSeenEndpoint+lastSeenETag - Result flag renamed remoteStale → remoteUnchanged; UI message reworded - Tests rewritten for ETag semantics, plus clock-skew regression test and endpoint-scoping regression test P1-3 (don't throw on edited synced copy): - existingCopy with different content → continue instead of throw. The stable conflict ID uniquely identifies the import event; content mismatch can only mean the user edited the copy - Rewrote the throw assertion test, added reviewer-reproduction regression test (import copy → append message → replay → succeed, copy unchanged) --- src/renderer/packages/sync/local.ts | 18 ++- src/renderer/packages/sync/service.test.ts | 141 ++++++++++++++++++-- src/renderer/packages/sync/service.ts | 76 +++++++---- src/renderer/packages/sync/snapshot.test.ts | 75 +++++++++-- src/renderer/packages/sync/snapshot.ts | 22 +-- src/renderer/packages/sync/types.ts | 2 - src/renderer/routes/settings/general.tsx | 4 +- src/shared/types/settings.ts | 5 + 8 files changed, 270 insertions(+), 73 deletions(-) diff --git a/src/renderer/packages/sync/local.ts b/src/renderer/packages/sync/local.ts index d1e0a9ac1e..9a6a2daca7 100644 --- a/src/renderer/packages/sync/local.ts +++ b/src/renderer/packages/sync/local.ts @@ -122,8 +122,19 @@ export function updateSyncLastSyncedAt(isoDate: string) { }) } -export function getSyncLastSyncedAt(): string | undefined { - return settingsStore.getState().sync?.lastSyncedAt +export function getSyncLastSeenSnapshot(): { endpoint: string; etag?: string } | undefined { + const sync = settingsStore.getState().sync + if (!sync?.lastSeenEndpoint) { + return undefined + } + return { endpoint: sync.lastSeenEndpoint, etag: sync.lastSeenETag } +} + +export function setSyncLastSeenSnapshot(seen: { endpoint: string; etag?: string }) { + settingsStore.getState().setSettings((settings) => { + settings.sync.lastSeenEndpoint = seen.endpoint + settings.sync.lastSeenETag = seen.etag + }) } export function createDefaultWebDAVSyncDeps(): WebDAVSyncDeps { @@ -136,6 +147,7 @@ export function createDefaultWebDAVSyncDeps(): WebDAVSyncDeps { deleteSession: deleteSyncSession, saveMetas: saveSyncMetas, updateLastSyncedAt: updateSyncLastSyncedAt, - getLastSyncedAt: getSyncLastSyncedAt, + getLastSeenSnapshot: getSyncLastSeenSnapshot, + setLastSeenSnapshot: setSyncLastSeenSnapshot, } } diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index 64f0e85511..0ec039a28f 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -235,7 +235,7 @@ describe('WebDAV sync service', () => { expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() }) - it('skips downloading when the remote snapshot is not newer than the last sync', async () => { + it('skips downloading when the remote snapshot is the one last synced with', async () => { const remote: SyncSnapshot = { version: 1, exportedAt: '2026-06-21T00:00:00.000Z', @@ -248,7 +248,7 @@ describe('WebDAV sync service', () => { platform: { webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ status: request.method === 'GET' ? 200 : 405, - headers: {}, + headers: { ETag: '"remote-etag"' }, body: JSON.stringify(envelope), })), }, @@ -259,21 +259,72 @@ describe('WebDAV sync service', () => { saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - getLastSyncedAt: vi.fn(() => '2026-06-21T00:00:00.000Z'), + getLastSeenSnapshot: vi.fn(() => ({ + endpoint: 'https://dav.example.com/files/me/\nalice', + etag: '"remote-etag"', + })), + setLastSeenSnapshot: vi.fn(), createConflictId: vi.fn(() => 'copy-id'), now: () => 2000, } const result = await downloadAndMergeWebDAVSnapshot(baseSettings, deps) - expect(result.remoteStale).toBe(true) + expect(result.remoteUnchanged).toBe(true) expect(result.imported).toBe(0) expect(result.conflicts).toBe(0) expect(deps.createSession).not.toHaveBeenCalled() expect(deps.listLocalSessions).not.toHaveBeenCalled() + expect(deps.setLastSeenSnapshot).not.toHaveBeenCalled() + }) + + it('downloads a snapshot whose ETag matches another endpoint only', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'remote text')], + metas: [meta('remote-1', 'Remote')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: { ETag: '"remote-etag"' }, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => []), + listLocalMetas: vi.fn(async () => []), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + // A matching ETag recorded for a different WebDAV endpoint/account must + // never suppress the merge against the current endpoint. + getLastSeenSnapshot: vi.fn(() => ({ + endpoint: 'https://other.example.com/dav/\nbob', + etag: '"remote-etag"', + })), + setLastSeenSnapshot: vi.fn(), + createConflictId: vi.fn(() => 'copy-id'), + now: () => 2000, + } + + const result = await downloadAndMergeWebDAVSnapshot(baseSettings, deps) + + expect(result.remoteUnchanged).toBeUndefined() + expect(result.imported).toBe(1) + expect(deps.createSession).toHaveBeenCalledTimes(1) + expect(deps.setLastSeenSnapshot).toHaveBeenCalledWith({ + endpoint: 'https://dav.example.com/files/me/\nalice', + etag: '"remote-etag"', + }) }) - it('skips re-merging an already synced remote snapshot during upload', async () => { + it('skips re-merging an already seen remote snapshot during upload', async () => { const requests: WebDAVRequest[] = [] const remote: SyncSnapshot = { version: 1, @@ -305,7 +356,11 @@ describe('WebDAV sync service', () => { saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - getLastSyncedAt: vi.fn(() => '2026-06-21T00:00:00.000Z'), + getLastSeenSnapshot: vi.fn(() => ({ + endpoint: 'https://dav.example.com/files/me/\nalice', + etag: '"remote-etag"', + })), + setLastSeenSnapshot: vi.fn(), createConflictId: vi.fn(() => 'copy-id'), now: () => 1000, } @@ -315,20 +370,24 @@ describe('WebDAV sync service', () => { const envelope = JSON.parse(put?.body ?? '{}') const decrypted = await decryptJsonEnvelope(envelope, 'sync-secret') - // The stale remote snapshot must not be merged back in, otherwise its - // divergent content would be re-uploaded as a "(Synced copy)" duplicate. + // The already-seen remote snapshot must not be merged back in, otherwise + // its divergent content would be re-uploaded as a "(Synced copy)" duplicate. expect(result.uploaded).toBe(1) expect(decrypted.sessions.map((item) => item.id)).toEqual(['same-id']) expect(decrypted.sessions[0].name).toBe('Local') - expect(decrypted.updatedAt).toBe(1000) + // The PUT response carried no ETag, so only the endpoint is remembered and + // the next download will merge idempotently instead of being skipped. + expect(deps.setLastSeenSnapshot).toHaveBeenCalledWith({ + endpoint: 'https://dav.example.com/files/me/\nalice', + etag: undefined, + }) }) - it('merges a remote snapshot that is newer than the last sync', async () => { + it('merges a remote snapshot that has not been seen before during upload', async () => { const requests: WebDAVRequest[] = [] const remote: SyncSnapshot = { version: 1, exportedAt: '2026-06-22T00:00:00.000Z', - updatedAt: Date.parse('2026-06-22T00:00:00.000Z'), deviceName: 'Phone', sessions: [session('remote-1', 'Remote', 'remote text')], metas: [meta('remote-1', 'Remote')], @@ -356,7 +415,11 @@ describe('WebDAV sync service', () => { saveMetas: vi.fn(), deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), - getLastSyncedAt: vi.fn(() => '2026-06-21T00:00:00.000Z'), + getLastSeenSnapshot: vi.fn(() => ({ + endpoint: 'https://dav.example.com/files/me/\nalice', + etag: '"previously-seen-etag"', + })), + setLastSeenSnapshot: vi.fn(), createConflictId: vi.fn(() => 'copy-id'), now: () => 1000, } @@ -370,6 +433,60 @@ describe('WebDAV sync service', () => { expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) }) + it('merges a remote snapshot with older timestamps but an unseen ETag during upload', async () => { + // Regression test for the wall-clock stale gate: a snapshot uploaded by a + // device with a slow clock (exportedAt behind our last sync) is still a + // genuinely new snapshot. Skipping its merge here would drop the + // remote-only session from the PUT body and delete it from the server. + const requests: WebDAVRequest[] = [] + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T09:59:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'remote text')], + metas: [meta('remote-1', 'Remote')], + } + const remoteEnvelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn((request: WebDAVRequest) => { + requests.push(request) + if (request.method === 'GET') { + return Promise.resolve({ + status: 200, + headers: { ETag: '"remote-etag"' }, + body: JSON.stringify(remoteEnvelope), + }) + } + return Promise.resolve({ status: request.method === 'PUT' ? 201 : 405, headers: {}, body: '' }) + }), + }, + listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), + listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + getLastSeenSnapshot: vi.fn(() => ({ + endpoint: 'https://dav.example.com/files/me/\nalice', + etag: '"previously-seen-etag"', + })), + setLastSeenSnapshot: vi.fn(), + createConflictId: vi.fn(() => 'copy-id'), + now: () => Date.parse('2026-06-21T10:00:00.000Z'), + } + + const result = await uploadWebDAVSnapshot(baseSettings, deps) + const put = requests.find((request) => request.method === 'PUT') + const envelope = JSON.parse(put?.body ?? '{}') + const decrypted = await decryptJsonEnvelope(envelope, 'sync-secret') + + expect(result.uploaded).toBe(2) + expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) + }) + it('downloads, decrypts, and saves missing remote sessions', async () => { const remote: SyncSnapshot = { version: 1, diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 464507b45e..7a4b9461f2 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -3,14 +3,13 @@ import { SessionMetaRecordSchema, SessionSchema } from '@shared/types/session' import { z } from 'zod' import { migrateSession } from '@/utils/session-utils' import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' -import { createSyncSnapshot, mergeRemoteSnapshot, snapshotUpdatedAt } from './snapshot' +import { createSyncSnapshot, mergeRemoteSnapshot } from './snapshot' import type { SyncCryptoEnvelope, SyncSnapshot, WebDAVRequest, WebDAVResponse } from './types' import { buildBasicAuthHeader, joinWebDAVUrl, requestWebDAV, SYNC_COLLECTION_PATH, SYNC_SNAPSHOT_PATH } from './webdav' const RawSyncSnapshotSchema = z.object({ version: z.literal(1), exportedAt: z.string(), - updatedAt: z.number().optional(), deviceName: z.string(), sessions: z.array(z.unknown()), metas: z.array(SessionMetaRecordSchema), @@ -30,6 +29,16 @@ type DownloadedWebDAVSnapshot = { etag?: string } +/** + * Identity of the remote snapshot this device last synced with. Scoped to the + * endpoint (URL + username) so that switching WebDAV servers or accounts never + * suppresses a merge against the new endpoint's snapshot. + */ +export type LastSeenSnapshot = { + endpoint: string + etag?: string +} + export type WebDAVSyncDeps = { platform: SyncPlatform listLocalSessions: () => Promise @@ -39,7 +48,8 @@ export type WebDAVSyncDeps = { deleteSession: (sessionId: string) => Promise saveMetas: (metas: SessionMetaRecord[]) => Promise updateLastSyncedAt: (isoDate: string) => Promise | void - getLastSyncedAt?: () => string | undefined | Promise + getLastSeenSnapshot?: () => LastSeenSnapshot | undefined | Promise + setLastSeenSnapshot?: (seen: LastSeenSnapshot) => Promise | void createConflictId?: (sourceSessionId: string, contentFingerprint: string) => string now?: () => number } @@ -55,31 +65,38 @@ export type DownloadWebDAVSnapshotResult = { saved: number lastSyncedAt?: string remoteMissing?: boolean - remoteStale?: boolean + remoteUnchanged?: boolean } -function parseIsoToMillis(value: string | undefined): number | undefined { - if (!value) { - return undefined - } - const parsed = Date.parse(value) - return Number.isFinite(parsed) ? parsed : undefined +function syncEndpoint(settings: Settings): string { + const webdav = getWebDAVSettings(settings) + return `${webdav.url}\n${webdav.username}` } /** - * Skip merging when the remote snapshot is not newer than the last sync. - * Merging an already-seen snapshot again would only recreate "(Synced copy)" - * duplicates on every device that replays it. + * Skip merging only when the remote snapshot is the exact one this device + * last synced with (same endpoint, same ETag). Device clocks are never + * compared: a clock-skewed device must still see every genuinely new + * snapshot, otherwise the skipped merge would let a later conditional upload + * overwrite remote-only sessions. When in doubt (no ETag, no record), merge. */ -async function isRemoteSnapshotStale(remote: SyncSnapshot, deps: WebDAVSyncDeps): Promise { - if (!deps.getLastSyncedAt) { +async function isRemoteSnapshotAlreadySeen( + remote: DownloadedWebDAVSnapshot, + settings: Settings, + deps: WebDAVSyncDeps +): Promise { + if (!remote.etag || !deps.getLastSeenSnapshot) { return false } - const lastSyncedAt = parseIsoToMillis(await deps.getLastSyncedAt()) - if (lastSyncedAt === undefined) { + const lastSeen = await deps.getLastSeenSnapshot() + if (!lastSeen) { return false } - return snapshotUpdatedAt(remote) <= lastSyncedAt + return lastSeen.endpoint === syncEndpoint(settings) && lastSeen.etag === remote.etag +} + +async function rememberRemoteSnapshot(etag: string | undefined, settings: Settings, deps: WebDAVSyncDeps) { + await deps.setLastSeenSnapshot?.({ endpoint: syncEndpoint(settings), etag }) } function getWebDAVSettings(settings: Settings) { @@ -219,22 +236,21 @@ export async function uploadWebDAVSnapshot( deps.listLocalMetas(), deps.platform.getDeviceName?.() ?? Promise.resolve('Unknown device'), ]) - const snapshotUpdatedAtMs = now() - const lastSyncedAt = new Date(snapshotUpdatedAtMs).toISOString() + const lastSyncedAt = new Date(now()).toISOString() const localSnapshot = createSyncSnapshot({ sessions, metas, deviceName, exportedAt: lastSyncedAt, - updatedAt: snapshotUpdatedAtMs, }) for (let attempt = 0; attempt < MAX_UPLOAD_ATTEMPTS; attempt += 1) { const remote = await downloadWebDAVSnapshot(settings, deps.platform) - // Only merge remote content that is newer than our last sync. Re-merging an - // already-synced snapshot would resurrect "(Synced copy)" duplicates that the - // local device has since folded back into its own sessions. - const shouldMergeRemote = remote.snapshot ? !(await isRemoteSnapshotStale(remote.snapshot, deps)) : false + // Only merge a remote snapshot this device has not synced with yet. + // Re-merging an already-seen snapshot would resurrect "(Synced copy)" + // duplicates that the local device has since folded back into its own + // sessions; skipping a genuinely new one would drop remote-only sessions. + const shouldMergeRemote = remote.snapshot ? !(await isRemoteSnapshotAlreadySeen(remote, settings, deps)) : false const mergeResult = shouldMergeRemote ? mergeRemoteSnapshot({ localSessions: localSnapshot.sessions, @@ -253,7 +269,6 @@ export async function uploadWebDAVSnapshot( metas: [...localSnapshot.metas, ...mergeResult.metasToSave], deviceName, exportedAt: lastSyncedAt, - updatedAt: snapshotUpdatedAtMs, }) : localSnapshot const preconditionHeaders: Record = remote.snapshot @@ -279,6 +294,10 @@ export async function uploadWebDAVSnapshot( } assertSuccess(response, 'Upload WebDAV sync snapshot', [200, 201, 204]) + // The remote now holds exactly what we wrote. Record the ETag returned by + // the PUT (if any) so the next download can recognize it as already seen; + // without one, the next download simply merges idempotently. + await rememberRemoteSnapshot(responseHeader(response.headers, 'etag'), settings, deps) await deps.updateLastSyncedAt(lastSyncedAt) return { uploaded: snapshot.sessions.length, @@ -303,12 +322,12 @@ export async function downloadAndMergeWebDAVSnapshot( } } - if (await isRemoteSnapshotStale(remote.snapshot, deps)) { + if (await isRemoteSnapshotAlreadySeen(remote, settings, deps)) { return { imported: 0, conflicts: 0, saved: 0, - remoteStale: true, + remoteUnchanged: true, } } @@ -373,6 +392,7 @@ export async function downloadAndMergeWebDAVSnapshot( } const lastSyncedAt = new Date((deps.now ?? Date.now)()).toISOString() + await rememberRemoteSnapshot(remote.etag, settings, deps) await deps.updateLastSyncedAt(lastSyncedAt) return { diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts index 09d5dc566c..ef6af9ac32 100644 --- a/src/renderer/packages/sync/snapshot.test.ts +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -429,9 +429,11 @@ describe('sync snapshot merge', () => { expect(first.session.id).toBe(second.session.id) }) - it('refuses to overwrite different content occupying a stable conflict ID', () => { + it('preserves an edited synced copy instead of aborting the merge', () => { const local = session('same-id', 'Local', 'local text') - const occupied = session('stable-copy-id', 'Unrelated', 'different content') + // The user kept chatting in the synced copy after importing it, so its + // content no longer matches the remote snapshot it was imported from. + const editedCopy = session('stable-copy-id', 'Unrelated', 'different content') const remote: SyncSnapshot = { version: 1, exportedAt: '2026-06-21T00:00:00.000Z', @@ -440,15 +442,66 @@ describe('sync snapshot merge', () => { metas: [meta('same-id', 'Remote')], } - expect(() => - mergeRemoteSnapshot({ - localSessions: [local, occupied], - localMetas: [meta('same-id', 'Local'), meta('stable-copy-id', 'Unrelated')], - remote, - now: 2000, - createConflictId: () => 'stable-copy-id', - }) - ).toThrow(/already used by different content/) + const result = mergeRemoteSnapshot({ + localSessions: [local, editedCopy], + localMetas: [meta('same-id', 'Local'), meta('stable-copy-id', 'Unrelated')], + remote, + now: 2000, + createConflictId: () => 'stable-copy-id', + }) + + // The copy is user-owned local data: it must be left completely untouched + // (no session change, no meta save, no new conflict) without failing. + expect(result.sessionChanges).toHaveLength(0) + expect(result.metasToSave).toHaveLength(0) + expect(result.conflicts).toBe(0) + expect(result.imported).toBe(0) + }) + + it('replays an unchanged remote conflict without touching the locally edited copy', () => { + // Reviewer reproduction: import a conflict copy, append one local message + // to it, then merge the same remote snapshot again. The merge must succeed + // and the copy must keep the user's appended message. + const local = session('same-id', 'Local', 'local text') + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('same-id', 'Remote', 'remote text')], + metas: [meta('same-id', 'Remote')], + } + const deps = { now: 2000, createConflictId: () => 'stable-copy-id' } + + const first = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Local')], + remote, + ...deps, + }) + const created = first.sessionChanges.find((change) => change.kind === 'create') + if (!created || created.kind !== 'create') throw new Error('Expected a synced copy to be created') + + const copyWithUserMessage: Session = { + ...created.session, + messages: [ + ...created.session.messages, + { + id: 'user-msg', + role: 'user', + contentParts: [{ type: 'text' as const, text: 'user follow-up' }], + }, + ], + } + const replay = mergeRemoteSnapshot({ + localSessions: [local, copyWithUserMessage], + localMetas: [meta('same-id', 'Local'), meta('stable-copy-id', 'Remote (Synced copy)')], + remote, + ...deps, + }) + + expect(replay.sessionChanges).toHaveLength(0) + expect(replay.metasToSave).toHaveLength(0) + expect(copyWithUserMessage.messages.map((item) => item.id)).toContain('user-msg') }) it('updates remote metadata for same-content sessions without creating synced copies when requested', () => { diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index 0126ac775c..40704ff3b6 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -245,7 +245,6 @@ export function createSyncSnapshot(input: { metas: SessionMetaRecord[] deviceName: string exportedAt?: string - updatedAt?: number }): SyncSnapshot { const sessions = input.sessions.filter(isChatSession).map(stripLocalSessionReferences) const sessionIds = new Set(sessions.map((session) => session.id)) @@ -254,7 +253,6 @@ export function createSyncSnapshot(input: { return { version: 1, exportedAt, - updatedAt: input.updatedAt ?? Date.parse(exportedAt), deviceName: input.deviceName, sessions, metas: input.metas @@ -263,18 +261,6 @@ export function createSyncSnapshot(input: { } } -/** - * Last-write time of a snapshot in epoch milliseconds. - * Snapshots written before `updatedAt` existed fall back to `exportedAt`. - */ -export function snapshotUpdatedAt(snapshot: Pick): number { - if (typeof snapshot.updatedAt === 'number' && Number.isFinite(snapshot.updatedAt)) { - return snapshot.updatedAt - } - const parsed = Date.parse(snapshot.exportedAt) - return Number.isFinite(parsed) ? parsed : 0 -} - export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemoteSnapshotResult { const localSessionById = new Map( input.localSessions.filter(isChatSession).map((session) => { @@ -360,7 +346,13 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot if (existingCopy) { if (!sessionContentEqual(existingCopy, copiedSession)) { - throw new Error(`Stable sync conflict ID ${conflictId} is already used by different content`) + // The synced copy is a normal mutable session: the user may have kept + // chatting in it after import. A content mismatch here means the copy + // is now user-owned local data, not an ID collision (the stable ID is + // derived from the source session ID and the remote content + // fingerprint, so an identical ID always refers to the same import). + // Preserve the edited copy untouched and never abort the sync over it. + continue } if (input.preferRemoteMetadata && !sessionMetadataEqual(existingCopy, copiedSession)) { sessionChanges.push({ diff --git a/src/renderer/packages/sync/types.ts b/src/renderer/packages/sync/types.ts index 748ad352b3..bc5497a529 100644 --- a/src/renderer/packages/sync/types.ts +++ b/src/renderer/packages/sync/types.ts @@ -17,8 +17,6 @@ export type SyncCryptoEnvelope = { export type SyncSnapshot = { version: 1 exportedAt: string - /** Snapshot write time in epoch milliseconds; older snapshots may omit it (fall back to exportedAt). */ - updatedAt?: number deviceName: string sessions: Session[] metas: SessionMetaRecord[] diff --git a/src/renderer/routes/settings/general.tsx b/src/renderer/routes/settings/general.tsx index 70b014738c..20286038e9 100644 --- a/src/renderer/routes/settings/general.tsx +++ b/src/renderer/routes/settings/general.tsx @@ -365,8 +365,8 @@ const WebDAVSyncSection = () => { if (result.remoteMissing) { return String(t('No remote sync snapshot found')) } - if (result.remoteStale) { - return String(t('Remote snapshot is not newer than local data, merge skipped')) + if (result.remoteUnchanged) { + return String(t('Remote snapshot has not changed since the last sync, merge skipped')) } return String( t('Imported {{imported}} conversations, created {{conflicts}} synced copies', { diff --git a/src/shared/types/settings.ts b/src/shared/types/settings.ts index 241dbbffad..c171726d5e 100644 --- a/src/shared/types/settings.ts +++ b/src/shared/types/settings.ts @@ -299,6 +299,11 @@ export const SyncSettingsSchema = z.object({ syncPassword: '', }), lastSyncedAt: z.string().optional().catch(undefined), + // Identity of the remote snapshot this device last synced with, used to skip + // re-merging an unchanged snapshot. Scoped to the endpoint (URL + username) + // so switching WebDAV servers or accounts never suppresses a merge. + lastSeenEndpoint: z.string().optional().catch(undefined), + lastSeenETag: z.string().optional().catch(undefined), }) export enum Theme { From 159bf8429de127814739fc3ebdc1a6dedeb9e78f Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Wed, 22 Jul 2026 09:11:15 +0800 Subject: [PATCH 12/19] fix(export): tolerate legacy settings missing sync/extension; test sync rollback AggregateError - sanitizeSettingsForExport now defensively handles settings.sync / settings.extension being absent (export reads raw storage via cast; old settings persisted before these fields existed may lack them until the next settings save re-normalizes) - Added regression test: legacy settings without sync/extension do not crash; non-secret redaction still applies to provider fields - Added AggregateError test for download rollback: when the second session create fails and the rollback delete also fails, the download returns AggregateError containing both errors (#3 checklist gap) --- src/renderer/packages/settings-export.test.ts | 24 ++++++++++ src/renderer/packages/settings-export.ts | 46 ++++++++++++------- src/renderer/packages/sync/service.test.ts | 38 +++++++++++++++ 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/src/renderer/packages/settings-export.test.ts b/src/renderer/packages/settings-export.test.ts index 7314769281..96a197b4de 100644 --- a/src/renderer/packages/settings-export.test.ts +++ b/src/renderer/packages/settings-export.test.ts @@ -120,4 +120,28 @@ describe('sanitizeSettingsForExport', () => { expect(sanitized.licenseDetail).toBeUndefined() expect(sanitized.licenseInstances).toBeUndefined() }) + + it('does not crash on settings persisted before sync and extension fields existed', () => { + // Raw storage from older app versions lacks these objects entirely; the + // export path casts without schema parsing, so sanitize must cope. + const legacy = { + ...defaults.settings(), + licenseKey: 'license-secret', + providers: { + openai: { + apiKey: 'sk-secret', + apiHost: 'https://api.example.com', + }, + }, + sync: undefined, + extension: undefined, + } as unknown as Settings + + const sanitized = sanitizeSettingsForExport(legacy, false) + + expect(sanitized.licenseKey).toBeUndefined() + expect(sanitized.providers?.openai).toEqual({ apiHost: 'https://api.example.com' }) + expect(sanitized.sync).toBeUndefined() + expect(sanitized.extension).toBeUndefined() + }) }) diff --git a/src/renderer/packages/settings-export.ts b/src/renderer/packages/settings-export.ts index 5317b2ca76..b4474603b0 100644 --- a/src/renderer/packages/settings-export.ts +++ b/src/renderer/packages/settings-export.ts @@ -13,23 +13,31 @@ function sanitizeProviderForExport(provider: ProviderSettings): ProviderSettings } export function sanitizeSettingsForExport(settings: Settings, includeSecrets: boolean): Settings { + // The export path reads raw storage via a type cast, and settings persisted + // before the sync/extension fields existed are not re-normalized until the + // next settings save — so both objects can be absent at runtime despite + // being required in the schema. Spread and redact defensively. const cleanedSettings: Settings = { ...settings, licenseDetail: undefined, licenseInstances: undefined, providers: settings.providers ? { ...settings.providers } : settings.providers, - extension: { - ...settings.extension, - webSearch: { - ...settings.extension.webSearch, - }, - }, - sync: { - ...settings.sync, - webdav: { - ...settings.sync.webdav, - }, - }, + extension: settings.extension + ? { + ...settings.extension, + webSearch: { + ...settings.extension.webSearch, + }, + } + : settings.extension, + sync: settings.sync + ? { + ...settings.sync, + webdav: { + ...settings.sync.webdav, + }, + } + : settings.sync, } if (!includeSecrets) { @@ -43,11 +51,15 @@ export function sanitizeSettingsForExport(settings: Settings, includeSecrets: bo Object.entries(cleanedSettings.providers).map(([id, provider]) => [id, sanitizeProviderForExport(provider)]) ) as Settings['providers'] } - delete cleanedSettings.extension.webSearch.tavilyApiKey - delete cleanedSettings.extension.webSearch.bochaApiKey - delete cleanedSettings.extension.webSearch.queritApiKey - cleanedSettings.sync.webdav.password = '' - cleanedSettings.sync.webdav.syncPassword = '' + if (cleanedSettings.extension?.webSearch) { + delete cleanedSettings.extension.webSearch.tavilyApiKey + delete cleanedSettings.extension.webSearch.bochaApiKey + delete cleanedSettings.extension.webSearch.queritApiKey + } + if (cleanedSettings.sync?.webdav) { + cleanedSettings.sync.webdav.password = '' + cleanedSettings.sync.webdav.syncPassword = '' + } } return cleanedSettings diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index 0ec039a28f..df09fa3e23 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -780,4 +780,42 @@ describe('WebDAV sync service', () => { expect(deps.saveMetas).not.toHaveBeenCalled() expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() }) + + it('wraps the original and rollback errors in an AggregateError when restoring also fails', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote 1', 'hi'), session('remote-2', 'Remote 2', 'hello')], + metas: [meta('remote-1', 'Remote 1'), meta('remote-2', 'Remote 2')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const importError = new Error('second session write failed') + const rollbackError = new Error('delete failed') + const deps = { + platform: { + webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => []), + listLocalMetas: vi.fn(async () => []), + createSession: vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(importError), + updateSessionMetadata: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(() => Promise.reject(rollbackError)), + updateLastSyncedAt: vi.fn(), + now: () => 2000, + } + + const failure = await downloadAndMergeWebDAVSnapshot(baseSettings, deps).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as Error).message).toMatch(/restoring local data also failed/) + expect((failure as AggregateError).errors).toEqual([importError, rollbackError]) + expect(deps.deleteSession).toHaveBeenCalledWith('remote-1') + expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() + }) }) From d90ba9c6162bf7614ab0000af5b86235976d61f4 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Wed, 22 Jul 2026 17:06:08 +0800 Subject: [PATCH 13/19] fix(export): strip device-local sync state and sanitize mineru/MCP secrets - Unconditionally strip lastSyncedAt/lastSeenEndpoint/lastSeenETag from sync exports so a restored device does not skip its first download merge. - Strip mineru.apiToken, MCP transport env (stdio) and headers (http) when includeSecrets is false. - Deep-copy documentParser/mineru and mcp.servers/transport before redaction to avoid polluting the original settings object. --- src/renderer/packages/settings-export.test.ts | 80 +++++++++++++++++++ src/renderer/packages/settings-export.ts | 37 +++++++++ 2 files changed, 117 insertions(+) diff --git a/src/renderer/packages/settings-export.test.ts b/src/renderer/packages/settings-export.test.ts index 96a197b4de..6bd6cdb82b 100644 --- a/src/renderer/packages/settings-export.test.ts +++ b/src/renderer/packages/settings-export.test.ts @@ -36,6 +36,8 @@ function settingsWithSecrets(): Settings { syncPassword: 'sync-secret', }, lastSyncedAt: '2026-07-05T00:00:00.000Z', + lastSeenEndpoint: 'https://dav.example.com/files/me/\nalice', + lastSeenETag: '"seen-etag"', }, extension: { ...defaults.settings().extension, @@ -46,6 +48,36 @@ function settingsWithSecrets(): Settings { bochaApiKey: 'bocha-secret', queritApiKey: 'querit-secret', }, + documentParser: { + type: 'mineru', + mineru: { apiToken: 'mineru-secret' }, + }, + }, + mcp: { + servers: [ + { + id: 'local-fs', + name: 'Local FS', + enabled: true, + transport: { + type: 'stdio', + command: 'npx', + args: ['srv'], + env: { GITHUB_TOKEN: 'ghp-secret' }, + }, + }, + { + id: 'remote-http', + name: 'Remote HTTP', + enabled: true, + transport: { + type: 'http', + url: 'https://mcp.example.com/', + headers: { Authorization: 'Bearer secret' }, + }, + }, + ], + enabledBuiltinServers: [], }, } } @@ -103,6 +135,54 @@ describe('sanitizeSettingsForExport', () => { expect(settings.extension.webSearch.queritApiKey).toBe('querit-secret') }) + it('removes device-local sync state from every export', () => { + const withSecrets = sanitizeSettingsForExport(settingsWithSecrets(), true) + const withoutSecrets = sanitizeSettingsForExport(settingsWithSecrets(), false) + + // lastSeen is this device's sync identity, not user configuration: a + // restored device inheriting it would skip its first download merge + // against the unchanged remote snapshot. Strip it regardless of the + // includeSecrets flag. + for (const sanitized of [withSecrets, withoutSecrets]) { + expect(sanitized.sync.lastSyncedAt).toBeUndefined() + expect(sanitized.sync.lastSeenEndpoint).toBeUndefined() + expect(sanitized.sync.lastSeenETag).toBeUndefined() + } + // Credentials still follow the includeSecrets flag; only the device-local + // state is always stripped. + expect(withSecrets.sync.webdav.password).toBe('dav-secret') + expect(withoutSecrets.sync.webdav.password).toBe('') + }) + + it('removes the MinerU API token when key export is not selected', () => { + const settings = settingsWithSecrets() + const sanitized = sanitizeSettingsForExport(settings, false) + + expect(sanitized.extension.documentParser?.type).toBe('mineru') + expect(sanitized.extension.documentParser?.mineru?.apiToken).toBe('') + expect(sanitizeSettingsForExport(settingsWithSecrets(), true).extension.documentParser?.mineru?.apiToken).toBe( + 'mineru-secret' + ) + // The original settings object must keep its token untouched. + expect(settings.extension.documentParser?.mineru?.apiToken).toBe('mineru-secret') + }) + + it('removes MCP transport env and headers when key export is not selected', () => { + const settings = settingsWithSecrets() + const sanitized = sanitizeSettingsForExport(settings, false) + + const [stdioServer, httpServer] = sanitized.mcp.servers + expect(stdioServer.transport).toEqual({ type: 'stdio', command: 'npx', args: ['srv'] }) + expect(httpServer.transport).toEqual({ type: 'http', url: 'https://mcp.example.com/' }) + // Transport credentials survive when key export is selected. + const withSecrets = sanitizeSettingsForExport(settingsWithSecrets(), true) + expect(withSecrets.mcp.servers[0].transport).toMatchObject({ env: { GITHUB_TOKEN: 'ghp-secret' } }) + expect(withSecrets.mcp.servers[1].transport).toMatchObject({ headers: { Authorization: 'Bearer secret' } }) + // The original settings object must keep its credentials untouched. + expect(settings.mcp.servers[0].transport).toMatchObject({ env: { GITHUB_TOKEN: 'ghp-secret' } }) + expect(settings.mcp.servers[1].transport).toMatchObject({ headers: { Authorization: 'Bearer secret' } }) + }) + it('keeps WebDAV and provider secrets when key export is selected', () => { const sanitized = sanitizeSettingsForExport(settingsWithSecrets(), true) diff --git a/src/renderer/packages/settings-export.ts b/src/renderer/packages/settings-export.ts index b4474603b0..700ca0116b 100644 --- a/src/renderer/packages/settings-export.ts +++ b/src/renderer/packages/settings-export.ts @@ -28,16 +28,39 @@ export function sanitizeSettingsForExport(settings: Settings, includeSecrets: bo webSearch: { ...settings.extension.webSearch, }, + documentParser: settings.extension.documentParser + ? { + ...settings.extension.documentParser, + mineru: settings.extension.documentParser.mineru + ? { ...settings.extension.documentParser.mineru } + : undefined, + } + : settings.extension.documentParser, } : settings.extension, sync: settings.sync ? { ...settings.sync, + // Device-local sync state (which snapshot this device last saw, and + // when it synced) must not travel with an export: a restored device + // would inherit the old device's lastSeen identity and skip its + // first download merge against the unchanged remote snapshot. + lastSyncedAt: undefined, + lastSeenEndpoint: undefined, + lastSeenETag: undefined, webdav: { ...settings.sync.webdav, }, } : settings.sync, + mcp: settings.mcp + ? { + ...settings.mcp, + servers: Array.isArray(settings.mcp.servers) + ? settings.mcp.servers.map((server) => ({ ...server, transport: { ...server.transport } })) + : settings.mcp.servers, + } + : settings.mcp, } if (!includeSecrets) { @@ -60,6 +83,20 @@ export function sanitizeSettingsForExport(settings: Settings, includeSecrets: bo cleanedSettings.sync.webdav.password = '' cleanedSettings.sync.webdav.syncPassword = '' } + if (cleanedSettings.extension?.documentParser?.mineru) { + cleanedSettings.extension.documentParser.mineru.apiToken = '' + } + // MCP transports routinely carry credentials — stdio env vars (API tokens) + // and HTTP headers (Authorization) must not leave the device in an export. + if (cleanedSettings.mcp?.servers) { + for (const server of cleanedSettings.mcp.servers) { + if (server.transport.type === 'stdio') { + delete server.transport.env + } else { + delete server.transport.headers + } + } + } } return cleanedSettings From 6274c17b14979b946a9d0392fc80f7733ed46932 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Wed, 22 Jul 2026 17:06:46 +0800 Subject: [PATCH 14/19] fix(sync): skip recording PUT ETag when upload merged unseen remote snapshot A merged upload writes remote-only sessions into the uploaded snapshot but never persists them locally. Recording the snapshot ETag as "last seen" would make every subsequent download report remoteUnchanged, and those remote-only sessions would never land on this device. Guard rememberRemoteSnapshot with `if (!mergeResult)` so the next download re-merges idempotently and persists sessions that were missing locally. --- src/renderer/packages/sync/service.test.ts | 75 ++++++++++++++++++++++ src/renderer/packages/sync/service.ts | 13 ++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index df09fa3e23..dc05232013 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -431,6 +431,81 @@ describe('WebDAV sync service', () => { expect(result.uploaded).toBe(2) expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) + // The upload merged remote sessions that were never persisted locally, so + // the new ETag must not be remembered — otherwise the next download would + // skip the very merge that saves them. + expect(deps.setLastSeenSnapshot).not.toHaveBeenCalled() + }) + + it('persists remote-only sessions on the download after a merged upload', async () => { + // Regression: a merged upload must not mark the uploaded snapshot as seen. + // The merged snapshot holds remote-only sessions that were never saved + // locally; remembering its ETag would make every later download report + // remoteUnchanged and those sessions would never land on this device. + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-22T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'remote text')], + metas: [meta('remote-1', 'Remote')], + } + let serverBody = JSON.stringify(await encryptJsonEnvelope(remote, 'sync-secret')) + let serverEtag = '"etag-v1"' + let lastSeen: { endpoint: string; etag?: string } | undefined + // Mirror the migrated shape production storage returns, so the round-trip + // through the upload/download migration does not look like a conflict. + const localSession = session('local-1', 'Local', 'local text') + localSession.settings = { temperature: undefined } + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn((request: WebDAVRequest) => { + if (request.method === 'GET') { + return Promise.resolve({ status: 200, headers: { ETag: serverEtag }, body: serverBody }) + } + if (request.method === 'PUT') { + serverBody = request.body ?? '' + serverEtag = '"etag-v2"' + return Promise.resolve({ status: 201, headers: { ETag: serverEtag }, body: '' }) + } + return Promise.resolve({ status: 405, headers: {}, body: '' }) + }), + }, + listLocalSessions: vi.fn(async () => [localSession]), + listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(async () => ({ name: 'Local', type: 'chat' as const })), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + getLastSeenSnapshot: vi.fn(() => lastSeen), + setLastSeenSnapshot: vi.fn((seen: { endpoint: string; etag?: string }) => { + lastSeen = seen + }), + createConflictId: vi.fn(() => 'copy-id'), + now: () => 1000, + } + + const upload = await uploadWebDAVSnapshot(baseSettings, deps) + + expect(upload.uploaded).toBe(2) + expect(lastSeen).toBeUndefined() + + const download = await downloadAndMergeWebDAVSnapshot(baseSettings, deps) + + expect(download.remoteUnchanged).toBeUndefined() + expect(download.imported).toBe(1) + expect(deps.createSession).toHaveBeenCalledTimes(1) + expect(deps.createSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'remote-1' }), + expect.objectContaining({ id: 'remote-1' }) + ) + // Once the download has actually persisted the merge, recording the ETag + // is safe again. + expect(lastSeen).toEqual({ + endpoint: 'https://dav.example.com/files/me/\nalice', + etag: '"etag-v2"', + }) }) it('merges a remote snapshot with older timestamps but an unseen ETag during upload', async () => { diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 7a4b9461f2..8eb6da282a 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -294,10 +294,15 @@ export async function uploadWebDAVSnapshot( } assertSuccess(response, 'Upload WebDAV sync snapshot', [200, 201, 204]) - // The remote now holds exactly what we wrote. Record the ETag returned by - // the PUT (if any) so the next download can recognize it as already seen; - // without one, the next download simply merges idempotently. - await rememberRemoteSnapshot(responseHeader(response.headers, 'etag'), settings, deps) + // Record the PUT's ETag as last seen only when no remote merge happened: + // without a merge the remote now holds exactly our local state. A merged + // snapshot, though, contains remote-only sessions that were never + // persisted locally — remembering its ETag would make the next download + // skip the very merge that saves them. Leave lastSeen untouched then; the + // next download re-merges idempotently and persists them. + if (!mergeResult) { + await rememberRemoteSnapshot(responseHeader(response.headers, 'etag'), settings, deps) + } await deps.updateLastSyncedAt(lastSyncedAt) return { uploaded: snapshot.sessions.length, From ade18596f777b3dc72c6bb6e1f08ef94b93b0446 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Wed, 22 Jul 2026 18:50:27 +0800 Subject: [PATCH 15/19] fix(sync): reject weak ETags, locale-independent conflict IDs, validate envelope params - service.ts: reject weak (W/) and malformed ETags in requireStrongWebDAVSnapshotETag before attempting conditional PUT. - snapshot.ts: replace localeCompare with code-unit sort so stable conflict UUIDs are deterministic across devices with different locales. - crypto.ts: validate envelope fields (iterations, salt, IV, ciphertext length, canonical Base64) before any key derivation, preventing KDF parameter injection attacks. - Add regression tests for all three fixes. --- src/renderer/packages/sync/crypto.test.ts | 37 +++++++++++- src/renderer/packages/sync/crypto.ts | 63 ++++++++++++++++++--- src/renderer/packages/sync/service.test.ts | 42 ++++++++++++++ src/renderer/packages/sync/service.ts | 35 +++++++----- src/renderer/packages/sync/snapshot.test.ts | 47 ++++++++++++++- src/renderer/packages/sync/snapshot.ts | 8 ++- 6 files changed, 209 insertions(+), 23 deletions(-) diff --git a/src/renderer/packages/sync/crypto.test.ts b/src/renderer/packages/sync/crypto.test.ts index 463c9428fd..6b48c81d10 100644 --- a/src/renderer/packages/sync/crypto.test.ts +++ b/src/renderer/packages/sync/crypto.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' describe('sync crypto envelope', () => { @@ -19,4 +19,39 @@ describe('sync crypto envelope', () => { await expect(decryptJsonEnvelope(envelope, 'wrong-password')).rejects.toThrow(/decrypt/i) }) + + it('rejects an unexpected PBKDF2 iteration count before deriving a key', async () => { + const envelope = await encryptJsonEnvelope({ secret: 'chat history' }, 'right-password') + const deriveKey = vi.spyOn(globalThis.crypto.subtle, 'deriveKey') + + try { + await expect(decryptJsonEnvelope({ ...envelope, iterations: 4_000_000_000 }, 'right-password')).rejects.toThrow( + /iterations must equal 250000/i + ) + expect(deriveKey).not.toHaveBeenCalled() + } finally { + deriveKey.mockRestore() + } + }) + + it('validates Base64 fields and fixed salt and IV lengths before deriving a key', async () => { + const envelope = await encryptJsonEnvelope({ secret: 'chat history' }, 'right-password') + + await expect(decryptJsonEnvelope({ ...envelope, salt: 'not-base64!' }, 'right-password')).rejects.toThrow( + /salt must be canonical Base64/i + ) + await expect(decryptJsonEnvelope({ ...envelope, salt: btoa('\0'.repeat(15)) }, 'right-password')).rejects.toThrow( + /salt must decode to 16 bytes/i + ) + await expect(decryptJsonEnvelope({ ...envelope, iv: btoa('\0'.repeat(11)) }, 'right-password')).rejects.toThrow( + /iv must decode to 12 bytes/i + ) + await expect(decryptJsonEnvelope({ ...envelope, ciphertext: '***' }, 'right-password')).rejects.toThrow( + /ciphertext must be canonical Base64/i + ) + const nonCanonicalSalt = `${btoa('\0'.repeat(16)).slice(0, -3)}B==` + await expect(decryptJsonEnvelope({ ...envelope, salt: nonCanonicalSalt }, 'right-password')).rejects.toThrow( + /salt must be canonical Base64/i + ) + }) }) diff --git a/src/renderer/packages/sync/crypto.ts b/src/renderer/packages/sync/crypto.ts index 2c02548796..ecd07048e6 100644 --- a/src/renderer/packages/sync/crypto.ts +++ b/src/renderer/packages/sync/crypto.ts @@ -4,6 +4,8 @@ const ENVELOPE_VERSION = 1 const PBKDF2_ITERATIONS = 250_000 const SALT_BYTES = 16 const IV_BYTES = 12 +const AES_GCM_TAG_BYTES = 16 +const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ function getCrypto(): Crypto { const cryptoImpl = globalThis.crypto @@ -30,6 +32,57 @@ function base64ToBytes(value: string): Uint8Array { return bytes } +function invalidEnvelope(reason: string): never { + throw new Error(`Invalid sync encryption envelope: ${reason}`) +} + +function decodeEnvelopeField(value: unknown, field: string, expectedBytes?: number): Uint8Array { + if (typeof value !== 'string' || !BASE64_PATTERN.test(value)) { + invalidEnvelope(`${field} must be canonical Base64`) + } + if (expectedBytes !== undefined && value.length !== Math.ceil(expectedBytes / 3) * 4) { + invalidEnvelope(`${field} must decode to ${expectedBytes} bytes`) + } + let decoded: Uint8Array + try { + decoded = base64ToBytes(value) + } catch { + return invalidEnvelope(`${field} must be canonical Base64`) + } + if (bytesToBase64(decoded) !== value) { + invalidEnvelope(`${field} must be canonical Base64`) + } + if (expectedBytes !== undefined && decoded.byteLength !== expectedBytes) { + invalidEnvelope(`${field} must decode to ${expectedBytes} bytes`) + } + return decoded +} + +function validateEnvelope(envelope: SyncCryptoEnvelope): { + salt: Uint8Array + iv: Uint8Array + ciphertext: Uint8Array +} { + const candidate = envelope as Partial | null + if (!candidate || typeof candidate !== 'object') { + invalidEnvelope('expected an object') + } + if (candidate.version !== ENVELOPE_VERSION || candidate.kdf !== 'PBKDF2-SHA256' || candidate.cipher !== 'AES-GCM') { + throw new Error('Unsupported sync encryption envelope') + } + if (candidate.iterations !== PBKDF2_ITERATIONS) { + invalidEnvelope(`iterations must equal ${PBKDF2_ITERATIONS}`) + } + + const salt = decodeEnvelopeField(candidate.salt, 'salt', SALT_BYTES) + const iv = decodeEnvelopeField(candidate.iv, 'iv', IV_BYTES) + const ciphertext = decodeEnvelopeField(candidate.ciphertext, 'ciphertext') + if (ciphertext.byteLength < AES_GCM_TAG_BYTES) { + invalidEnvelope(`ciphertext must include a ${AES_GCM_TAG_BYTES}-byte authentication tag`) + } + return { salt, iv, ciphertext } +} + function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer } @@ -79,19 +132,15 @@ export async function encryptJsonEnvelope(payload: T, password: string): Prom } export async function decryptJsonEnvelope(envelope: SyncCryptoEnvelope, password: string): Promise { - if (envelope.version !== ENVELOPE_VERSION || envelope.kdf !== 'PBKDF2-SHA256' || envelope.cipher !== 'AES-GCM') { - throw new Error('Unsupported sync encryption envelope') - } + const { salt, iv, ciphertext } = validateEnvelope(envelope) try { const cryptoImpl = getCrypto() - const salt = base64ToBytes(envelope.salt) - const iv = base64ToBytes(envelope.iv) - const key = await deriveAesKey(password, salt, envelope.iterations) + const key = await deriveAesKey(password, salt, PBKDF2_ITERATIONS) const decrypted = await cryptoImpl.subtle.decrypt( { name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, - toArrayBuffer(base64ToBytes(envelope.ciphertext)) + toArrayBuffer(ciphertext) ) return JSON.parse(new TextDecoder().decode(decrypted)) as T } catch (error) { diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index dc05232013..732bab275c 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -235,6 +235,48 @@ describe('WebDAV sync service', () => { expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() }) + it('refuses a weak ETag before attempting a conditional upload', async () => { + const requests: WebDAVRequest[] = [] + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'remote text')], + metas: [meta('remote-1', 'Remote')], + } + const remoteEnvelope = await encryptJsonEnvelope(remote, 'sync-secret') + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest: vi.fn((request: WebDAVRequest) => { + requests.push(request) + if (request.method === 'GET') { + return Promise.resolve({ + status: 200, + headers: { ETag: 'W/"remote-etag"' }, + body: JSON.stringify(remoteEnvelope), + }) + } + return Promise.resolve({ status: request.method === 'PUT' ? 412 : 405, headers: {}, body: '' }) + }), + }, + listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'local text')]), + listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + now: () => 1000, + } + + await expect(uploadWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/weak ETag.*If-Match/i) + + expect(requests.filter((request) => request.method === 'GET')).toHaveLength(1) + expect(requests.filter((request) => request.method === 'PUT')).toHaveLength(0) + expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() + }) + it('skips downloading when the remote snapshot is the one last synced with', async () => { const remote: SyncSnapshot = { version: 1, diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 8eb6da282a..56d6bb5828 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -18,6 +18,7 @@ const RawSyncSnapshotSchema = z.object({ const MAX_UPLOAD_ATTEMPTS = 3 const HTTP_NOT_FOUND = 404 const HTTP_PRECONDITION_FAILED = 412 +const STRONG_ETAG_PATTERN = /^"[\x21\x23-\x7e\x80-\xff]*"$/ type SyncPlatform = { getDeviceName?: () => Promise @@ -169,10 +170,16 @@ function responseHeader(headers: Record, name: string): string | return value ? value : undefined } -function requireWebDAVSnapshotETag(remote: DownloadedWebDAVSnapshot): string { +function requireStrongWebDAVSnapshotETag(remote: DownloadedWebDAVSnapshot): string { if (!remote.etag) { throw new Error('WebDAV server did not return an ETag for the sync snapshot; refusing to overwrite it') } + if (remote.etag.startsWith('W/')) { + throw new Error('WebDAV server returned a weak ETag that cannot be used with If-Match; refusing to overwrite it') + } + if (!STRONG_ETAG_PATTERN.test(remote.etag)) { + throw new Error('WebDAV server returned an invalid ETag for the sync snapshot; refusing to overwrite it') + } return remote.etag } @@ -246,20 +253,22 @@ export async function uploadWebDAVSnapshot( for (let attempt = 0; attempt < MAX_UPLOAD_ATTEMPTS; attempt += 1) { const remote = await downloadWebDAVSnapshot(settings, deps.platform) + const remoteSnapshot = remote.snapshot // Only merge a remote snapshot this device has not synced with yet. // Re-merging an already-seen snapshot would resurrect "(Synced copy)" // duplicates that the local device has since folded back into its own // sessions; skipping a genuinely new one would drop remote-only sessions. - const shouldMergeRemote = remote.snapshot ? !(await isRemoteSnapshotAlreadySeen(remote, settings, deps)) : false - const mergeResult = shouldMergeRemote - ? mergeRemoteSnapshot({ - localSessions: localSnapshot.sessions, - localMetas: localSnapshot.metas, - remote: remote.snapshot!, - now: now(), - createConflictId: deps.createConflictId, - }) - : undefined + const shouldMergeRemote = remoteSnapshot ? !(await isRemoteSnapshotAlreadySeen(remote, settings, deps)) : false + const mergeResult = + shouldMergeRemote && remoteSnapshot + ? mergeRemoteSnapshot({ + localSessions: localSnapshot.sessions, + localMetas: localSnapshot.metas, + remote: remoteSnapshot, + now: now(), + createConflictId: deps.createConflictId, + }) + : undefined const snapshot = mergeResult ? createSyncSnapshot({ sessions: [ @@ -271,8 +280,8 @@ export async function uploadWebDAVSnapshot( exportedAt: lastSyncedAt, }) : localSnapshot - const preconditionHeaders: Record = remote.snapshot - ? { 'If-Match': requireWebDAVSnapshotETag(remote) } + const preconditionHeaders: Record = remoteSnapshot + ? { 'If-Match': requireStrongWebDAVSnapshotETag(remote) } : { 'If-None-Match': '*' } const envelope = await encryptJsonEnvelope(snapshot, webdav.syncPassword) const response = await requestWebDAV(deps.platform, webdav.url, { diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts index ef6af9ac32..abc2d5b9a6 100644 --- a/src/renderer/packages/sync/snapshot.test.ts +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -1,5 +1,5 @@ import type { Session, SessionMetaRecord } from '@shared/types' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createSyncSnapshot, mergeRemoteSnapshot } from './snapshot' import type { SyncSnapshot } from './types' @@ -407,6 +407,51 @@ describe('sync snapshot merge', () => { expect(first.session.id).not.toBe(second.session.id) }) + it('uses locale-independent stable conflict IDs for Unicode object keys', () => { + const local = session('same-id', 'Project', 'local text') + const remoteSession = session('same-id', 'Project', 'remote text') + const unicodeKey = '\u00e4' + remoteSession.messages[0].contentParts = [ + { + type: 'tool-call', + state: 'call', + toolCallId: 'tool-1', + toolName: 'search', + args: { z: 1, [unicodeKey]: 2 }, + }, + ] + const conflictIdForLocale = (locale: string) => { + const collator = new Intl.Collator(locale) + const localeCompare = vi.spyOn(String.prototype, 'localeCompare').mockImplementation(function ( + this: string, + other: string + ) { + return collator.compare(String(this), other) + }) + try { + const result = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project')], + remote: { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [remoteSession], + metas: [meta('same-id', 'Project')], + }, + now: 2000, + }) + const change = result.sessionChanges[0] + if (change.kind !== 'create') throw new Error('Expected a conflict copy') + return change.session.id + } finally { + localeCompare.mockRestore() + } + } + + expect(conflictIdForLocale('en')).toBe(conflictIdForLocale('sv')) + }) + it('keeps the stable conflict ID when only remote metadata changes', () => { const local = session('same-id', 'Local', 'local text') const merge = (name: string, starred: boolean) => diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index 40704ff3b6..a4b0e63226 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -14,13 +14,19 @@ function isChatSessionMetaLike(item: Pick): boolean { return item.type === 'chat' || !item.type } +function compareStableKeys(left: string, right: string): number { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + function stableStringify(value: unknown): string { if (Array.isArray(value)) { return `[${value.map(stableStringify).join(',')}]` } if (value && typeof value === 'object') { return `{${Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => compareStableKeys(left, right)) .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`) .join(',')}}` } From a7ead8879b7ce0e3f5777aae002cff574124854c Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Wed, 22 Jul 2026 20:24:16 +0800 Subject: [PATCH 16/19] fix(sync): provenance tracking, active-generation guard, Android WebDAV transport - snapshot.ts: attach syncConflictSourceId to conflict copies so they can be recognized across devices; skip re-importing a copy whose source session already exists locally. - snapshot.ts: add sessionHasActiveGeneration() check to prevent uploading sessions with in-progress streaming responses. - snapshot.ts: strip runtime-only message state (cancel, generating, status, isStreamingMode) from synced snapshots. - service.ts: reject upload while any session is still generating, with a clear error message. - session.ts: add optional syncConflictSourceId field to SessionSchema. - mobile_platform.ts: use new @chatbox/capacitor-webdav-http Capacitor plugin for Android WebDAV transport. - Workspace: add packages/* for monorepo; add new capacitor plugin package. - Regression tests for all changes (provenance, generation detection, legacy copy recognition). --- package.json | 1 + .../android/build.gradle | 54 +++++++ .../android/proguard-rules.pro | 1 + .../android/src/main/AndroidManifest.xml | 1 + .../plugins/webdavhttp/WebDAVHttpPlugin.java | 68 ++++++++ .../webdavhttp/WebDAVHttpTransport.java | 120 ++++++++++++++ .../webdavhttp/WebDAVHttpTransportTest.java | 124 +++++++++++++++ packages/capacitor-webdav-http/package.json | 22 +++ pnpm-lock.yaml | 9 ++ pnpm-workspace.yaml | 1 + src/main/main.ts | 14 +- src/renderer/packages/sync/service.test.ts | 33 ++++ src/renderer/packages/sync/service.ts | 7 +- src/renderer/packages/sync/snapshot.test.ts | 147 +++++++++++++++++- src/renderer/packages/sync/snapshot.ts | 43 ++++- src/renderer/platform/mobile_platform.ts | 14 ++ src/shared/types/session.ts | 1 + 17 files changed, 654 insertions(+), 6 deletions(-) create mode 100644 packages/capacitor-webdav-http/android/build.gradle create mode 100644 packages/capacitor-webdav-http/android/proguard-rules.pro create mode 100644 packages/capacitor-webdav-http/android/src/main/AndroidManifest.xml create mode 100644 packages/capacitor-webdav-http/android/src/main/java/com/chatbox/plugins/webdavhttp/WebDAVHttpPlugin.java create mode 100644 packages/capacitor-webdav-http/android/src/main/java/com/chatbox/plugins/webdavhttp/WebDAVHttpTransport.java create mode 100644 packages/capacitor-webdav-http/android/src/test/java/com/chatbox/plugins/webdavhttp/WebDAVHttpTransportTest.java create mode 100644 packages/capacitor-webdav-http/package.json diff --git a/package.json b/package.json index 6f23871149..50f037947a 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "@babel/preset-env": "^7.28.0", "@biomejs/biome": "2.0.0", "@braintree/sanitize-url": "^6.0.4", + "@chatbox/capacitor-webdav-http": "workspace:*", "@capacitor-community/sqlite": "^7.0.2", "@capacitor/android": "^7.0.0", "@capacitor/app": "^7.0.0", diff --git a/packages/capacitor-webdav-http/android/build.gradle b/packages/capacitor-webdav-http/android/build.gradle new file mode 100644 index 0000000000..4648ae6531 --- /dev/null +++ b/packages/capacitor-webdav-http/android/build.gradle @@ -0,0 +1,54 @@ +ext { + junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2' + okhttpVersion = project.hasProperty('okhttpVersion') ? rootProject.ext.okhttpVersion : '4.12.0' +} + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.0.0' + } +} + +apply plugin: 'com.android.library' + +android { + namespace "com.chatbox.plugins.webdavhttp" + compileSdkVersion project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35 + + defaultConfig { + minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23 + targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + implementation project(':capacitor-android') + implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" + testImplementation "junit:junit:$junitVersion" + testImplementation "com.squareup.okhttp3:mockwebserver:$okhttpVersion" + testImplementation "com.squareup.okhttp3:okhttp-tls:$okhttpVersion" +} diff --git a/packages/capacitor-webdav-http/android/proguard-rules.pro b/packages/capacitor-webdav-http/android/proguard-rules.pro new file mode 100644 index 0000000000..2e802cdf6d --- /dev/null +++ b/packages/capacitor-webdav-http/android/proguard-rules.pro @@ -0,0 +1 @@ +# No consumer ProGuard rules are required. diff --git a/packages/capacitor-webdav-http/android/src/main/AndroidManifest.xml b/packages/capacitor-webdav-http/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..94cbbcfc39 --- /dev/null +++ b/packages/capacitor-webdav-http/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/capacitor-webdav-http/android/src/main/java/com/chatbox/plugins/webdavhttp/WebDAVHttpPlugin.java b/packages/capacitor-webdav-http/android/src/main/java/com/chatbox/plugins/webdavhttp/WebDAVHttpPlugin.java new file mode 100644 index 0000000000..8788f49159 --- /dev/null +++ b/packages/capacitor-webdav-http/android/src/main/java/com/chatbox/plugins/webdavhttp/WebDAVHttpPlugin.java @@ -0,0 +1,68 @@ +package com.chatbox.plugins.webdavhttp; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import okhttp3.Response; +import okhttp3.ResponseBody; + +@CapacitorPlugin(name = "WebDAVHttp") +public class WebDAVHttpPlugin extends Plugin { + private final ExecutorService executor = Executors.newCachedThreadPool(); + private final WebDAVHttpTransport transport = new WebDAVHttpTransport(); + + @PluginMethod + public void request(PluginCall call) { + String baseUrl = call.getString("baseUrl"); + String url = call.getString("url"); + String method = call.getString("method"); + JSObject headerObject = call.getObject("headers", new JSObject()); + String body = call.getString("body"); + if (baseUrl == null || url == null || method == null) { + call.reject("baseUrl, url, and method are required"); + return; + } + + Map headers = new HashMap<>(); + Iterator keys = headerObject.keys(); + while (keys.hasNext()) { + String key = keys.next(); + String value = headerObject.getString(key); + if (value == null) { + call.reject("WebDAV header values must be strings"); + return; + } + headers.put(key, value); + } + + executor.execute(() -> { + try (Response response = transport.execute(baseUrl, url, method, headers, body)) { + JSObject result = new JSObject(); + JSObject responseHeaders = new JSObject(); + for (String name : response.headers().names()) { + responseHeaders.put(name, response.header(name, "")); + } + ResponseBody responseBody = response.body(); + result.put("status", response.code()); + result.put("headers", responseHeaders); + result.put("body", responseBody == null ? "" : responseBody.string()); + call.resolve(result); + } catch (Exception error) { + call.reject(error.getMessage() == null ? "WebDAV request failed" : error.getMessage(), error); + } + }); + } + + @Override + protected void handleOnDestroy() { + executor.shutdownNow(); + super.handleOnDestroy(); + } +} diff --git a/packages/capacitor-webdav-http/android/src/main/java/com/chatbox/plugins/webdavhttp/WebDAVHttpTransport.java b/packages/capacitor-webdav-http/android/src/main/java/com/chatbox/plugins/webdavhttp/WebDAVHttpTransport.java new file mode 100644 index 0000000000..deb20dd025 --- /dev/null +++ b/packages/capacitor-webdav-http/android/src/main/java/com/chatbox/plugins/webdavhttp/WebDAVHttpTransport.java @@ -0,0 +1,120 @@ +package com.chatbox.plugins.webdavhttp; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +final class WebDAVHttpTransport { + private static final Map> ALLOWED_TARGETS; + private static final Set ALLOWED_HEADERS = immutableSet( + "authorization", + "content-type", + "depth", + "if-match", + "if-none-match" + ); + + static { + Map> targets = new HashMap<>(); + targets.put("ChatboxSync/", immutableSet("MKCOL")); + targets.put("ChatboxSync/v1/", immutableSet("MKCOL")); + targets.put("ChatboxSync/v1/snapshot.json.enc", immutableSet("GET", "PUT", "PROPFIND")); + ALLOWED_TARGETS = Collections.unmodifiableMap(targets); + } + + private final OkHttpClient client; + + WebDAVHttpTransport() { + this( + new OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .build() + ); + } + + WebDAVHttpTransport(OkHttpClient client) { + this.client = client; + } + + Response execute( + String baseUrl, + String requestUrl, + String method, + Map headers, + String body + ) throws IOException { + HttpUrl target = validateTarget(baseUrl, requestUrl, method, headers, body); + Request.Builder request = new Request.Builder().url(target); + for (Map.Entry header : headers.entrySet()) { + request.header(header.getKey(), header.getValue()); + } + + RequestBody requestBody = null; + if ("PUT".equals(method)) { + String contentType = "application/octet-stream"; + for (Map.Entry header : headers.entrySet()) { + if ("content-type".equalsIgnoreCase(header.getKey())) { + contentType = header.getValue(); + break; + } + } + requestBody = RequestBody.create(body == null ? "" : body, MediaType.parse(contentType)); + } + return client.newCall(request.method(method, requestBody).build()).execute(); + } + + private static HttpUrl validateTarget( + String baseUrl, + String requestUrl, + String method, + Map headers, + String body + ) { + HttpUrl base = HttpUrl.get(baseUrl); + HttpUrl target = HttpUrl.get(requestUrl); + if (!"https".equals(base.scheme()) || !"https".equals(target.scheme())) { + throw new IllegalArgumentException("WebDAV URL must use HTTPS"); + } + if (base.query() != null || base.fragment() != null || target.query() != null || target.fragment() != null) { + throw new IllegalArgumentException("WebDAV request target is not allowed"); + } + if (!base.scheme().equals(target.scheme()) || !base.host().equals(target.host()) || base.port() != target.port()) { + throw new IllegalArgumentException("WebDAV request target is not allowed"); + } + + String basePath = base.encodedPath().endsWith("/") ? base.encodedPath() : base.encodedPath() + "/"; + if (!target.encodedPath().startsWith(basePath)) { + throw new IllegalArgumentException("WebDAV request target is not allowed"); + } + String relativePath = target.encodedPath().substring(basePath.length()); + Set allowedMethods = ALLOWED_TARGETS.get(relativePath); + if (allowedMethods == null || !allowedMethods.contains(method)) { + throw new IllegalArgumentException("WebDAV request target is not allowed"); + } + for (Map.Entry header : headers.entrySet()) { + if (!ALLOWED_HEADERS.contains(header.getKey().toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("WebDAV request target is not allowed"); + } + } + if (body != null && !"PUT".equals(method)) { + throw new IllegalArgumentException("WebDAV request target is not allowed"); + } + return target; + } + + private static Set immutableSet(String... values) { + return Collections.unmodifiableSet(new HashSet<>(Arrays.asList(values))); + } +} diff --git a/packages/capacitor-webdav-http/android/src/test/java/com/chatbox/plugins/webdavhttp/WebDAVHttpTransportTest.java b/packages/capacitor-webdav-http/android/src/test/java/com/chatbox/plugins/webdavhttp/WebDAVHttpTransportTest.java new file mode 100644 index 0000000000..618e6c35d3 --- /dev/null +++ b/packages/capacitor-webdav-http/android/src/test/java/com/chatbox/plugins/webdavhttp/WebDAVHttpTransportTest.java @@ -0,0 +1,124 @@ +package com.chatbox.plugins.webdavhttp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.util.Map; +import okhttp3.OkHttpClient; +import okhttp3.Response; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okhttp3.tls.HandshakeCertificates; +import okhttp3.tls.HeldCertificate; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class WebDAVHttpTransportTest { + private MockWebServer server; + private WebDAVHttpTransport transport; + + @Before + public void setUp() throws Exception { + HeldCertificate certificate = new HeldCertificate.Builder() + .addSubjectAlternativeName("localhost") + .build(); + HandshakeCertificates serverCertificates = new HandshakeCertificates.Builder() + .heldCertificate(certificate) + .build(); + HandshakeCertificates clientCertificates = new HandshakeCertificates.Builder() + .addTrustedCertificate(certificate.certificate()) + .build(); + server = new MockWebServer(); + server.useHttps(serverCertificates.sslSocketFactory(), false); + server.start(); + transport = new WebDAVHttpTransport( + new OkHttpClient.Builder() + .sslSocketFactory(clientCertificates.sslSocketFactory(), clientCertificates.trustManager()) + .followRedirects(false) + .followSslRedirects(false) + .build() + ); + } + + @After + public void tearDown() throws Exception { + server.shutdown(); + } + + @Test + public void sendsMkcolWithoutHttpUrlConnectionMethodRestrictions() throws Exception { + server.enqueue(new MockResponse().setResponseCode(201)); + + try (Response response = transport.execute(baseUrl(), url("ChatboxSync/"), "MKCOL", Map.of(), null)) { + assertEquals(201, response.code()); + } + assertEquals("MKCOL", takeRequest().getMethod()); + } + + @Test + public void sendsPropfindAndPreservesDepthHeader() throws Exception { + server.enqueue(new MockResponse().setResponseCode(207)); + + try ( + Response response = transport.execute( + baseUrl(), + url("ChatboxSync/v1/snapshot.json.enc"), + "PROPFIND", + Map.of("Depth", "0"), + null + ) + ) { + assertEquals(207, response.code()); + } + RecordedRequest request = takeRequest(); + assertEquals("PROPFIND", request.getMethod()); + assertEquals("0", request.getHeader("Depth")); + } + + @Test + public void doesNotFollowRedirects() throws Exception { + server.enqueue( + new MockResponse() + .setResponseCode(302) + .setHeader("Location", url("ChatboxSync/v1/snapshot.json.enc")) + ); + + try ( + Response response = transport.execute( + baseUrl(), + url("ChatboxSync/v1/snapshot.json.enc"), + "GET", + Map.of(), + null + ) + ) { + assertEquals(302, response.code()); + } + assertEquals(1, server.getRequestCount()); + } + + @Test + public void rejectsTargetsOutsideTheFixedSyncPath() throws Exception { + try { + transport.execute(baseUrl(), url("private.json"), "GET", Map.of(), null); + fail("Expected fixed target validation to reject the request"); + } catch (IllegalArgumentException expected) { + assertEquals("WebDAV request target is not allowed", expected.getMessage()); + } + assertEquals(0, server.getRequestCount()); + } + + private String baseUrl() { + return server.url("/dav/").toString(); + } + + private String url(String relativePath) { + return baseUrl() + relativePath; + } + + private RecordedRequest takeRequest() throws Exception { + return server.takeRequest(); + } +} diff --git a/packages/capacitor-webdav-http/package.json b/packages/capacitor-webdav-http/package.json new file mode 100644 index 0000000000..d08ba746fb --- /dev/null +++ b/packages/capacitor-webdav-http/package.json @@ -0,0 +1,22 @@ +{ + "name": "@chatbox/capacitor-webdav-http", + "version": "0.0.1", + "private": true, + "description": "Android WebDAV transport for Chatbox", + "main": "dist/plugin.cjs.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "files": [ + "android/src/main/", + "android/build.gradle", + "dist/" + ], + "peerDependencies": { + "@capacitor/core": "^7.0.0" + }, + "capacitor": { + "android": { + "src": "android" + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f32c39bb65..bdc137bd05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: '@capacitor/toast': specifier: ^7.0.0 version: 7.0.3(@capacitor/core@7.4.5) + '@chatbox/capacitor-webdav-http': + specifier: workspace:* + version: link:packages/capacitor-webdav-http '@dnd-kit/core': specifier: ^6.0.8 version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -762,6 +765,12 @@ importers: specifier: ^5.0.6 version: 5.0.11(@types/react@18.3.27)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + packages/capacitor-webdav-http: + dependencies: + '@capacitor/core': + specifier: ^7.0.0 + version: 7.4.5 + release/app: dependencies: '@anthropic-ai/sandbox-runtime': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 39c19c00ad..d41867218e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - . + - packages/* - release/app # Supply chain security: only install packages published at least 7 days ago diff --git a/src/main/main.ts b/src/main/main.ts index f22c4aa1e6..27485c1fee 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -14,7 +14,19 @@ import './legacy-database-migration' */ import fs from 'node:fs' -import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, net, session, shell, Tray } from 'electron' +import { + app, + BrowserWindow, + dialog, + globalShortcut, + ipcMain, + Menu, + nativeTheme, + net, + session, + shell, + Tray, +} from 'electron' import electronDebug from 'electron-debug' import log from 'electron-log/main' import os from 'os' diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index 732bab275c..f034cacce5 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -84,6 +84,39 @@ describe('WebDAV sync service', () => { expect(deps.updateLastSyncedAt).toHaveBeenCalledWith('1970-01-01T00:00:01.000Z') }) + it('rejects uploads while any response is still generating', async () => { + const activeSession = session('s1', 'Local', 'hello') + activeSession.threads = [ + { + id: 'thread-1', + name: 'Thread', + createdAt: 1, + messages: [{ ...activeSession.messages[0], generating: true }], + }, + ] + const webdavRequest = vi.fn() + const deps = { + platform: { + getDeviceName: vi.fn(async () => 'Mac'), + webdavRequest, + }, + listLocalSessions: vi.fn(async () => [activeSession]), + listLocalMetas: vi.fn(async () => [meta('s1', 'Local')]), + createSession: vi.fn(), + updateSessionMetadata: vi.fn(), + saveMetas: vi.fn(), + deleteSession: vi.fn(), + updateLastSyncedAt: vi.fn(), + createConflictId: vi.fn(), + now: () => 1000, + } + + await expect(uploadWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/still generating/i) + + expect(webdavRequest).not.toHaveBeenCalled() + expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() + }) + it('merges the existing remote snapshot before uploading local sessions', async () => { const requests: WebDAVRequest[] = [] const remote: SyncSnapshot = { diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 56d6bb5828..2cf0835221 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -3,7 +3,7 @@ import { SessionMetaRecordSchema, SessionSchema } from '@shared/types/session' import { z } from 'zod' import { migrateSession } from '@/utils/session-utils' import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' -import { createSyncSnapshot, mergeRemoteSnapshot } from './snapshot' +import { createSyncSnapshot, mergeRemoteSnapshot, sessionHasActiveGeneration } from './snapshot' import type { SyncCryptoEnvelope, SyncSnapshot, WebDAVRequest, WebDAVResponse } from './types' import { buildBasicAuthHeader, joinWebDAVUrl, requestWebDAV, SYNC_COLLECTION_PATH, SYNC_SNAPSHOT_PATH } from './webdav' @@ -236,13 +236,16 @@ export async function uploadWebDAVSnapshot( ): Promise { const webdav = getWebDAVSettings(settings) const now = deps.now ?? Date.now - await ensureWebDAVCollections(settings, deps.platform) const [sessions, metas, deviceName] = await Promise.all([ deps.listLocalSessions(), deps.listLocalMetas(), deps.platform.getDeviceName?.() ?? Promise.resolve('Unknown device'), ]) + if (sessions.some(sessionHasActiveGeneration)) { + throw new Error('Cannot upload chat history while a response is still generating') + } + await ensureWebDAVCollections(settings, deps.platform) const lastSyncedAt = new Date(now()).toISOString() const localSnapshot = createSyncSnapshot({ sessions, diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts index abc2d5b9a6..2c681aae4d 100644 --- a/src/renderer/packages/sync/snapshot.test.ts +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -1,6 +1,6 @@ import type { Session, SessionMetaRecord } from '@shared/types' import { describe, expect, it, vi } from 'vitest' -import { createSyncSnapshot, mergeRemoteSnapshot } from './snapshot' +import { createSyncSnapshot, mergeRemoteSnapshot, sessionHasActiveGeneration } from './snapshot' import type { SyncSnapshot } from './types' function session(id: string, name: string, text: string, type: Session['type'] = 'chat'): Session { @@ -69,6 +69,10 @@ describe('sync snapshot merge', () => { local.assistantAvatarKey = 'avatar-key' local.backgroundImage = { type: 'storage-key', storageKey: 'background-key' } local.messages[0].contentParts.push({ type: 'image', storageKey: 'image-key' }) + local.messages[0].cancel = vi.fn() + local.messages[0].generating = true + local.messages[0].status = [{ type: 'retrying', attempt: 1, maxAttempts: 3 }] + local.messages[0].isStreamingMode = true local.messages[0].files = [ { id: 'file:/tmp/doc.txt-123-456', @@ -113,6 +117,9 @@ describe('sync snapshot merge', () => { { id: 'thread-message-1', role: 'user', + generating: true, + status: [{ type: 'loading_webpage' }], + isStreamingMode: true, contentParts: [{ type: 'image', storageKey: 'thread-image-key' }], }, ], @@ -129,6 +136,9 @@ describe('sync snapshot merge', () => { { id: 'fork-message-1', role: 'user', + generating: true, + status: [{ type: 'sending_file' }], + isStreamingMode: true, contentParts: [{ type: 'image', storageKey: 'fork-image-key' }], }, ], @@ -161,12 +171,51 @@ describe('sync snapshot merge', () => { ]) expect(message.links).toEqual([{ id: 'link-1', title: 'Example', url: 'https://example.com' }]) expect(message).not.toHaveProperty('pictures') + expect(message).not.toHaveProperty('cancel') + expect(message).not.toHaveProperty('generating') + expect(message).not.toHaveProperty('status') + expect(message).not.toHaveProperty('isStreamingMode') expect(synced.threads?.[0].messages[0].contentParts).toEqual([]) expect(synced.messageForksHash?.fork.lists[0].messages[0].contentParts).toEqual([]) + expect(synced.threads?.[0].messages[0]).not.toHaveProperty('generating') + expect(synced.threads?.[0].messages[0]).not.toHaveProperty('status') + expect(synced.threads?.[0].messages[0]).not.toHaveProperty('isStreamingMode') + expect(synced.messageForksHash?.fork.lists[0].messages[0]).not.toHaveProperty('generating') + expect(synced.messageForksHash?.fork.lists[0].messages[0]).not.toHaveProperty('status') + expect(synced.messageForksHash?.fork.lists[0].messages[0]).not.toHaveProperty('isStreamingMode') expect(snapshot.metas[0].assistantAvatarKey).toBeUndefined() expect(snapshot.metas[0].backgroundImage).toBeUndefined() }) + it('detects active generation in main messages, threads, and forks', () => { + const main = session('main', 'Main', 'hello') + main.messages[0].generating = true + + const thread = session('thread', 'Thread', 'hello') + thread.threads = [ + { + id: 'thread-1', + name: 'Thread', + createdAt: 1, + messages: [{ ...thread.messages[0], generating: true }], + }, + ] + + const fork = session('fork', 'Fork', 'hello') + fork.messageForksHash = { + fork: { + position: 0, + createdAt: 1, + lists: [{ id: 'fork-list', messages: [{ ...fork.messages[0], generating: true }] }], + }, + } + + expect(sessionHasActiveGeneration(session('idle', 'Idle', 'hello'))).toBe(false) + expect(sessionHasActiveGeneration(main)).toBe(true) + expect(sessionHasActiveGeneration(thread)).toBe(true) + expect(sessionHasActiveGeneration(fork)).toBe(true) + }) + it('imports missing remote sessions and metadata', () => { const remote: SyncSnapshot = { version: 1, @@ -194,6 +243,10 @@ describe('sync snapshot merge', () => { it('strips local-only blob references when importing remote sessions', () => { const remoteSession = session('remote-1', 'Remote', 'hello') remoteSession.assistantAvatarKey = 'avatar-key' + remoteSession.messages[0].cancel = vi.fn() + remoteSession.messages[0].generating = true + remoteSession.messages[0].status = [{ type: 'loading_webpage' }] + remoteSession.messages[0].isStreamingMode = true remoteSession.messages[0].contentParts.push({ type: 'image', storageKey: 'image-key' }) remoteSession.messages[0].files = [ { @@ -230,6 +283,10 @@ describe('sync snapshot merge', () => { if (created.kind !== 'create') throw new Error('Expected a created session') expect(created.session.assistantAvatarKey).toBeUndefined() expect(created.session.messages[0].contentParts).toEqual([{ type: 'text', text: 'hello' }]) + expect(created.session.messages[0]).not.toHaveProperty('cancel') + expect(created.session.messages[0]).not.toHaveProperty('generating') + expect(created.session.messages[0]).not.toHaveProperty('status') + expect(created.session.messages[0]).not.toHaveProperty('isStreamingMode') expect(created.session.messages[0].files).toEqual([ { id: 'synced-file:0:doc.txt', name: 'doc.txt', fileType: 'text/plain' }, ]) @@ -385,6 +442,94 @@ describe('sync snapshot merge', () => { expect(result.conflicts).toBe(1) }) + it('does not import a remote conflict copy already owned by its local source session', () => { + const deviceA = session('same-id', 'Project', 'device A text') + const deviceB = session('same-id', 'Project', 'device B text') + const deviceBCopyResult = mergeRemoteSnapshot({ + localSessions: [deviceA], + localMetas: [meta('same-id', 'Project')], + remote: { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Device B', + sessions: [deviceB], + metas: [meta('same-id', 'Project')], + }, + now: 2000, + }) + const deviceBCopy = deviceBCopyResult.sessionChanges[0] + if (deviceBCopy.kind !== 'create') throw new Error('Expected a Device B conflict copy') + expect(deviceBCopy.session.syncConflictSourceId).toBe('same-id') + + const result = mergeRemoteSnapshot({ + localSessions: [deviceB], + localMetas: [meta('same-id', 'Project')], + remote: { + version: 1, + exportedAt: '2026-06-21T00:01:00.000Z', + deviceName: 'Device A', + sessions: [deviceA, deviceBCopy.session], + metas: [meta('same-id', 'Project'), deviceBCopyResult.metasToSave[0]], + }, + now: 3000, + }) + + expect(result.sessionChanges).toHaveLength(1) + expect(result.sessionChanges[0]).toEqual( + expect.objectContaining({ + kind: 'create', + session: expect.objectContaining({ syncConflictSourceId: 'same-id' }), + }) + ) + expect( + result.sessionChanges.some((change) => change.kind === 'create' && change.session.id === deviceBCopy.session.id) + ).toBe(false) + expect(result.imported).toBe(0) + expect(result.conflicts).toBe(1) + }) + + it('recognizes existing stable conflict copies created before provenance was recorded', () => { + const deviceA = session('same-id', 'Project', 'device A text') + const deviceB = session('same-id', 'Project', 'device B text') + const deviceBCopyResult = mergeRemoteSnapshot({ + localSessions: [deviceA], + localMetas: [meta('same-id', 'Project')], + remote: { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Device B', + sessions: [deviceB], + metas: [meta('same-id', 'Project')], + }, + now: 2000, + }) + const deviceBCopy = deviceBCopyResult.sessionChanges[0] + if (deviceBCopy.kind !== 'create') throw new Error('Expected a Device B conflict copy') + const legacyCopy = { ...deviceBCopy.session } + delete legacyCopy.syncConflictSourceId + + const result = mergeRemoteSnapshot({ + localSessions: [deviceB], + localMetas: [meta('same-id', 'Project')], + remote: { + version: 1, + exportedAt: '2026-06-21T00:01:00.000Z', + deviceName: 'Device A', + sessions: [deviceA, legacyCopy], + metas: [meta('same-id', 'Project'), deviceBCopyResult.metasToSave[0]], + }, + now: 3000, + }) + + expect(result.sessionChanges).toHaveLength(1) + expect(result.sessionChanges[0]).not.toEqual( + expect.objectContaining({ + kind: 'create', + session: expect.objectContaining({ id: legacyCopy.id }), + }) + ) + }) + it('uses different stable conflict IDs when remote content changes', () => { const local = session('same-id', 'Project', 'local text') const merge = (text: string) => diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index a4b0e63226..8e6ebc7e9d 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -14,6 +14,18 @@ function isChatSessionMetaLike(item: Pick): boolean { return item.type === 'chat' || !item.type } +export function sessionHasActiveGeneration(session: Session): boolean { + if (session.messages.some((message) => message.generating)) { + return true + } + if (session.threads?.some((thread) => thread.messages.some((message) => message.generating))) { + return true + } + return Object.values(session.messageForksHash ?? {}).some((fork) => + fork.lists.some((list) => list.messages.some((message) => message.generating)) + ) +} + function compareStableKeys(left: string, right: string): number { if (left < right) return -1 if (left > right) return 1 @@ -42,6 +54,7 @@ function sessionContentFingerprint(session: Session): string { assistantAvatarKey: _assistantAvatarKey, picUrl: _picUrl, backgroundImage: _backgroundImage, + syncConflictSourceId: _syncConflictSourceId, ...content } = session return stableStringify(content) @@ -112,9 +125,16 @@ function stripLocalLinkReferences(link: MessageLink): MessageLink { } function stripLocalMessageReferences(message: Message): Message { - const { pictures: _pictures, ...messageWithoutLegacyPictures } = message as Message & { pictures?: unknown } + const { + pictures: _pictures, + cancel: _cancel, + generating: _generating, + status: _status, + isStreamingMode: _isStreamingMode, + ...messageWithoutRuntimeState + } = message as Message & { pictures?: unknown } const result: Message = { - ...messageWithoutLegacyPictures, + ...messageWithoutRuntimeState, contentParts: message.contentParts.filter((part) => part.type !== 'image'), } @@ -246,6 +266,19 @@ function defaultCreateConflictId(sourceSessionId: string, contentFingerprint: st return uuidv5(`chatbox:webdav-sync:v1:${sourceSessionId}:${contentFingerprint}`, uuidv5.URL) } +function conflictSourceIdForRemoteCopy( + remoteSession: Session, + localSessionById: Map +): string | undefined { + if (remoteSession.syncConflictSourceId) { + return remoteSession.syncConflictSourceId + } + const fingerprint = sessionContentFingerprint(remoteSession) + return [...localSessionById.keys()].find( + (sourceSessionId) => defaultCreateConflictId(sourceSessionId, fingerprint) === remoteSession.id + ) +} + export function createSyncSnapshot(input: { sessions: Session[] metas: SessionMetaRecord[] @@ -300,6 +333,11 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot const remoteSessionWithMeta = applyMetaToSession(remoteSession, remoteMeta) if (!localSession) { + const conflictSourceId = conflictSourceIdForRemoteCopy(remoteSession, localSessionById) + const localConflictSource = conflictSourceId ? localSessionById.get(conflictSourceId) : undefined + if (localConflictSource && sessionContentEqual(localConflictSource, remoteSession)) { + continue + } const meta = metaForSession(remoteSessionWithMeta, remoteMeta, input.now) sessionChanges.push({ kind: 'create', session: remoteSessionWithMeta }) metasToSaveById.set(meta.id, meta) @@ -343,6 +381,7 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot ...remoteSessionWithMeta, id: conflictId, name: copiedName, + syncConflictSourceId: remoteSession.syncConflictSourceId ?? remoteSession.id, } const copiedMeta = { ...metaForCopiedSession(copiedSession, remoteMeta, input.now), diff --git a/src/renderer/platform/mobile_platform.ts b/src/renderer/platform/mobile_platform.ts index 9f393728a5..5e86a05933 100644 --- a/src/renderer/platform/mobile_platform.ts +++ b/src/renderer/platform/mobile_platform.ts @@ -2,6 +2,7 @@ import { App } from '@capacitor/app' import { Browser } from '@capacitor/browser' import { CapacitorHttp } from '@capacitor/core' import { Device } from '@capacitor/device' +import { WebDAVHttp } from '@chatbox/capacitor-webdav-http' import * as defaults from '@shared/defaults' import { validateWebDAVRequestTarget } from '@shared/sync-webdav' import type { Config, Settings, ShortcutSetting } from '@shared/types' @@ -248,6 +249,19 @@ export default class MobilePlatform extends MobileSQLiteStorage implements Platf public async webdavRequest(request: WebDAVRequest, baseUrl: string): Promise { validateWebDAVRequestTarget(baseUrl, request) + if (CHATBOX_BUILD_PLATFORM === 'android') { + const response = await WebDAVHttp.request({ + baseUrl, + url: request.url, + method: request.method, + headers: request.headers, + body: request.body, + }) + if (response.status >= 300 && response.status <= 399) { + throw new Error('WebDAV redirects are not allowed') + } + return response + } const response = await CapacitorHttp.request({ url: request.url, method: request.method, diff --git a/src/shared/types/session.ts b/src/shared/types/session.ts index bc4b32d1bc..8e366fbf48 100644 --- a/src/shared/types/session.ts +++ b/src/shared/types/session.ts @@ -279,6 +279,7 @@ export const SessionSchema = z.object({ threads: z.array(SessionThreadSchema).optional(), threadName: z.string().optional(), messageForksHash: z.record(z.string(), MessageForkSchema).optional(), + syncConflictSourceId: z.string().optional(), compactionPoints: z.array(CompactionPointSchema).optional(), }) From 3701b492c3875f2b9e12e74e1bbf0b6a3201f5cf Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Wed, 22 Jul 2026 20:27:04 +0800 Subject: [PATCH 17/19] chore: add capacitor-webdav-http dist (force-added, excluded by global .gitignore dist rule) --- .../capacitor-webdav-http/dist/esm/index.d.ts | 19 +++++++++++++++++++ .../capacitor-webdav-http/dist/esm/index.js | 5 +++++ .../capacitor-webdav-http/dist/plugin.cjs.js | 7 +++++++ 3 files changed, 31 insertions(+) create mode 100644 packages/capacitor-webdav-http/dist/esm/index.d.ts create mode 100644 packages/capacitor-webdav-http/dist/esm/index.js create mode 100644 packages/capacitor-webdav-http/dist/plugin.cjs.js diff --git a/packages/capacitor-webdav-http/dist/esm/index.d.ts b/packages/capacitor-webdav-http/dist/esm/index.d.ts new file mode 100644 index 0000000000..f990d4529d --- /dev/null +++ b/packages/capacitor-webdav-http/dist/esm/index.d.ts @@ -0,0 +1,19 @@ +export interface WebDAVHttpRequestOptions { + baseUrl: string + url: string + method: 'GET' | 'PUT' | 'MKCOL' | 'PROPFIND' | 'DELETE' + headers?: Record + body?: string +} + +export interface WebDAVHttpResponse { + status: number + headers: Record + body: string +} + +export interface WebDAVHttpPlugin { + request(options: WebDAVHttpRequestOptions): Promise +} + +export declare const WebDAVHttp: WebDAVHttpPlugin diff --git a/packages/capacitor-webdav-http/dist/esm/index.js b/packages/capacitor-webdav-http/dist/esm/index.js new file mode 100644 index 0000000000..8515e1e2b4 --- /dev/null +++ b/packages/capacitor-webdav-http/dist/esm/index.js @@ -0,0 +1,5 @@ +import { registerPlugin } from '@capacitor/core' + +const WebDAVHttp = registerPlugin('WebDAVHttp') + +export { WebDAVHttp } diff --git a/packages/capacitor-webdav-http/dist/plugin.cjs.js b/packages/capacitor-webdav-http/dist/plugin.cjs.js new file mode 100644 index 0000000000..d2fd9eb0e4 --- /dev/null +++ b/packages/capacitor-webdav-http/dist/plugin.cjs.js @@ -0,0 +1,7 @@ +'use strict' + +const core = require('@capacitor/core') + +const WebDAVHttp = core.registerPlugin('WebDAVHttp') + +exports.WebDAVHttp = WebDAVHttp From e9f73c460ec928fb8b8e63eb06fc972bd65429c9 Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Fri, 24 Jul 2026 12:36:11 +0800 Subject: [PATCH 18/19] =?UTF-8?q?fix(sync):=20hardening=20=E2=80=94=20uplo?= =?UTF-8?q?ad=20preview,=20scoped=20undo,=20selective=20column=20updates,?= =?UTF-8?q?=20credential=20sanitization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings-export: strip customProviders credentials; drop all custom MCP servers on secret-free export (transport args/URL/path may embed secrets) - sync/local: add restoreSyncSessionMetadata with conditional field-by-field rollback; updateSyncSessionMetadata returns scoped undo records; track sortOrder/createdAt ordering separately - sync/service: add previewWebDAVUpload to warn before replacing remote sessions; testWebDAVConnection now verifies encryption password - sync/snapshot: track comparableLocalSessionById to avoid false conflicts when outgoing copy differs from local (e.g. differing sortOrder) - general.tsx: remove sync enabled toggle (manual-only); add upload preview modal (AdaptiveModal) with conflict count; flush StoreStorage before sync actions so main/renderer processes share the same WebDAV URL - SQLiteSessionMetaStorage.update: selective column SET instead of full-row replace — only touches fields explicitly provided in the patch - SessionMetaStorage (IndexedDB).update: read-then-write in a single transaction instead of blind put - StoreStorage: add flushItem for synchronous persistence - defaults / settings schema: remove sync.enabled (manual sync only) --- src/renderer/packages/settings-export.test.ts | 60 +++++- src/renderer/packages/settings-export.ts | 33 ++-- .../packages/sync/WebDAVSyncSection.test.tsx | 152 +++++++++++++++ src/renderer/packages/sync/local.test.ts | 83 ++++++-- src/renderer/packages/sync/local.ts | 112 +++++++++-- src/renderer/packages/sync/service.test.ts | 126 +++++++++++- src/renderer/packages/sync/service.ts | 108 ++++++++--- src/renderer/packages/sync/snapshot.test.ts | 182 ++++++++++++++++++ src/renderer/packages/sync/snapshot.ts | 161 +++++++++++----- src/renderer/routes/settings/general.tsx | 97 +++++++--- .../storage/SQLiteSessionMetaStorage.ts | 47 +++-- src/renderer/storage/SessionMetaStorage.ts | 23 ++- src/renderer/storage/StoreStorage.test.ts | 37 +++- src/renderer/storage/StoreStorage.ts | 12 +- src/shared/defaults.ts | 1 - src/shared/sync-settings.test.ts | 3 +- src/shared/types/settings.ts | 2 - 17 files changed, 1049 insertions(+), 190 deletions(-) create mode 100644 src/renderer/packages/sync/WebDAVSyncSection.test.tsx diff --git a/src/renderer/packages/settings-export.test.ts b/src/renderer/packages/settings-export.test.ts index 6bd6cdb82b..bc389f0b89 100644 --- a/src/renderer/packages/settings-export.test.ts +++ b/src/renderer/packages/settings-export.test.ts @@ -1,5 +1,5 @@ import * as defaults from '@shared/defaults' -import type { Settings } from '@shared/types' +import { ModelProviderType, type Settings } from '@shared/types' import { describe, expect, it } from 'vitest' import { sanitizeSettingsForExport } from './settings-export' @@ -26,8 +26,27 @@ function settingsWithSecrets(): Settings { activeAuthMode: 'oauth', }, }, + customProviders: [ + { + id: 'custom-openai', + name: 'Custom OpenAI', + type: ModelProviderType.OpenAI, + isCustom: true, + defaultSettings: { + apiKey: 'custom-sk-secret', + accessKey: 'custom-access-secret', + secretKey: 'custom-secret-key', + sessionToken: 'custom-session-token', + apiHost: 'https://custom-api.example.com', + oauth: { + accessToken: 'custom-oauth-access-secret', + refreshToken: 'custom-oauth-refresh-secret', + }, + activeAuthMode: 'oauth', + }, + }, + ], sync: { - enabled: true, provider: 'webdav', webdav: { url: 'https://dav.example.com/files/me/', @@ -62,7 +81,7 @@ function settingsWithSecrets(): Settings { transport: { type: 'stdio', command: 'npx', - args: ['srv'], + args: ['srv', '--api-key', 'stdio-arg-secret'], env: { GITHUB_TOKEN: 'ghp-secret' }, }, }, @@ -72,7 +91,7 @@ function settingsWithSecrets(): Settings { enabled: true, transport: { type: 'http', - url: 'https://mcp.example.com/', + url: 'https://mcp.example.com/?access_token=http-url-secret', headers: { Authorization: 'Bearer secret' }, }, }, @@ -114,6 +133,18 @@ describe('sanitizeSettingsForExport', () => { expect(settings.providers?.openai?.oauth?.refreshToken).toBe('oauth-refresh-secret') }) + it('removes credentials from custom provider default settings when key export is not selected', () => { + const settings = settingsWithSecrets() + const sanitized = sanitizeSettingsForExport(settings, false) + + expect(sanitized.customProviders?.[0].defaultSettings).toEqual({ + apiHost: 'https://custom-api.example.com', + activeAuthMode: 'oauth', + }) + expect(settings.customProviders?.[0].defaultSettings?.apiKey).toBe('custom-sk-secret') + expect(settings.customProviders?.[0].defaultSettings?.oauth?.accessToken).toBe('custom-oauth-access-secret') + }) + it('removes remembered and per-account license keys when key export is not selected', () => { const sanitized = sanitizeSettingsForExport(settingsWithSecrets(), false) @@ -167,17 +198,24 @@ describe('sanitizeSettingsForExport', () => { expect(settings.extension.documentParser?.mineru?.apiToken).toBe('mineru-secret') }) - it('removes MCP transport env and headers when key export is not selected', () => { + it('removes custom MCP servers when key export is not selected', () => { const settings = settingsWithSecrets() const sanitized = sanitizeSettingsForExport(settings, false) - const [stdioServer, httpServer] = sanitized.mcp.servers - expect(stdioServer.transport).toEqual({ type: 'stdio', command: 'npx', args: ['srv'] }) - expect(httpServer.transport).toEqual({ type: 'http', url: 'https://mcp.example.com/' }) + expect(sanitized.mcp.servers).toEqual([]) + expect(JSON.stringify(sanitized)).not.toMatch( + /ghp-secret|Bearer secret|stdio-arg-secret|http-url-secret/ + ) // Transport credentials survive when key export is selected. const withSecrets = sanitizeSettingsForExport(settingsWithSecrets(), true) - expect(withSecrets.mcp.servers[0].transport).toMatchObject({ env: { GITHUB_TOKEN: 'ghp-secret' } }) - expect(withSecrets.mcp.servers[1].transport).toMatchObject({ headers: { Authorization: 'Bearer secret' } }) + expect(withSecrets.mcp.servers[0].transport).toMatchObject({ + args: ['srv', '--api-key', 'stdio-arg-secret'], + env: { GITHUB_TOKEN: 'ghp-secret' }, + }) + expect(withSecrets.mcp.servers[1].transport).toMatchObject({ + url: 'https://mcp.example.com/?access_token=http-url-secret', + headers: { Authorization: 'Bearer secret' }, + }) // The original settings object must keep its credentials untouched. expect(settings.mcp.servers[0].transport).toMatchObject({ env: { GITHUB_TOKEN: 'ghp-secret' } }) expect(settings.mcp.servers[1].transport).toMatchObject({ headers: { Authorization: 'Bearer secret' } }) @@ -192,6 +230,8 @@ describe('sanitizeSettingsForExport', () => { expect(sanitized.providers?.openai?.apiKey).toBe('sk-secret') expect(sanitized.providers?.openai?.oauth?.accessToken).toBe('oauth-access-secret') expect(sanitized.providers?.openai?.oauth?.refreshToken).toBe('oauth-refresh-secret') + expect(sanitized.customProviders?.[0].defaultSettings?.apiKey).toBe('custom-sk-secret') + expect(sanitized.customProviders?.[0].defaultSettings?.oauth?.accessToken).toBe('custom-oauth-access-secret') expect(sanitized.extension.webSearch.tavilyApiKey).toBe('tavily-secret') expect(sanitized.extension.webSearch.bochaApiKey).toBe('bocha-secret') expect(sanitized.extension.webSearch.queritApiKey).toBe('querit-secret') diff --git a/src/renderer/packages/settings-export.ts b/src/renderer/packages/settings-export.ts index 700ca0116b..a609a9c1d2 100644 --- a/src/renderer/packages/settings-export.ts +++ b/src/renderer/packages/settings-export.ts @@ -22,6 +22,16 @@ export function sanitizeSettingsForExport(settings: Settings, includeSecrets: bo licenseDetail: undefined, licenseInstances: undefined, providers: settings.providers ? { ...settings.providers } : settings.providers, + customProviders: settings.customProviders + ? settings.customProviders.map((provider) => ({ + ...provider, + defaultSettings: provider.defaultSettings + ? includeSecrets + ? { ...provider.defaultSettings } + : sanitizeProviderForExport(provider.defaultSettings) + : provider.defaultSettings, + })) + : settings.customProviders, extension: settings.extension ? { ...settings.extension, @@ -56,9 +66,15 @@ export function sanitizeSettingsForExport(settings: Settings, includeSecrets: bo mcp: settings.mcp ? { ...settings.mcp, - servers: Array.isArray(settings.mcp.servers) - ? settings.mcp.servers.map((server) => ({ ...server, transport: { ...server.transport } })) - : settings.mcp.servers, + // MCP credentials can be embedded anywhere in a transport, including + // command arguments and URL paths/query parameters. There is no safe + // generic redaction for an arbitrary custom server, so secret-free + // exports omit those servers entirely. + servers: includeSecrets + ? Array.isArray(settings.mcp.servers) + ? settings.mcp.servers.map((server) => ({ ...server, transport: { ...server.transport } })) + : settings.mcp.servers + : [], } : settings.mcp, } @@ -86,17 +102,6 @@ export function sanitizeSettingsForExport(settings: Settings, includeSecrets: bo if (cleanedSettings.extension?.documentParser?.mineru) { cleanedSettings.extension.documentParser.mineru.apiToken = '' } - // MCP transports routinely carry credentials — stdio env vars (API tokens) - // and HTTP headers (Authorization) must not leave the device in an export. - if (cleanedSettings.mcp?.servers) { - for (const server of cleanedSettings.mcp.servers) { - if (server.transport.type === 'stdio') { - delete server.transport.env - } else { - delete server.transport.headers - } - } - } } return cleanedSettings diff --git a/src/renderer/packages/sync/WebDAVSyncSection.test.tsx b/src/renderer/packages/sync/WebDAVSyncSection.test.tsx new file mode 100644 index 0000000000..2de6813936 --- /dev/null +++ b/src/renderer/packages/sync/WebDAVSyncSection.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom + +import { MantineProvider } from '@mantine/core' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + +const mocks = vi.hoisted(() => ({ + flushItem: vi.fn(async () => undefined), + previewWebDAVUpload: vi.fn(async () => ({ + localCount: 1, + remoteCount: 2, + remoteOnlyCount: 1, + willRemoveRemoteCount: 1, + remoteMissing: false, + })), + testWebDAVConnection: vi.fn(async () => ({ snapshotExists: false, encryptionVerified: false })), + toastSuccess: vi.fn(), + uploadWebDAVSnapshot: vi.fn(async () => ({ uploaded: 1, lastSyncedAt: '2026-07-23T00:00:00.000Z' })), +})) + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: () => (config: unknown) => config, +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => + Object.entries(values ?? {}).reduce((result, [name, value]) => result.replace(`{{${name}}}`, String(value)), key), + }), +})) + +vi.mock('sonner', () => ({ toast: { success: mocks.toastSuccess } })) +vi.mock('@/components/AdaptiveSelect', () => ({ AdaptiveSelect: () => null })) +vi.mock('@/components/common/LazySlider', () => ({ default: () => null })) +vi.mock('@/components/common/AdaptiveModal', async () => { + const React = await import('react') + const AdaptiveModal = ({ opened, title, children }: { opened: boolean; title: ReactNode; children: ReactNode }) => + opened ? React.createElement('div', null, title, children) : null + AdaptiveModal.Actions = ({ children }: { children: ReactNode }) => React.createElement('div', null, children) + return { AdaptiveModal } +}) +vi.mock('@/i18n/locales', () => ({ languageNameMap: {}, languages: [] })) +vi.mock('@/packages/settings-export', () => ({ sanitizeSettingsForExport: (settings: unknown) => settings })) +vi.mock('@/packages/sync/local', () => ({ createDefaultWebDAVSyncDeps: () => ({}) })) +vi.mock('@/packages/sync/service', () => ({ + downloadAndMergeWebDAVSnapshot: vi.fn(), + previewWebDAVUpload: mocks.previewWebDAVUpload, + testWebDAVConnection: mocks.testWebDAVConnection, + uploadWebDAVSnapshot: mocks.uploadWebDAVSnapshot, +})) +vi.mock('@/packages/toast', () => ({ toastError: vi.fn() })) +vi.mock('@/platform', () => ({ + default: { type: 'desktop', exporter: {}, getDeviceName: vi.fn() }, +})) +vi.mock('@/storage', () => ({ + default: { + flushItem: mocks.flushItem, + getAllKeys: vi.fn(async () => []), + getItem: vi.fn(), + }, + StorageKey: { Settings: 'settings' }, +})) +vi.mock('@/stores/chatStore', () => ({ getMetaStorage: vi.fn(), recoverSessionList: vi.fn() })) +vi.mock('@/stores/migration', () => ({ migrateOnData: vi.fn() })) +vi.mock('@/stores/settingsStore', async () => { + const { createStore, useStore } = await import('zustand') + const sync = { + provider: 'webdav' as const, + webdav: { + url: 'https://dav.example.com/', + username: 'alice', + password: 'password', + syncPassword: 'sync-password', + }, + lastSyncedAt: undefined, + } + const store = createStore(() => ({ + sync, + setSettings: vi.fn(), + getSettings: () => ({ sync }), + })) + return { + settingsStore: store, + useSettingsStore: (selector: (state: ReturnType) => unknown) => useStore(store, selector), + } +}) + +import { WebDAVSyncSection } from '@/routes/settings/general' + +function renderSection() { + return render( + + + + ) +} + +describe('WebDAVSyncSection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders without an unstable Zustand selector loop', () => { + renderSection() + + expect(screen.getByText('Manual WebDAV Sync')).toBeTruthy() + expect(screen.queryByText('Enable WebDAV sync')).toBeNull() + }) + + it('flushes settings before testing and explains when encryption cannot be verified', async () => { + renderSection() + + fireEvent.click(screen.getByRole('button', { name: 'Test Connection' })) + + await waitFor(() => expect(mocks.testWebDAVConnection).toHaveBeenCalledTimes(1)) + expect(mocks.toastSuccess).toHaveBeenCalledWith( + 'Connection successful; no remote snapshot exists to verify the encryption password' + ) + expect(mocks.flushItem).toHaveBeenCalledWith('settings') + expect(mocks.flushItem.mock.invocationCallOrder[0]).toBeLessThan( + mocks.testWebDAVConnection.mock.invocationCallOrder[0] + ) + }) + + it('requires confirmation before removing remote-only conversations', async () => { + renderSection() + + fireEvent.click(screen.getByRole('button', { name: 'Merge and Upload' })) + + expect(await screen.findByText('Replace remote conversations?')).toBeTruthy() + expect(mocks.uploadWebDAVSnapshot).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Replace Remote Snapshot' })) + + await waitFor(() => expect(mocks.uploadWebDAVSnapshot).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/src/renderer/packages/sync/local.test.ts b/src/renderer/packages/sync/local.test.ts index 21f65676a3..d214c4104b 100644 --- a/src/renderer/packages/sync/local.test.ts +++ b/src/renderer/packages/sync/local.test.ts @@ -5,6 +5,7 @@ import { deleteSyncSession, listLocalSyncMetas, listLocalSyncSessions, + restoreSyncSessionMetadata, saveSyncMetas, updateSyncSessionMetadata, } from './local' @@ -112,7 +113,7 @@ describe('local sync data selection', () => { expect(createSessionWithId).toHaveBeenCalledWith(syncedSession, syncedMeta) }) - it('updates only metadata through the serialized chatStore path and returns the previous patch', async () => { + it('updates metadata through the serialized chatStore path and returns a scoped undo record', async () => { const current = session('same-id', 'chat') current.name = 'Local Name' current.messages = [ @@ -128,35 +129,93 @@ describe('local sync data selection', () => { return Promise.resolve({ ...current, ...patch }) }) - const previous = await updateSyncSessionMetadata('same-id', { + const undo = await updateSyncSessionMetadata('same-id', { name: 'Remote Name', type: 'chat', starred: true, }) - expect(previous).toMatchObject({ name: 'Local Name', type: 'chat' }) + expect(undo).toEqual({ + previousSession: { name: 'Local Name', type: 'chat', starred: undefined }, + appliedSession: { name: 'Remote Name', type: 'chat', starred: true }, + }) expect(updateSession).toHaveBeenCalledTimes(1) }) it('restores metadata through chatStore when an update fails after entering the queue', async () => { const current = session('same-id', 'chat') current.name = 'Local Name' - vi.mocked(updateSession) - .mockImplementationOnce((_id, updater) => { - if (typeof updater === 'function') updater(current) - return Promise.reject(new Error('meta update failed')) - }) - .mockResolvedValueOnce(current) + let persisted = current + let callCount = 0 + vi.mocked(updateSession).mockImplementation(async (_id, updater) => { + const next = typeof updater === 'function' ? updater(persisted) : { ...persisted, ...updater } + persisted = { ...persisted, ...next } + callCount += 1 + if (callCount === 1) { + throw new Error('meta update failed') + } + return persisted + }) await expect(updateSyncSessionMetadata('same-id', { name: 'Remote Name', type: 'chat' })).rejects.toThrow( /meta update failed/ ) - expect(updateSession).toHaveBeenNthCalledWith( - 2, + expect(updateSession).toHaveBeenCalledTimes(2) + expect(persisted).toMatchObject({ name: 'Local Name', type: 'chat' }) + }) + + it('updates ordering fields without replacing the full metadata record', async () => { + const current = session('same-id', 'chat') + current.name = 'Local Name' + const currentMeta = { ...meta('same-id', 'chat'), name: 'Locally Edited', sortOrder: 1, createdAt: 2 } + const metaStorage = { + getById: vi.fn(async () => currentMeta), + update: vi.fn(async (_id, updates) => ({ ...currentMeta, ...updates })), + } + vi.mocked(getMetaStorage).mockResolvedValue(metaStorage as never) + vi.mocked(updateSession).mockImplementation(async (_id, updater) => { + return typeof updater === 'function' ? ({ ...current, ...updater(current) } as Session) : { ...current, ...updater } + }) + + const undo = await updateSyncSessionMetadata( 'same-id', - expect.objectContaining({ name: 'Local Name', type: 'chat' }) + { name: 'Remote Name' }, + { sortOrder: 99, createdAt: 100 } ) + + expect(metaStorage.update).toHaveBeenCalledWith('same-id', { sortOrder: 99, createdAt: 100 }) + expect(metaStorage.update).not.toHaveBeenCalledWith('same-id', expect.objectContaining({ name: expect.anything() })) + expect(undo).toMatchObject({ + previousSession: { name: 'Local Name' }, + previousOrder: { sortOrder: 1, createdAt: 2 }, + }) + }) + + it('does not roll back metadata fields changed after the sync write', async () => { + let current = { ...session('same-id', 'chat'), name: 'User Edit', starred: true } + const currentMeta = { ...meta('same-id', 'chat'), sortOrder: 777, createdAt: 100 } + const metaStorage = { + getById: vi.fn(async () => currentMeta), + update: vi.fn(async (_id, updates) => ({ ...currentMeta, ...updates })), + } + vi.mocked(getMetaStorage).mockResolvedValue(metaStorage as never) + vi.mocked(updateSession).mockImplementation(async (_id, updater) => { + const next = typeof updater === 'function' ? updater(current) : { ...current, ...updater } + current = { ...current, ...next } + return current + }) + + await restoreSyncSessionMetadata('same-id', { + previousSession: { name: 'Local Name', starred: undefined }, + appliedSession: { name: 'Remote Name', starred: true }, + previousOrder: { sortOrder: 1, createdAt: 2 }, + appliedOrder: { sortOrder: 99, createdAt: 100 }, + }) + + expect(current.name).toBe('User Edit') + expect(current.starred).toBeUndefined() + expect(metaStorage.update).toHaveBeenCalledWith('same-id', { createdAt: 2 }) }) it('deletes rolled-back sessions through chatStore', async () => { diff --git a/src/renderer/packages/sync/local.ts b/src/renderer/packages/sync/local.ts index 9a6a2daca7..dfb38bc6aa 100644 --- a/src/renderer/packages/sync/local.ts +++ b/src/renderer/packages/sync/local.ts @@ -12,7 +12,7 @@ import { } from '@/stores/chatStore' import { settingsStore } from '@/stores/settingsStore' import { migrateSession } from '@/utils/session-utils' -import type { WebDAVSyncDeps } from './service' +import type { SyncMetadataOrder, SyncMetadataUndo, WebDAVSyncDeps } from './service' function isChatSessionLike(item: Pick | Pick): boolean { return item.type === 'chat' || !item.type @@ -57,23 +57,106 @@ function sessionMetadataPatch(session: SessionMeta): Omit { } } -export async function updateSyncSessionMetadata( - sessionId: string, - patch: Omit -): Promise> { - let previous: Omit | undefined - try { +function valuesForPatchedMetadata( + session: SessionMeta, + patch: Partial> +): Partial> { + const source = sessionMetadataPatch(session) as Record + const previous: Record = {} + for (const key of Object.keys(patch)) { + previous[key] = source[key] + } + return previous as Partial> +} + +function conditionalSessionRestore( + session: Session, + undo: SyncMetadataUndo +): Partial> { + const current = sessionMetadataPatch(session) as Record + const applied = undo.appliedSession as Record | undefined + const previous = undo.previousSession as Record | undefined + const restore: Record = {} + if (!applied || !previous) { + return restore as Partial> + } + for (const key of Object.keys(applied)) { + if (Object.is(current[key], applied[key])) { + restore[key] = previous[key] + } + } + return restore as Partial> +} + +export async function restoreSyncSessionMetadata(sessionId: string, undo: SyncMetadataUndo): Promise { + if (undo.appliedSession && undo.previousSession) { await updateSession(sessionId, (current) => { if (!current) { throw new Error(`Session ${sessionId} not found`) } - previous = sessionMetadataPatch(current) - return { ...current, ...patch } + return { ...current, ...conditionalSessionRestore(current, undo) } }) + } + + if (undo.appliedOrder && undo.previousOrder) { + const metaStorage = await getMetaStorage() + const current = await metaStorage.getById(sessionId) + if (!current) { + throw new Error(`Session metadata ${sessionId} not found`) + } + const restore: Partial = {} + if (current.sortOrder === undo.appliedOrder.sortOrder) { + restore.sortOrder = undo.previousOrder.sortOrder + } + if (current.createdAt === undo.appliedOrder.createdAt) { + restore.createdAt = undo.previousOrder.createdAt + } + if (Object.keys(restore).length > 0) { + const restored = await metaStorage.update(sessionId, restore) + if (!restored) { + throw new Error(`Session metadata ${sessionId} not found`) + } + await refreshSessionListCache() + } + } +} + +export async function updateSyncSessionMetadata( + sessionId: string, + patch: Partial> | undefined, + order: SyncMetadataOrder | undefined = undefined +): Promise { + const undo: SyncMetadataUndo = {} + try { + if (patch && Object.keys(patch).length > 0) { + await updateSession(sessionId, (current) => { + if (!current) { + throw new Error(`Session ${sessionId} not found`) + } + undo.previousSession = valuesForPatchedMetadata(current, patch) + undo.appliedSession = { ...patch } + return { ...current, ...patch } + }) + } + + if (order) { + const metaStorage = await getMetaStorage() + const current = await metaStorage.getById(sessionId) + if (!current) { + throw new Error(`Session metadata ${sessionId} not found`) + } + undo.previousOrder = { sortOrder: current.sortOrder, createdAt: current.createdAt } + undo.appliedOrder = { ...order } + const updated = await metaStorage.update(sessionId, order) + if (!updated) { + throw new Error(`Session metadata ${sessionId} not found`) + } + await refreshSessionListCache() + } } catch (error) { - if (previous) { + if (undo.appliedSession || undo.appliedOrder) { try { - await updateSession(sessionId, previous) + await restoreSyncSessionMetadata(sessionId, undo) } catch (rollbackError) { throw new AggregateError( [error, rollbackError], @@ -85,10 +168,7 @@ export async function updateSyncSessionMetadata( throw error } - if (!previous) { - throw new Error(`Session ${sessionId} not found`) - } - return previous + return undo } export async function saveSyncMetas(metas: SessionMetaRecord[]): Promise { @@ -144,8 +224,8 @@ export function createDefaultWebDAVSyncDeps(): WebDAVSyncDeps { listLocalMetas: listLocalSyncMetas, createSession: createSyncSession, updateSessionMetadata: updateSyncSessionMetadata, + restoreSessionMetadata: restoreSyncSessionMetadata, deleteSession: deleteSyncSession, - saveMetas: saveSyncMetas, updateLastSyncedAt: updateSyncLastSyncedAt, getLastSeenSnapshot: getSyncLastSeenSnapshot, setLastSeenSnapshot: setSyncLastSeenSnapshot, diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index f034cacce5..c7b4251811 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -1,12 +1,16 @@ import type { Session, Settings } from '@shared/types' import { describe, expect, it, vi } from 'vitest' import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' -import { downloadAndMergeWebDAVSnapshot, uploadWebDAVSnapshot } from './service' +import { + downloadAndMergeWebDAVSnapshot, + previewWebDAVUpload, + testWebDAVConnection, + uploadWebDAVSnapshot, +} from './service' import type { SyncSnapshot, WebDAVRequest, WebDAVResponse } from './types' const baseSettings = { sync: { - enabled: true, provider: 'webdav', webdav: { url: 'https://dav.example.com/files/me/', @@ -43,6 +47,124 @@ function meta(id: string, name: string, sortOrder = 1) { } describe('WebDAV sync service', () => { + it('tests WebDAV credentials and verifies the encryption password against an existing snapshot', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-07-23T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote', 'remote text')], + metas: [meta('remote-1', 'Remote')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const webdavRequest = vi.fn( + async (request: WebDAVRequest): Promise => ({ + status: request.method === 'GET' ? 200 : request.method === 'MKCOL' ? 405 : 400, + headers: request.method === 'GET' ? { ETag: '"remote-etag"' } : {}, + body: request.method === 'GET' ? JSON.stringify(envelope) : '', + }) + ) + + const result = await testWebDAVConnection(baseSettings, { platform: { webdavRequest } }) + + expect(result).toEqual({ snapshotExists: true, encryptionVerified: true }) + expect(webdavRequest.mock.calls.map(([request]) => request.method)).toEqual(['MKCOL', 'MKCOL', 'GET']) + }) + + it('rejects a wrong encryption password while testing an existing snapshot', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-07-23T00:00:00.000Z', + deviceName: 'Phone', + sessions: [], + metas: [], + } + const envelope = await encryptJsonEnvelope(remote, 'different-secret') + const webdavRequest = vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 200 : 405, + headers: {}, + body: request.method === 'GET' ? JSON.stringify(envelope) : '', + })) + + await expect(testWebDAVConnection(baseSettings, { platform: { webdavRequest } })).rejects.toThrow(/decrypt/i) + }) + + it('reports that the encryption password cannot be verified when no snapshot exists', async () => { + const webdavRequest = vi.fn(async (request: WebDAVRequest) => ({ + status: request.method === 'GET' ? 404 : 405, + headers: {}, + body: '', + })) + + await expect(testWebDAVConnection(baseSettings, { platform: { webdavRequest } })).resolves.toEqual({ + snapshotExists: false, + encryptionVerified: false, + }) + }) + + it('previews remote conversations that a replacement upload would remove', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-07-23T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('local-1', 'Local', 'same'), session('remote-only', 'Remote only', 'remote')], + metas: [meta('local-1', 'Local'), meta('remote-only', 'Remote only')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const platform = { + webdavRequest: vi.fn(async () => ({ + status: 200, + headers: { ETag: '"remote-etag"' }, + body: JSON.stringify(envelope), + })), + } + + const result = await previewWebDAVUpload(baseSettings, { + platform, + listLocalSessions: vi.fn(async () => [session('local-1', 'Local', 'same')]), + getLastSeenSnapshot: vi.fn(() => ({ + endpoint: 'https://dav.example.com/files/me/\nalice', + etag: '"remote-etag"', + })), + }) + + expect(result).toEqual({ + localCount: 1, + remoteCount: 2, + remoteOnlyCount: 1, + willRemoveRemoteCount: 1, + remoteMissing: false, + }) + }) + + it('does not report removals when an unseen remote snapshot will be merged first', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-07-23T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-only', 'Remote only', 'remote')], + metas: [meta('remote-only', 'Remote only')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + + const result = await previewWebDAVUpload(baseSettings, { + platform: { + webdavRequest: vi.fn(async () => ({ + status: 200, + headers: { ETag: '"new-etag"' }, + body: JSON.stringify(envelope), + })), + }, + listLocalSessions: vi.fn(async () => []), + getLastSeenSnapshot: vi.fn(() => ({ + endpoint: 'https://dav.example.com/files/me/\nalice', + etag: '"old-etag"', + })), + }) + + expect(result.remoteOnlyCount).toBe(1) + expect(result.willRemoveRemoteCount).toBe(0) + }) + it('uploads an encrypted snapshot to the fixed WebDAV path', async () => { const requests: WebDAVRequest[] = [] const deps = { diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index 2cf0835221..d1301f61ec 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -1,4 +1,4 @@ -import type { Session, SessionMeta, SessionMetaRecord, Settings } from '@shared/types' +import { isChatSession, type Session, type SessionMeta, type SessionMetaRecord, type Settings } from '@shared/types' import { SessionMetaRecordSchema, SessionSchema } from '@shared/types/session' import { z } from 'zod' import { migrateSession } from '@/utils/session-utils' @@ -40,14 +40,29 @@ export type LastSeenSnapshot = { etag?: string } +export type SyncMetadataOrder = Pick + +export type SyncMetadataUndo = { + previousSession?: Partial> + appliedSession?: Partial> + previousOrder?: SyncMetadataOrder + appliedOrder?: SyncMetadataOrder +} + export type WebDAVSyncDeps = { platform: SyncPlatform listLocalSessions: () => Promise listLocalMetas: () => Promise createSession: (session: Session, meta: SessionMetaRecord) => Promise - updateSessionMetadata: (sessionId: string, patch: Omit) => Promise> + updateSessionMetadata: ( + sessionId: string, + patch: Partial> | undefined, + order: SyncMetadataOrder | undefined + ) => Promise + restoreSessionMetadata?: (sessionId: string, undo: SyncMetadataUndo) => Promise deleteSession: (sessionId: string) => Promise - saveMetas: (metas: SessionMetaRecord[]) => Promise + /** @deprecated Existing metadata is now updated field-by-field by updateSessionMetadata. */ + saveMetas?: (metas: SessionMetaRecord[]) => Promise updateLastSyncedAt: (isoDate: string) => Promise | void getLastSeenSnapshot?: () => LastSeenSnapshot | undefined | Promise setLastSeenSnapshot?: (seen: LastSeenSnapshot) => Promise | void @@ -69,6 +84,19 @@ export type DownloadWebDAVSnapshotResult = { remoteUnchanged?: boolean } +export type TestWebDAVConnectionResult = { + snapshotExists: boolean + encryptionVerified: boolean +} + +export type PreviewWebDAVUploadResult = { + localCount: number + remoteCount: number + remoteOnlyCount: number + willRemoveRemoteCount: number + remoteMissing: boolean +} + function syncEndpoint(settings: Settings): string { const webdav = getWebDAVSettings(settings) return `${webdav.url}\n${webdav.username}` @@ -84,7 +112,7 @@ function syncEndpoint(settings: Settings): string { async function isRemoteSnapshotAlreadySeen( remote: DownloadedWebDAVSnapshot, settings: Settings, - deps: WebDAVSyncDeps + deps: Pick ): Promise { if (!remote.etag || !deps.getLastSeenSnapshot) { return false @@ -216,18 +244,38 @@ async function downloadWebDAVSnapshot(settings: Settings, platform: SyncPlatform } } -export async function testWebDAVConnection(settings: Settings, deps: Pick): Promise { - const webdav = getWebDAVSettings(settings) +export async function testWebDAVConnection( + settings: Settings, + deps: Pick +): Promise { await ensureWebDAVCollections(settings, deps.platform) - const response = await requestWebDAV(deps.platform, webdav.url, { - url: snapshotUrl(settings), - method: 'PROPFIND', - headers: { - ...authHeaders(settings), - Depth: '0', - }, - }) - assertSuccess(response, 'Check WebDAV snapshot', [200, 207, 404]) + const remote = await downloadWebDAVSnapshot(settings, deps.platform) + return { + snapshotExists: Boolean(remote.snapshot), + encryptionVerified: Boolean(remote.snapshot), + } +} + +export async function previewWebDAVUpload( + settings: Settings, + deps: Pick +): Promise { + const [sessions, remote] = await Promise.all([ + deps.listLocalSessions(), + downloadWebDAVSnapshot(settings, deps.platform), + ]) + const localSessionIds = new Set(sessions.filter(isChatSession).map((session) => session.id)) + const remoteSessions = remote.snapshot?.sessions.filter(isChatSession) ?? [] + const remoteOnlyCount = remoteSessions.filter((session) => !localSessionIds.has(session.id)).length + const remoteAlreadySeen = remote.snapshot ? await isRemoteSnapshotAlreadySeen(remote, settings, deps) : false + + return { + localCount: localSessionIds.size, + remoteCount: remoteSessions.length, + remoteOnlyCount, + willRemoveRemoteCount: remoteAlreadySeen ? remoteOnlyCount : 0, + remoteMissing: !remote.snapshot, + } } export async function uploadWebDAVSnapshot( @@ -362,9 +410,10 @@ export async function downloadAndMergeWebDAVSnapshot( const createdSessionIds = new Set() const undoOperations: Array< | { kind: 'delete-created'; sessionId: string } - | { kind: 'restore-metadata'; sessionId: string; patch: Omit } + | { kind: 'restore-metadata'; sessionId: string; undo: SyncMetadataUndo } > = [] try { + const updatedSessionIds = new Set() for (const change of result.sessionChanges) { if (change.kind === 'create') { const meta = metaById.get(change.session.id) @@ -375,14 +424,27 @@ export async function downloadAndMergeWebDAVSnapshot( createdSessionIds.add(change.session.id) undoOperations.push({ kind: 'delete-created', sessionId: change.session.id }) } else { - const previous = await deps.updateSessionMetadata(change.sessionId, change.patch) - undoOperations.push({ kind: 'restore-metadata', sessionId: change.sessionId, patch: previous }) + const meta = metaById.get(change.sessionId) + const order = meta ? { sortOrder: meta.sortOrder, createdAt: meta.createdAt } : undefined + const undo = await deps.updateSessionMetadata(change.sessionId, change.patch, order) + updatedSessionIds.add(change.sessionId) + undoOperations.push({ kind: 'restore-metadata', sessionId: change.sessionId, undo }) } } - const existingMetasToSave = result.metasToSave.filter((meta) => !createdSessionIds.has(meta.id)) - if (existingMetasToSave.length > 0) { - await deps.saveMetas(existingMetasToSave) + // A meta-only change contains ordering information. Session metadata such as + // name/starred is always represented by an update change above. Applying + // only these fields avoids replacing a concurrently edited full record. + for (const meta of result.metasToSave) { + if (createdSessionIds.has(meta.id) || updatedSessionIds.has(meta.id)) { + continue + } + const undo = await deps.updateSessionMetadata( + meta.id, + undefined, + { sortOrder: meta.sortOrder, createdAt: meta.createdAt } + ) + undoOperations.push({ kind: 'restore-metadata', sessionId: meta.id, undo }) } } catch (error) { const rollbackErrors: unknown[] = [] @@ -390,8 +452,10 @@ export async function downloadAndMergeWebDAVSnapshot( try { if (undo.kind === 'delete-created') { await deps.deleteSession(undo.sessionId) + } else if (deps.restoreSessionMetadata) { + await deps.restoreSessionMetadata(undo.sessionId, undo.undo) } else { - await deps.updateSessionMetadata(undo.sessionId, undo.patch) + await deps.updateSessionMetadata(undo.sessionId, undo.undo.previousSession, undo.undo.previousOrder) } } catch (rollbackError) { rollbackErrors.push(rollbackError) diff --git a/src/renderer/packages/sync/snapshot.test.ts b/src/renderer/packages/sync/snapshot.test.ts index 2c681aae4d..44b66bb709 100644 --- a/src/renderer/packages/sync/snapshot.test.ts +++ b/src/renderer/packages/sync/snapshot.test.ts @@ -1,4 +1,5 @@ import type { Session, SessionMetaRecord } from '@shared/types' +import { v4 as uuidv4 } from 'uuid' import { describe, expect, it, vi } from 'vitest' import { createSyncSnapshot, mergeRemoteSnapshot, sessionHasActiveGeneration } from './snapshot' import type { SyncSnapshot } from './types' @@ -71,6 +72,10 @@ describe('sync snapshot merge', () => { local.messages[0].contentParts.push({ type: 'image', storageKey: 'image-key' }) local.messages[0].cancel = vi.fn() local.messages[0].generating = true + local.messages[0].tokenCountMap = { default: 10 } + local.messages[0].tokenCalculatedAt = { default: 1000 } + local.messages[0].wordCount = 1 + local.messages[0].tokenCount = 2 local.messages[0].status = [{ type: 'retrying', attempt: 1, maxAttempts: 3 }] local.messages[0].isStreamingMode = true local.messages[0].files = [ @@ -173,6 +178,10 @@ describe('sync snapshot merge', () => { expect(message).not.toHaveProperty('pictures') expect(message).not.toHaveProperty('cancel') expect(message).not.toHaveProperty('generating') + expect(message).not.toHaveProperty('tokenCountMap') + expect(message).not.toHaveProperty('tokenCalculatedAt') + expect(message).not.toHaveProperty('wordCount') + expect(message).not.toHaveProperty('tokenCount') expect(message).not.toHaveProperty('status') expect(message).not.toHaveProperty('isStreamingMode') expect(synced.threads?.[0].messages[0].contentParts).toEqual([]) @@ -240,6 +249,32 @@ describe('sync snapshot merge', () => { expect(result.conflicts).toBe(0) }) + it('ignores device-local message token cache differences', () => { + const local = session('same-id', 'Project', 'same text') + const remoteSession = session('same-id', 'Project', 'same text') + remoteSession.messages[0].tokenCountMap = { default: 2 } + remoteSession.messages[0].tokenCalculatedAt = { default: 1234 } + remoteSession.messages[0].wordCount = 2 + remoteSession.messages[0].tokenCount = 3 + + const result = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [meta('same-id', 'Project')], + remote: { + version: 1, + exportedAt: '2026-07-23T00:00:00.000Z', + deviceName: 'Device B', + sessions: [remoteSession], + metas: [meta('same-id', 'Project')], + }, + now: 2000, + }) + + expect(result.sessionChanges).toEqual([]) + expect(result.metasToSave).toEqual([]) + expect(result.conflicts).toBe(0) + }) + it('strips local-only blob references when importing remote sessions', () => { const remoteSession = session('remote-1', 'Remote', 'hello') remoteSession.assistantAvatarKey = 'avatar-key' @@ -530,6 +565,105 @@ describe('sync snapshot merge', () => { ) }) + it('does not scan local sessions for ordinary UUIDv4 remote sessions', () => { + const sessionCount = 512 + const localSessions = Array.from({ length: sessionCount }, (_, index) => + session(uuidv4(), `Local ${index}`, `local ${index}`) + ) + const remoteSessions = Array.from({ length: sessionCount }, (_, index) => + session(uuidv4(), `Remote ${index}`, `remote ${index}`) + ) + const startedAt = performance.now() + + const result = mergeRemoteSnapshot({ + localSessions, + localMetas: localSessions.map((item, index) => meta(item.id, item.name, index)), + remote: { + version: 1, + exportedAt: '2026-06-21T00:01:00.000Z', + deviceName: 'Device B', + sessions: remoteSessions, + metas: remoteSessions.map((item, index) => meta(item.id, item.name, index)), + }, + now: 3000, + }) + + expect(result.sessionChanges).toHaveLength(sessionCount) + expect(performance.now() - startedAt).toBeLessThan(1500) + }) + + it('keeps nested conflict provenance tied to the immediate source copy', () => { + const localRoot = session('same-id', 'Project', 'local text') + const firstRemote = session('same-id', 'Project', 'remote text') + const first = mergeRemoteSnapshot({ + localSessions: [localRoot], + localMetas: [meta('same-id', 'Project')], + remote: { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Device B', + sessions: [firstRemote], + metas: [meta('same-id', 'Project')], + }, + now: 1000, + }) + const firstCopyChange = first.sessionChanges[0] + if (firstCopyChange.kind !== 'create') throw new Error('Expected the first conflict copy') + + const deviceACopy = { + ...firstCopyChange.session, + messages: [ + { + ...firstCopyChange.session.messages[0], + contentParts: [{ type: 'text' as const, text: 'device A edit' }], + }, + ], + } + const deviceBCopy = { + ...firstCopyChange.session, + messages: [ + { + ...firstCopyChange.session.messages[0], + contentParts: [{ type: 'text' as const, text: 'device B edit' }], + }, + ], + } + const nested = mergeRemoteSnapshot({ + localSessions: [localRoot, deviceACopy], + localMetas: [meta('same-id', 'Project'), meta(deviceACopy.id, deviceACopy.name)], + remote: { + version: 1, + exportedAt: '2026-06-21T00:01:00.000Z', + deviceName: 'Device B', + sessions: [deviceBCopy], + metas: [meta(deviceBCopy.id, deviceBCopy.name)], + }, + now: 2000, + }) + const nestedCopyChange = nested.sessionChanges[0] + if (nestedCopyChange.kind !== 'create') throw new Error('Expected the nested conflict copy') + + expect(nestedCopyChange.session.syncConflictSourceId).toBe(firstCopyChange.session.id) + + const replay = mergeRemoteSnapshot({ + localSessions: [localRoot, deviceBCopy], + localMetas: [meta('same-id', 'Project'), meta(deviceBCopy.id, deviceBCopy.name)], + remote: { + version: 1, + exportedAt: '2026-06-21T00:02:00.000Z', + deviceName: 'Device A', + sessions: [nestedCopyChange.session], + metas: [nested.metasToSave[0]], + }, + now: 3000, + }) + + expect(replay.sessionChanges).toEqual([]) + expect(replay.metasToSave).toEqual([]) + expect(replay.imported).toBe(0) + expect(replay.conflicts).toBe(0) + }) + it('uses different stable conflict IDs when remote content changes', () => { const local = session('same-id', 'Project', 'local text') const merge = (text: string) => @@ -733,6 +867,54 @@ describe('sync snapshot merge', () => { expect(result.conflicts).toBe(0) }) + it('preserves device-local avatar and background references during remote metadata updates', () => { + const local = session('same-id', 'Project', 'same text') + local.assistantAvatarKey = 'local-avatar' + local.backgroundImage = { type: 'storage-key', storageKey: 'local-background' } + const localMeta = { + ...meta('same-id', 'Project', 1), + assistantAvatarKey: 'local-avatar', + backgroundImage: { type: 'storage-key' as const, storageKey: 'local-background' }, + } + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-07-23T00:00:00.000Z', + deviceName: 'Phone', + sessions: [ + { + ...session('same-id', 'Project Remote', 'same text'), + starred: true, + }, + ], + metas: [{ ...meta('same-id', 'Project Remote', 99), starred: true }], + } + + const result = mergeRemoteSnapshot({ + localSessions: [local], + localMetas: [localMeta], + remote, + now: 2000, + preferRemoteMetadata: true, + }) + + const metadataChange = result.sessionChanges[0] + expect(metadataChange).toMatchObject({ + kind: 'update-metadata', + sessionId: 'same-id', + patch: { name: 'Project Remote', starred: true }, + }) + if (metadataChange.kind !== 'update-metadata') throw new Error('Expected a metadata update') + expect(metadataChange.patch).not.toHaveProperty('assistantAvatarKey') + expect(metadataChange.patch).not.toHaveProperty('backgroundImage') + expect(result.metasToSave[0]).toMatchObject({ + name: 'Project Remote', + starred: true, + sortOrder: 99, + assistantAvatarKey: 'local-avatar', + backgroundImage: { type: 'storage-key', storageKey: 'local-background' }, + }) + }) + it('keeps local metadata for same-content sessions during upload merges', () => { const local = session('same-id', 'Project', 'same text') const remoteSession = { diff --git a/src/renderer/packages/sync/snapshot.ts b/src/renderer/packages/sync/snapshot.ts index 8e6ebc7e9d..2e89a9b2d3 100644 --- a/src/renderer/packages/sync/snapshot.ts +++ b/src/renderer/packages/sync/snapshot.ts @@ -7,7 +7,7 @@ import { type SessionMeta, type SessionMetaRecord, } from '@shared/types' -import { v5 as uuidv5 } from 'uuid' +import { validate as uuidValidate, version as uuidVersion, v5 as uuidv5 } from 'uuid' import type { MergeRemoteSnapshotInput, MergeRemoteSnapshotResult, SyncSnapshot } from './types' function isChatSessionMetaLike(item: Pick): boolean { @@ -131,6 +131,10 @@ function stripLocalMessageReferences(message: Message): Message { generating: _generating, status: _status, isStreamingMode: _isStreamingMode, + tokenCountMap: _tokenCountMap, + tokenCalculatedAt: _tokenCalculatedAt, + wordCount: _wordCount, + tokenCount: _tokenCount, ...messageWithoutRuntimeState } = message as Message & { pictures?: unknown } const result: Message = { @@ -230,6 +234,36 @@ function metaForCopiedSession( } } +function metaForExistingSession( + session: Session, + localMeta: SessionMetaRecord | undefined, + remoteMeta: SessionMetaRecord | undefined, + now: number, + preferRemoteOrder: boolean +): SessionMetaRecord { + const primaryOrderMeta = preferRemoteOrder ? remoteMeta : localMeta + const fallbackOrderMeta = preferRemoteOrder ? localMeta : remoteMeta + const localBackground = + session.backgroundImage?.type === 'storage-key' + ? session.backgroundImage + : localMeta?.backgroundImage?.type === 'storage-key' + ? localMeta.backgroundImage + : session.backgroundImage + + return { + id: session.id, + name: session.name, + type: session.type, + starred: session.starred, + hidden: session.hidden, + assistantAvatarKey: session.assistantAvatarKey ?? localMeta?.assistantAvatarKey, + picUrl: session.picUrl, + backgroundImage: localBackground, + sortOrder: primaryOrderMeta?.sortOrder ?? fallbackOrderMeta?.sortOrder ?? now, + createdAt: primaryOrderMeta?.createdAt ?? fallbackOrderMeta?.createdAt ?? now, + } +} + function applyMetaToSession(session: Session, meta: SessionMetaRecord | undefined): Session { if (!meta) { return session @@ -246,7 +280,7 @@ function applyMetaToSession(session: Session, meta: SessionMetaRecord | undefine } } -function metadataPatch(session: Session): Omit { +function sessionMetadata(session: Session): Omit { return { name: session.name, type: session.type, @@ -258,14 +292,35 @@ function metadataPatch(session: Session): Omit { } } +function remoteMetadataPatch(localSession: Session, remoteSession: Session): Omit { + const patch: Omit = { + name: remoteSession.name, + type: remoteSession.type, + starred: remoteSession.starred, + hidden: remoteSession.hidden, + picUrl: remoteSession.picUrl, + } + + // A storage-key background and assistant avatar refer to blobs that exist + // only on this device. Remote snapshots cannot meaningfully replace them. + if (localSession.backgroundImage?.type !== 'storage-key') { + patch.backgroundImage = remoteSession.backgroundImage + } + return patch +} + function sessionMetadataEqual(left: Session, right: Session): boolean { - return stableStringify(metadataPatch(left)) === stableStringify(metadataPatch(right)) + return stableStringify(sessionMetadata(left)) === stableStringify(sessionMetadata(right)) } function defaultCreateConflictId(sourceSessionId: string, contentFingerprint: string): string { return uuidv5(`chatbox:webdav-sync:v1:${sourceSessionId}:${contentFingerprint}`, uuidv5.URL) } +function isPossibleLegacyConflictId(id: string): boolean { + return uuidValidate(id) && uuidVersion(id) === 5 +} + function conflictSourceIdForRemoteCopy( remoteSession: Session, localSessionById: Map @@ -273,10 +328,20 @@ function conflictSourceIdForRemoteCopy( if (remoteSession.syncConflictSourceId) { return remoteSession.syncConflictSourceId } + + // Only UUIDv5 IDs can be conflict copies created by the pre-provenance format. + // Normal UUIDv4 sessions should not pay the compatibility scan cost. + if (!isPossibleLegacyConflictId(remoteSession.id)) { + return undefined + } + const fingerprint = sessionContentFingerprint(remoteSession) - return [...localSessionById.keys()].find( - (sourceSessionId) => defaultCreateConflictId(sourceSessionId, fingerprint) === remoteSession.id - ) + for (const sourceSessionId of localSessionById.keys()) { + if (defaultCreateConflictId(sourceSessionId, fingerprint) === remoteSession.id) { + return sourceSessionId + } + } + return undefined } export function createSyncSnapshot(input: { @@ -301,17 +366,16 @@ export function createSyncSnapshot(input: { } export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemoteSnapshotResult { - const localSessionById = new Map( - input.localSessions.filter(isChatSession).map((session) => { - const sanitizedSession = stripLocalSessionReferences(session) - return [sanitizedSession.id, sanitizedSession] + const localSessions = input.localSessions.filter(isChatSession) + const localSessionById = new Map(localSessions.map((session) => [session.id, session] as const)) + const comparableLocalSessionById = new Map( + localSessions.map((session) => { + const comparableSession = stripLocalSessionReferences(session) + return [comparableSession.id, comparableSession] as const }) ) const localMetaById = new Map( - input.localMetas.filter(isChatSessionMetaLike).map((meta) => { - const sanitizedMeta = stripLocalMetaReferences(meta) - return [sanitizedMeta.id, sanitizedMeta] - }) + input.localMetas.filter(isChatSessionMetaLike).map((meta) => [meta.id, meta] as const) ) const remoteMetas = input.remote.metas.map(stripLocalMetaReferences) const remoteMetaById = new Map(remoteMetas.map((meta) => [meta.id, meta])) @@ -329,12 +393,13 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot seenRemoteSessionIds.add(remoteSession.id) const localSession = localSessionById.get(remoteSession.id) + const comparableLocalSession = comparableLocalSessionById.get(remoteSession.id) const remoteMeta = remoteMetaById.get(remoteSession.id) const remoteSessionWithMeta = applyMetaToSession(remoteSession, remoteMeta) if (!localSession) { - const conflictSourceId = conflictSourceIdForRemoteCopy(remoteSession, localSessionById) - const localConflictSource = conflictSourceId ? localSessionById.get(conflictSourceId) : undefined + const conflictSourceId = conflictSourceIdForRemoteCopy(remoteSession, comparableLocalSessionById) + const localConflictSource = conflictSourceId ? comparableLocalSessionById.get(conflictSourceId) : undefined if (localConflictSource && sessionContentEqual(localConflictSource, remoteSession)) { continue } @@ -342,32 +407,33 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot sessionChanges.push({ kind: 'create', session: remoteSessionWithMeta }) metasToSaveById.set(meta.id, meta) localSessionById.set(remoteSessionWithMeta.id, remoteSessionWithMeta) + comparableLocalSessionById.set(remoteSessionWithMeta.id, remoteSessionWithMeta) localMetaById.set(meta.id, meta) imported += 1 continue } - if (sessionContentEqual(localSession, remoteSession)) { + if (comparableLocalSession && sessionContentEqual(comparableLocalSession, remoteSession)) { if (input.preferRemoteMetadata) { - if (!sessionMetadataEqual(localSession, remoteSessionWithMeta)) { + const patch = remoteMetadataPatch(localSession, remoteSessionWithMeta) + const updatedSession = { ...localSession, ...patch } + if (!sessionMetadataEqual(localSession, updatedSession)) { sessionChanges.push({ kind: 'update-metadata', sessionId: remoteSession.id, - patch: metadataPatch(remoteSessionWithMeta), - }) - localSessionById.set(remoteSession.id, { - ...localSession, - ...metadataPatch(remoteSessionWithMeta), + patch, }) + localSessionById.set(remoteSession.id, updatedSession) + comparableLocalSessionById.set(remoteSession.id, stripLocalSessionReferences(updatedSession)) } const localMeta = localMetaById.get(remoteSession.id) - if (remoteMeta && (!localMeta || !metasEqual(localMeta, remoteMeta))) { - const meta = metaForSession(remoteSessionWithMeta, remoteMeta, input.now) + const meta = metaForExistingSession(updatedSession, localMeta, remoteMeta, input.now, true) + if (remoteMeta && (!localMeta || !metasEqual(localMeta, meta))) { metasToSaveById.set(meta.id, meta) localMetaById.set(meta.id, meta) } } else if (!localMetaById.has(remoteSession.id) && remoteMeta) { - const meta = metaForSession(remoteSessionWithMeta, remoteMeta, input.now) + const meta = metaForExistingSession(localSession, undefined, remoteMeta, input.now, true) metasToSaveById.set(meta.id, meta) localMetaById.set(meta.id, meta) } @@ -381,16 +447,17 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot ...remoteSessionWithMeta, id: conflictId, name: copiedName, - syncConflictSourceId: remoteSession.syncConflictSourceId ?? remoteSession.id, + syncConflictSourceId: remoteSession.id, } const copiedMeta = { ...metaForCopiedSession(copiedSession, remoteMeta, input.now), name: copiedName, } const existingCopy = localSessionById.get(conflictId) + const comparableExistingCopy = comparableLocalSessionById.get(conflictId) if (existingCopy) { - if (!sessionContentEqual(existingCopy, copiedSession)) { + if (!comparableExistingCopy || !sessionContentEqual(comparableExistingCopy, copiedSession)) { // The synced copy is a normal mutable session: the user may have kept // chatting in it after import. A content mismatch here means the copy // is now user-owned local data, not an ID collision (the stable ID is @@ -399,25 +466,28 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot // Preserve the edited copy untouched and never abort the sync over it. continue } - if (input.preferRemoteMetadata && !sessionMetadataEqual(existingCopy, copiedSession)) { - sessionChanges.push({ - kind: 'update-metadata', - sessionId: conflictId, - patch: metadataPatch(copiedSession), - }) - localSessionById.set(conflictId, { - ...existingCopy, - ...metadataPatch(copiedSession), - }) + let updatedCopy = existingCopy + if (input.preferRemoteMetadata) { + const patch = remoteMetadataPatch(existingCopy, copiedSession) + updatedCopy = { ...existingCopy, ...patch } + if (!sessionMetadataEqual(existingCopy, updatedCopy)) { + sessionChanges.push({ + kind: 'update-metadata', + sessionId: conflictId, + patch, + }) + localSessionById.set(conflictId, updatedCopy) + comparableLocalSessionById.set(conflictId, stripLocalSessionReferences(updatedCopy)) + } } const existingCopyMeta = localMetaById.get(conflictId) - const updatedCopyMeta = existingCopyMeta - ? { - ...copiedMeta, - sortOrder: existingCopyMeta.sortOrder, - createdAt: existingCopyMeta.createdAt, - } - : copiedMeta + const updatedCopyMeta = metaForExistingSession( + updatedCopy, + existingCopyMeta, + copiedMeta, + input.now, + false + ) if (input.preferRemoteMetadata && (!existingCopyMeta || !metasEqual(existingCopyMeta, updatedCopyMeta))) { metasToSaveById.set(conflictId, updatedCopyMeta) localMetaById.set(conflictId, updatedCopyMeta) @@ -428,6 +498,7 @@ export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemot sessionChanges.push({ kind: 'create', session: copiedSession }) metasToSaveById.set(conflictId, copiedMeta) localSessionById.set(conflictId, copiedSession) + comparableLocalSessionById.set(conflictId, copiedSession) localMetaById.set(conflictId, copiedMeta) conflicts += 1 } diff --git a/src/renderer/routes/settings/general.tsx b/src/renderer/routes/settings/general.tsx index 20286038e9..483cf3da87 100644 --- a/src/renderer/routes/settings/general.tsx +++ b/src/renderer/routes/settings/general.tsx @@ -22,11 +22,18 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { AdaptiveSelect } from '@/components/AdaptiveSelect' +import { AdaptiveModal } from '@/components/common/AdaptiveModal' import LazySlider from '@/components/common/LazySlider' import { languageNameMap, languages } from '@/i18n/locales' import { sanitizeSettingsForExport } from '@/packages/settings-export' import { createDefaultWebDAVSyncDeps } from '@/packages/sync/local' -import { downloadAndMergeWebDAVSnapshot, testWebDAVConnection, uploadWebDAVSnapshot } from '@/packages/sync/service' +import { + downloadAndMergeWebDAVSnapshot, + type PreviewWebDAVUploadResult, + previewWebDAVUpload, + testWebDAVConnection, + uploadWebDAVSnapshot, +} from '@/packages/sync/service' import { toastError } from '@/packages/toast' import platform from '@/platform' import storage, { StorageKey } from '@/storage' @@ -238,13 +245,12 @@ export function RouteComponent() { ) } -const WebDAVSyncSection = () => { +export const WebDAVSyncSection = () => { const { t } = useTranslation() - const { setSettings, sync } = useSettingsStore((state) => ({ - setSettings: state.setSettings, - sync: state.sync, - })) + const setSettings = useSettingsStore((state) => state.setSettings) + const sync = useSettingsStore((state) => state.sync) const [runningAction, setRunningAction] = useState<'test' | 'upload' | 'download' | null>(null) + const [uploadPreview, setUploadPreview] = useState(null) const updateWebDAVSettings = (patch: Partial) => { setSettings((settings) => { @@ -259,6 +265,9 @@ const WebDAVSyncSection = () => { if (runningAction) return setRunningAction(action) try { + // Desktop validates against settings read by the main process. Flush the + // debounced write so both processes use the same WebDAV URL. + await storage.flushItem(StorageKey.Settings) const message = await task() if (message) { toast.success(message) @@ -272,27 +281,22 @@ const WebDAVSyncSection = () => { const currentSettings = () => settingsStore.getState().getSettings() + const uploadCurrentSnapshot = async () => { + const result = await uploadWebDAVSnapshot(currentSettings(), createDefaultWebDAVSyncDeps()) + return String(t('Uploaded {{count}} conversations', { count: result.uploaded })) + } + return ( - {t('WebDAV Sync')} + {t('Manual WebDAV Sync')} {t( - 'Sync chat history through your own WebDAV storage. API keys, licenses, and provider credentials are not synced.' + 'Manually merge chat history through your own WebDAV storage. API keys, licenses, and provider credentials are not synced.' )} - - setSettings((settings) => { - settings.sync.enabled = event.currentTarget.checked - }) - } - /> - { disabled={Boolean(runningAction)} onClick={() => runSyncAction('test', async () => { - await testWebDAVConnection(currentSettings(), { platform }) - return String(t('Connection successful')) + const result = await testWebDAVConnection(currentSettings(), { platform }) + return result.encryptionVerified + ? String(t('Connection and encryption password verified')) + : String(t('Connection successful; no remote snapshot exists to verify the encryption password')) }) } > @@ -345,20 +351,24 @@ const WebDAVSyncSection = () => { + + setUploadPreview(null)} + > + + + {t( + 'This upload will remove {{count}} conversations that exist only in the last-seen remote snapshot. This cannot be undone by Chatbox.', + { count: uploadPreview?.willRemoveRemoteCount ?? 0 } + )} + + + {t('Local conversations: {{local}}; remote conversations: {{remote}}', { + local: uploadPreview?.localCount ?? 0, + remote: uploadPreview?.remoteCount ?? 0, + })} + + + + + + + ) } diff --git a/src/renderer/storage/SQLiteSessionMetaStorage.ts b/src/renderer/storage/SQLiteSessionMetaStorage.ts index b7727b8789..aab5921612 100644 --- a/src/renderer/storage/SQLiteSessionMetaStorage.ts +++ b/src/renderer/storage/SQLiteSessionMetaStorage.ts @@ -166,32 +166,29 @@ export class SQLiteSessionMetaStorage implements SessionMetaStorage { async update(id: string, updates: Partial): Promise { await this.initialize() - const existing = await this.getById(id) - if (!existing) return null - - const updated = { ...existing, ...updates } - const row = this.recordToRow(updated) - - await this.database.run( - `UPDATE session_meta SET - name = ?, starred = ?, hidden = ?, assistant_avatar_key = ?, pic_url = ?, - background_image = ?, type = ?, sort_order = ?, created_at = ? - WHERE id = ?`, - [ - row.name, - row.starred, - row.hidden, - row.assistant_avatar_key, - row.pic_url, - row.background_image, - row.type, - row.sort_order, - row.created_at, - id, - ] - ) + const columns: string[] = [] + const values: unknown[] = [] + const add = (property: keyof SessionMetaRecord, column: string, value: unknown) => { + if (Object.hasOwn(updates, property)) { + columns.push(`${column} = ?`) + values.push(value) + } + } - return updated + add('name', 'name', updates.name) + add('starred', 'starred', updates.starred ? 1 : 0) + add('hidden', 'hidden', updates.hidden ? 1 : 0) + add('assistantAvatarKey', 'assistant_avatar_key', updates.assistantAvatarKey || null) + add('picUrl', 'pic_url', updates.picUrl || null) + add('backgroundImage', 'background_image', updates.backgroundImage ? JSON.stringify(updates.backgroundImage) : null) + add('type', 'type', updates.type || null) + add('sortOrder', 'sort_order', updates.sortOrder) + add('createdAt', 'created_at', updates.createdAt) + + if (columns.length > 0) { + await this.database.run(`UPDATE session_meta SET ${columns.join(', ')} WHERE id = ?`, [...values, id]) + } + return await this.getById(id) } async getById(id: string): Promise { diff --git a/src/renderer/storage/SessionMetaStorage.ts b/src/renderer/storage/SessionMetaStorage.ts index acef2e8d5c..c4d6f8b3d5 100644 --- a/src/renderer/storage/SessionMetaStorage.ts +++ b/src/renderer/storage/SessionMetaStorage.ts @@ -99,15 +99,22 @@ export class IndexedDBSessionMetaStorage implements SessionMetaStorage { async update(id: string, updates: Partial): Promise { await this.initialize() - const existing = await this.getById(id) - if (!existing) return null - - const updated = { ...existing, ...updates } return new Promise((resolve, reject) => { - const store = this.getStore('readwrite') - const request = store.put(updated) - request.onsuccess = () => resolve(updated) - request.onerror = () => reject(request.error) + if (!this.db) throw new Error('Database not initialized') + const tx = this.db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + const request = store.get(id) + let updated: SessionMetaRecord | null = null + + request.onsuccess = () => { + const existing = request.result as SessionMetaRecord | undefined + if (!existing) return + updated = { ...existing, ...updates, id } + store.put(updated) + } + tx.oncomplete = () => resolve(updated) + tx.onerror = () => reject(tx.error) + tx.onabort = () => reject(tx.error) }) } diff --git a/src/renderer/storage/StoreStorage.test.ts b/src/renderer/storage/StoreStorage.test.ts index 258032956a..8d4c833860 100644 --- a/src/renderer/storage/StoreStorage.test.ts +++ b/src/renderer/storage/StoreStorage.test.ts @@ -1,9 +1,14 @@ -import { beforeAll, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + setStoreValue: vi.fn(async () => undefined), +})) vi.mock('@/platform', () => ({ default: { getStorageType: () => 'web', - setStoreValue: async () => undefined, + appLog: async () => undefined, + setStoreValue: mocks.setStoreValue, getStoreValue: async () => undefined, delStoreValue: async () => undefined, getAllStoreValues: async () => ({}), @@ -17,9 +22,19 @@ vi.mock('@/platform', () => ({ })) let StorageKeyGenerator: typeof import('./StoreStorage').StorageKeyGenerator +let StoreStorage: typeof import('./StoreStorage').default beforeAll(async () => { - ;({ StorageKeyGenerator } = await import('./StoreStorage')) + ;({ StorageKeyGenerator, default: StoreStorage } = await import('./StoreStorage')) +}) + +beforeEach(() => { + vi.useFakeTimers() + mocks.setStoreValue.mockClear() +}) + +afterEach(() => { + vi.useRealTimers() }) describe('StorageKeyGenerator', () => { @@ -48,3 +63,19 @@ describe('StorageKeyGenerator', () => { expect(StorageKeyGenerator.linkUniqKey('https://example.com/a')).toBe('link:https://example.com/a') }) }) + +describe('StoreStorage.flushItem', () => { + it('immediately persists the latest debounced value', async () => { + const storage = new StoreStorage() + + await storage.setItem('settings', { sync: { webdav: { url: 'https://dav.example.com/' } } }) + expect(mocks.setStoreValue).not.toHaveBeenCalled() + + await storage.flushItem('settings') + + expect(mocks.setStoreValue).toHaveBeenCalledTimes(1) + expect(mocks.setStoreValue).toHaveBeenCalledWith('settings', { + sync: { webdav: { url: 'https://dav.example.com/' } }, + }) + }) +}) diff --git a/src/renderer/storage/StoreStorage.ts b/src/renderer/storage/StoreStorage.ts index 13a572703f..1ba038aaa1 100644 --- a/src/renderer/storage/StoreStorage.ts +++ b/src/renderer/storage/StoreStorage.ts @@ -46,14 +46,20 @@ export default class StoreStorage extends BaseStorage { return value } - private debounceQueue = new Map void>>() + private debounceQueue = new Map Promise>>() - public async setItem(key: string, value: T): Promise { + public setItem(key: string, value: T): Promise { let debounced = this.debounceQueue.get(key) if (!debounced) { debounced = debounce(this.setItemNow.bind(this), 500, { maxWait: 2000 }) this.debounceQueue.set(key, debounced) } - debounced(key, value) + void debounced(key, value) + return Promise.resolve() + } + + /** Persist the latest queued value before another process reads this key. */ + public async flushItem(key: string): Promise { + await this.debounceQueue.get(key)?.flush() } } diff --git a/src/shared/defaults.ts b/src/shared/defaults.ts index d890d967b8..693ca8f933 100644 --- a/src/shared/defaults.ts +++ b/src/shared/defaults.ts @@ -152,7 +152,6 @@ export function settings(): Settings { translationEnabled: true, }, sync: { - enabled: false, provider: 'webdav', webdav: { url: '', diff --git a/src/shared/sync-settings.test.ts b/src/shared/sync-settings.test.ts index b51fd42f82..4651c06b6e 100644 --- a/src/shared/sync-settings.test.ts +++ b/src/shared/sync-settings.test.ts @@ -3,11 +3,10 @@ import * as defaults from './defaults' import { SettingsSchema } from './types' describe('sync settings defaults', () => { - it('defaults WebDAV sync to disabled and keeps credentials empty', () => { + it('defaults manual WebDAV sync credentials to empty values', () => { const settings = SettingsSchema.parse(defaults.settings()) expect(settings.sync).toEqual({ - enabled: false, provider: 'webdav', webdav: { url: '', diff --git a/src/shared/types/settings.ts b/src/shared/types/settings.ts index c171726d5e..1fca2b9afb 100644 --- a/src/shared/types/settings.ts +++ b/src/shared/types/settings.ts @@ -283,7 +283,6 @@ const MCPSettingsSchema = z.object({ }) export const SyncSettingsSchema = z.object({ - enabled: z.boolean().catch(false), provider: z.literal('webdav').catch('webdav'), webdav: z .object({ @@ -439,7 +438,6 @@ export const SettingsSchema = GlobalSessionSettingsSchema.extend({ translationEnabled: true, }), sync: SyncSettingsSchema.catch({ - enabled: false, provider: 'webdav', webdav: { url: '', From edb3c877412d1c9e3e727e9557c13889d1b2bf1d Mon Sep 17 00:00:00 2001 From: NewstarDevelop Date: Tue, 28 Jul 2026 15:40:54 +0800 Subject: [PATCH 19/19] =?UTF-8?q?fix(sync):=20harden=20WebDAV=20import=20?= =?UTF-8?q?=E2=80=94=20idempotent=20retry,=20parity-aware=20conflict=20det?= =?UTF-8?q?ection,=20partial-import=20retention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make WebDAV sync import resilient to crashes and race conditions: - Remove unused `saveSyncMetas`/`deleteSyncSession` exports and `saveMetas`/`deleteSession` dependency slots - `createSessionWithId` becomes idempotent: retry after a crash repairs orphaned data instead of rejecting; uses `normalizePersistedSession`/ `normalizeImportedMeta` to tolerate JSON/SQLite serialization differences when detecting true conflicts - Concurrent fixed-ID imports serialize: the second caller waits for the first, then reconciles against the now-present state - Failed imports keep created sessions instead of deleting them; retry reuses them directly Co-authored-by: Reasonix --- src/renderer/packages/settings-export.test.ts | 4 +- src/renderer/packages/sync/local.test.ts | 64 +--- src/renderer/packages/sync/local.ts | 36 +- src/renderer/packages/sync/service.test.ts | 315 ++++++++++++------ src/renderer/packages/sync/service.ts | 86 +++-- src/renderer/stores/chatStore.sync.test.ts | 157 ++++++++- src/renderer/stores/chatStore.ts | 130 +++++++- 7 files changed, 557 insertions(+), 235 deletions(-) diff --git a/src/renderer/packages/settings-export.test.ts b/src/renderer/packages/settings-export.test.ts index bc389f0b89..1dcc45059f 100644 --- a/src/renderer/packages/settings-export.test.ts +++ b/src/renderer/packages/settings-export.test.ts @@ -203,9 +203,7 @@ describe('sanitizeSettingsForExport', () => { const sanitized = sanitizeSettingsForExport(settings, false) expect(sanitized.mcp.servers).toEqual([]) - expect(JSON.stringify(sanitized)).not.toMatch( - /ghp-secret|Bearer secret|stdio-arg-secret|http-url-secret/ - ) + expect(JSON.stringify(sanitized)).not.toMatch(/ghp-secret|Bearer secret|stdio-arg-secret|http-url-secret/) // Transport credentials survive when key export is selected. const withSecrets = sanitizeSettingsForExport(settingsWithSecrets(), true) expect(withSecrets.mcp.servers[0].transport).toMatchObject({ diff --git a/src/renderer/packages/sync/local.test.ts b/src/renderer/packages/sync/local.test.ts index d214c4104b..94432304ca 100644 --- a/src/renderer/packages/sync/local.test.ts +++ b/src/renderer/packages/sync/local.test.ts @@ -2,17 +2,14 @@ import type { Session, SessionMetaRecord } from '@shared/types' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createSyncSession, - deleteSyncSession, listLocalSyncMetas, listLocalSyncSessions, restoreSyncSessionMetadata, - saveSyncMetas, updateSyncSessionMetadata, } from './local' vi.mock('@/stores/chatStore', () => ({ createSessionWithId: vi.fn(), - deleteSession: vi.fn(), getMetaStorage: vi.fn(), listAllSessionsMeta: vi.fn(), refreshSessionListCache: vi.fn(), @@ -25,14 +22,8 @@ vi.mock('@/storage', () => ({ }, })) -const { - createSessionWithId, - deleteSession, - getMetaStorage, - listAllSessionsMeta, - refreshSessionListCache, - updateSession, -} = await import('@/stores/chatStore') +const { createSessionWithId, getMetaStorage, listAllSessionsMeta, refreshSessionListCache, updateSession } = + await import('@/stores/chatStore') const { default: storage } = await import('@/storage') function session(id: string, type?: Session['type']): Session { @@ -59,7 +50,6 @@ describe('local sync data selection', () => { vi.mocked(listAllSessionsMeta).mockReset() vi.mocked(storage.getItem).mockReset() vi.mocked(createSessionWithId).mockReset() - vi.mocked(deleteSession).mockReset() vi.mocked(updateSession).mockReset() vi.mocked(getMetaStorage).mockReset() vi.mocked(refreshSessionListCache).mockReset() @@ -147,14 +137,14 @@ describe('local sync data selection', () => { current.name = 'Local Name' let persisted = current let callCount = 0 - vi.mocked(updateSession).mockImplementation(async (_id, updater) => { + vi.mocked(updateSession).mockImplementation((_id, updater) => { const next = typeof updater === 'function' ? updater(persisted) : { ...persisted, ...updater } persisted = { ...persisted, ...next } callCount += 1 if (callCount === 1) { - throw new Error('meta update failed') + return Promise.reject(new Error('meta update failed')) } - return persisted + return Promise.resolve(persisted) }) await expect(updateSyncSessionMetadata('same-id', { name: 'Remote Name', type: 'chat' })).rejects.toThrow( @@ -174,15 +164,13 @@ describe('local sync data selection', () => { update: vi.fn(async (_id, updates) => ({ ...currentMeta, ...updates })), } vi.mocked(getMetaStorage).mockResolvedValue(metaStorage as never) - vi.mocked(updateSession).mockImplementation(async (_id, updater) => { - return typeof updater === 'function' ? ({ ...current, ...updater(current) } as Session) : { ...current, ...updater } + vi.mocked(updateSession).mockImplementation((_id, updater) => { + const updated = + typeof updater === 'function' ? ({ ...current, ...updater(current) } as Session) : { ...current, ...updater } + return Promise.resolve(updated) }) - const undo = await updateSyncSessionMetadata( - 'same-id', - { name: 'Remote Name' }, - { sortOrder: 99, createdAt: 100 } - ) + const undo = await updateSyncSessionMetadata('same-id', { name: 'Remote Name' }, { sortOrder: 99, createdAt: 100 }) expect(metaStorage.update).toHaveBeenCalledWith('same-id', { sortOrder: 99, createdAt: 100 }) expect(metaStorage.update).not.toHaveBeenCalledWith('same-id', expect.objectContaining({ name: expect.anything() })) @@ -200,10 +188,10 @@ describe('local sync data selection', () => { update: vi.fn(async (_id, updates) => ({ ...currentMeta, ...updates })), } vi.mocked(getMetaStorage).mockResolvedValue(metaStorage as never) - vi.mocked(updateSession).mockImplementation(async (_id, updater) => { + vi.mocked(updateSession).mockImplementation((_id, updater) => { const next = typeof updater === 'function' ? updater(current) : { ...current, ...updater } current = { ...current, ...next } - return current + return Promise.resolve(current) }) await restoreSyncSessionMetadata('same-id', { @@ -217,32 +205,4 @@ describe('local sync data selection', () => { expect(current.starred).toBeUndefined() expect(metaStorage.update).toHaveBeenCalledWith('same-id', { createdAt: 2 }) }) - - it('deletes rolled-back sessions through chatStore', async () => { - await deleteSyncSession('remote-1') - - expect(deleteSession).toHaveBeenCalledWith('remote-1') - }) - - it('restores exact metadata when the batch is written but refreshing the cache fails', async () => { - const previous = meta('same-id', 'chat') - previous.name = 'Local Name' - const remote = { ...previous, name: 'Remote Name', sortOrder: 99 } - const metaStorage = { - getById: vi.fn(async () => previous), - createMany: vi.fn(async () => undefined), - deleteMany: vi.fn(async () => undefined), - } - vi.mocked(getMetaStorage).mockResolvedValue(metaStorage as never) - vi.mocked(refreshSessionListCache) - .mockRejectedValueOnce(new Error('cache refresh failed')) - .mockResolvedValueOnce(undefined) - - await expect(saveSyncMetas([remote])).rejects.toThrow(/cache refresh failed/) - - expect(metaStorage.createMany).toHaveBeenNthCalledWith(1, [remote]) - expect(metaStorage.createMany).toHaveBeenNthCalledWith(2, [previous]) - expect(metaStorage.deleteMany).toHaveBeenCalledWith([]) - expect(refreshSessionListCache).toHaveBeenCalledTimes(2) - }) }) diff --git a/src/renderer/packages/sync/local.ts b/src/renderer/packages/sync/local.ts index dfb38bc6aa..9c666d68e2 100644 --- a/src/renderer/packages/sync/local.ts +++ b/src/renderer/packages/sync/local.ts @@ -4,7 +4,6 @@ import storage from '@/storage' import { StorageKeyGenerator } from '@/storage/StoreStorage' import { createSessionWithId, - deleteSession, getMetaStorage, listAllSessionsMeta, refreshSessionListCache, @@ -41,10 +40,6 @@ export async function createSyncSession(session: Session, meta: SessionMetaRecor await createSessionWithId(session, meta) } -export async function deleteSyncSession(sessionId: string): Promise { - await deleteSession(sessionId) -} - function sessionMetadataPatch(session: SessionMeta): Omit { return { name: session.name, @@ -69,10 +64,7 @@ function valuesForPatchedMetadata( return previous as Partial> } -function conditionalSessionRestore( - session: Session, - undo: SyncMetadataUndo -): Partial> { +function conditionalSessionRestore(session: SessionMeta, undo: SyncMetadataUndo): Partial> { const current = sessionMetadataPatch(session) as Record const applied = undo.appliedSession as Record | undefined const previous = undo.previousSession as Record | undefined @@ -171,31 +163,6 @@ export async function updateSyncSessionMetadata( return undo } -export async function saveSyncMetas(metas: SessionMetaRecord[]): Promise { - if (metas.length === 0) { - return - } - const metaStorage = await getMetaStorage() - const previous = await Promise.all(metas.map((meta) => metaStorage.getById(meta.id))) - try { - await metaStorage.createMany(metas) - await refreshSessionListCache() - } catch (error) { - const previousRecords = previous.filter((meta): meta is SessionMetaRecord => meta !== null) - const newIds = metas.filter((_, index) => previous[index] === null).map((meta) => meta.id) - try { - await metaStorage.createMany(previousRecords) - await metaStorage.deleteMany(newIds) - await refreshSessionListCache() - } catch (rollbackError) { - throw new AggregateError([error, rollbackError], 'Failed to save synced metadata and restore previous records', { - cause: error, - }) - } - throw error - } -} - export function updateSyncLastSyncedAt(isoDate: string) { settingsStore.getState().setSettings((settings) => { settings.sync.lastSyncedAt = isoDate @@ -225,7 +192,6 @@ export function createDefaultWebDAVSyncDeps(): WebDAVSyncDeps { createSession: createSyncSession, updateSessionMetadata: updateSyncSessionMetadata, restoreSessionMetadata: restoreSyncSessionMetadata, - deleteSession: deleteSyncSession, updateLastSyncedAt: updateSyncLastSyncedAt, getLastSeenSnapshot: getSyncLastSeenSnapshot, setLastSeenSnapshot: setSyncLastSeenSnapshot, diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts index c7b4251811..4795efc8e4 100644 --- a/src/renderer/packages/sync/service.test.ts +++ b/src/renderer/packages/sync/service.test.ts @@ -1,4 +1,4 @@ -import type { Session, Settings } from '@shared/types' +import type { Session, SessionMetaRecord, Settings } from '@shared/types' import { describe, expect, it, vi } from 'vitest' import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto' import { @@ -183,8 +183,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => [meta('s1', 'Local')]), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), createConflictId: vi.fn(), now: () => 1000, @@ -226,8 +224,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => [meta('s1', 'Local')]), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), createConflictId: vi.fn(), now: () => 1000, @@ -268,8 +264,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), createConflictId: vi.fn(() => 'copy-id'), now: () => 1000, @@ -286,7 +280,6 @@ describe('WebDAV sync service', () => { expect(decrypted.sessions.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) expect(decrypted.metas.map((item) => item.id).sort()).toEqual(['local-1', 'remote-1']) expect(deps.createSession).not.toHaveBeenCalled() - expect(deps.saveMetas).not.toHaveBeenCalled() }) it('re-downloads and merges again when a conditional upload detects a changed remote snapshot', async () => { @@ -331,8 +324,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), createConflictId: vi.fn(() => 'copy-id'), now: () => 1000, @@ -377,8 +368,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), createConflictId: vi.fn(() => 'copy-id'), now: () => 1000, @@ -419,8 +408,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), now: () => 1000, } @@ -443,18 +430,18 @@ describe('WebDAV sync service', () => { const envelope = await encryptJsonEnvelope(remote, 'sync-secret') const deps = { platform: { - webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ - status: request.method === 'GET' ? 200 : 405, - headers: { ETag: '"remote-etag"' }, - body: JSON.stringify(envelope), - })), + webdavRequest: vi.fn( + async (request: WebDAVRequest): Promise => ({ + status: request.method === 'GET' ? 200 : 405, + headers: { ETag: '"remote-etag"' }, + body: JSON.stringify(envelope), + }) + ), }, listLocalSessions: vi.fn(async () => [session('same-id', 'Local', 'local text')]), listLocalMetas: vi.fn(async () => [meta('same-id', 'Local')]), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), getLastSeenSnapshot: vi.fn(() => ({ endpoint: 'https://dav.example.com/files/me/\nalice', @@ -496,8 +483,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => []), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), // A matching ETag recorded for a different WebDAV endpoint/account must // never suppress the merge against the current endpoint. @@ -550,8 +535,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => [meta('same-id', 'Local')]), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), getLastSeenSnapshot: vi.fn(() => ({ endpoint: 'https://dav.example.com/files/me/\nalice', @@ -609,8 +592,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), getLastSeenSnapshot: vi.fn(() => ({ endpoint: 'https://dav.example.com/files/me/\nalice', @@ -671,9 +652,7 @@ describe('WebDAV sync service', () => { listLocalSessions: vi.fn(async () => [localSession]), listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), createSession: vi.fn(), - updateSessionMetadata: vi.fn(async () => ({ name: 'Local', type: 'chat' as const })), - saveMetas: vi.fn(), - deleteSession: vi.fn(), + updateSessionMetadata: vi.fn(async () => ({})), updateLastSyncedAt: vi.fn(), getLastSeenSnapshot: vi.fn(() => lastSeen), setLastSeenSnapshot: vi.fn((seen: { endpoint: string; etag?: string }) => { @@ -738,8 +717,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => [meta('local-1', 'Local')]), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), getLastSeenSnapshot: vi.fn(() => ({ endpoint: 'https://dav.example.com/files/me/\nalice', @@ -780,8 +757,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => []), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), createConflictId: vi.fn(() => 'copy-id'), now: () => 2000, @@ -800,7 +775,6 @@ describe('WebDAV sync service', () => { }), remote.metas[0] ) - expect(deps.saveMetas).not.toHaveBeenCalled() expect(deps.updateLastSyncedAt).toHaveBeenCalledWith('1970-01-01T00:00:02.000Z') }) @@ -834,9 +808,12 @@ describe('WebDAV sync service', () => { listLocalSessions: vi.fn(async () => [local]), listLocalMetas: vi.fn(async () => [meta('same-id', 'Local Name', 1)]), createSession: vi.fn(), - updateSessionMetadata: vi.fn(async () => ({ name: 'Local Name', type: 'chat' as const })), - saveMetas: vi.fn(), - deleteSession: vi.fn(), + updateSessionMetadata: vi.fn(async () => ({ + previousSession: { name: 'Local Name', starred: undefined }, + appliedSession: { name: 'Remote Name', starred: true }, + previousOrder: { sortOrder: 1, createdAt: 1 }, + appliedOrder: { sortOrder: 99, createdAt: 99 }, + })), updateLastSyncedAt: vi.fn(), createConflictId: vi.fn(() => 'copy-id'), now: () => 2000, @@ -849,9 +826,9 @@ describe('WebDAV sync service', () => { expect(deps.createConflictId).not.toHaveBeenCalled() expect(deps.updateSessionMetadata).toHaveBeenCalledWith( 'same-id', - expect.objectContaining({ name: 'Remote Name', starred: true }) + expect.objectContaining({ name: 'Remote Name', starred: true }), + { sortOrder: 99, createdAt: 99 } ) - expect(deps.saveMetas).toHaveBeenCalledWith([expect.objectContaining(remoteMeta)]) }) it('rejects plaintext HTTP WebDAV URLs before sending credentials', async () => { @@ -863,8 +840,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => [meta('s1', 'Local')]), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), createConflictId: vi.fn(), } @@ -897,15 +872,12 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => []), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), createConflictId: vi.fn(() => 'copy-id'), } await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/invalid sync snapshot/i) expect(deps.createSession).not.toHaveBeenCalled() - expect(deps.saveMetas).not.toHaveBeenCalled() expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() }) @@ -943,8 +915,6 @@ describe('WebDAV sync service', () => { listLocalMetas: vi.fn(async () => []), createSession: vi.fn(), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), createConflictId: vi.fn(() => 'copy-id'), now: () => 2000, @@ -961,7 +931,7 @@ describe('WebDAV sync service', () => { ) }) - it('restores updated sessions and deletes only new sessions when metadata import fails', async () => { + it('restores existing metadata but keeps completed new sessions when a later create fails', async () => { const local = session('same-id', 'Local Name', 'same text') local.settings = { temperature: undefined } const remoteExisting = session('same-id', 'Remote Name', 'same text') @@ -969,18 +939,16 @@ describe('WebDAV sync service', () => { version: 1, exportedAt: '2026-06-21T00:00:00.000Z', deviceName: 'Phone', - sessions: [remoteExisting, session('remote-2', 'Remote 2', 'hello')], - metas: [meta('same-id', 'Remote Name', 99), meta('remote-2', 'Remote 2')], + sessions: [remoteExisting, session('remote-1', 'Remote 1', 'hi'), session('remote-2', 'Remote 2', 'hello')], + metas: [meta('same-id', 'Remote Name', 99), meta('remote-1', 'Remote 1'), meta('remote-2', 'Remote 2')], } const envelope = await encryptJsonEnvelope(remote, 'sync-secret') - const previousPatch = { - name: 'Local Name', - type: 'chat' as const, - starred: undefined, - hidden: undefined, - assistantAvatarKey: undefined, - picUrl: undefined, - backgroundImage: undefined, + const importError = new Error('second session write failed') + const undo = { + previousSession: { name: 'Local Name' }, + appliedSession: { name: 'Remote Name' }, + previousOrder: { sortOrder: 1, createdAt: 1 }, + appliedOrder: { sortOrder: 99, createdAt: 99 }, } const deps = { platform: { @@ -992,30 +960,29 @@ describe('WebDAV sync service', () => { }, listLocalSessions: vi.fn(async () => [local]), listLocalMetas: vi.fn(async () => [meta('same-id', 'Local Name')]), - createSession: vi.fn(), - updateSessionMetadata: vi.fn(async () => previousPatch), - saveMetas: vi.fn(() => Promise.reject(new Error('meta write failed'))), - deleteSession: vi.fn(), + createSession: vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(importError), + updateSessionMetadata: vi.fn(async () => undo), + restoreSessionMetadata: vi.fn(), updateLastSyncedAt: vi.fn(), createConflictId: vi.fn(() => 'copy-id'), now: () => 2000, } - await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/meta write failed/) + await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow( + /Completed imports were kept; retry to continue/ + ) - expect(deps.createSession).toHaveBeenCalledTimes(1) - expect(deps.deleteSession).toHaveBeenCalledWith('remote-2') - expect(deps.deleteSession).not.toHaveBeenCalledWith('same-id') - expect(deps.updateSessionMetadata).toHaveBeenNthCalledWith( - 1, + expect(deps.createSession.mock.calls.map(([created]) => created.id)).toEqual(['remote-1', 'remote-2']) + expect(deps.updateSessionMetadata).toHaveBeenCalledWith( 'same-id', - expect.objectContaining({ name: 'Remote Name' }) + expect.objectContaining({ name: 'Remote Name' }), + { sortOrder: 99, createdAt: 99 } ) - expect(deps.updateSessionMetadata).toHaveBeenNthCalledWith(2, 'same-id', previousPatch) + expect(deps.restoreSessionMetadata).toHaveBeenCalledWith('same-id', undo) expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() }) - it('removes earlier new sessions when a later create fails', async () => { + it('reuses completed imports and resumes without duplicates after a partial create failure', async () => { const remote: SyncSnapshot = { version: 1, exportedAt: '2026-06-21T00:00:00.000Z', @@ -1024,36 +991,122 @@ describe('WebDAV sync service', () => { metas: [meta('remote-1', 'Remote 1'), meta('remote-2', 'Remote 2')], } const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const localSessions: Session[] = [] + const localMetas: SessionMetaRecord[] = [] + let failSecondSession = true const deps = { platform: { - webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ - status: request.method === 'GET' ? 200 : 405, - headers: {}, - body: JSON.stringify(envelope), - })), + webdavRequest: vi.fn( + async (request: WebDAVRequest): Promise => ({ + status: request.method === 'GET' ? 200 : 405, + headers: request.method === 'GET' ? { ETag: '"remote-etag"' } : {}, + body: JSON.stringify(envelope), + }) + ), }, - listLocalSessions: vi.fn(async () => []), - listLocalMetas: vi.fn(async () => []), - createSession: vi - .fn() - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('second session write failed')), + listLocalSessions: vi.fn(() => Promise.resolve([...localSessions])), + listLocalMetas: vi.fn(() => Promise.resolve([...localMetas])), + createSession: vi.fn((created: Session, createdMeta: SessionMetaRecord) => { + if (created.id === 'remote-2' && failSecondSession) { + failSecondSession = false + return Promise.reject(new Error('second session write failed')) + } + localSessions.push(created) + localMetas.push(createdMeta) + return Promise.resolve() + }), updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(), updateLastSyncedAt: vi.fn(), + getLastSeenSnapshot: vi.fn(), + setLastSeenSnapshot: vi.fn(), now: () => 2000, } await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/second session write failed/) - expect(deps.deleteSession).toHaveBeenCalledTimes(1) - expect(deps.deleteSession).toHaveBeenCalledWith('remote-1') - expect(deps.saveMetas).not.toHaveBeenCalled() + expect(localSessions.map((item) => item.id)).toEqual(['remote-1']) expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() + expect(deps.setLastSeenSnapshot).not.toHaveBeenCalled() + + const retry = await downloadAndMergeWebDAVSnapshot(baseSettings, deps) + + expect(retry.imported).toBe(1) + expect(localSessions.map((item) => item.id)).toEqual(['remote-1', 'remote-2']) + expect(deps.createSession.mock.calls.map(([created]) => created.id)).toEqual(['remote-1', 'remote-2', 'remote-2']) + expect(deps.setLastSeenSnapshot).toHaveBeenCalledWith({ + endpoint: 'https://dav.example.com/files/me/\nalice', + etag: '"remote-etag"', + }) + expect(deps.updateLastSyncedAt).toHaveBeenCalledTimes(1) }) - it('wraps the original and rollback errors in an AggregateError when restoring also fails', async () => { + it('retries a failed create so the storage adapter can repair an orphaned session', async () => { + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [session('remote-1', 'Remote 1', 'hi')], + metas: [meta('remote-1', 'Remote 1')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const localMetas: SessionMetaRecord[] = [] + let orphanedSession: Session | undefined + let lastSeen: { endpoint: string; etag?: string } | undefined + const deps = { + platform: { + webdavRequest: vi.fn( + async (request: WebDAVRequest): Promise => ({ + status: request.method === 'GET' ? 200 : 405, + headers: request.method === 'GET' ? { ETag: '"remote-etag"' } : {}, + body: JSON.stringify(envelope), + }) + ), + }, + // Production enumeration is metadata-driven, so the session remains + // invisible until createSession repairs its missing metadata. + listLocalSessions: vi.fn(() => + Promise.resolve(localMetas.length > 0 && orphanedSession ? [orphanedSession] : []) + ), + listLocalMetas: vi.fn(() => Promise.resolve([...localMetas])), + createSession: vi.fn((created: Session, createdMeta: SessionMetaRecord) => { + if (!orphanedSession) { + orphanedSession = created + return Promise.reject(new Error('metadata write and session cleanup failed')) + } + expect(created).toEqual(orphanedSession) + localMetas.push(createdMeta) + return Promise.resolve() + }), + updateSessionMetadata: vi.fn(), + updateLastSyncedAt: vi.fn(), + getLastSeenSnapshot: vi.fn(() => lastSeen), + setLastSeenSnapshot: vi.fn((seen: { endpoint: string; etag?: string }) => { + lastSeen = seen + }), + now: () => 2000, + } + + await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow( + /metadata write and session cleanup failed/ + ) + + expect(orphanedSession?.id).toBe('remote-1') + expect(localMetas).toEqual([]) + expect(deps.setLastSeenSnapshot).not.toHaveBeenCalled() + expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() + + await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).resolves.toMatchObject({ imported: 1 }) + + expect(deps.createSession).toHaveBeenCalledTimes(2) + expect(localMetas.map((item) => item.id)).toEqual(['remote-1']) + expect(deps.setLastSeenSnapshot).toHaveBeenCalledWith({ + endpoint: 'https://dav.example.com/files/me/\nalice', + etag: '"remote-etag"', + }) + expect(deps.updateLastSyncedAt).toHaveBeenCalledTimes(1) + }) + + it('preserves edits made to a completed partial import before retrying', async () => { const remote: SyncSnapshot = { version: 1, exportedAt: '2026-06-21T00:00:00.000Z', @@ -1062,8 +1115,81 @@ describe('WebDAV sync service', () => { metas: [meta('remote-1', 'Remote 1'), meta('remote-2', 'Remote 2')], } const envelope = await encryptJsonEnvelope(remote, 'sync-secret') + const localSessions: Session[] = [] + const localMetas: SessionMetaRecord[] = [] + let failSecondSession = true + const deps = { + platform: { + webdavRequest: vi.fn( + async (request: WebDAVRequest): Promise => ({ + status: request.method === 'GET' ? 200 : 405, + headers: request.method === 'GET' ? { ETag: '"remote-etag"' } : {}, + body: JSON.stringify(envelope), + }) + ), + }, + listLocalSessions: vi.fn(() => Promise.resolve([...localSessions])), + listLocalMetas: vi.fn(() => Promise.resolve([...localMetas])), + createSession: vi.fn((created: Session, createdMeta: SessionMetaRecord) => { + if (created.id === 'remote-2' && failSecondSession) { + failSecondSession = false + localSessions[0].messages.push({ + id: 'local-edit', + role: 'user', + contentParts: [{ type: 'text', text: 'keep this edit' }], + }) + return Promise.reject(new Error('second session write failed')) + } + localSessions.push(created) + localMetas.push(createdMeta) + return Promise.resolve() + }), + updateSessionMetadata: vi.fn(), + updateLastSyncedAt: vi.fn(), + getLastSeenSnapshot: vi.fn(), + setLastSeenSnapshot: vi.fn(), + createConflictId: vi.fn((sourceSessionId: string) => `${sourceSessionId}-copy`), + now: () => 2000, + } + + await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/retry to continue/) + + expect(localSessions[0].messages.at(-1)).toMatchObject({ + id: 'local-edit', + contentParts: [{ type: 'text', text: 'keep this edit' }], + }) + + const retry = await downloadAndMergeWebDAVSnapshot(baseSettings, deps) + + expect(retry).toMatchObject({ imported: 1, conflicts: 1 }) + expect(localSessions.map((item) => item.id)).toEqual(['remote-1', 'remote-1-copy', 'remote-2']) + expect(localSessions.find((item) => item.id === 'remote-1')?.messages.at(-1)?.id).toBe('local-edit') + expect(localSessions.find((item) => item.id === 'remote-1-copy')?.messages).toEqual(remote.sessions[0].messages) + }) + + it('wraps the original and rollback errors in an AggregateError when restoring also fails', async () => { + const local = session('same-id', 'Local Name', 'same text') + local.settings = { temperature: undefined } + const remote: SyncSnapshot = { + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + deviceName: 'Phone', + sessions: [ + session('same-id', 'Remote Name', 'same text'), + session('remote-1', 'Remote 1', 'hi'), + session('remote-2', 'Remote 2', 'hello'), + ], + metas: [meta('same-id', 'Remote Name', 99), meta('remote-1', 'Remote 1'), meta('remote-2', 'Remote 2')], + } + const envelope = await encryptJsonEnvelope(remote, 'sync-secret') const importError = new Error('second session write failed') - const rollbackError = new Error('delete failed') + const rollbackError = new Error('metadata restore failed') + const undo = { + previousSession: { name: 'Local Name' }, + appliedSession: { name: 'Remote Name' }, + previousOrder: { sortOrder: 1, createdAt: 1 }, + appliedOrder: { sortOrder: 99, createdAt: 99 }, + } const deps = { platform: { webdavRequest: vi.fn(async (request: WebDAVRequest) => ({ @@ -1072,12 +1198,11 @@ describe('WebDAV sync service', () => { body: JSON.stringify(envelope), })), }, - listLocalSessions: vi.fn(async () => []), - listLocalMetas: vi.fn(async () => []), + listLocalSessions: vi.fn(async () => [local]), + listLocalMetas: vi.fn(async () => [meta('same-id', 'Local Name')]), createSession: vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(importError), - updateSessionMetadata: vi.fn(), - saveMetas: vi.fn(), - deleteSession: vi.fn(() => Promise.reject(rollbackError)), + updateSessionMetadata: vi.fn(async () => undo), + restoreSessionMetadata: vi.fn(() => Promise.reject(rollbackError)), updateLastSyncedAt: vi.fn(), now: () => 2000, } @@ -1087,7 +1212,7 @@ describe('WebDAV sync service', () => { expect(failure).toBeInstanceOf(AggregateError) expect((failure as Error).message).toMatch(/restoring local data also failed/) expect((failure as AggregateError).errors).toEqual([importError, rollbackError]) - expect(deps.deleteSession).toHaveBeenCalledWith('remote-1') + expect(deps.restoreSessionMetadata).toHaveBeenCalledWith('same-id', undo) expect(deps.updateLastSyncedAt).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/packages/sync/service.ts b/src/renderer/packages/sync/service.ts index d1301f61ec..92c3d4d4bf 100644 --- a/src/renderer/packages/sync/service.ts +++ b/src/renderer/packages/sync/service.ts @@ -60,9 +60,6 @@ export type WebDAVSyncDeps = { order: SyncMetadataOrder | undefined ) => Promise restoreSessionMetadata?: (sessionId: string, undo: SyncMetadataUndo) => Promise - deleteSession: (sessionId: string) => Promise - /** @deprecated Existing metadata is now updated field-by-field by updateSessionMetadata. */ - saveMetas?: (metas: SessionMetaRecord[]) => Promise updateLastSyncedAt: (isoDate: string) => Promise | void getLastSeenSnapshot?: () => LastSeenSnapshot | undefined | Promise setLastSeenSnapshot?: (seen: LastSeenSnapshot) => Promise | void @@ -407,52 +404,63 @@ export async function downloadAndMergeWebDAVSnapshot( }) const metaById = new Map(result.metasToSave.map((meta) => [meta.id, meta])) + const plannedCreatedSessionIds = new Set( + result.sessionChanges.filter((change) => change.kind === 'create').map((change) => change.session.id) + ) const createdSessionIds = new Set() - const undoOperations: Array< - | { kind: 'delete-created'; sessionId: string } - | { kind: 'restore-metadata'; sessionId: string; undo: SyncMetadataUndo } - > = [] + const undoOperations: Array<{ sessionId: string; undo: SyncMetadataUndo }> = [] try { const updatedSessionIds = new Set() + + // Update existing sessions first. These writes have field-scoped undo + // records, so a later failure can restore them without clobbering edits + // made concurrently by the user. for (const change of result.sessionChanges) { - if (change.kind === 'create') { - const meta = metaById.get(change.session.id) - if (!meta) { - throw new Error(`Missing metadata for synced session ${change.session.id}`) - } - await deps.createSession(change.session, meta) - createdSessionIds.add(change.session.id) - undoOperations.push({ kind: 'delete-created', sessionId: change.session.id }) - } else { - const meta = metaById.get(change.sessionId) - const order = meta ? { sortOrder: meta.sortOrder, createdAt: meta.createdAt } : undefined - const undo = await deps.updateSessionMetadata(change.sessionId, change.patch, order) - updatedSessionIds.add(change.sessionId) - undoOperations.push({ kind: 'restore-metadata', sessionId: change.sessionId, undo }) + if (change.kind !== 'update-metadata') { + continue } + const meta = metaById.get(change.sessionId) + const order = meta ? { sortOrder: meta.sortOrder, createdAt: meta.createdAt } : undefined + const undo = await deps.updateSessionMetadata(change.sessionId, change.patch, order) + updatedSessionIds.add(change.sessionId) + undoOperations.push({ sessionId: change.sessionId, undo }) } // A meta-only change contains ordering information. Session metadata such as // name/starred is always represented by an update change above. Applying // only these fields avoids replacing a concurrently edited full record. for (const meta of result.metasToSave) { - if (createdSessionIds.has(meta.id) || updatedSessionIds.has(meta.id)) { + if (plannedCreatedSessionIds.has(meta.id) || updatedSessionIds.has(meta.id)) { continue } - const undo = await deps.updateSessionMetadata( - meta.id, - undefined, - { sortOrder: meta.sortOrder, createdAt: meta.createdAt } - ) - undoOperations.push({ kind: 'restore-metadata', sessionId: meta.id, undo }) + const undo = await deps.updateSessionMetadata(meta.id, undefined, { + sortOrder: meta.sortOrder, + createdAt: meta.createdAt, + }) + undoOperations.push({ sessionId: meta.id, undo }) + } + + // Create new sessions last to minimize partial progress. Each creation is + // already atomic across the session value and its metadata. If a later + // creation fails, keep completed imports: their stable IDs make the next + // download idempotently resume instead of deleting data that may already + // have been opened or edited by the user. + for (const change of result.sessionChanges) { + if (change.kind !== 'create') { + continue + } + const meta = metaById.get(change.session.id) + if (!meta) { + throw new Error(`Missing metadata for synced session ${change.session.id}`) + } + await deps.createSession(change.session, meta) + createdSessionIds.add(change.session.id) } } catch (error) { const rollbackErrors: unknown[] = [] for (const undo of undoOperations.reverse()) { try { - if (undo.kind === 'delete-created') { - await deps.deleteSession(undo.sessionId) - } else if (deps.restoreSessionMetadata) { + if (deps.restoreSessionMetadata) { await deps.restoreSessionMetadata(undo.sessionId, undo.undo) } else { await deps.updateSessionMetadata(undo.sessionId, undo.undo.previousSession, undo.undo.previousOrder) @@ -463,9 +471,23 @@ export async function downloadAndMergeWebDAVSnapshot( } if (rollbackErrors.length > 0) { const message = error instanceof Error ? error.message : String(error) + const completedImportLabel = + createdSessionIds.size === 1 ? 'conversation import was' : 'conversation imports were' + const partialImport = + createdSessionIds.size > 0 + ? `; ${createdSessionIds.size} completed ${completedImportLabel} kept and will be reused on retry` + : '' throw new AggregateError( [error, ...rollbackErrors], - `WebDAV sync import failed: ${message}; restoring local data also failed`, + `WebDAV sync import failed: ${message}${partialImport}; restoring local data also failed`, + { cause: error } + ) + } + if (createdSessionIds.size > 0) { + const message = error instanceof Error ? error.message : String(error) + const conversationLabel = createdSessionIds.size === 1 ? 'conversation' : 'conversations' + throw new Error( + `WebDAV sync import failed after importing ${createdSessionIds.size} ${conversationLabel}: ${message}. Completed imports were kept; retry to continue.`, { cause: error } ) } diff --git a/src/renderer/stores/chatStore.sync.test.ts b/src/renderer/stores/chatStore.sync.test.ts index b3fc171817..b4855abcd7 100644 --- a/src/renderer/stores/chatStore.sync.test.ts +++ b/src/renderer/stores/chatStore.sync.test.ts @@ -115,12 +115,163 @@ describe('chatStore fixed-ID session creation', () => { }) }) - it('refuses to overwrite an existing session ID', async () => { - mocks.getStoreValue.mockResolvedValueOnce(session('remote-1')) + it('repairs metadata when a matching session survived an interrupted import', async () => { + const syncedSession = session('remote-1') + const syncedMeta = meta('remote-1') + mocks.getStoreValue.mockResolvedValue(syncedSession) + + await expect(createSessionWithId(syncedSession, syncedMeta)).resolves.toMatchObject(syncedSession) + + expect(mocks.storage.setItemNow).not.toHaveBeenCalled() + expect(mocks.metaStorage.create).toHaveBeenCalledWith(syncedMeta) + expect(mocks.queryClient.setQueryData).toHaveBeenCalled() + + const listUpdate = mocks.queryClient.setQueryData.mock.calls.find(([, value]) => typeof value === 'function')?.[1] + expect(listUpdate).toBeTypeOf('function') + const updatedCache = listUpdate({ + pages: [{ items: [syncedMeta], nextCursor: null, total: 1 }], + pageParams: [0], + }) + expect(updatedCache.pages[0].items.map((item: SessionMetaRecord) => item.id)).toEqual(['remote-1']) + expect(updatedCache.pages[0].total).toBe(1) + }) + + it('repairs the session when matching metadata exists without its value', async () => { + const syncedSession = session('remote-1') + const syncedMeta = meta('remote-1') + mocks.metaStorage.getById.mockResolvedValue(syncedMeta) + + await expect(createSessionWithId(syncedSession, syncedMeta)).resolves.toMatchObject(syncedSession) - await expect(createSessionWithId(session('remote-1'), meta('remote-1'))).rejects.toThrow(/already exists/) + expect(mocks.storage.setItemNow).toHaveBeenCalledWith('session:remote-1', syncedSession) + expect(mocks.metaStorage.create).not.toHaveBeenCalled() + expect(mocks.queryClient.setQueryData).toHaveBeenCalledTimes(1) + }) + + it('treats an already complete matching import as an idempotent success', async () => { + const syncedSession = session('remote-1') + const syncedMeta = { ...meta('remote-1'), starred: false } + mocks.getStoreValue.mockResolvedValue(syncedSession) + mocks.metaStorage.getById.mockResolvedValue({ ...syncedMeta, starred: undefined }) + + await expect(createSessionWithId(syncedSession, syncedMeta)).resolves.toMatchObject(syncedSession) + + expect(mocks.storage.setItemNow).not.toHaveBeenCalled() + expect(mocks.metaStorage.create).not.toHaveBeenCalled() + expect(mocks.queryClient.setQueryData).toHaveBeenCalledTimes(1) + }) + + it('refuses to overwrite different session data under an existing ID', async () => { + mocks.getStoreValue.mockResolvedValueOnce({ ...session('remote-1'), name: 'Local session' }) + + await expect(createSessionWithId(session('remote-1'), meta('remote-1'))).rejects.toThrow( + /already exists with different session data/ + ) expect(mocks.storage.setItemNow).not.toHaveBeenCalled() expect(mocks.metaStorage.create).not.toHaveBeenCalled() }) + + it('refuses to overwrite different metadata under an existing ID', async () => { + mocks.metaStorage.getById.mockResolvedValueOnce({ ...meta('remote-1'), name: 'Local metadata' }) + + await expect(createSessionWithId(session('remote-1'), meta('remote-1'))).rejects.toThrow( + /already exists with different metadata/ + ) + + expect(mocks.storage.setItemNow).not.toHaveBeenCalled() + expect(mocks.metaStorage.create).not.toHaveBeenCalled() + }) + + it('repairs an orphaned session on retry when the original cleanup also failed', async () => { + let persistedSession: Session | undefined + let persistedMeta: SessionMetaRecord | undefined + mocks.getStoreValue.mockImplementation(() => Promise.resolve(persistedSession)) + mocks.storage.setItemNow.mockImplementation((_key, value: Session) => { + persistedSession = value + return Promise.resolve() + }) + mocks.storage.removeItem.mockRejectedValueOnce(new Error('session cleanup failed')) + mocks.metaStorage.getById.mockImplementation(() => Promise.resolve(persistedMeta ?? null)) + mocks.metaStorage.create + .mockRejectedValueOnce(new Error('meta create failed')) + .mockImplementation((record: SessionMetaRecord) => { + persistedMeta = record + return Promise.resolve() + }) + + await expect(createSessionWithId(session('remote-1'), meta('remote-1'))).rejects.toThrow( + /remove its partially written data/ + ) + expect(persistedSession).toEqual(session('remote-1')) + expect(persistedMeta).toBeUndefined() + + await expect(createSessionWithId(session('remote-1'), meta('remote-1'))).resolves.toMatchObject({ + id: 'remote-1', + }) + + expect(mocks.storage.setItemNow).toHaveBeenCalledTimes(1) + expect(mocks.metaStorage.create).toHaveBeenCalledTimes(2) + expect(persistedMeta).toEqual(meta('remote-1')) + }) + + it('serializes concurrent matching imports and resolves both without duplicate writes', async () => { + let persistedSession: Session | undefined + let persistedMeta: SessionMetaRecord | undefined + let releaseMetadataWrite: (() => void) | undefined + const metadataWriteGate = new Promise((resolve) => { + releaseMetadataWrite = resolve + }) + mocks.getStoreValue.mockImplementation(() => Promise.resolve(persistedSession)) + mocks.storage.setItemNow.mockImplementation((_key, value: Session) => { + persistedSession = value + return Promise.resolve() + }) + mocks.metaStorage.getById.mockImplementation(() => Promise.resolve(persistedMeta ?? null)) + mocks.metaStorage.create.mockImplementation(async (record: SessionMetaRecord) => { + await metadataWriteGate + persistedMeta = record + }) + + const first = createSessionWithId(session('remote-1'), meta('remote-1')) + const second = createSessionWithId(session('remote-1'), meta('remote-1')) + await vi.waitFor(() => expect(mocks.metaStorage.create).toHaveBeenCalledTimes(1)) + releaseMetadataWrite?.() + + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + expect(mocks.storage.setItemNow).toHaveBeenCalledTimes(1) + expect(mocks.metaStorage.create).toHaveBeenCalledTimes(1) + }) + + it('rejects different content queued behind a concurrent fixed-ID import', async () => { + let persistedSession: Session | undefined + let persistedMeta: SessionMetaRecord | undefined + let releaseMetadataWrite: (() => void) | undefined + const metadataWriteGate = new Promise((resolve) => { + releaseMetadataWrite = resolve + }) + mocks.getStoreValue.mockImplementation(() => Promise.resolve(persistedSession)) + mocks.storage.setItemNow.mockImplementation((_key, value: Session) => { + persistedSession = value + return Promise.resolve() + }) + mocks.metaStorage.getById.mockImplementation(() => Promise.resolve(persistedMeta ?? null)) + mocks.metaStorage.create.mockImplementation(async (record: SessionMetaRecord) => { + await metadataWriteGate + persistedMeta = record + }) + + const first = createSessionWithId(session('remote-1'), meta('remote-1')) + const conflicting = createSessionWithId( + { ...session('remote-1'), name: 'Different remote session' }, + meta('remote-1') + ) + await vi.waitFor(() => expect(mocks.metaStorage.create).toHaveBeenCalledTimes(1)) + releaseMetadataWrite?.() + + await expect(first).resolves.toMatchObject({ id: 'remote-1' }) + await expect(conflicting).rejects.toThrow(/already exists with different session data/) + expect(mocks.storage.setItemNow).toHaveBeenCalledTimes(1) + expect(mocks.metaStorage.create).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/stores/chatStore.ts b/src/renderer/stores/chatStore.ts index 6478908cf3..ae36825ff3 100644 --- a/src/renderer/stores/chatStore.ts +++ b/src/renderer/stores/chatStore.ts @@ -17,6 +17,7 @@ import { import { type InfiniteData, useInfiniteQuery, useQuery } from '@tanstack/react-query' import compact from 'lodash/compact' import isEmpty from 'lodash/isEmpty' +import isEqual from 'lodash/isEqual' import { useMemo } from 'react' import { v4 as uuidv4 } from 'uuid' import platform from '@/platform' @@ -236,23 +237,117 @@ export async function createSession(newSession: Omit, previousId? const pendingSessionCreates = new Map>() +function normalizePersistedSession(session: Session): Session { + // Every platform persists sessions through JSON. Normalize both sides the + // same way before comparing so optional `undefined` properties and migrated + // legacy messages do not turn a crash-recovery retry into a false collision. + return migrateSession(JSON.parse(JSON.stringify(session)) as Session) +} + +function importedSessionsEqual(left: Session, right: Session): boolean { + try { + return isEqual(normalizePersistedSession(left), normalizePersistedSession(right)) + } catch { + return false + } +} + +function normalizeImportedMeta(record: SessionMetaRecord) { + // SQLite represents false booleans and empty optional strings as absent + // values. Treat those representations as equivalent to the incoming record. + return { + id: record.id, + name: record.name, + type: record.type || undefined, + starred: Boolean(record.starred), + hidden: Boolean(record.hidden), + assistantAvatarKey: record.assistantAvatarKey || undefined, + picUrl: record.picUrl || undefined, + backgroundImage: record.backgroundImage, + sortOrder: record.sortOrder, + createdAt: record.createdAt, + } +} + +function importedMetasEqual(left: SessionMetaRecord, right: SessionMetaRecord): boolean { + return isEqual(normalizeImportedMeta(left), normalizeImportedMeta(right)) +} + +function importedSessionCollision(sessionId: string, part: 'session data' | 'metadata'): Error { + return new Error(`Session ${sessionId} already exists with different ${part}`) +} + +function publishImportedSession(session: Session, record: SessionMetaRecord): void { + _setSessionCache(session.id, session) + updateSessionListData((items) => sortSessionRecords([...items.filter((item) => item.id !== record.id), record])) +} + +async function createImportedMetadata( + metaStorage: SessionMetaStorage, + record: SessionMetaRecord +): Promise { + try { + await metaStorage.create(record) + return record + } catch (error) { + // Some storage implementations can report an error after committing. If + // the exact row is now present, accepting it is safer than deleting the + // matching session and leaving the metadata orphaned. + try { + const committed = await metaStorage.getById(record.id) + if (committed && importedMetasEqual(committed, record)) { + return committed + } + } catch { + // Preserve the original create error when the verification read fails. + } + throw error + } +} + async function _createSessionWithId(session: Session, record: SessionMetaRecord): Promise { if (session.id !== record.id) { throw new Error('Session and metadata IDs must match') } const metaStorage = await getMetaStorage() - const [existingSession, existingMeta] = await Promise.all([ + const [existingSessionValue, existingMeta] = await Promise.all([ platform.getStoreValue(StorageKeyGenerator.session(session.id)), metaStorage.getById(session.id), ]) - if (existingSession || existingMeta) { - throw new Error(`Session ${session.id} already exists`) + + const existingSession = existingSessionValue ? normalizePersistedSession(existingSessionValue as Session) : undefined + if (existingSession && !importedSessionsEqual(existingSession, session)) { + throw importedSessionCollision(session.id, 'session data') + } + if (existingMeta && !importedMetasEqual(existingMeta, record)) { + throw importedSessionCollision(session.id, 'metadata') + } + + if (existingSession && existingMeta) { + // Metadata already contributes to the persisted list total. Re-appending + // it to a partially loaded paginated cache would incorrectly increment the + // cached total when the row is not in the currently loaded pages. + _setSessionCache(existingSession.id, existingSession) + return existingSession + } + + if (existingSession) { + const repairedMeta = await createImportedMetadata(metaStorage, record) + publishImportedSession(existingSession, repairedMeta) + return existingSession + } + + if (existingMeta) { + await storage.setItemNow(StorageKeyGenerator.session(session.id), session) + _setSessionCache(session.id, session) + return session } await storage.setItemNow(StorageKeyGenerator.session(session.id), session) + let createdMeta: SessionMetaRecord try { - await metaStorage.create(record) + createdMeta = await createImportedMetadata(metaStorage, record) } catch (error) { try { await storage.removeItem(StorageKeyGenerator.session(session.id)) @@ -265,26 +360,31 @@ async function _createSessionWithId(session: Session, record: SessionMetaRecord) } throw error } - - _setSessionCache(session.id, session) - updateSessionListData((items) => sortSessionRecords([...items, record])) + publishImportedSession(session, createdMeta) return session } /** Create an imported session with its existing ID and exact metadata. */ export async function createSessionWithId(session: Session, record: SessionMetaRecord): Promise { - const pending = pendingSessionCreates.get(session.id) - if (pending) { - await pending - throw new Error(`Session ${session.id} already exists`) - } - - const creation = _createSessionWithId(session, record) + const previous = pendingSessionCreates.get(session.id) + const creation = (async () => { + if (previous) { + try { + await previous + } catch { + // Reconcile persisted state after the failed attempt. It may have left + // either half of the fixed-ID session behind. + } + } + return _createSessionWithId(session, record) + })() pendingSessionCreates.set(session.id, creation) try { return await creation } finally { - pendingSessionCreates.delete(session.id) + if (pendingSessionCreates.get(session.id) === creation) { + pendingSessionCreates.delete(session.id) + } } }