diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9547eb342..e3dd7f59a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -57,7 +57,7 @@ url = "2.5" auto-launch = "0.5" once_cell = "1.21.3" base64 = "0.22" -rusqlite = { version = "0.31", features = ["bundled", "backup"] } +rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] } indexmap = { version = "2", features = ["serde"] } rust_decimal = "1.33" uuid = { version = "1.11", features = ["v4"] } diff --git a/src-tauri/src/commands/webdav_sync.rs b/src-tauri/src/commands/webdav_sync.rs index e1bbd70d7..787bd88f6 100644 --- a/src-tauri/src/commands/webdav_sync.rs +++ b/src-tauri/src/commands/webdav_sync.rs @@ -1,9 +1,7 @@ #![allow(non_snake_case)] -use serde_json::{Value, json}; -use std::future::Future; -use std::sync::OnceLock; -use tauri::State; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter, State}; use crate::commands::sync_support::{ attach_warning, post_sync_warning_from_result, run_post_import_sync, @@ -13,8 +11,9 @@ 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) { +fn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError, source: &str) { settings.status.last_error = Some(error.to_string()); + settings.status.last_error_source = Some(source.to_string()); let _ = settings::update_webdav_sync_status(settings.status.clone()); } @@ -57,20 +56,16 @@ fn resolve_password_for_request( incoming } +#[cfg(test)] fn webdav_sync_mutex() -> &'static tokio::sync::Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| tokio::sync::Mutex::new(())) + webdav_sync_service::sync_mutex() } async fn run_with_webdav_lock(operation: Fut) -> Result where - Fut: Future>, + Fut: std::future::Future>, { - let result = { - let _guard = webdav_sync_mutex().lock().await; - operation.await - }; - result + webdav_sync_service::run_with_sync_lock(operation).await } fn map_sync_result(result: Result, on_error: F) -> Result @@ -86,6 +81,64 @@ where } } +fn should_run_auto_sync(settings: Option<&WebDavSyncSettings>) -> bool { + let Some(sync) = settings else { + return false; + }; + sync.enabled && sync.auto_sync +} + +fn emit_webdav_sync_status_updated( + app: &AppHandle, + source: &str, + status: &str, + error: Option<&str>, +) { + let payload = match error { + Some(message) => json!({ + "source": source, + "status": status, + "error": message, + }), + None => json!({ + "source": source, + "status": status, + }), + }; + + if let Err(err) = app.emit("webdav-sync-status-updated", payload) { + log::debug!("[WebDAV] failed to emit sync status update event: {err}"); + } +} + +pub(crate) async fn run_auto_sync_upload( + db: &crate::database::Database, + app: &AppHandle, +) -> Result<(), AppError> { + let mut settings = settings::get_webdav_sync_settings(); + if !should_run_auto_sync(settings.as_ref()) { + return Ok(()); + } + + let mut sync_settings = match settings.take() { + Some(value) => value, + None => return Ok(()), + }; + + let result = run_with_webdav_lock(webdav_sync_service::upload(db, &mut sync_settings)).await; + match result { + Ok(_) => { + emit_webdav_sync_status_updated(app, "auto", "success", None); + Ok(()) + } + Err(err) => { + persist_sync_error(&mut sync_settings, &err, "auto"); + emit_webdav_sync_status_updated(app, "auto", "error", Some(&err.to_string())); + Err(err) + } + } +} + #[tauri::command] pub async fn webdav_test_connection( settings: WebDavSyncSettings, @@ -112,7 +165,9 @@ pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result) -> Result, } +fn register_db_change_hook(conn: &Connection) { + conn.update_hook(Some( + |action: Action, _database: &str, table: &str, _row_id: i64| match action { + Action::SQLITE_INSERT | Action::SQLITE_UPDATE | Action::SQLITE_DELETE => { + crate::services::webdav_auto_sync::notify_db_changed(table); + } + _ => {} + }, + )); +} + impl Database { /// 初始化数据库连接并创建表 /// @@ -93,6 +104,7 @@ impl Database { // 启用外键约束 conn.execute("PRAGMA foreign_keys = ON;", []) .map_err(|e| AppError::Database(e.to_string()))?; + register_db_change_hook(&conn); let db = Self { conn: Mutex::new(conn), @@ -111,6 +123,7 @@ impl Database { // 启用外键约束 conn.execute("PRAGMA foreign_keys = ON;", []) .map_err(|e| AppError::Database(e.to_string()))?; + register_db_change_hook(&conn); let db = Self { conn: Mutex::new(conn), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 92edd4aaf..2293899ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -702,6 +702,10 @@ pub fn run() { } let _tray = tray_builder.build(app)?; + crate::services::webdav_auto_sync::start_worker( + app_state.db.clone(), + app.handle().clone(), + ); // 将同一个实例注入到全局状态,避免重复创建导致的不一致 app.manage(app_state); diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 7a49e531c..e350372a0 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -11,6 +11,7 @@ pub mod speedtest; pub mod stream_check; pub mod usage_stats; pub mod webdav; +pub mod webdav_auto_sync; pub mod webdav_sync; pub use config::ConfigService; diff --git a/src-tauri/src/services/webdav_auto_sync.rs b/src-tauri/src/services/webdav_auto_sync.rs new file mode 100644 index 000000000..3eb0420f6 --- /dev/null +++ b/src-tauri/src/services/webdav_auto_sync.rs @@ -0,0 +1,171 @@ +use std::sync::Arc; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use tokio::sync::mpsc::error::TrySendError; +use tokio::sync::mpsc::{Receiver, Sender, channel}; + +const AUTO_SYNC_DEBOUNCE_MS: u64 = 1000; +pub(crate) const MAX_AUTO_SYNC_WAIT_MS: u64 = 10_000; + +static DB_CHANGE_TX: OnceLock> = OnceLock::new(); +static AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0); + +pub(crate) struct AutoSyncSuppressionGuard; + +impl AutoSyncSuppressionGuard { + pub fn new() -> Self { + AUTO_SYNC_SUPPRESS_DEPTH.fetch_add(1, Ordering::SeqCst); + Self + } +} + +impl Drop for AutoSyncSuppressionGuard { + fn drop(&mut self) { + let _ = AUTO_SYNC_SUPPRESS_DEPTH.fetch_update( + Ordering::SeqCst, + Ordering::SeqCst, + |value| Some(value.saturating_sub(1)), + ); + } +} + +pub(crate) fn is_auto_sync_suppressed() -> bool { + AUTO_SYNC_SUPPRESS_DEPTH.load(Ordering::SeqCst) > 0 +} + +pub fn should_trigger_for_table(table: &str) -> bool { + let normalized = table.trim().to_ascii_lowercase(); + matches!( + normalized.as_str(), + "providers" + | "provider_endpoints" + | "mcp_servers" + | "prompts" + | "skills" + | "skill_repos" + | "settings" + | "proxy_config" + ) +} + +pub(crate) fn enqueue_change_signal(tx: &Sender, table: &str) -> bool { + match tx.try_send(table.to_string()) { + Ok(()) => true, + Err(TrySendError::Full(_)) | Err(TrySendError::Closed(_)) => false, + } +} + +pub(crate) fn auto_sync_wait_duration(started_at: Instant, now: Instant) -> Option { + let max_wait = Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS); + let debounce = Duration::from_millis(AUTO_SYNC_DEBOUNCE_MS); + let elapsed = now.saturating_duration_since(started_at); + if elapsed >= max_wait { + return None; + } + Some(debounce.min(max_wait - elapsed)) +} + +pub fn notify_db_changed(table: &str) { + if is_auto_sync_suppressed() { + return; + } + if !should_trigger_for_table(table) { + return; + } + let Some(tx) = DB_CHANGE_TX.get() else { + return; + }; + let _ = enqueue_change_signal(tx, table); +} + +pub fn start_worker(db: Arc, app: tauri::AppHandle) { + if DB_CHANGE_TX.get().is_some() { + return; + } + + // Buffer size 1 is enough: we only need "dirty" signals, not every event. + let (tx, rx) = channel::(1); + if DB_CHANGE_TX.set(tx).is_err() { + return; + } + + tauri::async_runtime::spawn(async move { + run_worker_loop(db, rx, app).await; + }); +} + +async fn run_worker_loop( + db: Arc, + mut rx: Receiver, + app: tauri::AppHandle, +) { + while let Some(first_table) = rx.recv().await { + let started_at = Instant::now(); + let mut merged_count = 1usize; + + loop { + let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) else { + break; + }; + let timeout = tokio::time::timeout(wait_for, rx.recv()).await; + + match timeout { + Ok(Some(_)) => merged_count += 1, + Ok(None) => return, + Err(_) => break, + } + } + + log::debug!( + "[WebDAV][AutoSync] Triggered by table={first_table}, merged_changes={merged_count}" + ); + + if let Err(err) = crate::commands::run_auto_sync_upload(&db, &app).await { + log::warn!("[WebDAV][AutoSync] Upload failed: {err}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + AutoSyncSuppressionGuard, MAX_AUTO_SYNC_WAIT_MS, auto_sync_wait_duration, + enqueue_change_signal, is_auto_sync_suppressed, should_trigger_for_table, + }; + use std::time::{Duration, Instant}; + use tokio::sync::mpsc::channel; + + #[test] + fn should_trigger_sync_for_config_tables_only() { + assert!(should_trigger_for_table("providers")); + assert!(should_trigger_for_table("settings")); + assert!(!should_trigger_for_table("proxy_request_logs")); + assert!(!should_trigger_for_table("provider_health")); + } + + #[test] + fn suppression_guard_enables_and_restores_state() { + assert!(!is_auto_sync_suppressed()); + { + let _guard = AutoSyncSuppressionGuard::new(); + assert!(is_auto_sync_suppressed()); + } + assert!(!is_auto_sync_suppressed()); + } + + #[test] + fn max_wait_caps_flush_latency_for_continuous_events() { + let started = Instant::now(); + let later = started + Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS + 1); + assert!(auto_sync_wait_duration(started, later).is_none()); + } + + #[tokio::test] + async fn enqueue_change_signal_drops_when_channel_is_full() { + let (tx, _rx) = channel::(1); + assert!(enqueue_change_signal(&tx, "providers")); + assert!(!enqueue_change_signal(&tx, "providers")); + } +} diff --git a/src-tauri/src/services/webdav_sync.rs b/src-tauri/src/services/webdav_sync.rs index 99d787d26..2144e4e80 100644 --- a/src-tauri/src/services/webdav_sync.rs +++ b/src-tauri/src/services/webdav_sync.rs @@ -5,7 +5,9 @@ use std::collections::BTreeMap; use std::fs; +use std::future::Future; use std::process::Command; +use std::sync::OnceLock; use chrono::Utc; use serde::{Deserialize, Serialize}; @@ -34,6 +36,19 @@ const REMOTE_SKILLS_ZIP: &str = "skills.zip"; const REMOTE_MANIFEST: &str = "manifest.json"; const MAX_DEVICE_NAME_LEN: usize = 64; +pub fn sync_mutex() -> &'static tokio::sync::Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) +} + +pub async fn run_with_sync_lock(operation: Fut) -> Result +where + Fut: Future>, +{ + let _guard = sync_mutex().lock().await; + operation.await +} + fn localized(key: &'static str, zh: impl Into, en: impl Into) -> AppError { AppError::localized(key, zh, en) } @@ -214,6 +229,7 @@ fn persist_sync_success( let status = WebDavSyncStatus { last_sync_at: Some(Utc::now().timestamp()), last_error: None, + last_error_source: None, last_local_manifest_hash: Some(manifest_hash.clone()), last_remote_manifest_hash: Some(manifest_hash), last_remote_etag: etag, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 1735cac9a..7fafe209e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -72,6 +72,8 @@ pub struct WebDavSyncStatus { #[serde(default, skip_serializing_if = "Option::is_none")] pub last_error: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error_source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub last_remote_etag: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_local_manifest_hash: Option, @@ -93,6 +95,8 @@ pub struct WebDavSyncSettings { #[serde(default)] pub enabled: bool, #[serde(default)] + pub auto_sync: bool, + #[serde(default)] pub base_url: String, #[serde(default)] pub username: String, @@ -110,6 +114,7 @@ impl Default for WebDavSyncSettings { fn default() -> Self { Self { enabled: false, + auto_sync: false, base_url: String::new(), username: String::new(), password: String::new(), diff --git a/src/App.tsx b/src/App.tsx index 1eedef4da..681681e55 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { motion, AnimatePresence } from "framer-motion"; import { toast } from "sonner"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { useQueryClient } from "@tanstack/react-query"; import { Plus, @@ -81,6 +82,12 @@ type View = | "openclawTools" | "openclawAgents"; +interface WebDavSyncStatusUpdatedPayload { + source?: string; + status?: string; + error?: string; +} + const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px const HEADER_HEIGHT = 64; // px const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT; @@ -292,6 +299,49 @@ function App() { }; }, [queryClient]); + useEffect(() => { + let unsubscribe: (() => void) | undefined; + let active = true; + + const setupListener = async () => { + try { + const off = await listen( + "webdav-sync-status-updated", + async (event) => { + const payload = (event.payload ?? {}) as WebDavSyncStatusUpdatedPayload; + await queryClient.invalidateQueries({ queryKey: ["settings"] }); + + if (payload.source !== "auto" || payload.status !== "error") { + return; + } + + toast.error( + t("settings.webdavSync.autoSyncFailedToast", { + error: payload.error || t("common.unknown"), + }), + ); + }, + ); + if (!active) { + off(); + return; + } + unsubscribe = off; + } catch (error) { + console.error( + "[App] Failed to subscribe webdav-sync-status-updated event", + error, + ); + } + }; + + void setupListener(); + return () => { + active = false; + unsubscribe?.(); + }; + }, [queryClient, t]); + useEffect(() => { const checkEnvOnStartup = async () => { try { diff --git a/src/components/settings/WebdavSyncSection.tsx b/src/components/settings/WebdavSyncSection.tsx index 1d4c48ab7..69d7235d9 100644 --- a/src/components/settings/WebdavSyncSection.tsx +++ b/src/components/settings/WebdavSyncSection.tsx @@ -16,6 +16,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, @@ -162,6 +163,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { password: config?.password ?? "", remoteRoot: config?.remoteRoot ?? "cc-switch-sync", profile: config?.profile ?? "default", + autoSync: config?.autoSync ?? false, })); // Preset selector — derived from initial URL, updated on user selection @@ -196,6 +198,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { password: config.password ?? "", remoteRoot: config.remoteRoot ?? "cc-switch-sync", profile: config.profile ?? "default", + autoSync: config.autoSync ?? false, }); setPasswordTouched(false); setPresetId(detectPreset(config.baseUrl ?? "")); @@ -237,6 +240,16 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { } }, [form.baseUrl, presetId]); + const handleAutoSyncChange = useCallback((checked: boolean) => { + setForm((prev) => ({ ...prev, autoSync: checked })); + setDirty(true); + setJustSaved(false); + if (justSavedTimerRef.current) { + clearTimeout(justSavedTimerRef.current); + justSavedTimerRef.current = null; + } + }, []); + const buildSettings = useCallback((): WebDavSyncSettings | null => { const baseUrl = form.baseUrl.trim(); if (!baseUrl) return null; @@ -247,6 +260,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { password: form.password, remoteRoot: form.remoteRoot.trim() || "cc-switch-sync", profile: form.profile.trim() || "default", + autoSync: form.autoSync, }; }, [form]); @@ -433,6 +447,11 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { const lastSyncDisplay = lastSyncAt ? new Date(lastSyncAt * 1000).toLocaleString() : null; + const lastError = config?.status?.lastError?.trim(); + const showAutoSyncError = + !!lastError && + (config?.status?.lastErrorSource === "auto" || + (!config?.status?.lastErrorSource && !!config?.autoSync)); // ─── Render ───────────────────────────────────────────── @@ -559,6 +578,23 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { disabled={isLoading} /> + +
+ +
+ +
+
{/* Last sync time */} @@ -567,6 +603,17 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { {t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}

)} + {showAutoSyncError && ( +
+

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

+

{lastError}

+

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

+
+ )} {/* Config buttons + save status */}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c3bebbd16..a40a9926f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -283,6 +283,8 @@ "passwordPlaceholder": "App password", "remoteRoot": "Remote Root Directory", "profile": "Sync Profile Name", + "autoSync": "Auto Sync", + "autoSyncHint": "When enabled, each database change triggers an automatic WebDAV upload.", "test": "Test Connection", "testing": "Testing...", "testSuccess": "Connection successful", @@ -294,11 +296,14 @@ "uploading": "Uploading...", "uploadSuccess": "Uploaded to WebDAV", "uploadFailed": "Upload failed: {{error}}", + "autoSyncFailedToast": "Auto sync failed: {{error}}", "download": "Download from Cloud", "downloading": "Downloading...", "downloadSuccess": "Downloaded and restored from WebDAV", "downloadFailed": "Download failed: {{error}}", "lastSync": "Last sync: {{time}}", + "autoSyncLastErrorTitle": "Last auto sync failed", + "autoSyncLastErrorHint": "Please check network or WebDAV settings. Auto sync will retry on future changes.", "missingUrl": "Please enter the WebDAV server URL", "presets": { "label": "Provider", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5196bf4e5..b8a19acbc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -283,6 +283,8 @@ "passwordPlaceholder": "アプリパスワード", "remoteRoot": "リモートルートディレクトリ", "profile": "同期プロファイル名", + "autoSync": "自動同期", + "autoSyncHint": "有効にすると、データベース変更のたびに WebDAV へ自動アップロードします。", "test": "接続テスト", "testing": "テスト中...", "testSuccess": "接続成功", @@ -294,11 +296,14 @@ "uploading": "アップロード中...", "uploadSuccess": "WebDAV にアップロードしました", "uploadFailed": "アップロードに失敗しました:{{error}}", + "autoSyncFailedToast": "自動同期に失敗しました:{{error}}", "download": "クラウドからダウンロード", "downloading": "ダウンロード中...", "downloadSuccess": "WebDAV からダウンロード・復元しました", "downloadFailed": "ダウンロードに失敗しました:{{error}}", "lastSync": "前回の同期:{{time}}", + "autoSyncLastErrorTitle": "前回の自動同期に失敗しました", + "autoSyncLastErrorHint": "ネットワークまたは WebDAV 設定を確認してください。次回の変更時に自動再試行されます。", "missingUrl": "WebDAV サーバー URL を入力してください", "presets": { "label": "サービス", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cca58482b..b4f5f7173 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -283,6 +283,8 @@ "passwordPlaceholder": "应用密码(坚果云请使用「第三方应用密码」)", "remoteRoot": "远程根目录", "profile": "同步配置名", + "autoSync": "自动同步", + "autoSyncHint": "开启后每次数据库变更都会自动上传到 WebDAV。", "test": "测试连接", "testing": "测试中...", "testSuccess": "连接成功", @@ -294,11 +296,14 @@ "uploading": "上传中...", "uploadSuccess": "已上传到 WebDAV", "uploadFailed": "上传失败:{{error}}", + "autoSyncFailedToast": "自动同步失败:{{error}}", "download": "从云端下载", "downloading": "下载中...", "downloadSuccess": "已从 WebDAV 下载并恢复", "downloadFailed": "下载失败:{{error}}", "lastSync": "上次同步:{{time}}", + "autoSyncLastErrorTitle": "上次自动同步失败", + "autoSyncLastErrorHint": "请检查网络或 WebDAV 配置,系统会在后续变更时继续自动重试。", "missingUrl": "请填写 WebDAV 服务器地址", "presets": { "label": "服务商", diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index d226ef34c..87436c0d6 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -33,6 +33,7 @@ export const settingsSchema = z.object({ webdavSync: z .object({ enabled: z.boolean().optional(), + autoSync: z.boolean().optional(), baseUrl: z.string().trim().optional().or(z.literal("")), username: z.string().trim().optional().or(z.literal("")), password: z.string().optional(), @@ -42,6 +43,7 @@ export const settingsSchema = z.object({ .object({ lastSyncAt: z.number().nullable().optional(), lastError: z.string().nullable().optional(), + lastErrorSource: z.string().nullable().optional(), lastRemoteEtag: z.string().nullable().optional(), lastLocalManifestHash: z.string().nullable().optional(), lastRemoteManifestHash: z.string().nullable().optional(), diff --git a/src/types.ts b/src/types.ts index d42b86ee4..ecbd17a74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -167,6 +167,7 @@ export interface VisibleApps { export interface WebDavSyncStatus { lastSyncAt?: number | null; lastError?: string | null; + lastErrorSource?: string | null; lastRemoteEtag?: string | null; lastLocalManifestHash?: string | null; lastRemoteManifestHash?: string | null; @@ -175,6 +176,7 @@ export interface WebDavSyncStatus { // WebDAV v2 同步配置 export interface WebDavSyncSettings { enabled?: boolean; + autoSync?: boolean; baseUrl?: string; username?: string; password?: string; diff --git a/tests/components/WebdavSyncSection.test.tsx b/tests/components/WebdavSyncSection.test.tsx index 27a487a7b..5facc00db 100644 --- a/tests/components/WebdavSyncSection.test.tsx +++ b/tests/components/WebdavSyncSection.test.tsx @@ -34,6 +34,17 @@ vi.mock("@/components/ui/input", () => ({ Input: (props: any) => , })); +vi.mock("@/components/ui/switch", () => ({ + Switch: ({ checked, onCheckedChange, ...props }: any) => ( +