mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 01:25:33 +08:00
feat(webdav): follow-up 补齐自动同步与大文件防护 (#1043)
* feat(webdav): add robust auto sync with failure feedback (cherry picked from commit bb6760124a62a964b36902c004e173534910728f) * fix(webdav): enforce bounded download and extraction size (cherry picked from commit 7777d6ec2b9bba07c8bbba9b04fe3ea6b15e0e79) * fix(webdav): only show auto-sync callout for auto-source errors * refactor(webdav): remove services->commands auto-sync dependency
This commit is contained in:
+50
@@ -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 {
|
||||
|
||||
@@ -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,9 @@ 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";
|
||||
|
||||
// ─── Render ─────────────────────────────────────────────
|
||||
|
||||
@@ -559,6 +576,23 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<label className="w-40 text-xs font-medium text-foreground shrink-0">
|
||||
{t("settings.webdavSync.autoSync")}
|
||||
<span className="block text-[10px] font-normal text-muted-foreground">
|
||||
{t("settings.webdavSync.autoSyncHint")}
|
||||
</span>
|
||||
</label>
|
||||
<div className="pt-1">
|
||||
<Switch
|
||||
checked={form.autoSync}
|
||||
onCheckedChange={handleAutoSyncChange}
|
||||
aria-label={t("settings.webdavSync.autoSync")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last sync time */}
|
||||
@@ -567,6 +601,17 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
{t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}
|
||||
</p>
|
||||
)}
|
||||
{showAutoSyncError && (
|
||||
<div className="rounded-lg border border-red-300/70 bg-red-50/80 px-3 py-2 text-xs text-red-900 dark:border-red-500/50 dark:bg-red-950/30 dark:text-red-200">
|
||||
<p className="font-medium">
|
||||
{t("settings.webdavSync.autoSyncLastErrorTitle")}
|
||||
</p>
|
||||
<p className="mt-1 break-all whitespace-pre-wrap">{lastError}</p>
|
||||
<p className="mt-1 text-[11px] text-red-700/90 dark:text-red-300/80">
|
||||
{t("settings.webdavSync.autoSyncLastErrorHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config buttons + save status */}
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "サービス",
|
||||
|
||||
@@ -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": "服务商",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user