* 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
+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"