diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 49e1759f0..0950d9063 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -727,6 +727,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "sha2", "tauri", "tauri-build", "tauri-plugin-deep-link", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7346899a6..12e288837 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -61,6 +61,7 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] } indexmap = { version = "2", features = ["serde"] } rust_decimal = "1.33" uuid = { version = "1.11", features = ["v4"] } +sha2 = "0.10" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index 5e944bdc9..c4482629d 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -5,10 +5,15 @@ use std::path::PathBuf; use tauri::State; use tauri_plugin_dialog::DialogExt; +use crate::commands::sync_support::{ + post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning, +}; use crate::error::AppError; use crate::services::provider::ProviderService; use crate::store::AppState; +// ─── File import/export ────────────────────────────────────── + /// 导出数据库为 SQL 备份 #[tauri::command] pub async fn export_config_to_file( @@ -37,27 +42,15 @@ pub async fn import_config_from_file( state: State<'_, AppState>, ) -> Result { let db = state.db.clone(); - let db_for_state = db.clone(); + let db_for_sync = db.clone(); tauri::async_runtime::spawn_blocking(move || { let path_buf = PathBuf::from(&filePath); let backup_id = db.import_sql(&path_buf)?; - - // 导入后同步当前供应商到各自的 live 配置 - let app_state = AppState::new(db_for_state); - if let Err(err) = ProviderService::sync_current_to_live(&app_state) { - log::warn!("导入后同步 live 配置失败: {err}"); + let warning = post_sync_warning_from_result(Ok(run_post_import_sync(db_for_sync))); + if let Some(msg) = warning.as_ref() { + log::warn!("[Import] post-import sync warning: {msg}"); } - - // 重新加载设置到内存缓存,确保导入的设置生效 - if let Err(err) = crate::settings::reload_settings() { - log::warn!("导入后重载设置失败: {err}"); - } - - Ok::<_, AppError>(json!({ - "success": true, - "message": "SQL imported successfully", - "backupId": backup_id - })) + Ok::<_, AppError>(success_payload_with_warning(backup_id, warning)) }) .await .map_err(|e| format!("导入配置失败: {e}"))? @@ -80,6 +73,8 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7976c4316..f7a1b81c0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -17,7 +17,9 @@ mod session_manager; mod settings; pub mod skill; mod stream_check; +mod sync_support; mod usage; +mod webdav_sync; pub use config::*; pub use deeplink::*; @@ -37,3 +39,4 @@ pub use settings::*; pub use skill::*; pub use stream_check::*; pub use usage::*; +pub use webdav_sync::*; diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 2a6ca5039..8aac400f9 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -2,16 +2,28 @@ use tauri::AppHandle; +fn merge_settings_for_save( + mut incoming: crate::settings::AppSettings, + existing: &crate::settings::AppSettings, +) -> crate::settings::AppSettings { + if incoming.webdav_sync.is_none() { + incoming.webdav_sync = existing.webdav_sync.clone(); + } + incoming +} + /// 获取设置 #[tauri::command] pub async fn get_settings() -> Result { - Ok(crate::settings::get_settings()) + Ok(crate::settings::get_settings_for_frontend()) } /// 保存设置 #[tauri::command] pub async fn save_settings(settings: crate::settings::AppSettings) -> Result { - crate::settings::update_settings(settings).map_err(|e| e.to_string())?; + let existing = crate::settings::get_settings(); + let merged = merge_settings_for_save(settings, &existing); + crate::settings::update_settings(merged).map_err(|e| e.to_string())?; Ok(true) } @@ -54,6 +66,58 @@ pub async fn set_auto_launch(enabled: bool) -> Result { Ok(true) } +#[cfg(test)] +mod tests { + use super::merge_settings_for_save; + use crate::settings::{AppSettings, WebDavSyncSettings}; + + #[test] + fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() { + let mut existing = AppSettings::default(); + existing.webdav_sync = Some(WebDavSyncSettings { + base_url: "https://dav.example.com".to_string(), + username: "alice".to_string(), + password: "secret".to_string(), + ..WebDavSyncSettings::default() + }); + + let incoming = AppSettings::default(); + let merged = merge_settings_for_save(incoming, &existing); + + assert!(merged.webdav_sync.is_some()); + assert_eq!( + merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()), + Some("https://dav.example.com") + ); + } + + #[test] + fn save_settings_should_keep_incoming_webdav_when_present() { + let mut existing = AppSettings::default(); + existing.webdav_sync = Some(WebDavSyncSettings { + base_url: "https://dav.old.example.com".to_string(), + username: "old".to_string(), + password: "old-pass".to_string(), + ..WebDavSyncSettings::default() + }); + + let mut incoming = AppSettings::default(); + incoming.webdav_sync = Some(WebDavSyncSettings { + base_url: "https://dav.new.example.com".to_string(), + username: "new".to_string(), + password: "new-pass".to_string(), + ..WebDavSyncSettings::default() + }); + + let merged = merge_settings_for_save(incoming, &existing); + + assert_eq!( + merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()), + Some("https://dav.new.example.com") + ); + } +} + /// 获取开机自启状态 #[tauri::command] pub async fn get_auto_launch_status() -> Result { diff --git a/src-tauri/src/commands/sync_support.rs b/src-tauri/src/commands/sync_support.rs new file mode 100644 index 000000000..00793b3b8 --- /dev/null +++ b/src-tauri/src/commands/sync_support.rs @@ -0,0 +1,97 @@ +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::database::Database; +use crate::error::AppError; +use crate::services::provider::ProviderService; +use crate::settings; +use crate::store::AppState; + +pub(crate) fn run_post_import_sync(db: Arc) -> Result<(), AppError> { + let app_state = AppState::new(db); + ProviderService::sync_current_to_live(&app_state)?; + settings::reload_settings()?; + Ok(()) +} + +fn post_sync_warning(err: E) -> String { + AppError::localized( + "sync.post_operation_sync_failed", + format!("后置同步状态失败: {err}"), + format!("Post-operation synchronization failed: {err}"), + ) + .to_string() +} + +pub(crate) fn post_sync_warning_from_result( + result: Result, String>, +) -> Option { + match result { + Ok(Ok(())) => None, + Ok(Err(err)) => Some(post_sync_warning(err)), + Err(err) => Some(post_sync_warning(err)), + } +} + +pub(crate) fn attach_warning(mut value: Value, warning: Option) -> Value { + if let Some(message) = warning { + if let Some(obj) = value.as_object_mut() { + obj.insert("warning".to_string(), Value::String(message)); + } + } + value +} + +pub(crate) fn success_payload_with_warning(backup_id: String, warning: Option) -> Value { + attach_warning( + json!({ + "success": true, + "message": "SQL imported successfully", + "backupId": backup_id + }), + warning, + ) +} + +#[cfg(test)] +mod tests { + use super::{attach_warning, post_sync_warning_from_result}; + use serde_json::json; + + #[test] + fn post_sync_warning_from_result_returns_none_on_success() { + let warning = post_sync_warning_from_result(Ok(Ok(()))); + assert!(warning.is_none()); + } + + #[test] + fn post_sync_warning_from_result_returns_some_on_sync_error() { + let warning = + post_sync_warning_from_result(Ok(Err(crate::error::AppError::Config("boom".into())))); + assert!(warning.is_some()); + } + + #[tokio::test] + async fn post_sync_warning_from_result_returns_some_on_join_error() { + let handle = tokio::spawn(async move { + panic!("forced join error"); + }); + let join_err = handle.await.expect_err("task should panic"); + let warning = post_sync_warning_from_result(Err(join_err.to_string())); + assert!(warning.is_some()); + } + + #[test] + fn attach_warning_adds_warning_without_dropping_existing_fields() { + let payload = json!({ "status": "downloaded" }); + let updated = attach_warning(payload, Some("post sync warning".to_string())); + assert_eq!( + updated.get("status").and_then(|v| v.as_str()), + Some("downloaded") + ); + assert_eq!( + updated.get("warning").and_then(|v| v.as_str()), + Some("post sync warning") + ); + } +} diff --git a/src-tauri/src/commands/webdav_sync.rs b/src-tauri/src/commands/webdav_sync.rs new file mode 100644 index 000000000..e1bbd70d7 --- /dev/null +++ b/src-tauri/src/commands/webdav_sync.rs @@ -0,0 +1,357 @@ +#![allow(non_snake_case)] + +use serde_json::{Value, json}; +use std::future::Future; +use std::sync::OnceLock; +use tauri::State; + +use crate::commands::sync_support::{ + attach_warning, post_sync_warning_from_result, run_post_import_sync, +}; +use crate::error::AppError; +use crate::services::webdav_sync as webdav_sync_service; +use crate::settings::{self, WebDavSyncSettings}; +use crate::store::AppState; + +fn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError) { + settings.status.last_error = Some(error.to_string()); + let _ = settings::update_webdav_sync_status(settings.status.clone()); +} + +fn webdav_not_configured_error() -> String { + AppError::localized( + "webdav.sync.not_configured", + "未配置 WebDAV 同步", + "WebDAV sync is not configured.", + ) + .to_string() +} + +fn webdav_sync_disabled_error() -> String { + AppError::localized( + "webdav.sync.disabled", + "WebDAV 同步未启用", + "WebDAV sync is disabled.", + ) + .to_string() +} + +fn require_enabled_webdav_settings() -> Result { + let settings = settings::get_webdav_sync_settings().ok_or_else(webdav_not_configured_error)?; + if !settings.enabled { + return Err(webdav_sync_disabled_error()); + } + Ok(settings) +} + +fn resolve_password_for_request( + mut incoming: WebDavSyncSettings, + existing: Option, + preserve_empty_password: bool, +) -> WebDavSyncSettings { + if let Some(existing_settings) = existing { + if preserve_empty_password && incoming.password.is_empty() { + incoming.password = existing_settings.password; + } + } + incoming +} + +fn webdav_sync_mutex() -> &'static tokio::sync::Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) +} + +async fn run_with_webdav_lock(operation: Fut) -> Result +where + Fut: Future>, +{ + let result = { + let _guard = webdav_sync_mutex().lock().await; + operation.await + }; + result +} + +fn map_sync_result(result: Result, on_error: F) -> Result +where + F: FnOnce(&AppError), +{ + match result { + Ok(value) => Ok(value), + Err(err) => { + on_error(&err); + Err(err.to_string()) + } + } +} + +#[tauri::command] +pub async fn webdav_test_connection( + settings: WebDavSyncSettings, + #[allow(non_snake_case)] preserveEmptyPassword: Option, +) -> Result { + let preserve_empty = preserveEmptyPassword.unwrap_or(true); + let resolved = resolve_password_for_request( + settings, + settings::get_webdav_sync_settings(), + preserve_empty, + ); + webdav_sync_service::check_connection(&resolved) + .await + .map_err(|e| e.to_string())?; + Ok(json!({ + "success": true, + "message": "WebDAV connection ok" + })) +} + +#[tauri::command] +pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result { + let db = state.db.clone(); + let mut settings = require_enabled_webdav_settings()?; + + let result = run_with_webdav_lock(webdav_sync_service::upload(&db, &mut settings)).await; + map_sync_result(result, |error| persist_sync_error(&mut settings, error)) +} + +#[tauri::command] +pub async fn webdav_sync_download(state: State<'_, AppState>) -> Result { + let db = state.db.clone(); + let db_for_sync = db.clone(); + let mut settings = require_enabled_webdav_settings()?; + + let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await; + let mut result = map_sync_result(sync_result, |error| { + persist_sync_error(&mut settings, error) + })?; + + // Post-download sync is best-effort: snapshot restore has already succeeded. + let warning = post_sync_warning_from_result( + tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync)) + .await + .map_err(|e| e.to_string()), + ); + if let Some(msg) = warning.as_ref() { + log::warn!("[WebDAV] post-download sync warning: {msg}"); + } + result = attach_warning(result, warning); + + Ok(result) +} + +#[tauri::command] +pub async fn webdav_sync_save_settings( + settings: WebDavSyncSettings, + #[allow(non_snake_case)] passwordTouched: Option, +) -> Result { + let password_touched = passwordTouched.unwrap_or(false); + let existing = settings::get_webdav_sync_settings(); + let mut sync_settings = + resolve_password_for_request(settings, existing.clone(), !password_touched); + + // Preserve server-owned fields that the frontend does not manage + if let Some(existing_settings) = existing { + sync_settings.status = existing_settings.status; + } + + sync_settings.normalize(); + sync_settings.validate().map_err(|e| e.to_string())?; + settings::set_webdav_sync_settings(Some(sync_settings)).map_err(|e| e.to_string())?; + Ok(json!({ "success": true })) +} + +#[tauri::command] +pub async fn webdav_sync_fetch_remote_info() -> Result { + let settings = require_enabled_webdav_settings()?; + let info = webdav_sync_service::fetch_remote_info(&settings) + .await + .map_err(|e| e.to_string())?; + Ok(info.unwrap_or(json!({ "empty": true }))) +} + +#[cfg(test)] +mod tests { + use super::{ + map_sync_result, persist_sync_error, require_enabled_webdav_settings, + resolve_password_for_request, run_with_webdav_lock, webdav_sync_mutex, + }; + use crate::error::AppError; + use crate::settings::{AppSettings, WebDavSyncSettings}; + use serial_test::serial; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + #[tokio::test] + async fn webdav_sync_mutex_is_singleton() { + let a = webdav_sync_mutex() as *const _; + let b = webdav_sync_mutex() as *const _; + assert_eq!(a, b); + } + + #[tokio::test] + #[serial] + async fn webdav_sync_mutex_serializes_concurrent_access() { + let guard = webdav_sync_mutex().lock().await; + let acquired = Arc::new(AtomicBool::new(false)); + let acquired_bg = Arc::clone(&acquired); + + let waiter = tokio::spawn(async move { + let _inner_guard = webdav_sync_mutex().lock().await; + acquired_bg.store(true, Ordering::SeqCst); + }); + + tokio::time::sleep(Duration::from_millis(40)).await; + assert!(!acquired.load(Ordering::SeqCst)); + + drop(guard); + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("background task should complete after lock release") + .expect("background task should not panic"); + + assert!(acquired.load(Ordering::SeqCst)); + } + + #[tokio::test] + #[serial] + async fn map_sync_result_runs_error_handler_after_lock_release() { + let result = run_with_webdav_lock(async { + Err::<(), AppError>(AppError::Config("boom".to_string())) + }) + .await; + + let mut lock_released = false; + let mapped = map_sync_result(result, |_| { + lock_released = webdav_sync_mutex().try_lock().is_ok(); + }); + + assert!(mapped.is_err()); + assert!(lock_released); + } + + #[test] + fn resolve_password_for_request_preserves_existing_when_requested() { + let incoming = WebDavSyncSettings { + base_url: "https://dav.example.com".to_string(), + username: "alice".to_string(), + password: String::new(), + ..WebDavSyncSettings::default() + }; + let existing = Some(WebDavSyncSettings { + password: "secret".to_string(), + ..WebDavSyncSettings::default() + }); + let resolved = resolve_password_for_request(incoming, existing, true); + assert_eq!(resolved.password, "secret"); + } + + #[test] + fn resolve_password_for_request_allows_explicit_empty_password() { + let incoming = WebDavSyncSettings { + base_url: "https://dav.example.com".to_string(), + username: "alice".to_string(), + password: String::new(), + ..WebDavSyncSettings::default() + }; + let existing = Some(WebDavSyncSettings { + password: "secret".to_string(), + ..WebDavSyncSettings::default() + }); + let resolved = resolve_password_for_request(incoming, existing, false); + assert!(resolved.password.is_empty()); + } + + #[test] + #[serial] + fn persist_sync_error_updates_status_without_overwriting_credentials() { + let test_home = std::env::temp_dir().join("cc-switch-sync-error-status-test"); + let _ = std::fs::remove_dir_all(&test_home); + std::fs::create_dir_all(&test_home).expect("create test home"); + std::env::set_var("CC_SWITCH_TEST_HOME", &test_home); + + crate::settings::update_settings(AppSettings::default()).expect("reset settings"); + let mut current = WebDavSyncSettings { + enabled: true, + base_url: "https://dav.example.com/dav/".to_string(), + username: "alice".to_string(), + password: "secret".to_string(), + remote_root: "cc-switch-sync".to_string(), + profile: "default".to_string(), + ..WebDavSyncSettings::default() + }; + crate::settings::set_webdav_sync_settings(Some(current.clone())) + .expect("seed webdav settings"); + + persist_sync_error( + &mut current, + &crate::error::AppError::Config("boom".to_string()), + ); + + let after = crate::settings::get_webdav_sync_settings().expect("read webdav settings"); + assert_eq!(after.base_url, "https://dav.example.com/dav/"); + assert_eq!(after.username, "alice"); + assert_eq!(after.password, "secret"); + assert_eq!(after.remote_root, "cc-switch-sync"); + assert_eq!(after.profile, "default"); + assert!( + after + .status + .last_error + .as_deref() + .unwrap_or_default() + .contains("boom"), + "status error should be updated" + ); + } + + #[test] + #[serial] + fn require_enabled_webdav_settings_rejects_disabled_config() { + let test_home = std::env::temp_dir().join("cc-switch-sync-enabled-disabled-test"); + let _ = std::fs::remove_dir_all(&test_home); + std::fs::create_dir_all(&test_home).expect("create test home"); + std::env::set_var("CC_SWITCH_TEST_HOME", &test_home); + + crate::settings::update_settings(AppSettings::default()).expect("reset settings"); + crate::settings::set_webdav_sync_settings(Some(WebDavSyncSettings { + enabled: false, + base_url: "https://dav.example.com/dav/".to_string(), + username: "alice".to_string(), + password: "secret".to_string(), + ..WebDavSyncSettings::default() + })) + .expect("seed disabled webdav settings"); + + let err = require_enabled_webdav_settings().expect_err("disabled settings should fail"); + assert!( + err.contains("disabled") || err.contains("未启用"), + "unexpected error: {err}" + ); + } + + #[test] + #[serial] + fn require_enabled_webdav_settings_returns_settings_when_enabled() { + let test_home = std::env::temp_dir().join("cc-switch-sync-enabled-ok-test"); + let _ = std::fs::remove_dir_all(&test_home); + std::fs::create_dir_all(&test_home).expect("create test home"); + std::env::set_var("CC_SWITCH_TEST_HOME", &test_home); + + crate::settings::update_settings(AppSettings::default()).expect("reset settings"); + crate::settings::set_webdav_sync_settings(Some(WebDavSyncSettings { + enabled: true, + base_url: "https://dav.example.com/dav/".to_string(), + username: "alice".to_string(), + password: "secret".to_string(), + ..WebDavSyncSettings::default() + })) + .expect("seed enabled webdav settings"); + + let settings = + require_enabled_webdav_settings().expect("enabled settings should be accepted"); + assert!(settings.enabled); + assert_eq!(settings.base_url, "https://dav.example.com/dav/"); + } +} diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index bd3df4181..484b22569 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -16,10 +16,15 @@ use tempfile::NamedTempFile; const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出"; impl Database { + /// 导出为 SQLite 兼容的 SQL 文本(内存字符串) + pub fn export_sql_string(&self) -> Result { + let snapshot = self.snapshot_to_memory()?; + Self::dump_sql(&snapshot) + } + /// 导出为 SQLite 兼容的 SQL 文本 pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> { - let snapshot = self.snapshot_to_memory()?; - let dump = Self::dump_sql(&snapshot)?; + let dump = self.export_sql_string()?; if let Some(parent) = target_path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; @@ -38,6 +43,12 @@ impl Database { } let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?; + let sql_content = sql_raw.trim_start_matches('\u{feff}'); + self.import_sql_string(sql_content) + } + + /// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串) + pub fn import_sql_string(&self, sql_raw: &str) -> Result { let sql_content = sql_raw.trim_start_matches('\u{feff}'); Self::validate_cc_switch_sql_export(sql_content)?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e2657745a..ef47fdaee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -886,6 +886,11 @@ pub fn run() { // theirs: config import/export and dialogs commands::export_config_to_file, commands::import_config_from_file, + commands::webdav_test_connection, + commands::webdav_sync_upload, + commands::webdav_sync_download, + commands::webdav_sync_save_settings, + commands::webdav_sync_fetch_remote_info, commands::save_file_dialog, commands::open_file_dialog, commands::open_zip_file_dialog, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 0875fdeb3..7a49e531c 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -10,6 +10,8 @@ pub mod skill; pub mod speedtest; pub mod stream_check; pub mod usage_stats; +pub mod webdav; +pub mod webdav_sync; pub use config::ConfigService; pub use mcp::McpService; diff --git a/src-tauri/src/services/webdav.rs b/src-tauri/src/services/webdav.rs new file mode 100644 index 000000000..c9d376e70 --- /dev/null +++ b/src-tauri/src/services/webdav.rs @@ -0,0 +1,507 @@ +//! WebDAV HTTP transport layer. +//! +//! Low-level HTTP primitives for WebDAV operations (PUT, GET, HEAD, MKCOL, PROPFIND). +//! The sync protocol logic lives in [`super::webdav_sync`]. + +use reqwest::{Method, RequestBuilder, StatusCode, Url}; +use std::time::Duration; + +use crate::error::AppError; +use crate::proxy::http_client; + +const DEFAULT_TIMEOUT_SECS: u64 = 30; +/// Timeout for large file transfers (PUT/GET of db.sql, skills.zip). +const TRANSFER_TIMEOUT_SECS: u64 = 300; + +/// Auth pair: `(username, Some(password))`. +pub type WebDavAuth = Option<(String, Option)>; + +// ─── WebDAV extension methods ──────────────────────────────── + +fn method_propfind() -> Method { + Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid HTTP method") +} + +fn method_mkcol() -> Method { + Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method") +} + +// ─── URL utilities ─────────────────────────────────────────── + +/// Parse and validate a WebDAV base URL (must be http or https). +pub fn parse_base_url(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(AppError::localized( + "webdav.base_url.required", + "WebDAV 地址不能为空", + "WebDAV URL is required.", + )); + } + let url = Url::parse(trimmed).map_err(|e| { + AppError::localized( + "webdav.base_url.invalid", + format!("WebDAV 地址无效: {e}"), + format!("Invalid WebDAV URL: {e}"), + ) + })?; + match url.scheme() { + "http" | "https" => Ok(url), + _ => Err(AppError::localized( + "webdav.base_url.scheme_invalid", + "WebDAV 仅支持 http/https 地址", + "WebDAV URL must use http or https.", + )), + } +} + +/// Build a full URL from a base URL string and path segments. +/// +/// Each segment is individually percent-encoded by the `url` crate. +pub fn build_remote_url(base_url: &str, segments: &[String]) -> Result { + let mut url = parse_base_url(base_url)?; + { + let mut path = url.path_segments_mut().map_err(|_| { + AppError::localized( + "webdav.base_url.unusable", + "WebDAV 地址格式不支持追加路径", + "WebDAV URL format does not support appending path segments.", + ) + })?; + path.pop_if_empty(); + for seg in segments { + path.push(seg); + } + } + Ok(url.to_string()) +} + +/// Split a slash-delimited path into non-empty segments. +pub fn path_segments(raw: &str) -> impl Iterator { + raw.trim_matches('/').split('/').filter(|s| !s.is_empty()) +} + +// ─── Auth ──────────────────────────────────────────────────── + +/// Build auth from username/password. Returns `None` if username is blank. +pub fn auth_from_credentials(username: &str, password: &str) -> WebDavAuth { + let user = username.trim(); + if user.is_empty() { + return None; + } + Some((user.to_string(), Some(password.to_string()))) +} + +/// Apply Basic-Auth to a request builder if auth is present. +fn apply_auth(builder: RequestBuilder, auth: &WebDavAuth) -> RequestBuilder { + match auth { + Some((user, pass)) => builder.basic_auth(user, pass.as_deref()), + None => builder, + } +} + +fn webdav_transport_error( + key: &'static str, + op_zh: &str, + op_en: &str, + target_url: &str, + err: &reqwest::Error, +) -> AppError { + let (zh_reason, en_reason) = if err.is_timeout() { + ("请求超时", "request timed out") + } else if err.is_connect() { + ("连接失败", "connection failed") + } else if err.is_request() { + ("请求构造失败", "request build failed") + } else { + ("网络请求失败", "network request failed") + }; + + let safe_url = redact_url(target_url); + AppError::localized( + key, + format!("WebDAV {op_zh}失败({zh_reason}): {safe_url}"), + format!("WebDAV {op_en} failed ({en_reason}): {safe_url}"), + ) +} + +// ─── HTTP operations ───────────────────────────────────────── + +/// Test WebDAV connectivity via PROPFIND Depth=0 on the base URL. +pub async fn test_connection(base_url: &str, auth: &WebDavAuth) -> Result<(), AppError> { + let url = parse_base_url(base_url)?; + let client = http_client::get(); + + let resp = apply_auth( + client + .request(method_propfind(), url) + .header("Depth", "0") + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), + auth, + ) + .send() + .await + .map_err(|e| { + webdav_transport_error( + "webdav.connection_failed", + "连接", + "connection", + base_url, + &e, + ) + })?; + + if resp.status().is_success() || resp.status() == StatusCode::MULTI_STATUS { + return Ok(()); + } + Err(webdav_status_error("PROPFIND", resp.status(), base_url)) +} + +/// Ensure a chain of remote directories exists. +/// +/// Uses optimistic MKCOL: try creating first, fall back to PROPFIND verification +/// on ambiguous responses. This halves the round-trips vs PROPFIND-first approach. +pub async fn ensure_remote_directories( + base_url: &str, + segments: &[String], + auth: &WebDavAuth, +) -> Result<(), AppError> { + if segments.is_empty() { + return Ok(()); + } + let client = http_client::get(); + + for depth in 1..=segments.len() { + let prefix = &segments[..depth]; + let url = build_remote_url(base_url, prefix)?; + let dir_url = if url.ends_with('/') { + url + } else { + format!("{url}/") + }; + + let resp = apply_auth( + client + .request(method_mkcol(), &dir_url) + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), + auth, + ) + .send() + .await + .map_err(|e| { + webdav_transport_error( + "webdav.mkcol_failed", + "MKCOL 请求", + "MKCOL request", + &dir_url, + &e, + ) + })?; + + let status = resp.status(); + match status { + s if s == StatusCode::CREATED || s.is_success() => { + log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url)); + } + // 405 commonly means "already exists" on many WebDAV servers + StatusCode::METHOD_NOT_ALLOWED => {} + // Ambiguous — verify directory actually exists via PROPFIND + s if s == StatusCode::CONFLICT || s.is_redirection() => { + if !propfind_exists(&client, &dir_url, auth).await? { + return Err(webdav_status_error("MKCOL", status, &dir_url)); + } + } + _ => { + return Err(webdav_status_error("MKCOL", status, &dir_url)); + } + } + } + Ok(()) +} + +/// PUT bytes to a remote WebDAV URL. +pub async fn put_bytes( + url: &str, + auth: &WebDavAuth, + bytes: Vec, + content_type: &str, +) -> Result<(), AppError> { + let client = http_client::get(); + let resp = apply_auth( + client + .put(url) + .header("Content-Type", content_type) + .body(bytes) + .timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)), + auth, + ) + .send() + .await + .map_err(|e| { + webdav_transport_error( + "webdav.put_failed", + "PUT 请求", + "PUT request", + url, + &e, + ) + })?; + + if resp.status().is_success() { + return Ok(()); + } + Err(webdav_status_error("PUT", resp.status(), url)) +} + +/// GET bytes from a remote WebDAV URL. Returns `None` on 404. +/// +/// On success returns `(body_bytes, optional_etag)`. +pub async fn get_bytes( + url: &str, + auth: &WebDavAuth, +) -> Result, Option)>, AppError> { + let client = http_client::get(); + let resp = apply_auth( + client + .get(url) + .timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)), + auth, + ) + .send() + .await + .map_err(|e| { + webdav_transport_error( + "webdav.get_failed", + "GET 请求", + "GET request", + url, + &e, + ) + })?; + + if resp.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + if !resp.status().is_success() { + return Err(webdav_status_error("GET", resp.status(), url)); + } + let etag = resp + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let bytes = resp + .bytes() + .await + .map_err(|e| { + AppError::localized( + "webdav.response_read_failed", + format!("读取 WebDAV 响应失败: {e}"), + format!("Failed to read WebDAV response: {e}"), + ) + })?; + Ok(Some((bytes.to_vec(), etag))) +} + +/// HEAD request to retrieve the ETag. Returns `None` on 404. +pub async fn head_etag(url: &str, auth: &WebDavAuth) -> Result, AppError> { + let client = http_client::get(); + let resp = apply_auth( + client + .head(url) + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), + auth, + ) + .send() + .await + .map_err(|e| { + webdav_transport_error( + "webdav.head_failed", + "HEAD 请求", + "HEAD request", + url, + &e, + ) + })?; + + if resp.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + if !resp.status().is_success() { + return Err(webdav_status_error("HEAD", resp.status(), url)); + } + Ok(resp + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string())) +} + +// ─── Internal helpers ──────────────────────────────────────── + +/// PROPFIND Depth=0 to check if a remote resource exists. +async fn propfind_exists( + client: &reqwest::Client, + url: &str, + auth: &WebDavAuth, +) -> Result { + let resp = apply_auth( + client + .request(method_propfind(), url) + .header("Depth", "0") + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), + auth, + ) + .send() + .await; + match resp { + Ok(r) => Ok(r.status().is_success() || r.status() == StatusCode::MULTI_STATUS), + Err(e) => { + log::warn!( + "[WebDAV] PROPFIND check failed for {}: {e}", + redact_url(url) + ); + Ok(false) + } + } +} + +// ─── Service detection & error helpers ─────────────────────── + +/// Check if a URL points to Jianguoyun (坚果云). +pub fn is_jianguoyun(url: &str) -> bool { + Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_lowercase())) + .map(|host| host.contains("jianguoyun.com") || host.contains("nutstore")) + .unwrap_or(false) +} + +/// Build an `AppError` with service-specific hints for WebDAV failures. +pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError { + let safe_url = redact_url(url); + let mut zh = format!("WebDAV {op} 失败: {status} ({safe_url})"); + let mut en = format!("WebDAV {op} failed: {status} ({safe_url})"); + let jgy = is_jianguoyun(url); + + if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { + if jgy { + zh.push_str( + "。坚果云请使用「第三方应用密码」,并确认地址指向 /dav/ 下的目录。", + ); + en.push_str( + ". For Jianguoyun, use an app-specific password and ensure the URL points under /dav/.", + ); + } else { + zh.push_str("。请检查 WebDAV 用户名、密码及目录读写权限。"); + en.push_str(". Please check WebDAV username/password and directory permissions."); + } + } else if jgy && (status == StatusCode::NOT_FOUND || status.is_redirection()) { + zh.push_str("。坚果云常见原因:地址不在 /dav/ 可写目录下。"); + en.push_str(". Common Jianguoyun cause: URL is outside a writable /dav/ directory."); + } else if op == "MKCOL" && status == StatusCode::CONFLICT { + if jgy { + zh.push_str( + "。坚果云不允许自动创建顶层文件夹,请先在网页端手动创建后重试。", + ); + en.push_str( + ". Jianguoyun does not allow creating top-level folders automatically; create it manually first.", + ); + } else { + zh.push_str("。请确认上级目录存在。"); + en.push_str(". Please ensure the parent directory exists."); + } + } + + AppError::localized("webdav.http.status", zh, en) +} + +fn redact_url(raw: &str) -> String { + match Url::parse(raw) { + Ok(mut parsed) => { + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + + let mut out = format!("{}://", parsed.scheme()); + if let Some(host) = parsed.host_str() { + out.push_str(host); + } + if let Some(port) = parsed.port() { + out.push(':'); + out.push_str(&port.to_string()); + } + out.push_str(parsed.path()); + + let mut keys: Vec = parsed.query_pairs().map(|(k, _)| k.into_owned()).collect(); + keys.sort(); + keys.dedup(); + if !keys.is_empty() { + out.push_str("?[keys:"); + out.push_str(&keys.join(",")); + out.push(']'); + } + out + } + Err(_) => raw.split('?').next().unwrap_or(raw).to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_remote_url_encodes_path_segments() { + let url = build_remote_url( + "https://dav.example.com/remote.php/dav/files/demo/", + &[ + "cc switch-sync".to_string(), + "v2".to_string(), + "default profile".to_string(), + "manifest.json".to_string(), + ], + ) + .unwrap(); + assert_eq!( + url, + "https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v2/default%20profile/manifest.json" + ); + assert!(!url.contains("//cc"), "should not have double-slash"); + } + + #[test] + fn is_jianguoyun_detects_correctly() { + assert!(is_jianguoyun("https://dav.jianguoyun.com/dav")); + assert!(is_jianguoyun("https://dav.jianguoyun.com/dav/folder")); + assert!(!is_jianguoyun("https://nextcloud.example.com/dav")); + } + + #[test] + fn path_segments_splits_correctly() { + let segs: Vec<_> = path_segments("/a/b/c/").collect(); + assert_eq!(segs, vec!["a", "b", "c"]); + + let segs: Vec<_> = path_segments("single").collect(); + assert_eq!(segs, vec!["single"]); + + let segs: Vec<_> = path_segments("").collect(); + assert!(segs.is_empty()); + } + + #[test] + fn auth_from_credentials_trims_and_rejects_blank() { + assert!(auth_from_credentials(" ", "pass").is_none()); + let auth = auth_from_credentials(" user ", "pass"); + assert_eq!(auth, Some(("user".to_string(), Some("pass".to_string())))); + } + + #[test] + fn redact_url_hides_credentials_and_query_values() { + let redacted = redact_url("https://alice:secret@example.com:8443/dav?token=abc&foo=1"); + assert_eq!( + redacted, + "https://example.com:8443/dav?[keys:foo,token]" + ); + assert!(!redacted.contains("secret")); + } +} diff --git a/src-tauri/src/services/webdav_sync.rs b/src-tauri/src/services/webdav_sync.rs new file mode 100644 index 000000000..99d787d26 --- /dev/null +++ b/src-tauri/src/services/webdav_sync.rs @@ -0,0 +1,649 @@ +//! WebDAV v2 sync protocol layer. +//! +//! Implements manifest-based synchronization on top of the HTTP transport +//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`. + +use std::collections::BTreeMap; +use std::fs; +use std::process::Command; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tempfile::tempdir; + +use crate::error::AppError; +use crate::services::webdav::{ + auth_from_credentials, build_remote_url, ensure_remote_directories, get_bytes, head_etag, + path_segments, put_bytes, test_connection, WebDavAuth, +}; +use crate::settings::{update_webdav_sync_status, WebDavSyncSettings, WebDavSyncStatus}; + +mod archive; +use archive::{ + backup_current_skills, restore_skills_from_backup, restore_skills_zip, zip_skills_ssot, +}; + +// ─── Protocol constants ────────────────────────────────────── + +const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync"; +const PROTOCOL_VERSION: u32 = 2; +const REMOTE_DB_SQL: &str = "db.sql"; +const REMOTE_SKILLS_ZIP: &str = "skills.zip"; +const REMOTE_MANIFEST: &str = "manifest.json"; +const MAX_DEVICE_NAME_LEN: usize = 64; + +fn localized(key: &'static str, zh: impl Into, en: impl Into) -> AppError { + AppError::localized(key, zh, en) +} + +fn io_context_localized( + _key: &'static str, + zh: impl Into, + en: impl Into, + source: std::io::Error, +) -> AppError { + let zh_msg = zh.into(); + let en_msg = en.into(); + AppError::IoContext { + context: format!("{zh_msg} ({en_msg})"), + source, + } +} + +// ─── Types ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SyncManifest { + format: String, + version: u32, + device_name: String, + created_at: String, + artifacts: BTreeMap, + snapshot_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ArtifactMeta { + sha256: String, + size: u64, +} + +struct LocalSnapshot { + db_sql: Vec, + skills_zip: Vec, + manifest_bytes: Vec, + manifest_hash: String, +} + +// ─── Public API ────────────────────────────────────────────── + +/// Check WebDAV connectivity and ensure remote directory structure. +pub async fn check_connection(settings: &WebDavSyncSettings) -> Result<(), AppError> { + settings.validate()?; + let auth = auth_for(settings); + test_connection(&settings.base_url, &auth).await?; + let dir_segs = remote_dir_segments(settings); + ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?; + Ok(()) +} + +/// Upload local snapshot (db + skills) to remote. +pub async fn upload( + db: &crate::database::Database, + settings: &mut WebDavSyncSettings, +) -> Result { + settings.validate()?; + let auth = auth_for(settings); + let dir_segs = remote_dir_segments(settings); + 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)?; + put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?; + + let skills_url = remote_file_url(settings, REMOTE_SKILLS_ZIP)?; + put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?; + + let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?; + put_bytes( + &manifest_url, + &auth, + snapshot.manifest_bytes, + "application/json", + ) + .await?; + + // Fetch etag (best-effort, don't fail the upload) + let etag = match head_etag(&manifest_url, &auth).await { + Ok(e) => e, + Err(e) => { + log::debug!("[WebDAV] Failed to fetch ETag after upload: {e}"); + None + } + }; + + let _persisted = persist_sync_success_best_effort( + settings, + snapshot.manifest_hash, + etag, + persist_sync_success, + ); + Ok(serde_json::json!({ "status": "uploaded" })) +} + +/// Download remote snapshot and apply to local database + skills. +pub async fn download( + db: &crate::database::Database, + settings: &mut WebDavSyncSettings, +) -> 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).await?.ok_or_else(|| { + localized( + "webdav.sync.remote_empty", + "远端没有可下载的同步数据", + "No downloadable sync data found on the remote.", + ) + })?; + + 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)?; + + // 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?; + + // 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" })) +} + +/// 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).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 payload = serde_json::json!({ + "deviceName": manifest.device_name, + "createdAt": manifest.created_at, + "snapshotId": manifest.snapshot_id, + "version": manifest.version, + "compatible": compatible, + "artifacts": manifest.artifacts.keys().collect::>(), + }); + + Ok(Some(payload)) +} + +// ─── Sync status persistence (I3: deduplicated) ───────────── + +fn persist_sync_success( + settings: &mut WebDavSyncSettings, + manifest_hash: String, + etag: Option, +) -> Result<(), AppError> { + let status = WebDavSyncStatus { + last_sync_at: Some(Utc::now().timestamp()), + last_error: None, + last_local_manifest_hash: Some(manifest_hash.clone()), + last_remote_manifest_hash: Some(manifest_hash), + last_remote_etag: etag, + }; + settings.status = status.clone(); + update_webdav_sync_status(status) +} + +fn persist_sync_success_best_effort( + settings: &mut WebDavSyncSettings, + manifest_hash: String, + etag: Option, + persist_fn: F, +) -> bool +where + F: FnOnce(&mut WebDavSyncSettings, String, Option) -> Result<(), AppError>, +{ + match persist_fn(settings, manifest_hash, etag) { + Ok(()) => true, + Err(err) => { + log::warn!("[WebDAV] Persist sync status failed, keep operation success: {err}"); + false + } + } +} + +// ─── Snapshot building ─────────────────────────────────────── + +fn build_local_snapshot( + db: &crate::database::Database, + _settings: &WebDavSyncSettings, +) -> Result { + // Export database to SQL string + let sql_string = db.export_sql_string()?; + let db_sql = sql_string.into_bytes(); + + // Pack skills into deterministic ZIP + let tmp = tempdir().map_err(|e| { + io_context_localized( + "webdav.sync.snapshot_tmpdir_failed", + "创建 WebDAV 快照临时目录失败", + "Failed to create temporary directory for WebDAV snapshot", + e, + ) + })?; + let skills_zip_path = tmp.path().join(REMOTE_SKILLS_ZIP); + zip_skills_ssot(&skills_zip_path)?; + let skills_zip = fs::read(&skills_zip_path).map_err(|e| AppError::io(&skills_zip_path, e))?; + + // Build artifact map and compute hashes + let mut artifacts = BTreeMap::new(); + artifacts.insert( + REMOTE_DB_SQL.to_string(), + ArtifactMeta { + sha256: sha256_hex(&db_sql), + size: db_sql.len() as u64, + }, + ); + artifacts.insert( + REMOTE_SKILLS_ZIP.to_string(), + ArtifactMeta { + sha256: sha256_hex(&skills_zip), + size: skills_zip.len() as u64, + }, + ); + + let snapshot_id = compute_snapshot_id(&artifacts); + let manifest = SyncManifest { + format: PROTOCOL_FORMAT.to_string(), + version: PROTOCOL_VERSION, + device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()), + created_at: Utc::now().to_rfc3339(), + artifacts, + snapshot_id, + }; + let manifest_bytes = + serde_json::to_vec_pretty(&manifest).map_err(|e| AppError::JsonSerialize { source: e })?; + let manifest_hash = sha256_hex(&manifest_bytes); + + Ok(LocalSnapshot { + db_sql, + skills_zip, + manifest_bytes, + manifest_hash, + }) +} + +/// Compute a deterministic snapshot identity from artifact hashes. +/// +/// BTreeMap iteration order is sorted by key, ensuring stability. +fn compute_snapshot_id(artifacts: &BTreeMap) -> String { + let parts: Vec = artifacts + .iter() + .map(|(name, meta)| format!("{}:{}", name, meta.sha256)) + .collect(); + sha256_hex(parts.join("|").as_bytes()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +fn detect_system_device_name() -> Option { + let env_name = [ + "CC_SWITCH_DEVICE_NAME", + "COMPUTERNAME", + "HOSTNAME", + ] + .iter() + .filter_map(|key| std::env::var(key).ok()) + .find_map(|value| normalize_device_name(&value)); + + if env_name.is_some() { + return env_name; + } + + let output = Command::new("hostname").output().ok()?; + if !output.status.success() { + return None; + } + let hostname = String::from_utf8(output.stdout).ok()?; + normalize_device_name(&hostname) +} + +fn normalize_device_name(raw: &str) -> Option { + let compact = raw.chars().fold(String::with_capacity(raw.len()), |mut acc, ch| { + if ch.is_whitespace() { + acc.push(' '); + } else if !ch.is_control() { + acc.push(ch); + } + acc + }); + let normalized = compact.split_whitespace().collect::>().join(" "); + let trimmed = normalized.trim(); + if trimmed.is_empty() { + return None; + } + + let limited = trimmed.chars().take(MAX_DEVICE_NAME_LEN).collect::(); + if limited.is_empty() { + None + } else { + Some(limited) + } +} + +fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> { + if manifest.format != PROTOCOL_FORMAT { + return Err(localized( + "webdav.sync.manifest_format_incompatible", + format!("远端 manifest 格式不兼容: {}", manifest.format), + format!( + "Remote manifest format is incompatible: {}", + manifest.format + ), + )); + } + if manifest.version != PROTOCOL_VERSION { + return Err(localized( + "webdav.sync.manifest_version_incompatible", + format!( + "远端 manifest 协议版本不兼容: v{} (本地 v{PROTOCOL_VERSION})", + manifest.version + ), + format!( + "Remote manifest protocol version is incompatible: v{} (local v{PROTOCOL_VERSION})", + manifest.version + ), + )); + } + Ok(()) +} + +// ─── Download & verify ─────────────────────────────────────── + +async fn download_and_verify( + settings: &WebDavSyncSettings, + auth: &WebDavAuth, + artifact_name: &str, + artifacts: &BTreeMap, +) -> Result, AppError> { + let meta = artifacts.get(artifact_name).ok_or_else(|| { + localized( + "webdav.sync.manifest_missing_artifact", + format!("manifest 中缺少 artifact: {artifact_name}"), + format!("Manifest missing artifact: {artifact_name}"), + ) + })?; + let url = remote_file_url(settings, artifact_name)?; + let (bytes, _) = get_bytes(&url, auth).await?.ok_or_else(|| { + localized( + "webdav.sync.remote_missing_artifact", + format!("远端缺少 artifact 文件: {artifact_name}"), + format!("Remote artifact file missing: {artifact_name}"), + ) + })?; + + // Quick size check before expensive hash + if bytes.len() as u64 != meta.size { + return Err(localized( + "webdav.sync.artifact_size_mismatch", + format!( + "artifact {artifact_name} 大小不匹配 (expected: {}, got: {})", + meta.size, + bytes.len(), + ), + format!( + "Artifact {artifact_name} size mismatch (expected: {}, got: {})", + meta.size, + bytes.len(), + ), + )); + } + + let actual_hash = sha256_hex(&bytes); + if actual_hash != meta.sha256 { + return Err(localized( + "webdav.sync.artifact_hash_mismatch", + format!( + "artifact {artifact_name} SHA256 校验失败 (expected: {}..., got: {}...)", + meta.sha256.get(..8).unwrap_or(&meta.sha256), + actual_hash.get(..8).unwrap_or(&actual_hash), + ), + format!( + "Artifact {artifact_name} SHA256 verification failed (expected: {}..., got: {}...)", + meta.sha256.get(..8).unwrap_or(&meta.sha256), + actual_hash.get(..8).unwrap_or(&actual_hash), + ), + )); + } + Ok(bytes) +} + +fn apply_snapshot( + db: &crate::database::Database, + db_sql: &[u8], + skills_zip: &[u8], +) -> Result<(), AppError> { + let sql_str = std::str::from_utf8(db_sql).map_err(|e| { + localized( + "webdav.sync.sql_not_utf8", + format!("SQL 非 UTF-8: {e}"), + format!("SQL is not valid UTF-8: {e}"), + ) + })?; + let skills_backup = backup_current_skills()?; + + // 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免“半恢复”。 + restore_skills_zip(skills_zip)?; + + if let Err(db_err) = db.import_sql_string(sql_str) { + if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) { + return Err(localized( + "webdav.sync.db_import_and_rollback_failed", + format!("导入数据库失败: {db_err}; 同时回滚 Skills 失败: {rollback_err}"), + format!( + "Database import failed: {db_err}; skills rollback also failed: {rollback_err}" + ), + )); + } + return Err(db_err); + } + + Ok(()) +} + +// ─── Remote path helpers ───────────────────────────────────── + +fn remote_dir_segments(settings: &WebDavSyncSettings) -> Vec { + let mut segs = Vec::new(); + segs.extend(path_segments(&settings.remote_root).map(str::to_string)); + segs.push(format!("v{PROTOCOL_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); + segs.extend(path_segments(file_name).map(str::to_string)); + build_remote_url(&settings.base_url, &segs) +} + +fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth { + auth_from_credentials(&settings.username, &settings.password) +} + +// ─── Tests ─────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn artifact(sha256: &str, size: u64) -> ArtifactMeta { + ArtifactMeta { + sha256: sha256.to_string(), + size, + } + } + + #[test] + fn snapshot_id_is_stable() { + let mut artifacts = BTreeMap::new(); + artifacts.insert("db.sql".to_string(), artifact("abc123", 100)); + artifacts.insert("skills.zip".to_string(), artifact("def456", 200)); + + let id1 = compute_snapshot_id(&artifacts); + let id2 = compute_snapshot_id(&artifacts); + assert_eq!(id1, id2); + } + + #[test] + fn snapshot_id_changes_with_artifacts() { + let mut a1 = BTreeMap::new(); + a1.insert("db.sql".to_string(), artifact("hash-a", 1)); + + let mut a2 = BTreeMap::new(); + a2.insert("db.sql".to_string(), artifact("hash-b", 1)); + + assert_ne!(compute_snapshot_id(&a1), compute_snapshot_id(&a2)); + } + + #[test] + fn remote_dir_segments_uses_v2() { + 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", "v2", "default"]); + } + + #[test] + fn sha256_hex_is_correct() { + let hash = sha256_hex(b"hello"); + assert_eq!( + hash, + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } + + #[test] + fn persist_best_effort_returns_true_on_success() { + let mut settings = WebDavSyncSettings::default(); + let ok = persist_sync_success_best_effort( + &mut settings, + "hash".to_string(), + Some("etag".to_string()), + |_settings, _hash, _etag| Ok(()), + ); + assert!(ok); + } + + #[test] + fn persist_best_effort_returns_false_on_error() { + let mut settings = WebDavSyncSettings::default(); + let ok = persist_sync_success_best_effort( + &mut settings, + "hash".to_string(), + None, + |_settings, _hash, _etag| Err(AppError::Config("boom".to_string())), + ); + assert!(!ok); + } + + fn manifest_with(format: &str, version: 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, + device_name: "My MacBook".to_string(), + created_at: "2026-02-12T00:00:00Z".to_string(), + artifacts, + snapshot_id: "snap-1".to_string(), + } + } + + #[test] + fn validate_manifest_compat_accepts_supported_manifest() { + let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION); + assert!(validate_manifest_compat(&manifest).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()); + } + + #[test] + fn validate_manifest_compat_rejects_wrong_version() { + let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION + 1); + assert!(validate_manifest_compat(&manifest).is_err()); + } + + #[test] + fn normalize_device_name_returns_none_for_blank_input() { + assert_eq!(normalize_device_name(" \n\t "), None); + } + + #[test] + fn normalize_device_name_collapses_whitespace_and_drops_control_chars() { + assert_eq!( + normalize_device_name(" Mac\tBook \n Pro\u{0007} "), + Some("Mac Book Pro".to_string()) + ); + } + + #[test] + fn normalize_device_name_truncates_to_max_len() { + let long = "a".repeat(80); + assert_eq!(normalize_device_name(&long).map(|s| s.len()), Some(64)); + } + + #[test] + fn manifest_serialization_uses_device_name_only() { + let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION); + let value = serde_json::to_value(&manifest).expect("serialize manifest"); + assert!( + value.get("deviceName").is_some(), + "manifest should contain deviceName" + ); + assert!( + value.get("deviceId").is_none(), + "manifest should not contain deviceId" + ); + } +} diff --git a/src-tauri/src/services/webdav_sync/archive.rs b/src-tauri/src/services/webdav_sync/archive.rs new file mode 100644 index 000000000..2058429e1 --- /dev/null +++ b/src-tauri/src/services/webdav_sync/archive.rs @@ -0,0 +1,346 @@ +use std::collections::HashSet; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use tempfile::{tempdir, TempDir}; +use zip::write::SimpleFileOptions; +use zip::DateTime; + +use crate::error::AppError; +use crate::services::skill::SkillService; + +use super::{io_context_localized, localized, REMOTE_SKILLS_ZIP}; + +/// Maximum total bytes allowed during zip extraction (512 MB). +const MAX_EXTRACT_BYTES: u64 = 512 * 1024 * 1024; +/// Maximum number of entries allowed in a zip archive. +const MAX_EXTRACT_ENTRIES: usize = 10_000; + +pub(super) struct SkillsBackup { + _tmp: TempDir, + backup_dir: PathBuf, + ssot_path: PathBuf, + existed: bool, +} + +pub(super) fn zip_skills_ssot(dest_path: &Path) -> Result<(), AppError> { + let source = SkillService::get_ssot_dir().map_err(|e| { + localized( + "webdav.sync.skills_ssot_dir_failed", + format!("获取 Skills SSOT 目录失败: {e}"), + format!("Failed to resolve Skills SSOT directory: {e}"), + ) + })?; + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + let file = fs::File::create(dest_path).map_err(|e| AppError::io(dest_path, e))?; + let mut writer = zip::ZipWriter::new(file); + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .last_modified_time(DateTime::default()); + + if source.exists() { + let canonical_root = fs::canonicalize(&source).unwrap_or_else(|_| source.clone()); + let mut visited = HashSet::new(); + mark_visited_dir(&canonical_root, &mut visited)?; + zip_dir_recursive( + &canonical_root, + &canonical_root, + &mut writer, + options, + &mut visited, + )?; + } + + writer.finish().map_err(|e| { + localized( + "webdav.sync.skills_zip_write_failed", + format!("写入 skills.zip 失败: {e}"), + format!("Failed to write skills.zip: {e}"), + ) + })?; + Ok(()) +} + +pub(super) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> { + let tmp = tempdir().map_err(|e| { + io_context_localized( + "webdav.sync.skills_extract_tmpdir_failed", + "创建 skills 解压临时目录失败", + "Failed to create temporary directory for skills extraction", + e, + ) + })?; + let zip_path = tmp.path().join(REMOTE_SKILLS_ZIP); + fs::write(&zip_path, raw).map_err(|e| AppError::io(&zip_path, e))?; + + let file = fs::File::open(&zip_path).map_err(|e| AppError::io(&zip_path, e))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| { + localized( + "webdav.sync.skills_zip_parse_failed", + format!("解析 skills.zip 失败: {e}"), + format!("Failed to parse skills.zip: {e}"), + ) + })?; + + let extracted = tmp.path().join("skills-extracted"); + fs::create_dir_all(&extracted).map_err(|e| AppError::io(&extracted, e))?; + + if archive.len() > MAX_EXTRACT_ENTRIES { + return Err(localized( + "webdav.sync.skills_zip_too_many_entries", + format!("skills.zip 条目数过多({}),上限 {MAX_EXTRACT_ENTRIES}", archive.len()), + format!("skills.zip has too many entries ({}), limit is {MAX_EXTRACT_ENTRIES}", archive.len()), + )); + } + + let mut total_bytes: u64 = 0; + for idx in 0..archive.len() { + let mut entry = archive.by_index(idx).map_err(|e| { + localized( + "webdav.sync.skills_zip_entry_read_failed", + format!("读取 ZIP 项失败: {e}"), + format!("Failed to read ZIP entry: {e}"), + ) + })?; + let Some(safe_name) = entry.enclosed_name() else { + continue; + }; + let out_path = extracted.join(safe_name); + if entry.is_dir() { + fs::create_dir_all(&out_path).map_err(|e| AppError::io(&out_path, e))?; + continue; + } + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + let mut out = fs::File::create(&out_path).map_err(|e| AppError::io(&out_path, e))?; + let written = std::io::copy(&mut entry, &mut out).map_err(|e| AppError::io(&out_path, e))?; + total_bytes += written; + if total_bytes > MAX_EXTRACT_BYTES { + return Err(localized( + "webdav.sync.skills_zip_too_large", + format!("skills.zip 解压后体积超过上限({} MB)", MAX_EXTRACT_BYTES / 1024 / 1024), + format!("skills.zip extracted size exceeds limit ({} MB)", MAX_EXTRACT_BYTES / 1024 / 1024), + )); + } + } + + let ssot = SkillService::get_ssot_dir().map_err(|e| { + localized( + "webdav.sync.skills_ssot_dir_failed", + format!("获取 Skills SSOT 目录失败: {e}"), + format!("Failed to resolve Skills SSOT directory: {e}"), + ) + })?; + let bak = ssot.with_extension("bak"); + + if ssot.exists() { + if bak.exists() { + let _ = fs::remove_dir_all(&bak); + } + fs::rename(&ssot, &bak).map_err(|e| AppError::io(&ssot, e))?; + } + + if let Err(e) = copy_dir_recursive(&extracted, &ssot) { + if bak.exists() { + let _ = fs::remove_dir_all(&ssot); + let _ = fs::rename(&bak, &ssot); + } + return Err(e); + } + + let _ = fs::remove_dir_all(&bak); + Ok(()) +} + +pub(super) fn backup_current_skills() -> Result { + let ssot = SkillService::get_ssot_dir().map_err(|e| { + localized( + "webdav.sync.skills_ssot_dir_failed", + format!("获取 Skills SSOT 目录失败: {e}"), + format!("Failed to resolve Skills SSOT directory: {e}"), + ) + })?; + let tmp = tempdir().map_err(|e| { + io_context_localized( + "webdav.sync.skills_backup_tmpdir_failed", + "创建 skills 备份临时目录失败", + "Failed to create temporary directory for skills backup", + e, + ) + })?; + let backup_dir = tmp.path().join("skills-backup"); + + let existed = ssot.exists(); + if existed { + copy_dir_recursive(&ssot, &backup_dir)?; + } + + Ok(SkillsBackup { + _tmp: tmp, + backup_dir, + ssot_path: ssot, + existed, + }) +} + +pub(super) fn restore_skills_from_backup(backup: &SkillsBackup) -> Result<(), AppError> { + if backup.ssot_path.exists() { + fs::remove_dir_all(&backup.ssot_path).map_err(|e| AppError::io(&backup.ssot_path, e))?; + } + + if backup.existed { + copy_dir_recursive(&backup.backup_dir, &backup.ssot_path)?; + } + + Ok(()) +} + +fn zip_dir_recursive( + root: &Path, + current: &Path, + writer: &mut zip::ZipWriter, + options: SimpleFileOptions, + visited: &mut HashSet, +) -> Result<(), AppError> { + let mut entries: Vec<_> = fs::read_dir(current) + .map_err(|e| AppError::io(current, e))? + .collect::, _>>() + .map_err(|e| AppError::io(current, e))?; + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if name_str.starts_with('.') { + continue; + } + + let real_path = match fs::canonicalize(&path) { + Ok(p) if p.starts_with(root) => p, + Ok(_) => { + log::warn!( + "[WebDAV] Skipping symlink outside skills root: {}", + path.display() + ); + continue; + } + Err(_) => path.clone(), + }; + + let rel = real_path + .strip_prefix(root) + .or_else(|_| path.strip_prefix(root)) + .map_err(|e| { + localized( + "webdav.sync.zip_relative_path_failed", + format!("生成 ZIP 相对路径失败: {e}"), + format!("Failed to build relative ZIP path: {e}"), + ) + })?; + let rel_str = rel.to_string_lossy().replace('\\', "/"); + + if real_path.is_dir() { + if !mark_visited_dir(&real_path, visited)? { + log::warn!( + "[WebDAV] Skipping already visited directory: {}", + real_path.display() + ); + continue; + } + writer + .add_directory(format!("{rel_str}/"), options) + .map_err(|e| { + localized( + "webdav.sync.zip_add_directory_failed", + format!("写入 ZIP 目录失败: {e}"), + format!("Failed to write ZIP directory entry: {e}"), + ) + })?; + zip_dir_recursive(root, &real_path, writer, options, visited)?; + } else { + writer.start_file(&rel_str, options).map_err(|e| { + localized( + "webdav.sync.zip_start_file_failed", + format!("写入 ZIP 文件头失败: {e}"), + format!("Failed to start ZIP file entry: {e}"), + ) + })?; + let mut file = fs::File::open(&real_path).map_err(|e| AppError::io(&real_path, e))?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf) + .map_err(|e| AppError::io(&real_path, e))?; + writer.write_all(&buf).map_err(|e| { + localized( + "webdav.sync.zip_write_file_failed", + format!("写入 ZIP 文件内容失败: {e}"), + format!("Failed to write ZIP file content: {e}"), + ) + })?; + } + } + Ok(()) +} + +fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), AppError> { + let mut visited = HashSet::new(); + copy_dir_recursive_inner(src, dest, &mut visited) +} + +fn copy_dir_recursive_inner( + src: &Path, + dest: &Path, + visited: &mut HashSet, +) -> Result<(), AppError> { + if !src.exists() { + return Ok(()); + } + if !mark_visited_dir(src, visited)? { + log::warn!( + "[WebDAV] Skipping already visited copy path: {}", + src.display() + ); + return Ok(()); + } + fs::create_dir_all(dest).map_err(|e| AppError::io(dest, e))?; + for entry in fs::read_dir(src).map_err(|e| AppError::io(src, e))? { + let entry = entry.map_err(|e| AppError::io(src, e))?; + let path = entry.path(); + let dest_path = dest.join(entry.file_name()); + if path.is_dir() { + copy_dir_recursive_inner(&path, &dest_path, visited)?; + } else { + fs::copy(&path, &dest_path).map_err(|e| AppError::io(&dest_path, e))?; + } + } + Ok(()) +} + +fn mark_visited_dir(path: &Path, visited: &mut HashSet) -> Result { + let canonical = fs::canonicalize(path).map_err(|e| AppError::io(path, e))?; + Ok(visited.insert(canonical)) +} + +#[cfg(test)] +mod tests { + use super::mark_visited_dir; + use std::collections::HashSet; + use tempfile::tempdir; + + #[test] + fn mark_visited_dir_tracks_canonical_duplicates() { + let temp = tempdir().expect("tempdir"); + let dir = temp.path().join("skills"); + std::fs::create_dir_all(&dir).expect("create dir"); + + let mut visited = HashSet::new(); + assert!(mark_visited_dir(&dir, &mut visited).expect("first visit")); + assert!(!mark_visited_dir(&dir, &mut visited).expect("second visit")); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index f0e2ab8f5..e1ae3b213 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::fs; +use std::io::Write; use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; @@ -58,6 +59,101 @@ impl VisibleApps { } } +/// WebDAV 同步状态(持久化同步进度信息) +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct WebDavSyncStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_sync_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_remote_etag: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_local_manifest_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_remote_manifest_hash: Option, +} + +fn default_remote_root() -> String { + "cc-switch-sync".to_string() +} +fn default_profile() -> String { + "default".to_string() +} + +/// WebDAV v2 同步设置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebDavSyncSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub base_url: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default = "default_remote_root")] + pub remote_root: String, + #[serde(default = "default_profile")] + pub profile: String, + #[serde(default)] + pub status: WebDavSyncStatus, +} + +impl Default for WebDavSyncSettings { + fn default() -> Self { + Self { + enabled: false, + base_url: String::new(), + username: String::new(), + password: String::new(), + remote_root: default_remote_root(), + profile: default_profile(), + status: WebDavSyncStatus::default(), + } + } +} + +impl WebDavSyncSettings { + pub fn validate(&self) -> Result<(), crate::error::AppError> { + if self.base_url.trim().is_empty() { + return Err(crate::error::AppError::localized( + "webdav.base_url.required", + "WebDAV 地址不能为空", + "WebDAV URL is required.", + )); + } + if self.username.trim().is_empty() { + return Err(crate::error::AppError::localized( + "webdav.username.required", + "WebDAV 用户名不能为空", + "WebDAV username is required.", + )); + } + Ok(()) + } + + pub fn normalize(&mut self) { + self.base_url = self.base_url.trim().to_string(); + self.username = self.username.trim().to_string(); + self.remote_root = self.remote_root.trim().to_string(); + self.profile = self.profile.trim().to_string(); + if self.remote_root.is_empty() { + self.remote_root = default_remote_root(); + } + if self.profile.is_empty() { + self.profile = default_profile(); + } + } + + /// Returns true if all credential fields are blank (no config to persist). + fn is_empty(&self) -> bool { + self.base_url.is_empty() && self.username.is_empty() && self.password.is_empty() + } +} + /// 应用设置结构 /// /// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。 @@ -118,6 +214,14 @@ pub struct AppSettings { #[serde(default)] pub skill_sync_method: SyncMethod, + // ===== WebDAV 同步设置 ===== + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webdav_sync: Option, + + // ===== WebDAV 备份设置(旧版,保留向后兼容)===== + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webdav_backup: Option, + // ===== 终端设置 ===== /// 首选终端应用(可选,默认使用系统默认终端) /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" @@ -155,6 +259,8 @@ impl Default for AppSettings { current_provider_gemini: None, current_provider_opencode: None, skill_sync_method: SyncMethod::default(), + webdav_sync: None, + webdav_backup: None, preferred_terminal: None, } } @@ -205,6 +311,13 @@ impl AppSettings { .map(|s| s.trim()) .filter(|s| matches!(*s, "en" | "zh" | "ja")) .map(|s| s.to_string()); + + if let Some(sync) = &mut self.webdav_sync { + sync.normalize(); + if sync.is_empty() { + self.webdav_sync = None; + } + } } fn load_from_file() -> Self { @@ -245,7 +358,27 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { let json = serde_json::to_string_pretty(&normalized) .map_err(|e| AppError::JsonSerialize { source: e })?; - fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; + #[cfg(unix)] + { + use std::fs::OpenOptions; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(&path) + .map_err(|e| AppError::io(&path, e))?; + file.write_all(json.as_bytes()) + .map_err(|e| AppError::io(&path, e))?; + } + + #[cfg(not(unix))] + { + fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; + } + Ok(()) } @@ -283,6 +416,15 @@ pub fn get_settings() -> AppSettings { .clone() } +pub fn get_settings_for_frontend() -> AppSettings { + let mut settings = get_settings(); + if let Some(sync) = &mut settings.webdav_sync { + sync.password.clear(); + } + settings.webdav_backup = None; + settings +} + pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { new_settings.normalize_paths(); save_settings_file(&new_settings)?; @@ -295,6 +437,22 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { Ok(()) } +fn mutate_settings(mutator: F) -> Result<(), AppError> +where + F: FnOnce(&mut AppSettings), +{ + let mut guard = settings_store().write().unwrap_or_else(|e| { + log::warn!("设置锁已毒化,使用恢复值: {e}"); + e.into_inner() + }); + let mut next = guard.clone(); + mutator(&mut next); + next.normalize_paths(); + save_settings_file(&next)?; + *guard = next; + Ok(()) +} + /// 从文件重新加载设置到内存缓存 /// 用于导入配置等场景,确保内存缓存与文件同步 pub fn reload_settings() -> Result<(), AppError> { @@ -433,3 +591,26 @@ pub fn get_preferred_terminal() -> Option { .preferred_terminal .clone() } + +// ===== WebDAV 同步设置管理函数 ===== + +/// 获取 WebDAV 同步设置 +pub fn get_webdav_sync_settings() -> Option { + settings_store().read().ok()?.webdav_sync.clone() +} + +/// 保存 WebDAV 同步设置 +pub fn set_webdav_sync_settings(settings: Option) -> Result<(), AppError> { + mutate_settings(|current| { + current.webdav_sync = settings; + }) +} + +/// 仅更新 WebDAV 同步状态,避免覆写 credentials/root/profile 等字段 +pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppError> { + mutate_settings(|current| { + if let Some(sync) = current.webdav_sync.as_mut() { + sync.status = status; + } + }) +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 6f339610f..41e212151 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -39,6 +39,7 @@ import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSe import { TerminalSettings } from "@/components/settings/TerminalSettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings"; import { ImportExportSection } from "@/components/settings/ImportExportSection"; +import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection"; import { AboutSection } from "@/components/settings/AboutSection"; import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings"; import { ProxyPanel } from "@/components/proxy"; @@ -595,6 +596,11 @@ export function SettingsPage({ onExport={exportConfig} onClear={clearSelection} /> +
+ +
diff --git a/src/components/settings/WebdavSyncSection.tsx b/src/components/settings/WebdavSyncSection.tsx new file mode 100644 index 000000000..1d4c48ab7 --- /dev/null +++ b/src/components/settings/WebdavSyncSection.tsx @@ -0,0 +1,775 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { + Link2, + UploadCloud, + DownloadCloud, + Loader2, + Save, + Check, + Info, + AlertTriangle, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { settingsApi } from "@/lib/api"; +import type { RemoteSnapshotInfo, WebDavSyncSettings } from "@/types"; + +// ─── WebDAV service presets ───────────────────────────────── + +interface WebDavPreset { + id: string; + label: string; + baseUrl: string; + hint: string; + matchPattern?: string; // substring match on URL +} + +const WEBDAV_PRESETS: WebDavPreset[] = [ + { + id: "jianguoyun", + label: "settings.webdavSync.presets.jianguoyun", + baseUrl: "https://dav.jianguoyun.com/dav/", + hint: "settings.webdavSync.presets.jianguoyunHint", + matchPattern: "jianguoyun.com", + }, + { + id: "nextcloud", + label: "settings.webdavSync.presets.nextcloud", + baseUrl: "https://your-server/remote.php/dav/files/USERNAME/", + hint: "settings.webdavSync.presets.nextcloudHint", + matchPattern: "remote.php/dav", + }, + { + id: "synology", + label: "settings.webdavSync.presets.synology", + baseUrl: "http://your-nas-ip:5005/", + hint: "settings.webdavSync.presets.synologyHint", + matchPattern: ":5005", + }, + { + id: "custom", + label: "settings.webdavSync.presets.custom", + baseUrl: "", + hint: "", + }, +]; + +/** Match a URL to one of the preset providers, or "custom". */ +function detectPreset(url: string): string { + if (!url) return "custom"; + for (const preset of WEBDAV_PRESETS) { + if (preset.matchPattern && url.includes(preset.matchPattern)) { + return preset.id; + } + } + return "custom"; +} + +/** Format an RFC 3339 date string for display; falls back to raw string. */ +function formatDate(rfc3339: string): string { + const d = new Date(rfc3339); + return Number.isNaN(d.getTime()) ? rfc3339 : d.toLocaleString(); +} + +// ─── Types ────────────────────────────────────────────────── + +type ActionState = + | "idle" + | "testing" + | "saving" + | "uploading" + | "downloading" + | "fetching_remote"; + +type DialogType = "upload" | "download" | null; + +interface WebdavSyncSectionProps { + config?: WebDavSyncSettings; +} + +// ─── ActionButton ─────────────────────────────────────────── + +/** Reusable button with loading spinner. */ +function ActionButton({ + actionState, + targetState, + alsoActiveFor, + icon: Icon, + activeLabel, + idleLabel, + disabled, + ...props +}: { + actionState: ActionState; + targetState: ActionState; + alsoActiveFor?: ActionState[]; + icon: LucideIcon; + activeLabel: ReactNode; + idleLabel: ReactNode; +} & Omit, "children">) { + const isActive = + actionState === targetState || + (alsoActiveFor?.includes(actionState) ?? false); + return ( + + ); +} + +// ─── Main component ───────────────────────────────────────── + +export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [actionState, setActionState] = useState("idle"); + const [dirty, setDirty] = useState(false); + const [passwordTouched, setPasswordTouched] = useState(false); + const [justSaved, setJustSaved] = useState(false); + const justSavedTimerRef = useRef | null>(null); + + // Local form state — credentials are only persisted on explicit "Save". + const [form, setForm] = useState(() => ({ + baseUrl: config?.baseUrl ?? "", + username: config?.username ?? "", + password: config?.password ?? "", + remoteRoot: config?.remoteRoot ?? "cc-switch-sync", + profile: config?.profile ?? "default", + })); + + // Preset selector — derived from initial URL, updated on user selection + const [presetId, setPresetId] = useState(() => + detectPreset(config?.baseUrl ?? ""), + ); + + const activePreset = WEBDAV_PRESETS.find((p) => p.id === presetId); + + // Confirmation dialog state + const [dialogType, setDialogType] = useState(null); + const [remoteInfo, setRemoteInfo] = useState(null); + + const closeDialog = useCallback(() => { + setDialogType(null); + setRemoteInfo(null); + }, []); + + // Cleanup justSaved timer on unmount + useEffect(() => { + return () => { + if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current); + }; + }, []); + + // Sync form when config is loaded/updated from backend, but not while user is editing + useEffect(() => { + if (!config || dirty) return; + setForm({ + baseUrl: config.baseUrl ?? "", + username: config.username ?? "", + password: config.password ?? "", + remoteRoot: config.remoteRoot ?? "cc-switch-sync", + profile: config.profile ?? "default", + }); + setPasswordTouched(false); + setPresetId(detectPreset(config.baseUrl ?? "")); + }, [config, dirty]); + + const updateField = useCallback((field: keyof typeof form, value: string) => { + setForm((prev) => ({ ...prev, [field]: value })); + if (field === "password") { + setPasswordTouched(true); + } + setDirty(true); + setJustSaved(false); + if (justSavedTimerRef.current) { + clearTimeout(justSavedTimerRef.current); + justSavedTimerRef.current = null; + } + }, []); + + const handlePresetChange = useCallback((id: string) => { + setPresetId(id); + const preset = WEBDAV_PRESETS.find((p) => p.id === id); + if (preset?.baseUrl) { + setForm((prev) => ({ ...prev, baseUrl: preset.baseUrl })); + setDirty(true); + setJustSaved(false); + if (justSavedTimerRef.current) { + clearTimeout(justSavedTimerRef.current); + justSavedTimerRef.current = null; + } + } + }, []); + + // When user edits the URL, check if it still matches the current preset on blur + const handleBaseUrlBlur = useCallback(() => { + if (presetId === "custom") return; + const detected = detectPreset(form.baseUrl); + if (detected !== presetId) { + setPresetId("custom"); + } + }, [form.baseUrl, presetId]); + + const buildSettings = useCallback((): WebDavSyncSettings | null => { + const baseUrl = form.baseUrl.trim(); + if (!baseUrl) return null; + return { + enabled: true, + baseUrl, + username: form.username.trim(), + password: form.password, + remoteRoot: form.remoteRoot.trim() || "cc-switch-sync", + profile: form.profile.trim() || "default", + }; + }, [form]); + + // ─── Handlers ─────────────────────────────────────────── + + const handleTest = useCallback(async () => { + const settings = buildSettings(); + if (!settings) { + toast.error(t("settings.webdavSync.missingUrl")); + return; + } + setActionState("testing"); + try { + await settingsApi.webdavTestConnection(settings, !passwordTouched); + toast.success(t("settings.webdavSync.testSuccess")); + } catch (error) { + toast.error( + t("settings.webdavSync.testFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + } finally { + setActionState("idle"); + } + }, [buildSettings, passwordTouched, t]); + + const handleSave = useCallback(async () => { + const settings = buildSettings(); + if (!settings) { + toast.error(t("settings.webdavSync.missingUrl")); + return; + } + setActionState("saving"); + try { + await settingsApi.webdavSyncSaveSettings(settings, passwordTouched); + setDirty(false); + setPasswordTouched(false); + // Show "saved" indicator for 2 seconds + setJustSaved(true); + if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current); + justSavedTimerRef.current = setTimeout(() => { + setJustSaved(false); + justSavedTimerRef.current = null; + }, 2000); + await queryClient.invalidateQueries(); + } catch (error) { + toast.error( + t("settings.webdavSync.saveFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + setActionState("idle"); + return; + } + + // Auto-test connection after save + setActionState("testing"); + try { + await settingsApi.webdavTestConnection(settings, true); + toast.success(t("settings.webdavSync.saveAndTestSuccess")); + } catch (error) { + toast.warning( + t("settings.webdavSync.saveAndTestFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + } finally { + setActionState("idle"); + } + }, [buildSettings, passwordTouched, queryClient, t]); + + /** Fetch remote info, then open upload confirmation dialog. */ + const handleUploadClick = useCallback(async () => { + if (dirty) { + toast.error(t("settings.webdavSync.unsavedChanges")); + return; + } + setActionState("fetching_remote"); + try { + const info = await settingsApi.webdavSyncFetchRemoteInfo(); + if ("empty" in info) { + setRemoteInfo(null); + } else { + setRemoteInfo(info); + } + setDialogType("upload"); + } catch { + setRemoteInfo(null); + toast.error(t("settings.webdavSync.fetchRemoteFailed")); + setActionState("idle"); + return; + } + setActionState("idle"); + }, [dirty, t]); + + /** Actually perform the upload after user confirms. */ + const handleUploadConfirm = useCallback(async () => { + if (dirty) { + toast.error(t("settings.webdavSync.unsavedChanges")); + return; + } + closeDialog(); + setActionState("uploading"); + try { + await settingsApi.webdavSyncUpload(); + toast.success(t("settings.webdavSync.uploadSuccess")); + await queryClient.invalidateQueries(); + } catch (error) { + toast.error( + t("settings.webdavSync.uploadFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + } finally { + setActionState("idle"); + } + }, [closeDialog, dirty, queryClient, t]); + + /** Fetch remote info, then open download confirmation dialog. */ + const handleDownloadClick = useCallback(async () => { + if (dirty) { + toast.error(t("settings.webdavSync.unsavedChanges")); + return; + } + setActionState("fetching_remote"); + try { + const info = await settingsApi.webdavSyncFetchRemoteInfo(); + if ("empty" in info) { + toast.info(t("settings.webdavSync.noRemoteData")); + return; + } + if (!info.compatible) { + toast.error( + t("settings.webdavSync.incompatibleVersion", { + version: info.version, + }), + ); + return; + } + setRemoteInfo(info); + setDialogType("download"); + } catch (error) { + toast.error( + t("settings.webdavSync.downloadFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + } finally { + setActionState("idle"); + } + }, [dirty, t]); + + /** Actually perform the download after user confirms. */ + const handleDownloadConfirm = useCallback(async () => { + if (dirty) { + toast.error(t("settings.webdavSync.unsavedChanges")); + return; + } + closeDialog(); + setActionState("downloading"); + try { + await settingsApi.webdavSyncDownload(); + toast.success(t("settings.webdavSync.downloadSuccess")); + await queryClient.invalidateQueries(); + } catch (error) { + toast.error( + t("settings.webdavSync.downloadFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + } finally { + setActionState("idle"); + } + }, [closeDialog, dirty, queryClient, t]); + + // ─── Derived state ────────────────────────────────────── + + const isLoading = actionState !== "idle"; + const hasSavedConfig = Boolean( + config?.baseUrl?.trim() && config?.username?.trim(), + ); + + const lastSyncAt = config?.status?.lastSyncAt; + const lastSyncDisplay = lastSyncAt + ? new Date(lastSyncAt * 1000).toLocaleString() + : null; + + // ─── Render ───────────────────────────────────────────── + + return ( +
+
+

+ {t("settings.webdavSync.title")} +

+

+ {t("settings.webdavSync.description")} +

+
+ +
+ {/* Config fields */} +
+ {/* Service preset selector */} +
+ + +
+ + {/* Server URL */} +
+ + updateField("baseUrl", e.target.value)} + onBlur={handleBaseUrlBlur} + placeholder={t("settings.webdavSync.baseUrlPlaceholder")} + className="text-xs flex-1" + disabled={isLoading} + /> +
+ + {/* Username */} +
+ + updateField("username", e.target.value)} + placeholder={t("settings.webdavSync.usernamePlaceholder")} + className="text-xs flex-1" + disabled={isLoading} + /> +
+ + {/* Password */} +
+ + updateField("password", e.target.value)} + placeholder={t("settings.webdavSync.passwordPlaceholder")} + className="text-xs flex-1" + autoComplete="off" + disabled={isLoading} + /> +
+ + {/* Preset hint */} + {activePreset?.hint && ( +
+ + {t(activePreset.hint)} +
+ )} + + {/* Remote Root */} +
+ + updateField("remoteRoot", e.target.value)} + placeholder="cc-switch-sync" + className="text-xs flex-1" + disabled={isLoading} + /> +
+ + {/* Profile */} +
+ + updateField("profile", e.target.value)} + placeholder="default" + className="text-xs flex-1" + disabled={isLoading} + /> +
+
+ + {/* Last sync time */} + {lastSyncDisplay && ( +

+ {t("settings.webdavSync.lastSync", { time: lastSyncDisplay })} +

+ )} + + {/* Config buttons + save status */} +
+ + + + {/* Save status indicator */} + {dirty && ( + + + {t("settings.webdavSync.unsaved")} + + )} + {!dirty && justSaved && ( + + + {t("settings.webdavSync.saved")} + + )} +
+ + {/* Sync buttons */} +
+ + +
+ {!hasSavedConfig && ( +

+ {t("settings.webdavSync.saveBeforeSync")} +

+ )} +
+ + {/* ─── Upload confirmation dialog ──────────────────── */} + { + if (!open) closeDialog(); + }} + > + + + + + {t("settings.webdavSync.confirmUpload.title")} + + +
+

