feat: own sync server

This commit is contained in:
zhom committed 2026-10-13 12:59:44 +04:00
1 parent d6c21de169
commit 8f64745b1a
42 files changed
+1965 -136

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuServer } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import { SoftFields } from "@/components/ui/field-variant";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
FeatureIcon,
SettingsPanel,
StatusLight,
} from "@/components/ui/settings-panel";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { SyncServerCheck } from "@/types";
type Probe =
| { state: "unknown" | "testing" | "unreachable" | "no-accounts" }
| { state: "ready" }
| { state: "mismatch"; address: string };
/** A quick check before saving. The backend compares the exact form. */
function sameAddress(a: string, b: string): boolean {
const trim = (value: string) =>
value.trim().replace(/\/+$/, "").toLowerCase();
return trim(a) === trim(b);
}
/**
* A signed-in paid account's own donut-sync server. The app signs in to it
* with a token the Donut API binds to its address, so the plan's limit and
* the team's rules apply there as on Donut cloud.
*/
export function AccountSyncServer({
paid,
teamSeat,
}: {
paid: boolean;
teamSeat: boolean;
}) {
const { t } = useTranslation();
const [current, setCurrent] = useState<string | null>(null);
const [serverUrl, setServerUrl] = useState("");
const [probe, setProbe] = useState<Probe>({ state: "unknown" });
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
void invoke<string | null>("get_account_sync_server")
.then((server) => {
setCurrent(server);
setServerUrl(server ?? "");
})
.catch((error: unknown) => {
console.error("Failed to load the account sync server:", error);
});
}, []);
const restartSync = useCallback(async () => {
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
}, []);
const handleTest = useCallback(async () => {
setProbe({ state: "testing" });
try {
const result = await invoke<SyncServerCheck>(
"check_sync_server_connection",
{ serverUrl },
);
if (
!result.server_reachable ||
result.storage_ready === false ||
result.storage_reachable === false
) {
setProbe({ state: "unreachable" });
} else if (!result.account_url) {
setProbe({ state: "no-accounts" });
} else if (!sameAddress(result.account_url, serverUrl)) {
setProbe({ state: "mismatch", address: result.account_url });
} else {
setProbe({ state: "ready" });
}
} catch {
setProbe({ state: "unreachable" });
}
}, [serverUrl]);
const save = useCallback(
async (next: string | null) => {
setIsSaving(true);
try {
const saved = await invoke<string | null>("set_account_sync_server", {
serverUrl: next,
});
setCurrent(saved);
setServerUrl(saved ?? "");
setProbe({ state: saved ? "ready" : "unknown" });
await restartSync();
showSuccessToast(
saved
? t("account.ownServer.saved")
: t("account.ownServer.backToCloud"),
);
} catch (error) {
showErrorToast(translateBackendError(t as never, error));
} finally {
setIsSaving(false);
}
},
[restartSync, t],
);
const probeTone = {
unknown: "muted",
testing: "muted",
ready: "success",
unreachable: "destructive",
"no-accounts": "destructive",
mismatch: "destructive",
} as const;
const probeLabel =
probe.state === "mismatch"
? t("backendErrors.syncServerAddressMismatch", { address: probe.address })
: {
unknown: t("account.selfHosted.statusUnknown"),
testing: t("appFeedback.checking"),
ready: t("account.ownServer.accountsOn"),
unreachable: t("backendErrors.syncServerUnreachable"),
"no-accounts": t("backendErrors.syncServerNoAccounts"),
}[probe.state];
return (
<SettingsPanel className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<FeatureIcon active={current !== null}>
<LuServer />
</FeatureIcon>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("account.ownServer.title")}</p>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
{t("account.ownServer.description")}
</p>
<p className="mt-1.5 text-xs font-medium break-all">
{current
? t("account.ownServer.current", { server: current })
: t("account.ownServer.currentCloud")}
</p>
</div>
</div>
{paid ? (
<>
<SoftFields>
<div className="space-y-1.5">
<Label
htmlFor="account-sync-server-url"
className="text-xs font-medium text-muted-foreground"
>
{t("sync.serverUrl")}
</Label>
<Input
id="account-sync-server-url"
type="url"
placeholder={t("sync.serverUrlPlaceholder")}
value={serverUrl}
onChange={(e) => {
setServerUrl(e.target.value);
setProbe({ state: "unknown" });
}}
autoComplete="off"
spellCheck={false}
/>
</div>
</SoftFields>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
<li>{t("account.ownServer.cloudOnly")}</li>
{teamSeat && <li>{t("account.ownServer.teamNote")}</li>}
</ul>
<div className="flex flex-wrap items-center gap-2">
<span
role="status"
className="mr-auto inline-flex min-w-0 items-center gap-2 text-xs text-muted-foreground"
>
<StatusLight
tone={probeTone[probe.state]}
live={probe.state === "testing" || probe.state === "ready"}
/>
<span className="min-w-0 font-medium text-foreground">
{probeLabel}
</span>
</span>
{current && (
<Button
size="sm"
variant="subtle"
disabled={isSaving}
onClick={() => void save(null)}
className="h-8 rounded-lg text-xs"
>
{t("account.ownServer.useDonutCloud")}
</Button>
)}
<LoadingButton
size="sm"
variant="soft"
isLoading={probe.state === "testing"}
disabled={!serverUrl.trim() || isSaving}
onClick={() => void handleTest()}
className="h-8 rounded-lg text-xs"
>
{t("account.selfHosted.testConnection")}
</LoadingButton>
<LoadingButton
size="sm"
isLoading={isSaving}
disabled={
!serverUrl.trim() ||
probe.state === "testing" ||
(current !== null && sameAddress(current, serverUrl))
}
onClick={() => void save(serverUrl)}
className="h-8 rounded-lg text-xs"
>
{t("common.buttons.save")}
</LoadingButton>
</div>
</>
) : (
<p className="text-xs text-muted-foreground">
{t("backendErrors.accountSyncPlanRequired")}
</p>
)}
</SettingsPanel>
);
}
+14
View File
@@ -76,6 +76,7 @@ export function SyncConfigDialog({
} = useCloudAuth();
const [activeTab, setActiveTab] = useState<string>("cloud");
const [accountServer, setAccountServer] = useState<string | null>(null);
const [, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
const [connectionStatus, setConnectionStatus] = useState<
@@ -146,6 +147,11 @@ export function SyncConfigDialog({
if (isOpen) {
setConnectionStatus("unknown");
void loadSettings();
void invoke<string | null>("get_account_sync_server")
.then(setAccountServer)
.catch(() => {
setAccountServer(null);
});
void invoke<ProxyUsage | null>("cloud_get_proxy_usage")
.then(setLiveProxyUsage)
.catch(() => {
@@ -309,6 +315,14 @@ export function SyncConfigDialog({
{user.planPeriod ? ` (${user.planPeriod})` : ""}
</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">
{t("sync.cloud.server")}
</span>
<span className="truncate">
{accountServer ?? t("account.ownServer.donutCloud")}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("sync.cloud.profiles")}