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/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
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 df93e33941..27485c1fee 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -14,13 +14,26 @@ 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 +776,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/settings-export.test.ts b/src/renderer/packages/settings-export.test.ts
new file mode 100644
index 0000000000..1dcc45059f
--- /dev/null
+++ b/src/renderer/packages/settings-export.test.ts
@@ -0,0 +1,265 @@
+import * as defaults from '@shared/defaults'
+import { ModelProviderType, 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' },
+ memorizedManualLicenseKey: 'memorized-license-secret',
+ lastSelectedLicenseByUser: { 'alice@example.com': 'selected-license-secret' },
+ providers: {
+ openai: {
+ apiKey: 'sk-secret',
+ accessKey: 'access-secret',
+ secretKey: 'secret-key',
+ sessionToken: 'session-token',
+ apiHost: 'https://api.example.com',
+ oauth: {
+ accessToken: 'oauth-access-secret',
+ refreshToken: 'oauth-refresh-secret',
+ expiresAt: 1893456000000,
+ },
+ 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: {
+ 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',
+ lastSeenEndpoint: 'https://dav.example.com/files/me/\nalice',
+ lastSeenETag: '"seen-etag"',
+ },
+ extension: {
+ ...defaults.settings().extension,
+ webSearch: {
+ ...defaults.settings().extension.webSearch,
+ provider: 'tavily',
+ tavilyApiKey: 'tavily-secret',
+ 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', '--api-key', 'stdio-arg-secret'],
+ env: { GITHUB_TOKEN: 'ghp-secret' },
+ },
+ },
+ {
+ id: 'remote-http',
+ name: 'Remote HTTP',
+ enabled: true,
+ transport: {
+ type: 'http',
+ url: 'https://mcp.example.com/?access_token=http-url-secret',
+ headers: { Authorization: 'Bearer secret' },
+ },
+ },
+ ],
+ enabledBuiltinServers: [],
+ },
+ }
+}
+
+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',
+ activeAuthMode: 'oauth',
+ })
+ 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('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 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)
+
+ 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('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 custom MCP servers when key export is not selected', () => {
+ const settings = settingsWithSecrets()
+ 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/)
+ // Transport credentials survive when key export is selected.
+ const withSecrets = sanitizeSettingsForExport(settingsWithSecrets(), true)
+ 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' } })
+ })
+
+ 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.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')
+ expect(sanitized.sync.webdav.password).toBe('dav-secret')
+ expect(sanitized.sync.webdav.syncPassword).toBe('sync-secret')
+ 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
new file mode 100644
index 0000000000..a609a9c1d2
--- /dev/null
+++ b/src/renderer/packages/settings-export.ts
@@ -0,0 +1,108 @@
+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
+ // 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
+}
+
+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,
+ 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,
+ 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,
+ // 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,
+ }
+
+ 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']
+ }
+ 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 = ''
+ }
+ if (cleanedSettings.extension?.documentParser?.mineru) {
+ cleanedSettings.extension.documentParser.mineru.apiToken = ''
+ }
+ }
+
+ 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/crypto.test.ts b/src/renderer/packages/sync/crypto.test.ts
new file mode 100644
index 0000000000..6b48c81d10
--- /dev/null
+++ b/src/renderer/packages/sync/crypto.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } 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)
+ })
+
+ 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
new file mode 100644
index 0000000000..ecd07048e6
--- /dev/null
+++ b/src/renderer/packages/sync/crypto.ts
@@ -0,0 +1,149 @@
+import type { SyncCryptoEnvelope } from './types'
+
+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
+ 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 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
+}
+
+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 {
+ const { salt, iv, ciphertext } = validateEnvelope(envelope)
+
+ try {
+ const cryptoImpl = getCrypto()
+ const key = await deriveAesKey(password, salt, PBKDF2_ITERATIONS)
+ const decrypted = await cryptoImpl.subtle.decrypt(
+ { name: 'AES-GCM', iv: toArrayBuffer(iv) },
+ key,
+ toArrayBuffer(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..94432304ca
--- /dev/null
+++ b/src/renderer/packages/sync/local.test.ts
@@ -0,0 +1,208 @@
+import type { Session, SessionMetaRecord } from '@shared/types'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ createSyncSession,
+ listLocalSyncMetas,
+ listLocalSyncSessions,
+ restoreSyncSessionMetadata,
+ updateSyncSessionMetadata,
+} from './local'
+
+vi.mock('@/stores/chatStore', () => ({
+ createSessionWithId: vi.fn(),
+ getMetaStorage: vi.fn(),
+ listAllSessionsMeta: vi.fn(),
+ refreshSessionListCache: vi.fn(),
+ updateSession: vi.fn(),
+}))
+
+vi.mock('@/storage', () => ({
+ default: {
+ getItem: vi.fn(),
+ },
+}))
+
+const { createSessionWithId, getMetaStorage, listAllSessionsMeta, refreshSessionListCache, updateSession } =
+ 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()
+ vi.mocked(createSessionWithId).mockReset()
+ vi.mocked(updateSession).mockReset()
+ vi.mocked(getMetaStorage).mockReset()
+ vi.mocked(refreshSessionListCache).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' }])
+ })
+
+ 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 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 = [
+ {
+ 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 undo = await updateSyncSessionMetadata('same-id', {
+ name: 'Remote Name',
+ type: 'chat',
+ starred: true,
+ })
+
+ 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'
+ let persisted = current
+ let callCount = 0
+ vi.mocked(updateSession).mockImplementation((_id, updater) => {
+ const next = typeof updater === 'function' ? updater(persisted) : { ...persisted, ...updater }
+ persisted = { ...persisted, ...next }
+ callCount += 1
+ if (callCount === 1) {
+ return Promise.reject(new Error('meta update failed'))
+ }
+ return Promise.resolve(persisted)
+ })
+
+ await expect(updateSyncSessionMetadata('same-id', { name: 'Remote Name', type: 'chat' })).rejects.toThrow(
+ /meta update failed/
+ )
+
+ 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((_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 })
+
+ 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((_id, updater) => {
+ const next = typeof updater === 'function' ? updater(current) : { ...current, ...updater }
+ current = { ...current, ...next }
+ return Promise.resolve(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 })
+ })
+})
diff --git a/src/renderer/packages/sync/local.ts b/src/renderer/packages/sync/local.ts
new file mode 100644
index 0000000000..9c666d68e2
--- /dev/null
+++ b/src/renderer/packages/sync/local.ts
@@ -0,0 +1,199 @@
+import type { Session, SessionMeta, SessionMetaRecord } from '@shared/types'
+import platform from '@/platform'
+import storage from '@/storage'
+import { StorageKeyGenerator } from '@/storage/StoreStorage'
+import {
+ createSessionWithId,
+ getMetaStorage,
+ listAllSessionsMeta,
+ refreshSessionListCache,
+ updateSession,
+} from '@/stores/chatStore'
+import { settingsStore } from '@/stores/settingsStore'
+import { migrateSession } from '@/utils/session-utils'
+import type { SyncMetadataOrder, SyncMetadataUndo, 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 createSyncSession(session: Session, meta: SessionMetaRecord): Promise {
+ await createSessionWithId(session, meta)
+}
+
+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,
+ }
+}
+
+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: SessionMeta, 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`)
+ }
+ 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 (undo.appliedSession || undo.appliedOrder) {
+ try {
+ await restoreSyncSessionMetadata(sessionId, undo)
+ } catch (rollbackError) {
+ throw new AggregateError(
+ [error, rollbackError],
+ `Failed to update synced session ${sessionId} and restore its metadata`,
+ { cause: error }
+ )
+ }
+ }
+ throw error
+ }
+
+ return undo
+}
+
+export function updateSyncLastSyncedAt(isoDate: string) {
+ settingsStore.getState().setSettings((settings) => {
+ settings.sync.lastSyncedAt = isoDate
+ })
+}
+
+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 {
+ return {
+ platform,
+ listLocalSessions: listLocalSyncSessions,
+ listLocalMetas: listLocalSyncMetas,
+ createSession: createSyncSession,
+ updateSessionMetadata: updateSyncSessionMetadata,
+ restoreSessionMetadata: restoreSyncSessionMetadata,
+ updateLastSyncedAt: updateSyncLastSyncedAt,
+ getLastSeenSnapshot: getSyncLastSeenSnapshot,
+ setLastSeenSnapshot: setSyncLastSeenSnapshot,
+ }
+}
diff --git a/src/renderer/packages/sync/service.test.ts b/src/renderer/packages/sync/service.test.ts
new file mode 100644
index 0000000000..4795efc8e4
--- /dev/null
+++ b/src/renderer/packages/sync/service.test.ts
@@ -0,0 +1,1218 @@
+import type { Session, SessionMetaRecord, Settings } from '@shared/types'
+import { describe, expect, it, vi } from 'vitest'
+import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto'
+import {
+ downloadAndMergeWebDAVSnapshot,
+ previewWebDAVUpload,
+ testWebDAVConnection,
+ uploadWebDAVSnapshot,
+} from './service'
+import type { SyncSnapshot, WebDAVRequest, WebDAVResponse } from './types'
+
+const baseSettings = {
+ sync: {
+ 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): Session {
+ 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('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 = {
+ platform: {
+ getDeviceName: vi.fn(async () => 'Mac'),
+ webdavRequest: vi.fn((request: WebDAVRequest) => {
+ requests.push(request)
+ return Promise.resolve({
+ status: request.method === 'PUT' ? 201 : request.method === 'GET' ? 404 : 405,
+ headers: {},
+ body: '',
+ })
+ }),
+ },
+ listLocalSessions: vi.fn(async () => [session('s1', 'Local', 'hello')]),
+ listLocalMetas: vi.fn(async () => [meta('s1', 'Local')]),
+ createSession: vi.fn(),
+ updateSessionMetadata: vi.fn(),
+ updateLastSyncedAt: vi.fn(),
+ createConflictId: 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?.headers?.['If-None-Match']).toBe('*')
+ expect(put?.headers?.['If-Match']).toBeUndefined()
+ 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('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(),
+ 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 = {
+ 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: '"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(),
+ updateLastSyncedAt: vi.fn(),
+ 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(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.createSession).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')]),
+ createSession: vi.fn(),
+ updateSessionMetadata: vi.fn(),
+ updateLastSyncedAt: vi.fn(),
+ createConflictId: 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')]),
+ createSession: vi.fn(),
+ updateSessionMetadata: vi.fn(),
+ updateLastSyncedAt: vi.fn(),
+ createConflictId: 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('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(),
+ 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,
+ 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): 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(),
+ updateLastSyncedAt: vi.fn(),
+ 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.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(),
+ 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 seen 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(),
+ updateLastSyncedAt: vi.fn(),
+ getLastSeenSnapshot: vi.fn(() => ({
+ endpoint: 'https://dav.example.com/files/me/\nalice',
+ etag: '"remote-etag"',
+ })),
+ setLastSeenSnapshot: vi.fn(),
+ 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 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')
+ // 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 has not been seen before during upload', async () => {
+ const requests: WebDAVRequest[] = []
+ 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')],
+ }
+ 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(),
+ 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: () => 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'])
+ // 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 () => ({})),
+ 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 () => {
+ // 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(),
+ 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,
+ 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 () => []),
+ createSession: vi.fn(),
+ updateSessionMetadata: vi.fn(),
+ updateLastSyncedAt: vi.fn(),
+ createConflictId: vi.fn(() => 'copy-id'),
+ now: () => 2000,
+ }
+
+ const result = await downloadAndMergeWebDAVSnapshot(baseSettings, deps)
+
+ expect(result.imported).toBe(1)
+ expect(result.conflicts).toBe(0)
+ expect(deps.createSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'remote-1',
+ messages: remote.sessions[0].messages,
+ name: 'Remote',
+ type: 'chat',
+ }),
+ remote.metas[0]
+ )
+ 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)]),
+ createSession: 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,
+ }
+
+ const result = await downloadAndMergeWebDAVSnapshot(baseSettings, deps)
+
+ expect(result.imported).toBe(0)
+ expect(result.conflicts).toBe(0)
+ expect(deps.createConflictId).not.toHaveBeenCalled()
+ expect(deps.updateSessionMetadata).toHaveBeenCalledWith(
+ 'same-id',
+ expect.objectContaining({ name: 'Remote Name', starred: true }),
+ { sortOrder: 99, createdAt: 99 }
+ )
+ })
+
+ 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')]),
+ createSession: vi.fn(),
+ updateSessionMetadata: vi.fn(),
+ updateLastSyncedAt: vi.fn(),
+ createConflictId: 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 () => []),
+ createSession: vi.fn(),
+ updateSessionMetadata: 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.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 () => []),
+ createSession: vi.fn(),
+ updateSessionMetadata: vi.fn(),
+ updateLastSyncedAt: vi.fn(),
+ createConflictId: vi.fn(() => 'copy-id'),
+ now: () => 2000,
+ }
+
+ await downloadAndMergeWebDAVSnapshot(baseSettings, deps)
+
+ expect(deps.createSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'legacy-remote',
+ messages: [expect.objectContaining({ contentParts: [{ type: 'text', text: 'legacy remote text' }] })],
+ }),
+ remote.metas[0]
+ )
+ })
+
+ 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')
+ const remote: SyncSnapshot = {
+ version: 1,
+ exportedAt: '2026-06-21T00:00:00.000Z',
+ deviceName: 'Phone',
+ 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 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: {
+ 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().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(
+ /Completed imports were kept; retry to continue/
+ )
+
+ 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' }),
+ { sortOrder: 99, createdAt: 99 }
+ )
+ expect(deps.restoreSessionMetadata).toHaveBeenCalledWith('same-id', undo)
+ expect(deps.updateLastSyncedAt).not.toHaveBeenCalled()
+ })
+
+ 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',
+ 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 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
+ 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(),
+ now: () => 2000,
+ }
+
+ await expect(downloadAndMergeWebDAVSnapshot(baseSettings, deps)).rejects.toThrow(/second session write failed/)
+
+ 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('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',
+ 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 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('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) => ({
+ 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().mockResolvedValueOnce(undefined).mockRejectedValueOnce(importError),
+ updateSessionMetadata: vi.fn(async () => undo),
+ restoreSessionMetadata: 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.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
new file mode 100644
index 0000000000..92c3d4d4bf
--- /dev/null
+++ b/src/renderer/packages/sync/service.ts
@@ -0,0 +1,507 @@
+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'
+import { decryptJsonEnvelope, encryptJsonEnvelope } from './crypto'
+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'
+
+const RawSyncSnapshotSchema = z.object({
+ version: z.literal(1),
+ exportedAt: z.string(),
+ deviceName: z.string(),
+ sessions: z.array(z.unknown()),
+ metas: z.array(SessionMetaRecordSchema),
+})
+
+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
+ webdavRequest?: (request: WebDAVRequest, baseUrl: string) => Promise
+}
+
+type DownloadedWebDAVSnapshot = {
+ snapshot?: SyncSnapshot
+ 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 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: Partial> | undefined,
+ order: SyncMetadataOrder | undefined
+ ) => Promise
+ restoreSessionMetadata?: (sessionId: string, undo: SyncMetadataUndo) => Promise
+ updateLastSyncedAt: (isoDate: string) => Promise | void
+ getLastSeenSnapshot?: () => LastSeenSnapshot | undefined | Promise
+ setLastSeenSnapshot?: (seen: LastSeenSnapshot) => Promise | void
+ createConflictId?: (sourceSessionId: string, contentFingerprint: string) => string
+ now?: () => number
+}
+
+export type UploadWebDAVSnapshotResult = {
+ uploaded: number
+ lastSyncedAt: string
+}
+
+export type DownloadWebDAVSnapshotResult = {
+ imported: number
+ conflicts: number
+ saved: number
+ lastSyncedAt?: string
+ remoteMissing?: boolean
+ 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}`
+}
+
+/**
+ * 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 isRemoteSnapshotAlreadySeen(
+ remote: DownloadedWebDAVSnapshot,
+ settings: Settings,
+ deps: Pick
+): Promise {
+ if (!remote.etag || !deps.getLastSeenSnapshot) {
+ return false
+ }
+ const lastSeen = await deps.getLastSeenSnapshot()
+ if (!lastSeen) {
+ return false
+ }
+ 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) {
+ 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}` : ''}`)
+ }
+}
+
+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 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
+}
+
+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])
+ }
+}
+
+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 === HTTP_NOT_FOUND) {
+ return {}
+ }
+ assertSuccess(response, 'Download WebDAV sync snapshot', [200])
+
+ const envelope = JSON.parse(response.body) as SyncCryptoEnvelope
+ return {
+ snapshot: parseSyncSnapshot(await decryptJsonEnvelope(envelope, webdav.syncPassword)),
+ etag: responseHeader(response.headers, 'etag'),
+ }
+}
+
+export async function testWebDAVConnection(
+ settings: Settings,
+ deps: Pick
+): Promise {
+ await ensureWebDAVCollections(settings, deps.platform)
+ 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(
+ settings: Settings,
+ deps: WebDAVSyncDeps
+): Promise {
+ const webdav = getWebDAVSettings(settings)
+ const now = deps.now ?? Date.now
+
+ 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,
+ metas,
+ deviceName,
+ exportedAt: lastSyncedAt,
+ })
+
+ 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 = 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: [
+ ...localSnapshot.sessions,
+ ...mergeResult.sessionChanges.filter((change) => change.kind === 'create').map((change) => change.session),
+ ],
+ metas: [...localSnapshot.metas, ...mergeResult.metasToSave],
+ deviceName,
+ exportedAt: lastSyncedAt,
+ })
+ : localSnapshot
+ 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, {
+ 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])
+ // 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,
+ lastSyncedAt,
+ }
+ }
+
+ throw new Error('Upload WebDAV sync snapshot failed because the remote snapshot changed during upload')
+}
+
+export async function downloadAndMergeWebDAVSnapshot(
+ settings: Settings,
+ deps: WebDAVSyncDeps
+): Promise {
+ const remote = await downloadWebDAVSnapshot(settings, deps.platform)
+ if (!remote.snapshot) {
+ return {
+ imported: 0,
+ conflicts: 0,
+ saved: 0,
+ remoteMissing: true,
+ }
+ }
+
+ if (await isRemoteSnapshotAlreadySeen(remote, settings, deps)) {
+ return {
+ imported: 0,
+ conflicts: 0,
+ saved: 0,
+ remoteUnchanged: true,
+ }
+ }
+
+ const [localSessions, localMetas] = await Promise.all([deps.listLocalSessions(), deps.listLocalMetas()])
+ const result = mergeRemoteSnapshot({
+ localSessions,
+ localMetas,
+ remote: remote.snapshot,
+ now: (deps.now ?? Date.now)(),
+ createConflictId: deps.createConflictId,
+ preferRemoteMetadata: true,
+ })
+
+ 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<{ 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 !== '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 (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({ 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 (deps.restoreSessionMetadata) {
+ await deps.restoreSessionMetadata(undo.sessionId, undo.undo)
+ } else {
+ await deps.updateSessionMetadata(undo.sessionId, undo.undo.previousSession, undo.undo.previousOrder)
+ }
+ } catch (rollbackError) {
+ rollbackErrors.push(rollbackError)
+ }
+ }
+ 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}${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 }
+ )
+ }
+ throw error
+ }
+
+ const lastSyncedAt = new Date((deps.now ?? Date.now)()).toISOString()
+ await rememberRemoteSnapshot(remote.etag, settings, deps)
+ await deps.updateLastSyncedAt(lastSyncedAt)
+
+ return {
+ imported: result.imported,
+ conflicts: result.conflicts,
+ saved: result.sessionChanges.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..44b66bb709
--- /dev/null
+++ b/src/renderer/packages/sync/snapshot.test.ts
@@ -0,0 +1,945 @@
+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'
+
+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('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].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 = [
+ {
+ id: 'file:/tmp/doc.txt-123-456',
+ name: 'doc.txt',
+ fileType: 'text/plain',
+ storageKey: 'file:/tmp/doc.txt-123-456',
+ localPath: '/tmp/doc.txt',
+ ragMode: 'session-retrieval',
+ sessionAttachmentId: 12,
+ sessionAttachmentAvailability: 'allowed',
+ tokenCountMap: { default: 10 },
+ 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 = [
+ {
+ 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',
+ generating: true,
+ status: [{ type: 'loading_webpage' }],
+ isStreamingMode: true,
+ 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',
+ generating: true,
+ status: [{ type: 'sending_file' }],
+ isStreamingMode: true,
+ 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: '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(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([])
+ 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,
+ 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,
+ })
+
+ 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)
+ })
+
+ 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'
+ 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 = [
+ {
+ 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,
+ 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,
+ })
+
+ 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]).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' },
+ ])
+ 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,
+ 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,
+ })
+
+ 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],
+ ])
+ })
+
+ 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,
+ createConflictId: () => 'copy-id',
+ })
+
+ 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)',
+ sortOrder: 2000,
+ createdAt: 2000,
+ })
+ expect(result.imported).toBe(0)
+ 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('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('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) =>
+ 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('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) =>
+ 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('preserves an edited synced copy instead of aborting the merge', () => {
+ const local = session('same-id', 'Local', 'local text')
+ // 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',
+ deviceName: 'Phone',
+ sessions: [session('same-id', 'Remote', 'remote text')],
+ metas: [meta('same-id', 'Remote')],
+ }
+
+ 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', () => {
+ 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,
+ createConflictId: () => 'copy-id',
+ preferRemoteMetadata: true,
+ })
+
+ 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)
+ })
+
+ 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 = {
+ ...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,
+ createConflictId: () => 'copy-id',
+ })
+
+ 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
new file mode 100644
index 0000000000..2e89a9b2d3
--- /dev/null
+++ b/src/renderer/packages/sync/snapshot.ts
@@ -0,0 +1,512 @@
+import {
+ isChatSession,
+ type Message,
+ type MessageFile,
+ type MessageLink,
+ type Session,
+ type SessionMeta,
+ type SessionMetaRecord,
+} from '@shared/types'
+import { validate as uuidValidate, version as uuidVersion, v5 as uuidv5 } from 'uuid'
+import type { MergeRemoteSnapshotInput, MergeRemoteSnapshotResult, SyncSnapshot } from './types'
+
+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
+ 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]) => compareStableKeys(left, right))
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`)
+ .join(',')}}`
+ }
+ return JSON.stringify(value)
+}
+
+function sessionContentFingerprint(session: Session): string {
+ const {
+ id: _id,
+ name: _name,
+ starred: _starred,
+ hidden: _hidden,
+ assistantAvatarKey: _assistantAvatarKey,
+ picUrl: _picUrl,
+ backgroundImage: _backgroundImage,
+ syncConflictSourceId: _syncConflictSourceId,
+ ...content
+ } = session
+ return stableStringify(content)
+}
+
+function sessionContentEqual(left: Session, right: Session): boolean {
+ return sessionContentFingerprint(left) === sessionContentFingerprint(right)
+}
+
+function metasEqual(left: SessionMetaRecord, right: SessionMetaRecord): boolean {
+ 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 {
+ 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,
+ 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 {
+ id: shouldNormalizeFileAttachmentId(file) ? `synced-file:${index}:${file.name}` : id,
+ ...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,
+ cancel: _cancel,
+ generating: _generating,
+ status: _status,
+ isStreamingMode: _isStreamingMode,
+ tokenCountMap: _tokenCountMap,
+ tokenCalculatedAt: _tokenCalculatedAt,
+ wordCount: _wordCount,
+ tokenCount: _tokenCount,
+ ...messageWithoutRuntimeState
+ } = message as Message & { pictures?: unknown }
+ const result: Message = {
+ ...messageWithoutRuntimeState,
+ 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
+ const messageForkEntries = Object.entries(session.messageForksHash ?? {})
+ const messageForksHash =
+ messageForkEntries.length > 0
+ ? Object.fromEntries(
+ messageForkEntries.map(([key, fork]) => [
+ key,
+ {
+ ...fork,
+ lists: fork.lists.map((list) => ({
+ ...list,
+ messages: list.messages.map(stripLocalMessageReferences),
+ })),
+ },
+ ])
+ )
+ : 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,
+ }
+}
+
+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 {
+ 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,
+ }
+}
+
+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
+ }
+ return {
+ ...session,
+ name: meta.name,
+ type: session.type,
+ starred: meta.starred,
+ hidden: meta.hidden,
+ assistantAvatarKey: meta.assistantAvatarKey,
+ picUrl: meta.picUrl,
+ backgroundImage: meta.backgroundImage,
+ }
+}
+
+function sessionMetadata(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 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(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
+): string | undefined {
+ 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)
+ for (const sourceSessionId of localSessionById.keys()) {
+ if (defaultCreateConflictId(sourceSessionId, fingerprint) === remoteSession.id) {
+ return sourceSessionId
+ }
+ }
+ return undefined
+}
+
+export function createSyncSnapshot(input: {
+ sessions: Session[]
+ metas: SessionMetaRecord[]
+ deviceName: string
+ exportedAt?: string
+}): 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,
+ deviceName: input.deviceName,
+ sessions,
+ metas: input.metas
+ .filter((meta) => sessionIds.has(meta.id) && isChatSessionMetaLike(meta))
+ .map(stripLocalMetaReferences),
+ }
+}
+
+export function mergeRemoteSnapshot(input: MergeRemoteSnapshotInput): MergeRemoteSnapshotResult {
+ 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) => [meta.id, meta] as const)
+ )
+ const remoteMetas = input.remote.metas.map(stripLocalMetaReferences)
+ const remoteMetaById = new Map(remoteMetas.map((meta) => [meta.id, meta]))
+ const seenRemoteSessionIds = new Set()
+
+ const sessionChanges: MergeRemoteSnapshotResult['sessionChanges'] = []
+ const metasToSaveById = new Map()
+ let imported = 0
+ let conflicts = 0
+
+ for (const remoteSession of input.remote.sessions.map(stripLocalSessionReferences)) {
+ if (!isChatSession(remoteSession) || seenRemoteSessionIds.has(remoteSession.id)) {
+ continue
+ }
+ 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, comparableLocalSessionById)
+ const localConflictSource = conflictSourceId ? comparableLocalSessionById.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)
+ localSessionById.set(remoteSessionWithMeta.id, remoteSessionWithMeta)
+ comparableLocalSessionById.set(remoteSessionWithMeta.id, remoteSessionWithMeta)
+ localMetaById.set(meta.id, meta)
+ imported += 1
+ continue
+ }
+
+ if (comparableLocalSession && sessionContentEqual(comparableLocalSession, remoteSession)) {
+ if (input.preferRemoteMetadata) {
+ const patch = remoteMetadataPatch(localSession, remoteSessionWithMeta)
+ const updatedSession = { ...localSession, ...patch }
+ if (!sessionMetadataEqual(localSession, updatedSession)) {
+ sessionChanges.push({
+ kind: 'update-metadata',
+ sessionId: remoteSession.id,
+ patch,
+ })
+ localSessionById.set(remoteSession.id, updatedSession)
+ comparableLocalSessionById.set(remoteSession.id, stripLocalSessionReferences(updatedSession))
+ }
+ const localMeta = localMetaById.get(remoteSession.id)
+ 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 = metaForExistingSession(localSession, undefined, remoteMeta, input.now, true)
+ 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: conflictId,
+ name: copiedName,
+ 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 (!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
+ // 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
+ }
+ 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 = metaForExistingSession(
+ updatedCopy,
+ existingCopyMeta,
+ copiedMeta,
+ input.now,
+ false
+ )
+ 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)
+ comparableLocalSessionById.set(conflictId, copiedSession)
+ localMetaById.set(conflictId, copiedMeta)
+ conflicts += 1
+ }
+
+ return {
+ sessionChanges,
+ metasToSave: [...metasToSaveById.values()],
+ imported,
+ conflicts,
+ }
+}
diff --git a/src/renderer/packages/sync/types.ts b/src/renderer/packages/sync/types.ts
new file mode 100644
index 0000000000..bc5497a529
--- /dev/null
+++ b/src/renderer/packages/sync/types.ts
@@ -0,0 +1,50 @@
+import type { Session, SessionMeta, 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
+ 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 = {
+ sessionChanges: SyncSessionChange[]
+ 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..b6c77aef4e
--- /dev/null
+++ b/src/renderer/packages/sync/webdav.test.ts
@@ -0,0 +1,197 @@
+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('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,
+ 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/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',
+ 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..8a8ad8e20d
--- /dev/null
+++ b/src/renderer/packages/sync/webdav.ts
@@ -0,0 +1,35 @@
+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 {
+ 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(
+ 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/pages/SettingDialog/AdvancedSettingTab.tsx b/src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx
index 639bf9898b..d42ed9fad4 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,11 +164,11 @@ 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
- delete (data[StorageKey.Settings] as Settings).providers
+ if (data[StorageKey.Settings]) {
+ data[StorageKey.Settings] = sanitizeSettingsForExport(
+ data[StorageKey.Settings] as Settings,
+ exportItems.includes(ExportDataItem.Key)
+ )
}
if (!exportItems.includes(ExportDataItem.Setting)) {
delete data[StorageKey.Settings]
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..5e86a05933 100644
--- a/src/renderer/platform/mobile_platform.ts
+++ b/src/renderer/platform/mobile_platform.ts
@@ -1,7 +1,10 @@
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'
import localforage from 'localforage'
import { v4 as uuidv4 } from 'uuid'
@@ -13,6 +16,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 +247,42 @@ export default class MobilePlatform extends MobileSQLiteStorage implements Platf
return
}
+ 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,
+ 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..483cf3da87 100644
--- a/src/renderer/routes/settings/general.tsx
+++ b/src/renderer/routes/settings/general.tsx
@@ -5,30 +5,41 @@ import {
Divider,
FileButton,
Flex,
+ PasswordInput,
Radio,
- Select,
Stack,
Switch,
Text,
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, uniqBy } from 'lodash'
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,
+ type PreviewWebDAVUploadResult,
+ previewWebDAVUpload,
+ 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 +167,14 @@ export function RouteComponent() {
{/* Data Recovery */}
+ {platform.type !== 'web' && (
+ <>
+
+
+
+ >
+ )}
+
{/* import and export data */}
@@ -226,6 +245,192 @@ export function RouteComponent() {
)
}
+export const WebDAVSyncSection = () => {
+ const { t } = useTranslation()
+ 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) => {
+ settings.sync.webdav = {
+ ...settings.sync.webdav,
+ ...patch,
+ }
+ })
+ }
+
+ const runSyncAction = async (action: 'test' | 'upload' | 'download', task: () => Promise) => {
+ 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)
+ }
+ } catch (error) {
+ toastError(error instanceof Error ? error.message : String(error))
+ } finally {
+ setRunningAction(null)
+ }
+ }
+
+ 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('Manual WebDAV Sync')}
+
+ {t(
+ 'Manually merge chat history through your own WebDAV storage. API keys, licenses, and provider credentials are not synced.'
+ )}
+
+
+
+ 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') })}
+
+ )}
+
+
+
+
+
+
+
+ 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,
+ })}
+
+
+
+
+
+
+
+
+ )
+}
+
const DataRecoverySection = () => {
const { t } = useTranslation()
const [isRecovering, setIsRecovering] = useState(false)
@@ -369,23 +574,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)}`
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/renderer/stores/chatStore.sync.test.ts b/src/renderer/stores/chatStore.sync.test.ts
new file mode 100644
index 0000000000..b4855abcd7
--- /dev/null
+++ b/src/renderer/stores/chatStore.sync.test.ts
@@ -0,0 +1,277 @@
+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('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)
+
+ 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 ed8a61cab2..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'
@@ -234,6 +235,159 @@ export async function createSession(newSession: Omit, previousId?
return session
}
+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 [existingSessionValue, existingMeta] = await Promise.all([
+ platform.getStoreValue(StorageKeyGenerator.session(session.id)),
+ metaStorage.getById(session.id),
+ ])
+
+ 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 {
+ createdMeta = await createImportedMetadata(metaStorage, 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
+ }
+ 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 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 {
+ if (pendingSessionCreates.get(session.id) === creation) {
+ pendingSessionCreates.delete(session.id)
+ }
+ }
+}
+
const sessionUpdateQueues: Record> = {}
export async function updateSessionWithMessages(sessionId: string, updater: Updater) {
diff --git a/src/shared/defaults.ts b/src/shared/defaults.ts
index 69ffa004e7..693ca8f933 100644
--- a/src/shared/defaults.ts
+++ b/src/shared/defaults.ts
@@ -151,6 +151,16 @@ export function settings(): Settings {
enabledSkillNames: [],
translationEnabled: true,
},
+ sync: {
+ 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..4651c06b6e
--- /dev/null
+++ b/src/shared/sync-settings.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import * as defaults from './defaults'
+import { SettingsSchema } from './types'
+
+describe('sync settings defaults', () => {
+ it('defaults manual WebDAV sync credentials to empty values', () => {
+ const settings = SettingsSchema.parse(defaults.settings())
+
+ expect(settings.sync).toEqual({
+ 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..3918b6c987
--- /dev/null
+++ b/src/shared/sync-webdav.ts
@@ -0,0 +1,125 @@
+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', 'if-match', 'if-none-match'])
+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/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(),
})
diff --git a/src/shared/types/settings.ts b/src/shared/types/settings.ts
index 5b809c9973..1fca2b9afb 100644
--- a/src/shared/types/settings.ts
+++ b/src/shared/types/settings.ts
@@ -282,6 +282,29 @@ const MCPSettingsSchema = z.object({
enabledBuiltinServers: z.array(z.string()),
})
+export const SyncSettingsSchema = z.object({
+ 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),
+ // 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 {
Dark,
Light,
@@ -414,6 +437,16 @@ export const SettingsSchema = GlobalSessionSettingsSchema.extend({
enabledSkillNames: [],
translationEnabled: true,
}),
+ sync: SyncSettingsSchema.catch({
+ provider: 'webdav',
+ webdav: {
+ url: '',
+ username: '',
+ password: '',
+ syncPassword: '',
+ },
+ lastSyncedAt: undefined,
+ }),
})
// TODO: provider的 base info 和 settings混在一起了,可以考虑像 session settings 和 global settings一样拆开
@@ -441,6 +474,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'