diff --git a/src-tauri/src/services/webdav.rs b/src-tauri/src/services/webdav.rs
index abf9d246c..f0d0717e7 100644
--- a/src-tauri/src/services/webdav.rs
+++ b/src-tauri/src/services/webdav.rs
@@ -477,7 +477,8 @@ mod tests {
"https://dav.example.com/remote.php/dav/files/demo/",
&[
"cc switch-sync".to_string(),
- "v3".to_string(),
+ "v2".to_string(),
+ "db-v6".to_string(),
"default profile".to_string(),
"manifest.json".to_string(),
],
@@ -485,7 +486,7 @@ mod tests {
.unwrap();
assert_eq!(
url,
- "https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v3/default%20profile/manifest.json"
+ "https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v2/db-v6/default%20profile/manifest.json"
);
assert!(!url.contains("//cc"), "should not have double-slash");
}
diff --git a/src-tauri/src/services/webdav_sync.rs b/src-tauri/src/services/webdav_sync.rs
index 94c756865..db1ca70f6 100644
--- a/src-tauri/src/services/webdav_sync.rs
+++ b/src-tauri/src/services/webdav_sync.rs
@@ -1,4 +1,4 @@
-//! WebDAV v3 sync protocol layer.
+//! WebDAV v2 sync protocol layer with DB compatibility subdirectories.
//!
//! Implements manifest-based synchronization on top of the HTTP transport
//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`.
@@ -30,7 +30,9 @@ use archive::{
// ─── Protocol constants ──────────────────────────────────────
const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync";
-const PROTOCOL_VERSION: u32 = 3;
+const PROTOCOL_VERSION: u32 = 2;
+const DB_COMPAT_VERSION: u32 = 6;
+const LEGACY_DB_COMPAT_VERSION: u32 = 5;
const REMOTE_DB_SQL: &str = "db.sql";
const REMOTE_SKILLS_ZIP: &str = "skills.zip";
const REMOTE_MANIFEST: &str = "manifest.json";
@@ -76,6 +78,8 @@ fn io_context_localized(
struct SyncManifest {
format: String,
version: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ db_compat_version: Option,
device_name: String,
created_at: String,
artifacts: BTreeMap,
@@ -95,6 +99,28 @@ struct LocalSnapshot {
manifest_hash: String,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RemoteLayout {
+ Current,
+ Legacy,
+}
+
+impl RemoteLayout {
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::Current => "current",
+ Self::Legacy => "legacy",
+ }
+ }
+}
+
+struct RemoteSnapshot {
+ layout: RemoteLayout,
+ manifest: SyncManifest,
+ manifest_bytes: Vec,
+ manifest_etag: Option,
+}
+
// ─── Public API ──────────────────────────────────────────────
/// Check WebDAV connectivity and ensure remote directory structure.
@@ -102,7 +128,7 @@ pub async fn check_connection(settings: &WebDavSyncSettings) -> Result<(), AppEr
settings.validate()?;
let auth = auth_for(settings);
test_connection(&settings.base_url, &auth).await?;
- let dir_segs = remote_dir_segments(settings);
+ let dir_segs = remote_dir_segments(settings, RemoteLayout::Current);
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
Ok(())
}
@@ -114,19 +140,19 @@ pub async fn upload(
) -> Result {
settings.validate()?;
let auth = auth_for(settings);
- let dir_segs = remote_dir_segments(settings);
+ let dir_segs = remote_dir_segments(settings, RemoteLayout::Current);
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
let snapshot = build_local_snapshot(db, settings)?;
// Upload order: artifacts first, manifest last (best-effort consistency)
- let db_url = remote_file_url(settings, REMOTE_DB_SQL)?;
+ let db_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_DB_SQL)?;
put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?;
- let skills_url = remote_file_url(settings, REMOTE_SKILLS_ZIP)?;
+ let skills_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_SKILLS_ZIP)?;
put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?;
- let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
+ let manifest_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_MANIFEST)?;
put_bytes(
&manifest_url,
&auth,
@@ -160,9 +186,7 @@ pub async fn download(
) -> Result {
settings.validate()?;
let auth = auth_for(settings);
-
- let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
- let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES)
+ let snapshot = find_remote_snapshot(settings, &auth)
.await?
.ok_or_else(|| {
localized(
@@ -172,52 +196,64 @@ pub async fn download(
)
})?;
- let manifest: SyncManifest =
- serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
- path: REMOTE_MANIFEST.to_string(),
- source: e,
- })?;
-
- validate_manifest_compat(&manifest)?;
+ validate_manifest_compat(&snapshot.manifest, snapshot.layout)?;
// Download and verify artifacts
- let db_sql = download_and_verify(settings, &auth, REMOTE_DB_SQL, &manifest.artifacts).await?;
- let skills_zip =
- download_and_verify(settings, &auth, REMOTE_SKILLS_ZIP, &manifest.artifacts).await?;
+ let db_sql = download_and_verify(
+ settings,
+ &auth,
+ snapshot.layout,
+ REMOTE_DB_SQL,
+ &snapshot.manifest.artifacts,
+ )
+ .await?;
+ let skills_zip = download_and_verify(
+ settings,
+ &auth,
+ snapshot.layout,
+ REMOTE_SKILLS_ZIP,
+ &snapshot.manifest.artifacts,
+ )
+ .await?;
// Apply snapshot
apply_snapshot(db, &db_sql, &skills_zip)?;
- let manifest_hash = sha256_hex(&manifest_bytes);
- let _persisted =
- persist_sync_success_best_effort(settings, manifest_hash, etag, persist_sync_success);
- Ok(serde_json::json!({ "status": "downloaded" }))
+ let manifest_hash = sha256_hex(&snapshot.manifest_bytes);
+ let _persisted = persist_sync_success_best_effort(
+ settings,
+ manifest_hash,
+ snapshot.manifest_etag,
+ persist_sync_success,
+ );
+ Ok(serde_json::json!({
+ "status": "downloaded",
+ "sourceLayout": snapshot.layout.as_str(),
+ "sourcePath": remote_dir_display(settings, snapshot.layout),
+ }))
}
/// Fetch remote manifest info without downloading artifacts.
pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result
{remoteInfo && (
@@ -742,14 +751,35 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
{t("settings.webdavSync.confirmUpload.createdAt")}
{formatDate(remoteInfo.createdAt)}
+
+ {t("settings.webdavSync.confirmUpload.path")}
+
+
+
+ {remoteInfo.remotePath}
+
+
+ {remoteDbCompatDisplay && (
+ <>
+
+ {t("settings.webdavSync.confirmUpload.dbCompat")}
+
+ {remoteDbCompatDisplay}
+ >
+ )}
)}
- {remoteInfo && (
+ {remoteInfo && !remoteIsLegacy && (
{t("settings.webdavSync.confirmUpload.warning")}
)}
+ {remoteInfo && remoteIsLegacy && (
+
+ {t("settings.webdavSync.confirmUpload.legacyNotice")}
+
+ )}
@@ -793,12 +823,33 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
{t("settings.webdavSync.confirmDownload.createdAt")}
{formatDate(remoteInfo.createdAt)}
+
+ {t("settings.webdavSync.confirmDownload.path")}
+
+
+
+ {remoteInfo.remotePath}
+
+
+ {remoteDbCompatDisplay && (
+ <>
+
+ {t("settings.webdavSync.confirmDownload.dbCompat")}
+
+ {remoteDbCompatDisplay}
+ >
+ )}
{t("settings.webdavSync.confirmDownload.artifacts")}
{remoteInfo.artifacts.join(", ")}
)}
+ {remoteInfo?.layout === "legacy" && (
+
+ {t("settings.webdavSync.confirmDownload.legacyNotice")}
+
+ )}
{t("settings.webdavSync.confirmDownload.warning")}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 137fbc7a2..a00875a14 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -397,7 +397,7 @@
"saveAndTestSuccess": "Config saved, connection OK",
"saveAndTestFailed": "Config saved, but connection test failed: {{error}}",
"noRemoteData": "No sync data found on the remote server",
- "incompatibleVersion": "Remote data version incompatible (v{{version}}), current supports v2",
+ "incompatibleVersion": "Remote data is incompatible (protocol v{{protocolVersion}}, database {{dbCompatVersion}}). This client supports protocol v2 / db-v6.",
"unsaved": "Unsaved",
"saved": "Saved",
"unsavedChanges": "Please save config first",
@@ -408,7 +408,10 @@
"title": "Restore from Cloud",
"deviceName": "Uploaded by",
"createdAt": "Uploaded at",
+ "path": "Remote path",
+ "dbCompat": "DB compatibility",
"artifacts": "Contents",
+ "legacyNotice": "A legacy remote path was detected. After restoring, the next upload will write to the new v2/db-v6 path.",
"warning": "This will overwrite all local data and skill configurations",
"confirm": "Confirm Restore"
},
@@ -421,7 +424,10 @@
"existingData": "Existing cloud data",
"deviceName": "Uploaded by",
"createdAt": "Uploaded at",
+ "path": "Remote path",
+ "dbCompat": "DB compatibility",
"warning": "This will overwrite existing sync data on the remote server",
+ "legacyNotice": "Legacy remote data was detected. This upload will write to the new v2/db-v6 path and will not overwrite the legacy path.",
"confirm": "Confirm Upload"
}
},
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 2809dca9a..02d9b166a 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -397,7 +397,7 @@
"saveAndTestSuccess": "設定を保存しました。接続正常です",
"saveAndTestFailed": "設定を保存しましたが、接続テストに失敗しました:{{error}}",
"noRemoteData": "クラウドに同期データが見つかりません",
- "incompatibleVersion": "リモートデータのバージョンに互換性がありません(v{{version}})。現在 v2 をサポートしています",
+ "incompatibleVersion": "リモートデータに互換性がありません(プロトコル v{{protocolVersion}}、データベース {{dbCompatVersion}})。このクライアントは protocol v2 / db-v6 をサポートしています。",
"unsaved": "未保存",
"saved": "保存済み",
"unsavedChanges": "先に設定を保存してください",
@@ -408,7 +408,10 @@
"title": "クラウドから復元",
"deviceName": "アップロード元",
"createdAt": "アップロード日時",
+ "path": "リモートパス",
+ "dbCompat": "DB 互換レイヤー",
"artifacts": "内容",
+ "legacyNotice": "旧レイアウトのリモートパスを検出しました。復元後、次回のアップロードは新しい v2/db-v6 パスに書き込まれます。",
"warning": "ローカルのすべてのデータとスキル設定が上書きされます",
"confirm": "復元を実行"
},
@@ -421,7 +424,10 @@
"existingData": "クラウドの既存データ",
"deviceName": "アップロード元",
"createdAt": "アップロード日時",
+ "path": "リモートパス",
+ "dbCompat": "DB 互換レイヤー",
"warning": "リモートの既存同期データが上書きされます",
+ "legacyNotice": "旧レイアウトのリモートデータを検出しました。今回のアップロードは新しい v2/db-v6 パスに書き込み、旧パスは上書きしません。",
"confirm": "アップロードを実行"
}
},
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 9d664d7fb..5551b2bae 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -397,7 +397,7 @@
"saveAndTestSuccess": "配置已保存,连接正常",
"saveAndTestFailed": "配置已保存,但连接测试失败:{{error}}",
"noRemoteData": "云端没有找到同步数据",
- "incompatibleVersion": "远端数据版本不兼容(v{{version}}),当前支持 v2",
+ "incompatibleVersion": "远端数据版本不兼容(协议 v{{protocolVersion}},数据库 {{dbCompatVersion}}),当前支持协议 v2 / db-v6",
"unsaved": "未保存",
"saved": "已保存",
"unsavedChanges": "请先保存配置",
@@ -408,7 +408,10 @@
"title": "从云端恢复",
"deviceName": "上传设备",
"createdAt": "上传时间",
+ "path": "远端路径",
+ "dbCompat": "数据库兼容层",
"artifacts": "包含内容",
+ "legacyNotice": "检测到旧版云端路径。恢复完成后,下次上传将写入新路径 v2/db-v6。",
"warning": "恢复将覆盖本地所有数据和技能配置",
"confirm": "确认恢复"
},
@@ -421,7 +424,10 @@
"existingData": "云端已有数据",
"deviceName": "上传设备",
"createdAt": "上传时间",
+ "path": "远端路径",
+ "dbCompat": "数据库兼容层",
"warning": "将覆盖云端已有的同步数据",
+ "legacyNotice": "检测到旧版云端路径数据。本次上传将写入新路径 v2/db-v6,不会覆盖旧路径。",
"confirm": "确认上传"
}
},
diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts
index 4d42c3533..7102e4ba8 100644
--- a/src/lib/api/settings.ts
+++ b/src/lib/api/settings.ts
@@ -102,7 +102,7 @@ export const settingsApi = {
return await invoke("import_config_from_file", { filePath });
},
- // ─── WebDAV v2 sync ───────────────────────────────────────
+ // ─── WebDAV sync ──────────────────────────────────────────
async webdavTestConnection(
settings: WebDavSyncSettings,
diff --git a/src/types.ts b/src/types.ts
index e8a1311b5..b876aa57e 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -169,7 +169,7 @@ export interface VisibleApps {
openclaw: boolean;
}
-// WebDAV v2 同步状态
+// WebDAV 同步状态
export interface WebDavSyncStatus {
lastSyncAt?: number | null;
lastError?: string | null;
@@ -179,7 +179,7 @@ export interface WebDavSyncStatus {
lastRemoteManifestHash?: string | null;
}
-// WebDAV v2 同步配置
+// WebDAV 同步配置
export interface WebDavSyncSettings {
enabled?: boolean;
autoSync?: boolean;
@@ -191,14 +191,20 @@ export interface WebDavSyncSettings {
status?: WebDavSyncStatus;
}
+export type RemoteSnapshotLayout = "current" | "legacy";
+
// 远端快照信息(下载前预览)
export interface RemoteSnapshotInfo {
deviceName: string;
createdAt: string;
snapshotId: string;
version: number;
+ protocolVersion: number;
+ dbCompatVersion?: number | null;
compatible: boolean;
artifacts: string[];
+ layout: RemoteSnapshotLayout;
+ remotePath: string;
}
// 应用设置类型(用于设置对话框与 Tauri API)