* feat: WebDAV backup/restore

- Add WebDAV test/backup/restore commands and settings\n- Fix ja i18n missing keys; decode PROPFIND href as UTF-8\n- Stabilize Windows prompt auto-import tests via CC_SWITCH_TEST_HOME

* chore: format and minor cleanups

* fix: update build config

* feat(webdav): unify sync UX and hardening fixes

* fix(webdav): harden sync flow and stabilize sync UX/tests

* fix(webdav): add resource limits to skills.zip extraction

Prevent zip bomb / resource exhaustion by enforcing:
- MAX_EXTRACT_ENTRIES (10,000 files)
- MAX_EXTRACT_BYTES (512 MB cumulative)

* refactor(webdav): drop deviceId and display deviceName only

---------

Co-authored-by: small-lovely-cat <77799160+small-lovely-cat@users.noreply.github.com>
Co-authored-by: saladday <1203511142@qq.com>
This commit is contained in:
clx
2026-02-14 15:24:24 +08:00
committed by GitHub
parent 721771bfe5
commit 6098fa7536
26 changed files with 3755 additions and 41 deletions
+1
View File
@@ -727,6 +727,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"serial_test",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
+1
View File
@@ -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"
+12 -17
View File
@@ -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<Value, String> {
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<V
.map_err(|e: AppError| e.to_string())
}
// ─── File dialogs ────────────────────────────────────────────
/// 保存文件对话框
#[tauri::command]
pub async fn save_file_dialog<R: tauri::Runtime>(
+3
View File
@@ -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::*;
+66 -2
View File
@@ -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<crate::settings::AppSettings, String> {
Ok(crate::settings::get_settings())
Ok(crate::settings::get_settings_for_frontend())
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
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<bool, String> {
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<bool, String> {
+97
View File
@@ -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<Database>) -> Result<(), AppError> {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
settings::reload_settings()?;
Ok(())
}
fn post_sync_warning<E: std::fmt::Display>(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<Result<(), AppError>, String>,
) -> Option<String> {
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<String>) -> 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<String>) -> 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")
);
}
}
+357
View File
@@ -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<WebDavSyncSettings, String> {
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<WebDavSyncSettings>,
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<tokio::sync::Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}
async fn run_with_webdav_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
where
Fut: Future<Output = Result<T, AppError>>,
{
let result = {
let _guard = webdav_sync_mutex().lock().await;
operation.await
};
result
}
fn map_sync_result<T, F>(result: Result<T, AppError>, on_error: F) -> Result<T, String>
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<bool>,
) -> Result<Value, String> {
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<Value, String> {
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<Value, String> {
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<bool>,
) -> Result<Value, String> {
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<Value, String> {
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/");
}
}
+13 -2
View File
@@ -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<String, AppError> {
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<String, AppError> {
let sql_content = sql_raw.trim_start_matches('\u{feff}');
Self::validate_cc_switch_sql_export(sql_content)?;
+5
View File
@@ -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,
+2
View File
@@ -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;
+507
View File
@@ -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<String>)>;
// ─── 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<Url, AppError> {
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<String, AppError> {
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<Item = &str> {
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<u8>,
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<(Vec<u8>, Option<String>)>, 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<Option<String>, 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<bool, AppError> {
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<String> = 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"));
}
}
+649
View File
@@ -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<String>, en: impl Into<String>) -> AppError {
AppError::localized(key, zh, en)
}
fn io_context_localized(
_key: &'static str,
zh: impl Into<String>,
en: impl Into<String>,
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<String, ArtifactMeta>,
snapshot_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ArtifactMeta {
sha256: String,
size: u64,
}
struct LocalSnapshot {
db_sql: Vec<u8>,
skills_zip: Vec<u8>,
manifest_bytes: Vec<u8>,
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<Value, AppError> {
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<Value, AppError> {
settings.validate()?;
let auth = auth_for(settings);
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth).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<Option<Value>, AppError> {
settings.validate()?;
let auth = auth_for(settings);
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
let Some((bytes, _)) = get_bytes(&manifest_url, &auth).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::<Vec<_>>(),
});
Ok(Some(payload))
}
// ─── Sync status persistence (I3: deduplicated) ─────────────
fn persist_sync_success(
settings: &mut WebDavSyncSettings,
manifest_hash: String,
etag: Option<String>,
) -> 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<F>(
settings: &mut WebDavSyncSettings,
manifest_hash: String,
etag: Option<String>,
persist_fn: F,
) -> bool
where
F: FnOnce(&mut WebDavSyncSettings, String, Option<String>) -> 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<LocalSnapshot, AppError> {
// 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, ArtifactMeta>) -> String {
let parts: Vec<String> = 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<String> {
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<String> {
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::<Vec<_>>().join(" ");
let trimmed = normalized.trim();
if trimmed.is_empty() {
return None;
}
let limited = trimmed.chars().take(MAX_DEVICE_NAME_LEN).collect::<String>();
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<String, ArtifactMeta>,
) -> Result<Vec<u8>, 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<String> {
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<String, AppError> {
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"
);
}
}
@@ -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<SkillsBackup, AppError> {
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<fs::File>,
options: SimpleFileOptions,
visited: &mut HashSet<PathBuf>,
) -> Result<(), AppError> {
let mut entries: Vec<_> = fs::read_dir(current)
.map_err(|e| AppError::io(current, e))?
.collect::<Result<Vec<_>, _>>()
.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<PathBuf>,
) -> 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<PathBuf>) -> Result<bool, AppError> {
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"));
}
}
+182 -1
View File
@@ -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<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_remote_etag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_local_manifest_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_remote_manifest_hash: Option<String>,
}
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<WebDavSyncSettings>,
// ===== WebDAV 备份设置(旧版,保留向后兼容)=====
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webdav_backup: Option<serde_json::Value>,
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - 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<F>(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<String> {
.preferred_terminal
.clone()
}
// ===== WebDAV 同步设置管理函数 =====
/// 获取 WebDAV 同步设置
pub fn get_webdav_sync_settings() -> Option<WebDavSyncSettings> {
settings_store().read().ok()?.webdav_sync.clone()
}
/// 保存 WebDAV 同步设置
pub fn set_webdav_sync_settings(settings: Option<WebDavSyncSettings>) -> 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;
}
})
}
+6
View File
@@ -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}
/>
<div className="pt-6">
<WebdavSyncSection
config={settings?.webdavSync}
/>
</div>
</AccordionContent>
</AccordionItem>
@@ -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<React.ComponentPropsWithoutRef<typeof Button>, "children">) {
const isActive =
actionState === targetState ||
(alsoActiveFor?.includes(actionState) ?? false);
return (
<Button {...props} disabled={actionState !== "idle" || disabled}>
<span className="inline-flex items-center gap-2">
{isActive ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Icon className="h-3.5 w-3.5" />
)}
{isActive ? activeLabel : idleLabel}
</span>
</Button>
);
}
// ─── Main component ─────────────────────────────────────────
export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [actionState, setActionState] = useState<ActionState>("idle");
const [dirty, setDirty] = useState(false);
const [passwordTouched, setPasswordTouched] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const justSavedTimerRef = useRef<ReturnType<typeof setTimeout> | 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<DialogType>(null);
const [remoteInfo, setRemoteInfo] = useState<RemoteSnapshotInfo | null>(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 (
<section className="space-y-4">
<header className="space-y-2">
<h3 className="text-base font-semibold text-foreground">
{t("settings.webdavSync.title")}
</h3>
<p className="text-sm text-muted-foreground">
{t("settings.webdavSync.description")}
</p>
</header>
<div className="space-y-4 rounded-lg border border-border bg-muted/40 p-6">
{/* Config fields */}
<div className="space-y-3">
{/* Service preset selector */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.presets.label")}
</label>
<Select
value={presetId}
onValueChange={handlePresetChange}
disabled={isLoading}
>
<SelectTrigger className="text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WEBDAV_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{t(preset.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Server URL */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.baseUrl")}
</label>
<Input
value={form.baseUrl}
onChange={(e) => updateField("baseUrl", e.target.value)}
onBlur={handleBaseUrlBlur}
placeholder={t("settings.webdavSync.baseUrlPlaceholder")}
className="text-xs flex-1"
disabled={isLoading}
/>
</div>
{/* Username */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.username")}
</label>
<Input
value={form.username}
onChange={(e) => updateField("username", e.target.value)}
placeholder={t("settings.webdavSync.usernamePlaceholder")}
className="text-xs flex-1"
disabled={isLoading}
/>
</div>
{/* Password */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.password")}
</label>
<Input
type="password"
value={form.password}
onChange={(e) => updateField("password", e.target.value)}
placeholder={t("settings.webdavSync.passwordPlaceholder")}
className="text-xs flex-1"
autoComplete="off"
disabled={isLoading}
/>
</div>
{/* Preset hint */}
{activePreset?.hint && (
<div className="flex items-start gap-2 pl-44 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span>{t(activePreset.hint)}</span>
</div>
)}
{/* Remote Root */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.remoteRoot")}
<span className="block text-[10px] font-normal text-muted-foreground">
{t("settings.webdavSync.remoteRootDefault")}
</span>
</label>
<Input
value={form.remoteRoot}
onChange={(e) => updateField("remoteRoot", e.target.value)}
placeholder="cc-switch-sync"
className="text-xs flex-1"
disabled={isLoading}
/>
</div>
{/* Profile */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.profile")}
<span className="block text-[10px] font-normal text-muted-foreground">
{t("settings.webdavSync.profileDefault")}
</span>
</label>
<Input
value={form.profile}
onChange={(e) => updateField("profile", e.target.value)}
placeholder="default"
className="text-xs flex-1"
disabled={isLoading}
/>
</div>
</div>
{/* Last sync time */}
{lastSyncDisplay && (
<p className="text-xs text-muted-foreground">
{t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}
</p>
)}
{/* Config buttons + save status */}
<div className="flex flex-wrap items-center gap-3 pt-2">
<ActionButton
type="button"
variant="outline"
size="sm"
onClick={handleTest}
actionState={actionState}
targetState="testing"
icon={Link2}
activeLabel={t("settings.webdavSync.testing")}
idleLabel={t("settings.webdavSync.test")}
/>
<ActionButton
type="button"
variant="outline"
size="sm"
onClick={handleSave}
actionState={actionState}
targetState="saving"
icon={Save}
activeLabel={t("settings.webdavSync.saving")}
idleLabel={t("settings.webdavSync.save")}
/>
{/* Save status indicator */}
{dirty && (
<span className="inline-flex items-center gap-1.5 text-xs text-amber-500 dark:text-amber-400 animate-in fade-in duration-200">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500 dark:bg-amber-400" />
{t("settings.webdavSync.unsaved")}
</span>
)}
{!dirty && justSaved && (
<span className="inline-flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400 animate-in fade-in duration-200">
<Check className="h-3 w-3" />
{t("settings.webdavSync.saved")}
</span>
)}
</div>
{/* Sync buttons */}
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
<ActionButton
type="button"
size="sm"
onClick={handleUploadClick}
disabled={!hasSavedConfig}
actionState={actionState}
targetState="uploading"
alsoActiveFor={["fetching_remote"]}
icon={UploadCloud}
activeLabel={
actionState === "fetching_remote"
? t("settings.webdavSync.fetchingRemote")
: t("settings.webdavSync.uploading")
}
idleLabel={t("settings.webdavSync.upload")}
/>
<ActionButton
type="button"
variant="secondary"
size="sm"
onClick={handleDownloadClick}
disabled={!hasSavedConfig}
actionState={actionState}
targetState="downloading"
alsoActiveFor={["fetching_remote"]}
icon={DownloadCloud}
activeLabel={
actionState === "fetching_remote"
? t("settings.webdavSync.fetchingRemote")
: t("settings.webdavSync.downloading")
}
idleLabel={t("settings.webdavSync.download")}
/>
</div>
{!hasSavedConfig && (
<p className="text-xs text-muted-foreground">
{t("settings.webdavSync.saveBeforeSync")}
</p>
)}
</div>
{/* ─── Upload confirmation dialog ──────────────────── */}
<Dialog
open={dialogType === "upload"}
onOpenChange={(open) => {
if (!open) closeDialog();
}}
>
<DialogContent className="max-w-sm" zIndex="alert">
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
{t("settings.webdavSync.confirmUpload.title")}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3 text-sm leading-relaxed">
<p>{t("settings.webdavSync.confirmUpload.content")}</p>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>{t("settings.webdavSync.confirmUpload.dbItem")}</li>
<li>{t("settings.webdavSync.confirmUpload.skillsItem")}</li>
</ul>
<p className="text-muted-foreground">
{t("settings.webdavSync.confirmUpload.targetPath")}
{": "}
<code className="ml-1 text-xs bg-muted px-1.5 py-0.5 rounded">
/{form.remoteRoot.trim() || "cc-switch-sync"}/v2/
{form.profile.trim() || "default"}
</code>
</p>
{remoteInfo && (
<div className="rounded-lg border border-border bg-muted/50 p-3 space-y-2">
<p className="text-xs font-medium text-foreground">
{t("settings.webdavSync.confirmUpload.existingData")}
</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmUpload.deviceName")}
</dt>
<dd>
<code className="bg-muted px-1.5 py-0.5 rounded">
{remoteInfo.deviceName}
</code>
</dd>
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmUpload.createdAt")}
</dt>
<dd>{formatDate(remoteInfo.createdAt)}</dd>
</dl>
</div>
)}
{remoteInfo && (
<p className="text-destructive font-medium">
{t("settings.webdavSync.confirmUpload.warning")}
</p>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={closeDialog}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleUploadConfirm}>
{t("settings.webdavSync.confirmUpload.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ─── Download confirmation dialog ────────────────── */}
<Dialog
open={dialogType === "download"}
onOpenChange={(open) => {
if (!open) closeDialog();
}}
>
<DialogContent className="max-w-sm" zIndex="alert">
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
{t("settings.webdavSync.confirmDownload.title")}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3 text-sm leading-relaxed">
{remoteInfo && (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-muted-foreground">
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmDownload.deviceName")}
</dt>
<dd>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{remoteInfo.deviceName}
</code>
</dd>
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmDownload.createdAt")}
</dt>
<dd>{formatDate(remoteInfo.createdAt)}</dd>
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmDownload.artifacts")}
</dt>
<dd>{remoteInfo.artifacts.join(", ")}</dd>
</dl>
)}
<p className="text-destructive font-medium">
{t("settings.webdavSync.confirmDownload.warning")}
</p>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={closeDialog}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDownloadConfirm}>
{t("settings.webdavSync.confirmDownload.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
+4 -2
View File
@@ -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,
+71
View File
@@ -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",
+71
View File
@@ -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",
+71
View File
@@ -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",
+46 -1
View File
@@ -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<Settings> {
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<WebDavTestResult> {
return await invoke("webdav_test_connection", {
settings,
preserveEmptyPassword,
});
},
async webdavSyncUpload(): Promise<WebDavSyncResult> {
return await invoke("webdav_sync_upload");
},
async webdavSyncDownload(): Promise<WebDavSyncResult> {
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<void> {
const result = (await invoke("sync_current_providers_live")) as {
success?: boolean;
+21
View File
@@ -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<typeof settingsSchema>;
+33
View File
@@ -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"
+45 -16
View File
@@ -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) => <div>{children}</div>,
DialogFooter: ({ children }: any) => <div>{children}</div>,
DialogTitle: ({ children }: any) => <h2>{children}</h2>,
DialogDescription: ({ children }: any) => <div>{children}</div>,
}));
vi.mock("@/components/ui/tabs", () => {
@@ -235,8 +237,29 @@ vi.mock("@/components/settings/AboutSection", () => ({
AboutSection: ({ isPortable }: any) => <div>about:{String(isPortable)}</div>,
}));
vi.mock("@/components/settings/WebdavSyncSection", () => ({
WebdavSyncSection: ({ config }: any) => (
<div>webdav-sync-section:{config?.baseUrl ?? "none"}</div>
),
}));
let settingsApi: any;
const renderSettingsPage = (
props?: Partial<ComponentProps<typeof SettingsPage>>,
) => {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return render(
<QueryClientProvider client={client}>
<SettingsPage open={true} onOpenChange={vi.fn()} {...props} />
</QueryClientProvider>,
);
};
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(<SettingsPage open={true} onOpenChange={vi.fn()} />);
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(
<SettingsPage open={false} onOpenChange={vi.fn()} />,
<QueryClientProvider client={client}>
<SettingsPage open={false} onOpenChange={vi.fn()} />
</QueryClientProvider>,
);
importExportMock.resetStatus.mockClear();
rerender(<SettingsPage open={true} onOpenChange={vi.fn()} />);
rerender(
<QueryClientProvider client={client}>
<SettingsPage open={true} onOpenChange={vi.fn()} />
</QueryClientProvider>,
);
expect(importExportMock.resetStatus).toHaveBeenCalledTimes(1);
});
@@ -289,7 +323,7 @@ describe("SettingsPage Component", () => {
selectedFile: "/tmp/config.json",
});
render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
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(
<SettingsPage
open={true}
onOpenChange={vi.fn()}
onImportSuccess={onImportSuccess}
/>,
);
renderSettingsPage({ onImportSuccess });
expect(useImportExportSpy).toHaveBeenCalledWith(
expect.objectContaining({ onImportSuccess }),
@@ -349,7 +378,7 @@ describe("SettingsPage Component", () => {
const onOpenChange = vi.fn();
importExportMock = createImportExportMock();
render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
renderSettingsPage({ onOpenChange });
// 保存按钮在 advanced tab 中
fireEvent.click(screen.getByText("settings.tabAdvanced"));
@@ -370,7 +399,7 @@ describe("SettingsPage Component", () => {
saveSettings: vi.fn().mockResolvedValue({ requiresRestart: true }),
});
render(<SettingsPage open={true} onOpenChange={vi.fn()} />);
renderSettingsPage();
expect(
await screen.findByText("settings.restartRequired"),
@@ -390,7 +419,7 @@ describe("SettingsPage Component", () => {
const onOpenChange = vi.fn();
settingsMock = createSettingsMock({ requiresRestart: true });
render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
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(<SettingsPage open={true} onOpenChange={vi.fn()} />);
renderSettingsPage();
fireEvent.click(screen.getByText("settings.tabAdvanced"));
fireEvent.click(screen.getByText("settings.advanced.configDir.title"));
+370
View File
@@ -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) => <button {...props}>{children}</button>,
}));
vi.mock("@/components/ui/input", () => ({
Input: (props: any) => <input {...props} />,
}));
vi.mock("@/components/ui/select", () => ({
Select: ({ value, onValueChange, children }: any) => (
<select
data-testid="webdav-preset-select"
value={value}
onChange={(event) => onValueChange?.(event.target.value)}
>
{children}
</select>
),
SelectTrigger: ({ children }: any) => <>{children}</>,
SelectValue: () => null,
SelectContent: ({ children }: any) => <>{children}</>,
SelectItem: ({ value, children }: any) => (
<option value={value}>{children}</option>
),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: any) => <div>{children}</div>,
DialogDescription: ({ children }: any) => <div>{children}</div>,
DialogFooter: ({ children }: any) => <div>{children}</div>,
DialogHeader: ({ children }: any) => <div>{children}</div>,
DialogTitle: ({ children }: any) => <h2>{children}</h2>,
}));
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(
<QueryClientProvider client={client}>
<WebdavSyncSection config={config} />
</QueryClientProvider>,
);
}
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",
);
});
});
});
@@ -28,6 +28,7 @@ vi.mock("@/components/ui/dialog", () => ({
DialogHeader: ({ children }: any) => <div>{children}</div>,
DialogFooter: ({ children }: any) => <div>{children}</div>,
DialogTitle: ({ children }: any) => <h2>{children}</h2>,
DialogDescription: ({ children }: any) => <div>{children}</div>,
}));
const TabsContext = React.createContext<{