mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-03-24 16:33:48 +08:00
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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<u32>,
|
||||
device_name: String,
|
||||
created_at: String,
|
||||
artifacts: BTreeMap<String, ArtifactMeta>,
|
||||
@@ -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<u8>,
|
||||
manifest_etag: Option<String>,
|
||||
}
|
||||
|
||||
// ─── 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<Value, AppError> {
|
||||
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<Value, AppError> {
|
||||
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<Option<Value>, 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::<Vec<_>>(),
|
||||
"artifacts": snapshot.manifest.artifacts.keys().collect::<Vec<_>>(),
|
||||
"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<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 {
|
||||
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<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 ───────────────────────────────────────
|
||||
|
||||
async fn download_and_verify(
|
||||
settings: &WebDavSyncSettings,
|
||||
auth: &WebDavAuth,
|
||||
layout: RemoteLayout,
|
||||
artifact_name: &str,
|
||||
artifacts: &BTreeMap<String, ArtifactMeta>,
|
||||
) -> Result<Vec<u8>, 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<String> {
|
||||
fn remote_dir_segments(settings: &WebDavSyncSettings, layout: RemoteLayout) -> Vec<String> {
|
||||
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<String, AppError> {
|
||||
let mut segs = remote_dir_segments(settings);
|
||||
fn remote_file_url(
|
||||
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));
|
||||
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<u32>) -> 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"
|
||||
|
||||
@@ -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")}
|
||||
{": "}
|
||||
<code className="ml-1 text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
/{form.remoteRoot.trim() || "cc-switch-sync"}/v2/
|
||||
{form.profile.trim() || "default"}
|
||||
{currentRemotePath}
|
||||
</code>
|
||||
</p>
|
||||
{remoteInfo && (
|
||||
@@ -742,14 +751,35 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
{t("settings.webdavSync.confirmUpload.createdAt")}
|
||||
</dt>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{remoteInfo && (
|
||||
{remoteInfo && !remoteIsLegacy && (
|
||||
<p className="text-destructive font-medium">
|
||||
{t("settings.webdavSync.confirmUpload.warning")}
|
||||
</p>
|
||||
)}
|
||||
{remoteInfo && remoteIsLegacy && (
|
||||
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{t("settings.webdavSync.confirmUpload.legacyNotice")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -793,12 +823,33 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
{t("settings.webdavSync.confirmDownload.createdAt")}
|
||||
</dt>
|
||||
<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">
|
||||
{t("settings.webdavSync.confirmDownload.artifacts")}
|
||||
</dt>
|
||||
<dd>{remoteInfo.artifacts.join(", ")}</dd>
|
||||
</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">
|
||||
{t("settings.webdavSync.confirmDownload.warning")}
|
||||
</p>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "アップロードを実行"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "确认上传"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -102,7 +102,7 @@ export const settingsApi = {
|
||||
return await invoke("import_config_from_file", { filePath });
|
||||
},
|
||||
|
||||
// ─── WebDAV v2 sync ───────────────────────────────────────
|
||||
// ─── WebDAV sync ──────────────────────────────────────────
|
||||
|
||||
async webdavTestConnection(
|
||||
settings: WebDavSyncSettings,
|
||||
|
||||
10
src/types.ts
10
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)
|
||||
|
||||
Reference in New Issue
Block a user