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, AppError> { settings.validate()?; let auth = auth_for(settings); - let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?; - - let Some((bytes, _)) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES).await? else { + let Some(snapshot) = find_remote_snapshot(settings, &auth).await? else { return Ok(None); }; - - let manifest: SyncManifest = serde_json::from_slice(&bytes).map_err(|e| AppError::Json { - path: REMOTE_MANIFEST.to_string(), - source: e, - })?; - - let compatible = validate_manifest_compat(&manifest).is_ok(); + let compatible = validate_manifest_compat(&snapshot.manifest, snapshot.layout).is_ok(); + let db_compat_version = effective_db_compat_version(&snapshot.manifest, snapshot.layout); let payload = serde_json::json!({ - "deviceName": manifest.device_name, - "createdAt": manifest.created_at, - "snapshotId": manifest.snapshot_id, - "version": manifest.version, + "deviceName": snapshot.manifest.device_name, + "createdAt": snapshot.manifest.created_at, + "snapshotId": snapshot.manifest.snapshot_id, + "version": snapshot.manifest.version, + "protocolVersion": snapshot.manifest.version, + "dbCompatVersion": db_compat_version, "compatible": compatible, - "artifacts": manifest.artifacts.keys().collect::>(), + "artifacts": snapshot.manifest.artifacts.keys().collect::>(), + "layout": snapshot.layout.as_str(), + "remotePath": remote_dir_display(settings, snapshot.layout), }); Ok(Some(payload)) @@ -304,6 +340,7 @@ fn build_local_snapshot( let manifest = SyncManifest { format: PROTOCOL_FORMAT.to_string(), version: PROTOCOL_VERSION, + db_compat_version: Some(DB_COMPAT_VERSION), device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()), created_at: Utc::now().to_rfc3339(), artifacts, @@ -384,7 +421,13 @@ fn normalize_device_name(raw: &str) -> Option { } } -fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> { +fn effective_db_compat_version(manifest: &SyncManifest, layout: RemoteLayout) -> Option { + manifest + .db_compat_version + .or_else(|| (layout == RemoteLayout::Legacy).then_some(LEGACY_DB_COMPAT_VERSION)) +} + +fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Result<(), AppError> { if manifest.format != PROTOCOL_FORMAT { return Err(localized( "webdav.sync.manifest_format_incompatible", @@ -408,14 +451,87 @@ fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> { ), )); } + let Some(db_compat_version) = effective_db_compat_version(manifest, layout) else { + return Err(localized( + "webdav.sync.manifest_db_version_missing", + "远端 manifest 缺少数据库兼容版本", + "Remote manifest is missing the database compatibility version.", + )); + }; + match layout { + RemoteLayout::Current if db_compat_version != DB_COMPAT_VERSION => { + return Err(localized( + "webdav.sync.manifest_db_version_incompatible", + format!( + "远端数据库快照版本不兼容: db-v{} (本地 db-v{DB_COMPAT_VERSION})", + db_compat_version + ), + format!( + "Remote database snapshot version is incompatible: db-v{} (local db-v{DB_COMPAT_VERSION})", + db_compat_version + ), + )); + } + RemoteLayout::Legacy if db_compat_version > DB_COMPAT_VERSION => { + return Err(localized( + "webdav.sync.manifest_db_version_incompatible", + format!( + "远端数据库快照版本不兼容: db-v{} (本地最高支持 db-v{DB_COMPAT_VERSION})", + db_compat_version + ), + format!( + "Remote database snapshot version is incompatible: db-v{} (local supports up to db-v{DB_COMPAT_VERSION})", + db_compat_version + ), + )); + } + _ => {} + } Ok(()) } +async fn find_remote_snapshot( + settings: &WebDavSyncSettings, + auth: &WebDavAuth, +) -> Result, AppError> { + if let Some(snapshot) = fetch_remote_snapshot(settings, auth, RemoteLayout::Current).await? { + return Ok(Some(snapshot)); + } + fetch_remote_snapshot(settings, auth, RemoteLayout::Legacy).await +} + +async fn fetch_remote_snapshot( + settings: &WebDavSyncSettings, + auth: &WebDavAuth, + layout: RemoteLayout, +) -> Result, AppError> { + let manifest_url = remote_file_url(settings, layout, REMOTE_MANIFEST)?; + let Some((manifest_bytes, manifest_etag)) = + get_bytes(&manifest_url, auth, MAX_MANIFEST_BYTES).await? + else { + return Ok(None); + }; + + let manifest: SyncManifest = + serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json { + path: REMOTE_MANIFEST.to_string(), + source: e, + })?; + + Ok(Some(RemoteSnapshot { + layout, + manifest, + manifest_bytes, + manifest_etag, + })) +} + // ─── Download & verify ─────────────────────────────────────── async fn download_and_verify( settings: &WebDavSyncSettings, auth: &WebDavAuth, + layout: RemoteLayout, artifact_name: &str, artifacts: &BTreeMap, ) -> Result, AppError> { @@ -428,7 +544,7 @@ async fn download_and_verify( })?; validate_artifact_size_limit(artifact_name, meta.size)?; - let url = remote_file_url(settings, artifact_name)?; + let url = remote_file_url(settings, layout, artifact_name)?; let (bytes, _) = get_bytes(&url, auth, MAX_SYNC_ARTIFACT_BYTES as usize) .await? .ok_or_else(|| { @@ -510,20 +626,32 @@ fn apply_snapshot( // ─── Remote path helpers ───────────────────────────────────── -fn remote_dir_segments(settings: &WebDavSyncSettings) -> Vec { +fn remote_dir_segments(settings: &WebDavSyncSettings, layout: RemoteLayout) -> Vec { let mut segs = Vec::new(); segs.extend(path_segments(&settings.remote_root).map(str::to_string)); segs.push(format!("v{PROTOCOL_VERSION}")); + if layout == RemoteLayout::Current { + segs.push(format!("db-v{DB_COMPAT_VERSION}")); + } segs.extend(path_segments(&settings.profile).map(str::to_string)); segs } -fn remote_file_url(settings: &WebDavSyncSettings, file_name: &str) -> Result { - let mut segs = remote_dir_segments(settings); +fn remote_file_url( + settings: &WebDavSyncSettings, + layout: RemoteLayout, + file_name: &str, +) -> Result { + let mut segs = remote_dir_segments(settings, layout); segs.extend(path_segments(file_name).map(str::to_string)); build_remote_url(&settings.base_url, &segs) } +fn remote_dir_display(settings: &WebDavSyncSettings, layout: RemoteLayout) -> String { + let segs = remote_dir_segments(settings, layout); + format!("/{}", segs.join("/")) +} + fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth { auth_from_credentials(&settings.username, &settings.password) } @@ -579,14 +707,25 @@ mod tests { } #[test] - fn remote_dir_segments_uses_v3() { + fn remote_dir_segments_uses_current_layout() { let settings = WebDavSyncSettings { remote_root: "cc-switch-sync".to_string(), profile: "default".to_string(), ..WebDavSyncSettings::default() }; - let segs = remote_dir_segments(&settings); - assert_eq!(segs, vec!["cc-switch-sync", "v3", "default"]); + let segs = remote_dir_segments(&settings, RemoteLayout::Current); + assert_eq!(segs, vec!["cc-switch-sync", "v2", "db-v6", "default"]); + } + + #[test] + fn remote_dir_segments_uses_legacy_layout() { + let settings = WebDavSyncSettings { + remote_root: "cc-switch-sync".to_string(), + profile: "default".to_string(), + ..WebDavSyncSettings::default() + }; + let segs = remote_dir_segments(&settings, RemoteLayout::Legacy); + assert_eq!(segs, vec!["cc-switch-sync", "v2", "default"]); } #[test] @@ -622,13 +761,14 @@ mod tests { assert!(!ok); } - fn manifest_with(format: &str, version: u32) -> SyncManifest { + fn manifest_with(format: &str, version: u32, db_compat_version: Option) -> SyncManifest { let mut artifacts = BTreeMap::new(); artifacts.insert("db.sql".to_string(), artifact("abc", 1)); artifacts.insert("skills.zip".to_string(), artifact("def", 2)); SyncManifest { format: format.to_string(), version, + db_compat_version, device_name: "My MacBook".to_string(), created_at: "2026-02-12T00:00:00Z".to_string(), artifacts, @@ -638,20 +778,63 @@ mod tests { #[test] fn validate_manifest_compat_accepts_supported_manifest() { - let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION); - assert!(validate_manifest_compat(&manifest).is_ok()); + let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION)); + assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_ok()); } #[test] fn validate_manifest_compat_rejects_wrong_format() { - let manifest = manifest_with("other-format", PROTOCOL_VERSION); - assert!(validate_manifest_compat(&manifest).is_err()); + let manifest = manifest_with("other-format", PROTOCOL_VERSION, Some(DB_COMPAT_VERSION)); + assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err()); } #[test] fn validate_manifest_compat_rejects_wrong_version() { - let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION + 1); - assert!(validate_manifest_compat(&manifest).is_err()); + let manifest = manifest_with( + PROTOCOL_FORMAT, + PROTOCOL_VERSION + 1, + Some(DB_COMPAT_VERSION), + ); + assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err()); + } + + #[test] + fn validate_manifest_compat_accepts_legacy_manifest_without_db_compat() { + let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None); + assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_ok()); + } + + #[test] + fn validate_manifest_compat_rejects_current_manifest_with_wrong_db_compat() { + let manifest = manifest_with( + PROTOCOL_FORMAT, + PROTOCOL_VERSION, + Some(LEGACY_DB_COMPAT_VERSION), + ); + assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err()); + } + + #[test] + fn validate_manifest_compat_rejects_legacy_manifest_from_newer_db_generation() { + let manifest = manifest_with( + PROTOCOL_FORMAT, + PROTOCOL_VERSION, + Some(DB_COMPAT_VERSION + 1), + ); + assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_err()); + } + + #[test] + fn effective_db_compat_version_defaults_legacy_layout_to_v5() { + let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None); + assert_eq!( + effective_db_compat_version(&manifest, RemoteLayout::Legacy), + Some(LEGACY_DB_COMPAT_VERSION) + ); + assert_eq!( + effective_db_compat_version(&manifest, RemoteLayout::Current), + None + ); } #[test] @@ -675,12 +858,16 @@ mod tests { #[test] fn manifest_serialization_uses_device_name_only() { - let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION); + let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION)); let value = serde_json::to_value(&manifest).expect("serialize manifest"); assert!( value.get("deviceName").is_some(), "manifest should contain deviceName" ); + assert_eq!( + value.get("dbCompatVersion").and_then(|v| v.as_u64()), + Some(DB_COMPAT_VERSION as u64) + ); assert!( value.get("deviceId").is_none(), "manifest should not contain deviceId" diff --git a/src/components/settings/WebdavSyncSection.tsx b/src/components/settings/WebdavSyncSection.tsx index ef4e2c544..c8ff65374 100644 --- a/src/components/settings/WebdavSyncSection.tsx +++ b/src/components/settings/WebdavSyncSection.tsx @@ -92,6 +92,10 @@ function formatDate(rfc3339: string): string { return Number.isNaN(d.getTime()) ? rfc3339 : d.toLocaleString(); } +function formatDbCompatVersion(version?: number | null): string | null { + return typeof version === "number" ? `db-v${version}` : null; +} + // ─── Types ────────────────────────────────────────────────── type ActionState = @@ -395,7 +399,10 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { if (!info.compatible) { toast.error( t("settings.webdavSync.incompatibleVersion", { - version: info.version, + protocolVersion: info.protocolVersion, + dbCompatVersion: + formatDbCompatVersion(info.dbCompatVersion) ?? + t("common.unknown"), }), ); return; @@ -450,6 +457,9 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { const lastError = config?.status?.lastError?.trim(); const showAutoSyncError = !!lastError && config?.status?.lastErrorSource === "auto"; + const currentRemotePath = `/${form.remoteRoot.trim() || "cc-switch-sync"}/v2/db-v6/${form.profile.trim() || "default"}`; + const remoteDbCompatDisplay = formatDbCompatVersion(remoteInfo?.dbCompatVersion); + const remoteIsLegacy = remoteInfo?.layout === "legacy"; // ─── Render ───────────────────────────────────────────── @@ -720,8 +730,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { {t("settings.webdavSync.confirmUpload.targetPath")} {": "} - /{form.remoteRoot.trim() || "cc-switch-sync"}/v2/ - {form.profile.trim() || "default"} + {currentRemotePath}

{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)