{t("settings.webdavSync.confirmUpload.content")}

+
    +
  • {t("settings.webdavSync.confirmUpload.dbItem")}
  • +
  • {t("settings.webdavSync.confirmUpload.skillsItem")}
  • +
+

+ {t("settings.webdavSync.confirmUpload.targetPath")} + {": "} + + /{form.remoteRoot.trim() || "cc-switch-sync"}/v2/ + {form.profile.trim() || "default"} + +

+ {remoteInfo && ( +
+

+ {t("settings.webdavSync.confirmUpload.existingData")} +

+
+
+ {t("settings.webdavSync.confirmUpload.deviceName")} +
+
+ + {remoteInfo.deviceName} + +
+
+ {t("settings.webdavSync.confirmUpload.createdAt")} +
+
{formatDate(remoteInfo.createdAt)}
+
+
+ )} + {remoteInfo && ( +

+ {t("settings.webdavSync.confirmUpload.warning")} +

+ )} +
+
+
+ + + + +
+
+ + {/* ─── Download confirmation dialog ────────────────── */} + { + if (!open) closeDialog(); + }} + > + + + + + {t("settings.webdavSync.confirmDownload.title")} + + +
+ {remoteInfo && ( +
+
+ {t("settings.webdavSync.confirmDownload.deviceName")} +
+
+ + {remoteInfo.deviceName} + +
+
+ {t("settings.webdavSync.confirmDownload.createdAt")} +
+
{formatDate(remoteInfo.createdAt)}
+
+ {t("settings.webdavSync.confirmDownload.artifacts")} +
+
{remoteInfo.artifacts.join(", ")}
+
+ )} +

