"use client"; import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { LoadingButton } from "@/components/loading-button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { translateBackendError } from "@/lib/backend-errors"; import { pickParsedProxy } from "@/lib/proxy-string"; import type { ProxyParseResult, StoredProxy } from "@/types"; import { RippleButton } from "./ui/ripple"; interface ProxyFormData { name: string; proxy_type: string; host: string; port: number; username: string; password: string; vless_uri: string; } interface ProxyFormDialogProps { isOpen: boolean; onClose: () => void; editingProxy?: StoredProxy | null; } const DEFAULT_FORM: ProxyFormData = { name: "", proxy_type: "http", host: "", port: 8080, username: "", password: "", vless_uri: "", }; interface VlessEndpoint { host: string; port: number; } function parseVlessEndpoint(uri: string): VlessEndpoint | null { try { const parsed = new URL(uri.trim()); const port = Number.parseInt(parsed.port, 10); if ( parsed.protocol !== "vless:" || !parsed.hostname || !Number.isInteger(port) || port < 1 || port > 65535 ) { return null; } const host = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]") ? parsed.hostname.slice(1, -1) : parsed.hostname; return { host, port }; } catch { return null; } } export function ProxyFormDialog({ isOpen, onClose, editingProxy, }: ProxyFormDialogProps) { const { t } = useTranslation(); const [isSubmitting, setIsSubmitting] = useState(false); const [form, setForm] = useState(DEFAULT_FORM); // The local parse only covers scheme/host/port. Whether Donut can actually // use the server — REALITY, XTLS Vision, plain TCP — is decided by the Rust // parser, so ask it (below) and show the specific reason while the user is // still editing rather than after they save. Declared here because // `handleSubmit` guards on it. const [vlessUnsupported, setVlessUnsupported] = useState(null); const resetForm = useCallback(() => { setForm(DEFAULT_FORM); }, []); useEffect(() => { if (!isOpen) { return; } if (!editingProxy) { resetForm(); return; } setForm({ name: editingProxy.name, proxy_type: editingProxy.proxy_settings.proxy_type, host: editingProxy.proxy_settings.host, port: editingProxy.proxy_settings.port, username: editingProxy.proxy_settings.username ?? "", password: editingProxy.proxy_settings.password ?? "", vless_uri: editingProxy.proxy_settings.vless_uri ?? "", }); }, [editingProxy, isOpen, resetForm]); const handleSubmit = useCallback(async () => { if (!form.name.trim()) { toast.error(t("proxies.form.nameRequired")); return; } const isVless = form.proxy_type === "vless"; const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null; if (isVless && !form.vless_uri.trim()) { toast.error(t("proxies.form.vlessUriRequired")); return; } if (isVless && !vlessEndpoint) { toast.error(t("proxies.form.vlessUriInvalid")); return; } if (isVless && vlessUnsupported) { toast.error(vlessUnsupported); return; } if (!isVless && (!form.host.trim() || !form.port)) { toast.error(t("proxies.form.hostPortRequired")); return; } if ( form.proxy_type === "ss" && (!form.username.trim() || !form.password.trim()) ) { toast.error(t("proxies.form.ssCipherRequired")); return; } setIsSubmitting(true); try { const payload = { name: form.name.trim(), proxySettings: { proxy_type: form.proxy_type, host: vlessEndpoint?.host ?? form.host.trim(), port: vlessEndpoint?.port ?? form.port, username: isVless ? undefined : form.username.trim() || undefined, password: isVless ? undefined : form.password.trim() || undefined, vless_uri: isVless ? form.vless_uri.trim() : undefined, }, }; if (editingProxy) { await invoke("update_stored_proxy", { proxyId: editingProxy.id, ...payload, }); toast.success(t("toasts.success.proxyUpdated")); } else { await invoke("create_stored_proxy", payload); toast.success(t("toasts.success.proxyCreated")); } onClose(); } catch (error) { console.error("Failed to save proxy:", error); toast.error( t("proxies.form.saveFailed", { error: translateBackendError(t, error), }), ); } finally { setIsSubmitting(false); } }, [editingProxy, form, onClose, t, vlessUnsupported]); const handleClose = useCallback(() => { if (!isSubmitting) { onClose(); } }, [isSubmitting, onClose]); // Proxies are copied around as one string — `socks5://user:pass@host:1080`, // `host:1080:user:pass`, and a dozen variants of both — so a paste into any // one field is almost never meant for that field alone. Hand the clipboard to // the same Rust parser the import dialog uses and spread the result across // the form. The default paste is left alone until the answer comes back, so a // string that isn't a proxy (a hostname, a port) lands where it was dropped. const handleProxyPaste = useCallback( (event: React.ClipboardEvent) => { const content = event.clipboardData.getData("text").trim(); if (!content) { return; } // Captured before the browser applies the paste, so a proxy string // dropped into the empty name field names the proxy after its endpoint // instead of keeping the raw line. const nameBeforePaste = form.name.trim(); void invoke("parse_txt_proxies", { content }) .then((results) => { const parsed = pickParsedProxy(results); if (!parsed) { return; } setForm((previous) => ({ ...previous, name: nameBeforePaste || `${parsed.host}:${parsed.port}`, proxy_type: parsed.proxy_type, host: parsed.host, port: parsed.port, username: parsed.username ?? "", password: parsed.password ?? "", vless_uri: parsed.vless_uri ?? "", })); }) .catch((error: unknown) => { console.error("Failed to parse pasted proxy:", error); }); }, [form.name], ); const isVless = form.proxy_type === "vless"; const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null; const trimmedVlessUri = form.vless_uri.trim(); useEffect(() => { if (!isVless || trimmedVlessUri.length === 0) { setVlessUnsupported(null); return; } let cancelled = false; const timer = window.setTimeout(() => { void invoke("validate_vless_uri", { uri: trimmedVlessUri }) .then(() => { if (!cancelled) setVlessUnsupported(null); }) .catch((error: unknown) => { if (!cancelled) setVlessUnsupported(translateBackendError(t, error)); }); }, 300); return () => { cancelled = true; window.clearTimeout(timer); }; }, [isVless, trimmedVlessUri, t]); const hasInvalidVlessUri = isVless && trimmedVlessUri.length > 0 && (!vlessEndpoint || vlessUnsupported !== null); const isFormValid = form.name.trim() && (isVless ? vlessEndpoint !== null && vlessUnsupported === null : form.host.trim() && form.port > 0 && form.port <= 65535 && (form.proxy_type !== "ss" || (form.username.trim() && form.password.trim()))); return ( {editingProxy ? t("proxies.edit") : t("proxies.add")}
{ setForm({ ...form, name: e.target.value }); }} onPaste={handleProxyPaste} placeholder={t("proxies.form.namePlaceholder")} disabled={isSubmitting} />
{isVless ? (