mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
Webdav (#923)
* 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user