+ {t("settings.webdavSync.confirmDownload.warning")} +

+
+
+
+ + + + +
+
+
+ ); +} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 55d73d0e1..402da1682 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -135,9 +135,10 @@ export function useSettings(): UseSettingsResult { const sanitizedOpencodeDir = sanitizeDir( mergedSettings.opencodeConfigDir, ); + const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings; const payload: Settings = { - ...mergedSettings, + ...restSettings, claudeConfigDir: sanitizedClaudeDir, codexConfigDir: sanitizedCodexDir, geminiConfigDir: sanitizedGeminiDir, @@ -251,9 +252,10 @@ export function useSettings(): UseSettingsResult { const previousCodexDir = sanitizeDir(data?.codexConfigDir); const previousGeminiDir = sanitizeDir(data?.geminiConfigDir); const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir); + const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings; const payload: Settings = { - ...mergedSettings, + ...restSettings, claudeConfigDir: sanitizedClaudeDir, codexConfigDir: sanitizedCodexDir, geminiConfigDir: sanitizedGeminiDir, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7c65a6857..695a776fb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -266,6 +266,77 @@ "selectFileFailed": "Please choose a valid SQL backup file", "configCorrupted": "SQL file may be corrupted or invalid", "backupId": "Backup ID", + "webdavSync": { + "title": "WebDAV Cloud Sync", + "description": "Sync database and skill configurations across devices via WebDAV.", + "baseUrl": "WebDAV Server URL", + "baseUrlPlaceholder": "https://dav.example.com/dav/", + "username": "WebDAV Account", + "usernamePlaceholder": "Email or username", + "password": "WebDAV Password", + "passwordPlaceholder": "App password", + "remoteRoot": "Remote Root Directory", + "profile": "Sync Profile Name", + "test": "Test Connection", + "testing": "Testing...", + "testSuccess": "Connection successful", + "testFailed": "Connection failed: {{error}}", + "save": "Save Config", + "saving": "Saving...", + "saveFailed": "Failed to save config: {{error}}", + "upload": "Upload to Cloud", + "uploading": "Uploading...", + "uploadSuccess": "Uploaded to WebDAV", + "uploadFailed": "Upload failed: {{error}}", + "download": "Download from Cloud", + "downloading": "Downloading...", + "downloadSuccess": "Downloaded and restored from WebDAV", + "downloadFailed": "Download failed: {{error}}", + "lastSync": "Last sync: {{time}}", + "missingUrl": "Please enter the WebDAV server URL", + "presets": { + "label": "Provider", + "jianguoyun": "Jianguoyun", + "jianguoyunHint": "Generate an \"App Password\" in Jianguoyun security settings. Do not use your login password.", + "nextcloud": "Nextcloud", + "nextcloudHint": "Replace your-server with your Nextcloud domain and USERNAME with your username.", + "synology": "Synology NAS", + "synologyHint": "Install and enable the WebDAV Server package in Synology Package Center first.", + "custom": "Custom" + }, + "remoteRootDefault": "Default: cc-switch-sync", + "profileDefault": "Default: default", + "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", + "unsaved": "Unsaved", + "saved": "Saved", + "unsavedChanges": "Please save config first", + "saveBeforeSync": "Save configuration first to enable upload/download.", + "fetchingRemote": "Fetching remote info...", + "fetchRemoteFailed": "Failed to fetch remote info. Please check configuration and network.", + "confirmDownload": { + "title": "Restore from Cloud", + "deviceName": "Uploaded by", + "createdAt": "Uploaded at", + "artifacts": "Contents", + "warning": "This will overwrite all local data and skill configurations", + "confirm": "Confirm Restore" + }, + "confirmUpload": { + "title": "Upload to Cloud", + "content": "The following will be synced to the WebDAV server:", + "dbItem": "Database (all provider configs and data)", + "skillsItem": "Skills (all custom skills)", + "targetPath": "Target path", + "existingData": "Existing cloud data", + "deviceName": "Uploaded by", + "createdAt": "Uploaded at", + "warning": "This will overwrite existing sync data on the remote server", + "confirm": "Confirm Upload" + } + }, "autoReload": "Data refreshed", "languageOptionChinese": "中文", "languageOptionEnglish": "English", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f8c9243fe..825e11a6d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -266,6 +266,77 @@ "selectFileFailed": "有効な SQL バックアップファイルを選択してください", "configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります", "backupId": "バックアップ ID", + "webdavSync": { + "title": "WebDAV クラウド同期", + "description": "WebDAV を使ってデバイス間でデータベースとスキル設定を同期します。", + "baseUrl": "WebDAV サーバー URL", + "baseUrlPlaceholder": "https://example.com/remote.php/dav/files/user", + "username": "ユーザー名", + "usernamePlaceholder": "メールアドレスまたはユーザー名", + "password": "パスワード", + "passwordPlaceholder": "アプリパスワード", + "remoteRoot": "リモートルートディレクトリ", + "profile": "同期プロファイル名", + "test": "接続テスト", + "testing": "テスト中...", + "testSuccess": "接続成功", + "testFailed": "接続失敗:{{error}}", + "save": "設定を保存", + "saving": "保存中...", + "saveFailed": "設定の保存に失敗しました:{{error}}", + "upload": "クラウドにアップロード", + "uploading": "アップロード中...", + "uploadSuccess": "WebDAV にアップロードしました", + "uploadFailed": "アップロードに失敗しました:{{error}}", + "download": "クラウドからダウンロード", + "downloading": "ダウンロード中...", + "downloadSuccess": "WebDAV からダウンロード・復元しました", + "downloadFailed": "ダウンロードに失敗しました:{{error}}", + "lastSync": "前回の同期:{{time}}", + "missingUrl": "WebDAV サーバー URL を入力してください", + "presets": { + "label": "サービス", + "jianguoyun": "坚果云", + "jianguoyunHint": "坚果云の「セキュリティ設定」で「サードパーティアプリパスワード」を生成してください。ログインパスワードは使用しないでください。", + "nextcloud": "Nextcloud", + "nextcloudHint": "your-server を Nextcloud サーバーのアドレスに、USERNAME をユーザー名に置き換えてください。", + "synology": "Synology NAS", + "synologyHint": "Synology の「パッケージセンター」で WebDAV Server パッケージをインストール・有効化してください。", + "custom": "カスタム" + }, + "remoteRootDefault": "デフォルト: cc-switch-sync", + "profileDefault": "デフォルト: default", + "saveAndTestSuccess": "設定を保存しました。接続正常です", + "saveAndTestFailed": "設定を保存しましたが、接続テストに失敗しました:{{error}}", + "noRemoteData": "クラウドに同期データが見つかりません", + "incompatibleVersion": "リモートデータのバージョンに互換性がありません(v{{version}})。現在 v2 をサポートしています", + "unsaved": "未保存", + "saved": "保存済み", + "unsavedChanges": "先に設定を保存してください", + "saveBeforeSync": "アップロード/ダウンロードを有効にするには、先に設定を保存してください。", + "fetchingRemote": "リモート情報を取得中...", + "fetchRemoteFailed": "リモート情報の取得に失敗しました。設定とネットワークを確認してください。", + "confirmDownload": { + "title": "クラウドから復元", + "deviceName": "アップロード元", + "createdAt": "アップロード日時", + "artifacts": "内容", + "warning": "ローカルのすべてのデータとスキル設定が上書きされます", + "confirm": "復元を実行" + }, + "confirmUpload": { + "title": "クラウドにアップロード", + "content": "以下の内容を WebDAV サーバーに同期します:", + "dbItem": "データベース(すべてのプロバイダー設定とデータ)", + "skillsItem": "スキル(すべてのカスタムスキル)", + "targetPath": "保存先パス", + "existingData": "クラウドの既存データ", + "deviceName": "アップロード元", + "createdAt": "アップロード日時", + "warning": "リモートの既存同期データが上書きされます", + "confirm": "アップロードを実行" + } + }, "autoReload": "データを更新しました", "languageOptionChinese": "中文", "languageOptionEnglish": "English", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a5f54bd4e..c044c40e7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -266,6 +266,77 @@ "selectFileFailed": "请选择有效的 SQL 备份文件", "configCorrupted": "SQL 文件可能已损坏或格式不正确", "backupId": "备份ID", + "webdavSync": { + "title": "WebDAV 云同步", + "description": "通过 WebDAV 在多设备间同步数据库和技能配置。", + "baseUrl": "WebDAV 服务器地址", + "baseUrlPlaceholder": "https://dav.jianguoyun.com/dav/", + "username": "WebDAV 账户", + "usernamePlaceholder": "邮箱账号", + "password": "WebDAV 密码", + "passwordPlaceholder": "应用密码(坚果云请使用「第三方应用密码」)", + "remoteRoot": "远程根目录", + "profile": "同步配置名", + "test": "测试连接", + "testing": "测试中...", + "testSuccess": "连接成功", + "testFailed": "连接失败:{{error}}", + "save": "保存配置", + "saving": "保存中...", + "saveFailed": "保存配置失败:{{error}}", + "upload": "上传到云端", + "uploading": "上传中...", + "uploadSuccess": "已上传到 WebDAV", + "uploadFailed": "上传失败:{{error}}", + "download": "从云端下载", + "downloading": "下载中...", + "downloadSuccess": "已从 WebDAV 下载并恢复", + "downloadFailed": "下载失败:{{error}}", + "lastSync": "上次同步:{{time}}", + "missingUrl": "请填写 WebDAV 服务器地址", + "presets": { + "label": "服务商", + "jianguoyun": "坚果云", + "jianguoyunHint": "请在坚果云「安全选项」中生成「第三方应用密码」,不要使用登录密码。", + "nextcloud": "Nextcloud", + "nextcloudHint": "请将 your-server 替换为你的 Nextcloud 服务器地址,USERNAME 替换为你的用户名。", + "synology": "群晖 NAS", + "synologyHint": "请先在群晖「套件中心」安装并启用 WebDAV Server 套件。", + "custom": "自定义" + }, + "remoteRootDefault": "默认: cc-switch-sync", + "profileDefault": "默认: default", + "saveAndTestSuccess": "配置已保存,连接正常", + "saveAndTestFailed": "配置已保存,但连接测试失败:{{error}}", + "noRemoteData": "云端没有找到同步数据", + "incompatibleVersion": "远端数据版本不兼容(v{{version}}),当前支持 v2", + "unsaved": "未保存", + "saved": "已保存", + "unsavedChanges": "请先保存配置", + "saveBeforeSync": "请先保存配置,再使用上传/下载。", + "fetchingRemote": "获取远端信息...", + "fetchRemoteFailed": "获取远端信息失败,请检查配置和网络后重试。", + "confirmDownload": { + "title": "从云端恢复", + "deviceName": "上传设备", + "createdAt": "上传时间", + "artifacts": "包含内容", + "warning": "恢复将覆盖本地所有数据和技能配置", + "confirm": "确认恢复" + }, + "confirmUpload": { + "title": "上传到云端", + "content": "将同步以下内容到 WebDAV 服务器:", + "dbItem": "数据库(所有 Provider 配置和数据)", + "skillsItem": "技能包(所有自定义技能)", + "targetPath": "目标路径", + "existingData": "云端已有数据", + "deviceName": "上传设备", + "createdAt": "上传时间", + "warning": "将覆盖云端已有的同步数据", + "confirm": "确认上传" + } + }, "autoReload": "数据已刷新", "languageOptionChinese": "中文", "languageOptionEnglish": "English", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index cd8de3666..6b791659d 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Settings } from "@/types"; +import type { Settings, WebDavSyncSettings, RemoteSnapshotInfo } from "@/types"; import type { AppId } from "./types"; export interface ConfigTransferResult { @@ -9,6 +9,15 @@ export interface ConfigTransferResult { backupId?: string; } +export interface WebDavTestResult { + success: boolean; + message?: string; +} + +export interface WebDavSyncResult { + status: string; +} + export const settingsApi = { async get(): Promise { return await invoke("get_settings"); @@ -93,6 +102,42 @@ export const settingsApi = { return await invoke("import_config_from_file", { filePath }); }, + // ─── WebDAV v2 sync ─────────────────────────────────────── + + async webdavTestConnection( + settings: WebDavSyncSettings, + preserveEmptyPassword = true, + ): Promise { + return await invoke("webdav_test_connection", { + settings, + preserveEmptyPassword, + }); + }, + + async webdavSyncUpload(): Promise { + return await invoke("webdav_sync_upload"); + }, + + async webdavSyncDownload(): Promise { + return await invoke("webdav_sync_download"); + }, + + async webdavSyncSaveSettings( + settings: WebDavSyncSettings, + passwordTouched = false, + ): Promise<{ success: boolean }> { + return await invoke("webdav_sync_save_settings", { + settings, + passwordTouched, + }); + }, + + async webdavSyncFetchRemoteInfo(): Promise< + RemoteSnapshotInfo | { empty: true } + > { + return await invoke("webdav_sync_fetch_remote_info"); + }, + async syncCurrentProvidersLive(): Promise { const result = (await invoke("sync_current_providers_live")) as { success?: boolean; diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index 74db80585..d226ef34c 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -28,6 +28,27 @@ export const settingsSchema = z.object({ // Skill 同步设置 skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(), + + // WebDAV v2 同步设置(通过专用命令保存,schema 仅用于读取) + webdavSync: z + .object({ + enabled: z.boolean().optional(), + baseUrl: z.string().trim().optional().or(z.literal("")), + username: z.string().trim().optional().or(z.literal("")), + password: z.string().optional(), + remoteRoot: z.string().trim().optional().or(z.literal("")), + profile: z.string().trim().optional().or(z.literal("")), + status: z + .object({ + lastSyncAt: z.number().nullable().optional(), + lastError: z.string().nullable().optional(), + lastRemoteEtag: z.string().nullable().optional(), + lastLocalManifestHash: z.string().nullable().optional(), + lastRemoteManifestHash: z.string().nullable().optional(), + }) + .optional(), + }) + .optional(), }); export type SettingsFormData = z.infer; diff --git a/src/types.ts b/src/types.ts index 94af23ea7..5d219c4f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -162,6 +162,36 @@ export interface VisibleApps { opencode: boolean; } +// WebDAV v2 同步状态 +export interface WebDavSyncStatus { + lastSyncAt?: number | null; + lastError?: string | null; + lastRemoteEtag?: string | null; + lastLocalManifestHash?: string | null; + lastRemoteManifestHash?: string | null; +} + +// WebDAV v2 同步配置 +export interface WebDavSyncSettings { + enabled?: boolean; + baseUrl?: string; + username?: string; + password?: string; + remoteRoot?: string; + profile?: string; + status?: WebDavSyncStatus; +} + +// 远端快照信息(下载前预览) +export interface RemoteSnapshotInfo { + deviceName: string; + createdAt: string; + snapshotId: string; + version: number; + compatible: boolean; + artifacts: string[]; +} + // 应用设置类型(用于设置对话框与 Tauri API) // 存储在本地 ~/.cc-switch/settings.json,不随数据库同步 export interface Settings { @@ -206,6 +236,9 @@ export interface Settings { // Skill 同步方式:auto(默认,优先 symlink)、symlink、copy skillSyncMethod?: SkillSyncMethod; + // ===== WebDAV v2 同步设置 ===== + webdavSync?: WebDavSyncSettings; + // ===== 终端设置 ===== // 首选终端应用(可选,默认使用系统默认终端) // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" diff --git a/tests/components/SettingsDialog.test.tsx b/tests/components/SettingsDialog.test.tsx index 2e8015843..ec29f17fd 100644 --- a/tests/components/SettingsDialog.test.tsx +++ b/tests/components/SettingsDialog.test.tsx @@ -1,7 +1,8 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import "@testing-library/jest-dom"; -import { createContext, useContext } from "react"; +import { createContext, useContext, type ComponentProps } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { SettingsPage } from "@/components/settings/SettingsPage"; const toastSuccessMock = vi.fn(); @@ -156,6 +157,7 @@ vi.mock("@/components/ui/dialog", () => ({ DialogHeader: ({ children }: any) =>
{children}
, DialogFooter: ({ children }: any) =>
{children}
, DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>
{children}
, })); vi.mock("@/components/ui/tabs", () => { @@ -235,8 +237,29 @@ vi.mock("@/components/settings/AboutSection", () => ({ AboutSection: ({ isPortable }: any) =>
about:{String(isPortable)}
, })); +vi.mock("@/components/settings/WebdavSyncSection", () => ({ + WebdavSyncSection: ({ config }: any) => ( +
webdav-sync-section:{config?.baseUrl ?? "none"}
+ ), +})); + let settingsApi: any; +const renderSettingsPage = ( + props?: Partial>, +) => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return render( + + + , + ); +}; + describe("SettingsPage Component", () => { beforeEach(async () => { tMock.mockImplementation((key: string) => key); @@ -263,7 +286,7 @@ describe("SettingsPage Component", () => { it("should not render form content when loading", () => { settingsMock = createSettingsMock({ settings: null, isLoading: true }); - render(); + renderSettingsPage(); expect(screen.queryByText("language:zh")).not.toBeInTheDocument(); // 加载状态下显示 spinner 而不是表单内容 @@ -271,13 +294,24 @@ describe("SettingsPage Component", () => { }); it("should reset import/export status when dialog transitions to open", () => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); const { rerender } = render( - , + + + , ); importExportMock.resetStatus.mockClear(); - rerender(); + rerender( + + + , + ); expect(importExportMock.resetStatus).toHaveBeenCalledTimes(1); }); @@ -289,7 +323,7 @@ describe("SettingsPage Component", () => { selectedFile: "/tmp/config.json", }); - render(); + renderSettingsPage({ onOpenChange }); expect(screen.getByText("language:zh")).toBeInTheDocument(); expect(screen.getByText("theme-settings")).toBeInTheDocument(); @@ -306,6 +340,7 @@ describe("SettingsPage Component", () => { fireEvent.click(screen.getByText("settings.tabAdvanced")); fireEvent.click(screen.getByText("settings.advanced.data.title")); + expect(screen.getByText("webdav-sync-section:none")).toBeInTheDocument(); // 有文件时,点击导入按钮执行 importConfig fireEvent.click( @@ -326,13 +361,7 @@ describe("SettingsPage Component", () => { it("should pass onImportSuccess callback to useImportExport hook", async () => { const onImportSuccess = vi.fn(); - render( - , - ); + renderSettingsPage({ onImportSuccess }); expect(useImportExportSpy).toHaveBeenCalledWith( expect.objectContaining({ onImportSuccess }), @@ -349,7 +378,7 @@ describe("SettingsPage Component", () => { const onOpenChange = vi.fn(); importExportMock = createImportExportMock(); - render(); + renderSettingsPage({ onOpenChange }); // 保存按钮在 advanced tab 中 fireEvent.click(screen.getByText("settings.tabAdvanced")); @@ -370,7 +399,7 @@ describe("SettingsPage Component", () => { saveSettings: vi.fn().mockResolvedValue({ requiresRestart: true }), }); - render(); + renderSettingsPage(); expect( await screen.findByText("settings.restartRequired"), @@ -390,7 +419,7 @@ describe("SettingsPage Component", () => { const onOpenChange = vi.fn(); settingsMock = createSettingsMock({ requiresRestart: true }); - render(); + renderSettingsPage({ onOpenChange }); expect( await screen.findByText("settings.restartRequired"), @@ -409,7 +438,7 @@ describe("SettingsPage Component", () => { }); it("should trigger directory management callbacks inside advanced tab", () => { - render(); + renderSettingsPage(); fireEvent.click(screen.getByText("settings.tabAdvanced")); fireEvent.click(screen.getByText("settings.advanced.configDir.title")); diff --git a/tests/components/WebdavSyncSection.test.tsx b/tests/components/WebdavSyncSection.test.tsx new file mode 100644 index 000000000..27a487a7b --- /dev/null +++ b/tests/components/WebdavSyncSection.test.tsx @@ -0,0 +1,370 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import "@testing-library/jest-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection"; +import type { WebDavSyncSettings } from "@/types"; + +const toastSuccessMock = vi.fn(); +const toastErrorMock = vi.fn(); +const toastWarningMock = vi.fn(); +const toastInfoMock = vi.fn(); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccessMock(...args), + error: (...args: unknown[]) => toastErrorMock(...args), + warning: (...args: unknown[]) => toastWarningMock(...args), + info: (...args: unknown[]) => toastInfoMock(...args), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => , +})); + +vi.mock("@/components/ui/input", () => ({ + Input: (props: any) => , +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ value, onValueChange, children }: any) => ( + + ), + SelectTrigger: ({ children }: any) => <>{children}, + SelectValue: () => null, + SelectContent: ({ children }: any) => <>{children}, + SelectItem: ({ value, children }: any) => ( + + ), +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: any) => (open ?
{children}
: null), + DialogContent: ({ children }: any) =>
{children}
, + DialogDescription: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, +})); + +const { settingsApiMock } = vi.hoisted(() => ({ + settingsApiMock: { + webdavTestConnection: vi.fn(), + webdavSyncSaveSettings: vi.fn(), + webdavSyncFetchRemoteInfo: vi.fn(), + webdavSyncUpload: vi.fn(), + webdavSyncDownload: vi.fn(), + }, +})); + +vi.mock("@/lib/api", () => ({ + settingsApi: settingsApiMock, +})); + +const baseConfig: WebDavSyncSettings = { + enabled: true, + baseUrl: "https://dav.example.com/dav/", + username: "alice", + password: "secret", + remoteRoot: "cc-switch-sync", + profile: "default", + status: {}, +}; + +function renderSection(config?: WebDavSyncSettings) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render( + + + , + ); +} + +describe("WebdavSyncSection", () => { + beforeEach(() => { + toastSuccessMock.mockReset(); + toastErrorMock.mockReset(); + toastWarningMock.mockReset(); + toastInfoMock.mockReset(); + settingsApiMock.webdavTestConnection.mockReset(); + settingsApiMock.webdavSyncSaveSettings.mockReset(); + settingsApiMock.webdavSyncFetchRemoteInfo.mockReset(); + settingsApiMock.webdavSyncUpload.mockReset(); + settingsApiMock.webdavSyncDownload.mockReset(); + + settingsApiMock.webdavSyncSaveSettings.mockResolvedValue({ success: true }); + settingsApiMock.webdavTestConnection.mockResolvedValue({ + success: true, + message: "ok", + }); + settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValue({ + deviceName: "My MacBook", + createdAt: "2026-02-01T10:00:00Z", + snapshotId: "snapshot-1", + version: 2, + compatible: true, + artifacts: ["db.sql", "skills.zip"], + }); + settingsApiMock.webdavSyncUpload.mockResolvedValue({ status: "uploaded" }); + settingsApiMock.webdavSyncDownload.mockResolvedValue({ status: "downloaded" }); + }); + + it("shows validation error when saving without base url", async () => { + renderSection({ ...baseConfig, baseUrl: "" }); + + fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" })); + + expect(toastErrorMock).toHaveBeenCalledWith("settings.webdavSync.missingUrl"); + expect(settingsApiMock.webdavSyncSaveSettings).not.toHaveBeenCalled(); + }); + + it("saves settings and auto tests connection", async () => { + renderSection(baseConfig); + + fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" })); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1); + }); + expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://dav.example.com/dav/", + username: "alice", + password: "secret", + }), + false, + ); + await waitFor(() => { + expect(settingsApiMock.webdavTestConnection).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://dav.example.com/dav/", + }), + true, + ); + }); + expect(toastSuccessMock).toHaveBeenCalledWith( + "settings.webdavSync.saveAndTestSuccess", + ); + }); + + it("blocks upload when there are unsaved changes", async () => { + renderSection(baseConfig); + + fireEvent.change( + screen.getByPlaceholderText("settings.webdavSync.usernamePlaceholder"), + { target: { value: "bob" } }, + ); + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.upload" }), + ); + + expect(toastErrorMock).toHaveBeenCalledWith( + "settings.webdavSync.unsavedChanges", + ); + expect(settingsApiMock.webdavSyncFetchRemoteInfo).not.toHaveBeenCalled(); + }); + + it("disables sync buttons until config is saved", () => { + renderSection(undefined); + + const uploadButton = screen.getByRole("button", { + name: "settings.webdavSync.upload", + }); + const downloadButton = screen.getByRole("button", { + name: "settings.webdavSync.download", + }); + + expect(uploadButton).toBeDisabled(); + expect(downloadButton).toBeDisabled(); + expect(settingsApiMock.webdavSyncFetchRemoteInfo).not.toHaveBeenCalled(); + }); + + it("fetches remote info and uploads after confirmation", async () => { + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.upload" }), + ); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1); + }); + + fireEvent.click( + screen.getByRole("button", { + name: "settings.webdavSync.confirmUpload.confirm", + }), + ); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncUpload).toHaveBeenCalledTimes(1); + }); + expect(toastSuccessMock).toHaveBeenCalledWith( + "settings.webdavSync.uploadSuccess", + ); + }); + + it("blocks upload confirmation if form changes after dialog opens", async () => { + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.upload" }), + ); + await waitFor(() => { + expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1); + }); + + fireEvent.change(screen.getByPlaceholderText("cc-switch-sync"), { + target: { value: "new-root" }, + }); + fireEvent.click( + screen.getByRole("button", { + name: "settings.webdavSync.confirmUpload.confirm", + }), + ); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "settings.webdavSync.unsavedChanges", + ); + }); + expect(settingsApiMock.webdavSyncUpload).not.toHaveBeenCalled(); + }); + + it("fetches remote info and downloads after confirmation", async () => { + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.download" }), + ); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1); + }); + + fireEvent.click( + screen.getByRole("button", { + name: "settings.webdavSync.confirmDownload.confirm", + }), + ); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncDownload).toHaveBeenCalledTimes(1); + }); + expect(toastSuccessMock).toHaveBeenCalledWith( + "settings.webdavSync.downloadSuccess", + ); + }); + + it("blocks download confirmation if form changes after dialog opens", async () => { + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.download" }), + ); + await waitFor(() => { + expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1); + }); + + fireEvent.change(screen.getByPlaceholderText("default"), { + target: { value: "new-profile" }, + }); + fireEvent.click( + screen.getByRole("button", { + name: "settings.webdavSync.confirmDownload.confirm", + }), + ); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "settings.webdavSync.unsavedChanges", + ); + }); + expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled(); + }); + + it("shows info when no remote snapshot is found for download", async () => { + settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValueOnce({ empty: true }); + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.download" }), + ); + + await waitFor(() => { + expect(toastInfoMock).toHaveBeenCalledWith("settings.webdavSync.noRemoteData"); + }); + expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled(); + }); + + it("blocks download when remote snapshot is incompatible", async () => { + settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValueOnce({ + deviceName: "Legacy Machine", + createdAt: "2025-01-01T00:00:00Z", + snapshotId: "legacy-snapshot", + version: 1, + compatible: false, + artifacts: ["db.sql"], + }); + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.download" }), + ); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "settings.webdavSync.incompatibleVersion", + ); + }); + expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled(); + expect( + screen.queryByRole("button", { + name: "settings.webdavSync.confirmDownload.confirm", + }), + ).not.toBeInTheDocument(); + }); + + it("shows error when download fails after confirmation", async () => { + settingsApiMock.webdavSyncDownload.mockRejectedValueOnce(new Error("boom")); + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.download" }), + ); + await waitFor(() => { + expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1); + }); + + fireEvent.click( + screen.getByRole("button", { + name: "settings.webdavSync.confirmDownload.confirm", + }), + ); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "settings.webdavSync.downloadFailed", + ); + }); + }); +}); diff --git a/tests/integration/SettingsDialog.test.tsx b/tests/integration/SettingsDialog.test.tsx index 0772e4a8c..be1a689f8 100644 --- a/tests/integration/SettingsDialog.test.tsx +++ b/tests/integration/SettingsDialog.test.tsx @@ -28,6 +28,7 @@ vi.mock("@/components/ui/dialog", () => ({ DialogHeader: ({ children }: any) =>
{children}
, DialogFooter: ({ children }: any) =>
{children}
, DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>
{children}
, })); const TabsContext = React.createContext<{