feat: add dual-layer versioning to WebDAV sync (protocol v2 + db-v6)

Separate protocol version from database compatibility version in WebDAV
sync paths. Upload writes to v2/db-v6/<profile>, download falls back to
legacy v2/<profile> when current path has no data. Extend manifest with
optional dbCompatVersion field and add legacy layout detection to UI.
This commit is contained in:
Jason
2026-03-08 19:33:20 +08:00
parent bf40b0138c
commit c0737f2cfe
8 changed files with 330 additions and 67 deletions
+3 -2
View File
@@ -477,7 +477,8 @@ mod tests {
"https://dav.example.com/remote.php/dav/files/demo/", "https://dav.example.com/remote.php/dav/files/demo/",
&[ &[
"cc switch-sync".to_string(), "cc switch-sync".to_string(),
"v3".to_string(), "v2".to_string(),
"db-v6".to_string(),
"default profile".to_string(), "default profile".to_string(),
"manifest.json".to_string(), "manifest.json".to_string(),
], ],
@@ -485,7 +486,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
url, 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"); assert!(!url.contains("//cc"), "should not have double-slash");
} }
+242 -55
View File
@@ -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 //! Implements manifest-based synchronization on top of the HTTP transport
//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`. //! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`.
@@ -30,7 +30,9 @@ use archive::{
// ─── Protocol constants ────────────────────────────────────── // ─── Protocol constants ──────────────────────────────────────
const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync"; 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_DB_SQL: &str = "db.sql";
const REMOTE_SKILLS_ZIP: &str = "skills.zip"; const REMOTE_SKILLS_ZIP: &str = "skills.zip";
const REMOTE_MANIFEST: &str = "manifest.json"; const REMOTE_MANIFEST: &str = "manifest.json";
@@ -76,6 +78,8 @@ fn io_context_localized(
struct SyncManifest { struct SyncManifest {
format: String, format: String,
version: u32, version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
db_compat_version: Option<u32>,
device_name: String, device_name: String,
created_at: String, created_at: String,
artifacts: BTreeMap<String, ArtifactMeta>, artifacts: BTreeMap<String, ArtifactMeta>,
@@ -95,6 +99,28 @@ struct LocalSnapshot {
manifest_hash: String, 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<u8>,
manifest_etag: Option<String>,
}
// ─── Public API ────────────────────────────────────────────── // ─── Public API ──────────────────────────────────────────────
/// Check WebDAV connectivity and ensure remote directory structure. /// Check WebDAV connectivity and ensure remote directory structure.
@@ -102,7 +128,7 @@ pub async fn check_connection(settings: &WebDavSyncSettings) -> Result<(), AppEr
settings.validate()?; settings.validate()?;
let auth = auth_for(settings); let auth = auth_for(settings);
test_connection(&settings.base_url, &auth).await?; 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?; ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
Ok(()) Ok(())
} }
@@ -114,19 +140,19 @@ pub async fn upload(
) -> Result<Value, AppError> { ) -> Result<Value, AppError> {
settings.validate()?; settings.validate()?;
let auth = auth_for(settings); 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?; ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
let snapshot = build_local_snapshot(db, settings)?; let snapshot = build_local_snapshot(db, settings)?;
// Upload order: artifacts first, manifest last (best-effort consistency) // 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?; 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?; 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( put_bytes(
&manifest_url, &manifest_url,
&auth, &auth,
@@ -160,9 +186,7 @@ pub async fn download(
) -> Result<Value, AppError> { ) -> Result<Value, AppError> {
settings.validate()?; settings.validate()?;
let auth = auth_for(settings); let auth = auth_for(settings);
let snapshot = find_remote_snapshot(settings, &auth)
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
localized( localized(
@@ -172,52 +196,64 @@ pub async fn download(
) )
})?; })?;
let manifest: SyncManifest = validate_manifest_compat(&snapshot.manifest, snapshot.layout)?;
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
path: REMOTE_MANIFEST.to_string(),
source: e,
})?;
validate_manifest_compat(&manifest)?;
// Download and verify artifacts // Download and verify artifacts
let db_sql = download_and_verify(settings, &auth, REMOTE_DB_SQL, &manifest.artifacts).await?; let db_sql = download_and_verify(
let skills_zip = settings,
download_and_verify(settings, &auth, REMOTE_SKILLS_ZIP, &manifest.artifacts).await?; &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
apply_snapshot(db, &db_sql, &skills_zip)?; apply_snapshot(db, &db_sql, &skills_zip)?;
let manifest_hash = sha256_hex(&manifest_bytes); let manifest_hash = sha256_hex(&snapshot.manifest_bytes);
let _persisted = let _persisted = persist_sync_success_best_effort(
persist_sync_success_best_effort(settings, manifest_hash, etag, persist_sync_success); settings,
Ok(serde_json::json!({ "status": "downloaded" })) 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. /// Fetch remote manifest info without downloading artifacts.
pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result<Option<Value>, AppError> { pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result<Option<Value>, AppError> {
settings.validate()?; settings.validate()?;
let auth = auth_for(settings); let auth = auth_for(settings);
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?; let Some(snapshot) = find_remote_snapshot(settings, &auth).await? else {
let Some((bytes, _)) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES).await? else {
return Ok(None); return Ok(None);
}; };
let compatible = validate_manifest_compat(&snapshot.manifest, snapshot.layout).is_ok();
let manifest: SyncManifest = serde_json::from_slice(&bytes).map_err(|e| AppError::Json { let db_compat_version = effective_db_compat_version(&snapshot.manifest, snapshot.layout);
path: REMOTE_MANIFEST.to_string(),
source: e,
})?;
let compatible = validate_manifest_compat(&manifest).is_ok();
let payload = serde_json::json!({ let payload = serde_json::json!({
"deviceName": manifest.device_name, "deviceName": snapshot.manifest.device_name,
"createdAt": manifest.created_at, "createdAt": snapshot.manifest.created_at,
"snapshotId": manifest.snapshot_id, "snapshotId": snapshot.manifest.snapshot_id,
"version": manifest.version, "version": snapshot.manifest.version,
"protocolVersion": snapshot.manifest.version,
"dbCompatVersion": db_compat_version,
"compatible": compatible, "compatible": compatible,
"artifacts": manifest.artifacts.keys().collect::<Vec<_>>(), "artifacts": snapshot.manifest.artifacts.keys().collect::<Vec<_>>(),
"layout": snapshot.layout.as_str(),
"remotePath": remote_dir_display(settings, snapshot.layout),
}); });
Ok(Some(payload)) Ok(Some(payload))
@@ -304,6 +340,7 @@ fn build_local_snapshot(
let manifest = SyncManifest { let manifest = SyncManifest {
format: PROTOCOL_FORMAT.to_string(), format: PROTOCOL_FORMAT.to_string(),
version: PROTOCOL_VERSION, version: PROTOCOL_VERSION,
db_compat_version: Some(DB_COMPAT_VERSION),
device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()), device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()),
created_at: Utc::now().to_rfc3339(), created_at: Utc::now().to_rfc3339(),
artifacts, artifacts,
@@ -384,7 +421,13 @@ fn normalize_device_name(raw: &str) -> Option<String> {
} }
} }
fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> { fn effective_db_compat_version(manifest: &SyncManifest, layout: RemoteLayout) -> Option<u32> {
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 { if manifest.format != PROTOCOL_FORMAT {
return Err(localized( return Err(localized(
"webdav.sync.manifest_format_incompatible", "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(()) Ok(())
} }
async fn find_remote_snapshot(
settings: &WebDavSyncSettings,
auth: &WebDavAuth,
) -> Result<Option<RemoteSnapshot>, 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<Option<RemoteSnapshot>, 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 ─────────────────────────────────────── // ─── Download & verify ───────────────────────────────────────
async fn download_and_verify( async fn download_and_verify(
settings: &WebDavSyncSettings, settings: &WebDavSyncSettings,
auth: &WebDavAuth, auth: &WebDavAuth,
layout: RemoteLayout,
artifact_name: &str, artifact_name: &str,
artifacts: &BTreeMap<String, ArtifactMeta>, artifacts: &BTreeMap<String, ArtifactMeta>,
) -> Result<Vec<u8>, AppError> { ) -> Result<Vec<u8>, AppError> {
@@ -428,7 +544,7 @@ async fn download_and_verify(
})?; })?;
validate_artifact_size_limit(artifact_name, meta.size)?; 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) let (bytes, _) = get_bytes(&url, auth, MAX_SYNC_ARTIFACT_BYTES as usize)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
@@ -510,20 +626,32 @@ fn apply_snapshot(
// ─── Remote path helpers ───────────────────────────────────── // ─── Remote path helpers ─────────────────────────────────────
fn remote_dir_segments(settings: &WebDavSyncSettings) -> Vec<String> { fn remote_dir_segments(settings: &WebDavSyncSettings, layout: RemoteLayout) -> Vec<String> {
let mut segs = Vec::new(); let mut segs = Vec::new();
segs.extend(path_segments(&settings.remote_root).map(str::to_string)); segs.extend(path_segments(&settings.remote_root).map(str::to_string));
segs.push(format!("v{PROTOCOL_VERSION}")); 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.extend(path_segments(&settings.profile).map(str::to_string));
segs segs
} }
fn remote_file_url(settings: &WebDavSyncSettings, file_name: &str) -> Result<String, AppError> { fn remote_file_url(
let mut segs = remote_dir_segments(settings); settings: &WebDavSyncSettings,
layout: RemoteLayout,
file_name: &str,
) -> Result<String, AppError> {
let mut segs = remote_dir_segments(settings, layout);
segs.extend(path_segments(file_name).map(str::to_string)); segs.extend(path_segments(file_name).map(str::to_string));
build_remote_url(&settings.base_url, &segs) 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 { fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth {
auth_from_credentials(&settings.username, &settings.password) auth_from_credentials(&settings.username, &settings.password)
} }
@@ -579,14 +707,25 @@ mod tests {
} }
#[test] #[test]
fn remote_dir_segments_uses_v3() { fn remote_dir_segments_uses_current_layout() {
let settings = WebDavSyncSettings { let settings = WebDavSyncSettings {
remote_root: "cc-switch-sync".to_string(), remote_root: "cc-switch-sync".to_string(),
profile: "default".to_string(), profile: "default".to_string(),
..WebDavSyncSettings::default() ..WebDavSyncSettings::default()
}; };
let segs = remote_dir_segments(&settings); let segs = remote_dir_segments(&settings, RemoteLayout::Current);
assert_eq!(segs, vec!["cc-switch-sync", "v3", "default"]); 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] #[test]
@@ -622,13 +761,14 @@ mod tests {
assert!(!ok); assert!(!ok);
} }
fn manifest_with(format: &str, version: u32) -> SyncManifest { fn manifest_with(format: &str, version: u32, db_compat_version: Option<u32>) -> SyncManifest {
let mut artifacts = BTreeMap::new(); let mut artifacts = BTreeMap::new();
artifacts.insert("db.sql".to_string(), artifact("abc", 1)); artifacts.insert("db.sql".to_string(), artifact("abc", 1));
artifacts.insert("skills.zip".to_string(), artifact("def", 2)); artifacts.insert("skills.zip".to_string(), artifact("def", 2));
SyncManifest { SyncManifest {
format: format.to_string(), format: format.to_string(),
version, version,
db_compat_version,
device_name: "My MacBook".to_string(), device_name: "My MacBook".to_string(),
created_at: "2026-02-12T00:00:00Z".to_string(), created_at: "2026-02-12T00:00:00Z".to_string(),
artifacts, artifacts,
@@ -638,20 +778,63 @@ mod tests {
#[test] #[test]
fn validate_manifest_compat_accepts_supported_manifest() { fn validate_manifest_compat_accepts_supported_manifest() {
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION); let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
assert!(validate_manifest_compat(&manifest).is_ok()); assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_ok());
} }
#[test] #[test]
fn validate_manifest_compat_rejects_wrong_format() { fn validate_manifest_compat_rejects_wrong_format() {
let manifest = manifest_with("other-format", PROTOCOL_VERSION); let manifest = manifest_with("other-format", PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
assert!(validate_manifest_compat(&manifest).is_err()); assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
} }
#[test] #[test]
fn validate_manifest_compat_rejects_wrong_version() { fn validate_manifest_compat_rejects_wrong_version() {
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION + 1); let manifest = manifest_with(
assert!(validate_manifest_compat(&manifest).is_err()); 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] #[test]
@@ -675,12 +858,16 @@ mod tests {
#[test] #[test]
fn manifest_serialization_uses_device_name_only() { 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"); let value = serde_json::to_value(&manifest).expect("serialize manifest");
assert!( assert!(
value.get("deviceName").is_some(), value.get("deviceName").is_some(),
"manifest should contain deviceName" "manifest should contain deviceName"
); );
assert_eq!(
value.get("dbCompatVersion").and_then(|v| v.as_u64()),
Some(DB_COMPAT_VERSION as u64)
);
assert!( assert!(
value.get("deviceId").is_none(), value.get("deviceId").is_none(),
"manifest should not contain deviceId" "manifest should not contain deviceId"
+55 -4
View File
@@ -92,6 +92,10 @@ function formatDate(rfc3339: string): string {
return Number.isNaN(d.getTime()) ? rfc3339 : d.toLocaleString(); return Number.isNaN(d.getTime()) ? rfc3339 : d.toLocaleString();
} }
function formatDbCompatVersion(version?: number | null): string | null {
return typeof version === "number" ? `db-v${version}` : null;
}
// ─── Types ────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────
type ActionState = type ActionState =
@@ -395,7 +399,10 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
if (!info.compatible) { if (!info.compatible) {
toast.error( toast.error(
t("settings.webdavSync.incompatibleVersion", { t("settings.webdavSync.incompatibleVersion", {
version: info.version, protocolVersion: info.protocolVersion,
dbCompatVersion:
formatDbCompatVersion(info.dbCompatVersion) ??
t("common.unknown"),
}), }),
); );
return; return;
@@ -450,6 +457,9 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
const lastError = config?.status?.lastError?.trim(); const lastError = config?.status?.lastError?.trim();
const showAutoSyncError = const showAutoSyncError =
!!lastError && config?.status?.lastErrorSource === "auto"; !!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 ───────────────────────────────────────────── // ─── Render ─────────────────────────────────────────────
@@ -720,8 +730,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
{t("settings.webdavSync.confirmUpload.targetPath")} {t("settings.webdavSync.confirmUpload.targetPath")}
{": "} {": "}
<code className="ml-1 text-xs bg-muted px-1.5 py-0.5 rounded"> <code className="ml-1 text-xs bg-muted px-1.5 py-0.5 rounded">
/{form.remoteRoot.trim() || "cc-switch-sync"}/v2/ {currentRemotePath}
{form.profile.trim() || "default"}
</code> </code>
</p> </p>
{remoteInfo && ( {remoteInfo && (
@@ -742,14 +751,35 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
{t("settings.webdavSync.confirmUpload.createdAt")} {t("settings.webdavSync.confirmUpload.createdAt")}
</dt> </dt>
<dd>{formatDate(remoteInfo.createdAt)}</dd> <dd>{formatDate(remoteInfo.createdAt)}</dd>
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmUpload.path")}
</dt>
<dd>
<code className="bg-muted px-1.5 py-0.5 rounded">
{remoteInfo.remotePath}
</code>
</dd>
{remoteDbCompatDisplay && (
<>
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmUpload.dbCompat")}
</dt>
<dd>{remoteDbCompatDisplay}</dd>
</>
)}
</dl> </dl>
</div> </div>
)} )}
{remoteInfo && ( {remoteInfo && !remoteIsLegacy && (
<p className="text-destructive font-medium"> <p className="text-destructive font-medium">
{t("settings.webdavSync.confirmUpload.warning")} {t("settings.webdavSync.confirmUpload.warning")}
</p> </p>
)} )}
{remoteInfo && remoteIsLegacy && (
<p className="font-medium text-amber-600 dark:text-amber-400">
{t("settings.webdavSync.confirmUpload.legacyNotice")}
</p>
)}
</div> </div>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -793,12 +823,33 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
{t("settings.webdavSync.confirmDownload.createdAt")} {t("settings.webdavSync.confirmDownload.createdAt")}
</dt> </dt>
<dd>{formatDate(remoteInfo.createdAt)}</dd> <dd>{formatDate(remoteInfo.createdAt)}</dd>
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmDownload.path")}
</dt>
<dd>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{remoteInfo.remotePath}
</code>
</dd>
{remoteDbCompatDisplay && (
<>
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmDownload.dbCompat")}
</dt>
<dd>{remoteDbCompatDisplay}</dd>
</>
)}
<dt className="font-medium text-foreground"> <dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmDownload.artifacts")} {t("settings.webdavSync.confirmDownload.artifacts")}
</dt> </dt>
<dd>{remoteInfo.artifacts.join(", ")}</dd> <dd>{remoteInfo.artifacts.join(", ")}</dd>
</dl> </dl>
)} )}
{remoteInfo?.layout === "legacy" && (
<p className="font-medium text-amber-600 dark:text-amber-400">
{t("settings.webdavSync.confirmDownload.legacyNotice")}
</p>
)}
<p className="text-destructive font-medium"> <p className="text-destructive font-medium">
{t("settings.webdavSync.confirmDownload.warning")} {t("settings.webdavSync.confirmDownload.warning")}
</p> </p>
+7 -1
View File
@@ -397,7 +397,7 @@
"saveAndTestSuccess": "Config saved, connection OK", "saveAndTestSuccess": "Config saved, connection OK",
"saveAndTestFailed": "Config saved, but connection test failed: {{error}}", "saveAndTestFailed": "Config saved, but connection test failed: {{error}}",
"noRemoteData": "No sync data found on the remote server", "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", "unsaved": "Unsaved",
"saved": "Saved", "saved": "Saved",
"unsavedChanges": "Please save config first", "unsavedChanges": "Please save config first",
@@ -408,7 +408,10 @@
"title": "Restore from Cloud", "title": "Restore from Cloud",
"deviceName": "Uploaded by", "deviceName": "Uploaded by",
"createdAt": "Uploaded at", "createdAt": "Uploaded at",
"path": "Remote path",
"dbCompat": "DB compatibility",
"artifacts": "Contents", "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", "warning": "This will overwrite all local data and skill configurations",
"confirm": "Confirm Restore" "confirm": "Confirm Restore"
}, },
@@ -421,7 +424,10 @@
"existingData": "Existing cloud data", "existingData": "Existing cloud data",
"deviceName": "Uploaded by", "deviceName": "Uploaded by",
"createdAt": "Uploaded at", "createdAt": "Uploaded at",
"path": "Remote path",
"dbCompat": "DB compatibility",
"warning": "This will overwrite existing sync data on the remote server", "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" "confirm": "Confirm Upload"
} }
}, },
+7 -1
View File
@@ -397,7 +397,7 @@
"saveAndTestSuccess": "設定を保存しました。接続正常です", "saveAndTestSuccess": "設定を保存しました。接続正常です",
"saveAndTestFailed": "設定を保存しましたが、接続テストに失敗しました:{{error}}", "saveAndTestFailed": "設定を保存しましたが、接続テストに失敗しました:{{error}}",
"noRemoteData": "クラウドに同期データが見つかりません", "noRemoteData": "クラウドに同期データが見つかりません",
"incompatibleVersion": "リモートデータのバージョンに互換性がありません(v{{version}})。現在 v2 をサポートしています", "incompatibleVersion": "リモートデータに互換性がありません(プロトコル v{{protocolVersion}}、データベース {{dbCompatVersion}})。このクライアントは protocol v2 / db-v6 をサポートしています",
"unsaved": "未保存", "unsaved": "未保存",
"saved": "保存済み", "saved": "保存済み",
"unsavedChanges": "先に設定を保存してください", "unsavedChanges": "先に設定を保存してください",
@@ -408,7 +408,10 @@
"title": "クラウドから復元", "title": "クラウドから復元",
"deviceName": "アップロード元", "deviceName": "アップロード元",
"createdAt": "アップロード日時", "createdAt": "アップロード日時",
"path": "リモートパス",
"dbCompat": "DB 互換レイヤー",
"artifacts": "内容", "artifacts": "内容",
"legacyNotice": "旧レイアウトのリモートパスを検出しました。復元後、次回のアップロードは新しい v2/db-v6 パスに書き込まれます。",
"warning": "ローカルのすべてのデータとスキル設定が上書きされます", "warning": "ローカルのすべてのデータとスキル設定が上書きされます",
"confirm": "復元を実行" "confirm": "復元を実行"
}, },
@@ -421,7 +424,10 @@
"existingData": "クラウドの既存データ", "existingData": "クラウドの既存データ",
"deviceName": "アップロード元", "deviceName": "アップロード元",
"createdAt": "アップロード日時", "createdAt": "アップロード日時",
"path": "リモートパス",
"dbCompat": "DB 互換レイヤー",
"warning": "リモートの既存同期データが上書きされます", "warning": "リモートの既存同期データが上書きされます",
"legacyNotice": "旧レイアウトのリモートデータを検出しました。今回のアップロードは新しい v2/db-v6 パスに書き込み、旧パスは上書きしません。",
"confirm": "アップロードを実行" "confirm": "アップロードを実行"
} }
}, },
+7 -1
View File
@@ -397,7 +397,7 @@
"saveAndTestSuccess": "配置已保存,连接正常", "saveAndTestSuccess": "配置已保存,连接正常",
"saveAndTestFailed": "配置已保存,但连接测试失败:{{error}}", "saveAndTestFailed": "配置已保存,但连接测试失败:{{error}}",
"noRemoteData": "云端没有找到同步数据", "noRemoteData": "云端没有找到同步数据",
"incompatibleVersion": "远端数据版本不兼容(v{{version}}),当前支持 v2", "incompatibleVersion": "远端数据版本不兼容(协议 v{{protocolVersion}},数据库 {{dbCompatVersion}}),当前支持协议 v2 / db-v6",
"unsaved": "未保存", "unsaved": "未保存",
"saved": "已保存", "saved": "已保存",
"unsavedChanges": "请先保存配置", "unsavedChanges": "请先保存配置",
@@ -408,7 +408,10 @@
"title": "从云端恢复", "title": "从云端恢复",
"deviceName": "上传设备", "deviceName": "上传设备",
"createdAt": "上传时间", "createdAt": "上传时间",
"path": "远端路径",
"dbCompat": "数据库兼容层",
"artifacts": "包含内容", "artifacts": "包含内容",
"legacyNotice": "检测到旧版云端路径。恢复完成后,下次上传将写入新路径 v2/db-v6。",
"warning": "恢复将覆盖本地所有数据和技能配置", "warning": "恢复将覆盖本地所有数据和技能配置",
"confirm": "确认恢复" "confirm": "确认恢复"
}, },
@@ -421,7 +424,10 @@
"existingData": "云端已有数据", "existingData": "云端已有数据",
"deviceName": "上传设备", "deviceName": "上传设备",
"createdAt": "上传时间", "createdAt": "上传时间",
"path": "远端路径",
"dbCompat": "数据库兼容层",
"warning": "将覆盖云端已有的同步数据", "warning": "将覆盖云端已有的同步数据",
"legacyNotice": "检测到旧版云端路径数据。本次上传将写入新路径 v2/db-v6,不会覆盖旧路径。",
"confirm": "确认上传" "confirm": "确认上传"
} }
}, },
+1 -1
View File
@@ -102,7 +102,7 @@ export const settingsApi = {
return await invoke("import_config_from_file", { filePath }); return await invoke("import_config_from_file", { filePath });
}, },
// ─── WebDAV v2 sync ─────────────────────────────────────── // ─── WebDAV sync ──────────────────────────────────────────
async webdavTestConnection( async webdavTestConnection(
settings: WebDavSyncSettings, settings: WebDavSyncSettings,
+8 -2
View File
@@ -169,7 +169,7 @@ export interface VisibleApps {
openclaw: boolean; openclaw: boolean;
} }
// WebDAV v2 同步状态 // WebDAV 同步状态
export interface WebDavSyncStatus { export interface WebDavSyncStatus {
lastSyncAt?: number | null; lastSyncAt?: number | null;
lastError?: string | null; lastError?: string | null;
@@ -179,7 +179,7 @@ export interface WebDavSyncStatus {
lastRemoteManifestHash?: string | null; lastRemoteManifestHash?: string | null;
} }
// WebDAV v2 同步配置 // WebDAV 同步配置
export interface WebDavSyncSettings { export interface WebDavSyncSettings {
enabled?: boolean; enabled?: boolean;
autoSync?: boolean; autoSync?: boolean;
@@ -191,14 +191,20 @@ export interface WebDavSyncSettings {
status?: WebDavSyncStatus; status?: WebDavSyncStatus;
} }
export type RemoteSnapshotLayout = "current" | "legacy";
// 远端快照信息(下载前预览) // 远端快照信息(下载前预览)
export interface RemoteSnapshotInfo { export interface RemoteSnapshotInfo {
deviceName: string; deviceName: string;
createdAt: string; createdAt: string;
snapshotId: string; snapshotId: string;
version: number; version: number;
protocolVersion: number;
dbCompatVersion?: number | null;
compatible: boolean; compatible: boolean;
artifacts: string[]; artifacts: string[];
layout: RemoteSnapshotLayout;
remotePath: string;
} }
// 应用设置类型(用于设置对话框与 Tauri API) // 应用设置类型(用于设置对话框与 Tauri API)