Files
clash-verge-rev-lite/src/components/profile/profile-viewer.tsx

586 lines
20 KiB
TypeScript

import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import {
createProfile,
patchProfile,
importProfile,
enhanceProfiles,
createProfileFromShareLink,
} from "@/services/cmds";
import { useProfiles } from "@/hooks/use-profiles";
import { showNotice } from "@/services/noticeService";
import { version } from "@root/package.json";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ClipboardPaste, Loader2, X } from "lucide-react";
import { readText } from "@tauri-apps/plugin-clipboard-manager";
import { cn } from "@root/lib/utils";
interface Props {
onChange: (isActivating?: boolean) => void;
}
export interface ProfileViewerRef {
create: () => void;
edit: (item: IProfileItem) => void;
}
export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
(props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [openType, setOpenType] = useState<"new" | "edit">("new");
const { profiles } = useProfiles();
const fileDataRef = useRef<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [importUrl, setImportUrl] = useState("");
const [isUrlValid, setIsUrlValid] = useState(true);
const [isCheckingUrl, setIsCheckingUrl] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [loading, setLoading] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState("default");
const form = useForm<IProfileItem>({
defaultValues: {
type: "remote",
name: "",
desc: "",
url: "",
option: {
with_proxy: false,
self_proxy: false,
danger_accept_invalid_certs: false,
},
},
});
const { control, watch, handleSubmit, reset, setValue } = form;
useImperativeHandle(ref, () => ({
create: () => {
reset({
type: "remote",
name: "",
desc: "",
url: "",
option: {
with_proxy: false,
self_proxy: false,
danger_accept_invalid_certs: false,
},
});
fileDataRef.current = null;
setImportUrl("");
setShowAdvanced(false);
setOpenType("new");
setOpen(true);
},
edit: (item) => {
reset(item);
fileDataRef.current = null;
setImportUrl(item.url || "");
setShowAdvanced(true);
setOpenType("edit");
setOpen(true);
},
}));
const selfProxy = watch("option.self_proxy");
const withProxy = watch("option.with_proxy");
useEffect(() => {
if (selfProxy) setValue("option.with_proxy", false);
}, [selfProxy, setValue]);
useEffect(() => {
if (withProxy) setValue("option.self_proxy", false);
}, [withProxy, setValue]);
useEffect(() => {
if (!importUrl) {
setIsUrlValid(true);
setIsCheckingUrl(false);
return;
}
setIsCheckingUrl(true);
const handler = setTimeout(() => {
const isValid = /^(https?|vmess|vless|ss|socks|trojan):\/\//.test(
importUrl,
);
setIsUrlValid(isValid);
setIsCheckingUrl(false);
}, 500);
return () => {
clearTimeout(handler);
};
}, [importUrl]);
const handleImport = useLockFn(async () => {
if (!importUrl || !isUrlValid) return;
setIsImporting(true);
const isShareLink = /^(vmess|vless|ss|socks|trojan):\/\//.test(importUrl);
try {
if (isShareLink) {
await createProfileFromShareLink(importUrl, selectedTemplate);
showNotice("success", t("Profile created from link successfully"));
} else {
await importProfile(importUrl);
showNotice("success", t("Profile Imported Successfully"));
}
props.onChange();
await enhanceProfiles();
setOpen(false);
} catch (err: any) {
const errorMessage =
typeof err === "string" ? err : err.message || String(err);
const lowerErrorMessage = errorMessage.toLowerCase();
if (
lowerErrorMessage.includes("device") ||
lowerErrorMessage.includes("устройств")
) {
window.dispatchEvent(
new CustomEvent("show-hwid-error", { detail: errorMessage }),
);
} else if (!isShareLink && errorMessage.includes("failed to fetch")) {
showNotice("info", t("Import failed, retrying with Clash proxy..."));
try {
await importProfile(importUrl, {
with_proxy: false,
self_proxy: true,
});
showNotice("success", t("Profile Imported with Clash proxy"));
props.onChange();
await enhanceProfiles();
setOpen(false);
} catch (retryErr: any) {
showNotice(
"error",
`${t("Import failed even with Clash proxy")}: ${retryErr?.message || retryErr.toString()}`,
);
}
} else {
showNotice("error", errorMessage);
}
} finally {
setIsImporting(false);
}
});
const onCopyLink = async () => {
const text = await readText();
if (text) setImportUrl(text);
};
const handleSaveAdvanced = useLockFn(
handleSubmit(async (formData) => {
const form = { ...formData, url: formData.url || importUrl };
setLoading(true);
try {
if (!form.type) throw new Error("`Type` should not be null");
if (form.type === "remote" && !form.url)
throw new Error("The URL should not be null");
if (form.option?.update_interval)
form.option.update_interval = +form.option.update_interval;
else delete form.option?.update_interval;
if (form.option?.user_agent === "") delete form.option.user_agent;
const name = form.name || `${form.type} file`;
const item = { ...form, name };
const isUpdate = openType === "edit";
const isActivating =
isUpdate && form.uid === (profiles?.current ?? "");
if (openType === "new") {
await createProfile(item, fileDataRef.current);
} else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, item);
}
setOpen(false);
props.onChange(isActivating);
} catch (err: any) {
showNotice("error", err.message || err.toString());
} finally {
setLoading(false);
}
}),
);
const formType = watch("type");
const isRemote = formType === "remote";
const isLocal = formType === "local";
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[95vw] sm:max-w-md">
<DialogHeader>
<DialogTitle>
{openType === "new" ? t("Create Profile") : t("Edit Profile")}
</DialogTitle>
</DialogHeader>
{openType === "new" && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 flex-grow sm:flex-grow-0">
<Input
type="text"
placeholder={t("Profile URL")}
value={importUrl}
onChange={(e) => setImportUrl(e.target.value)}
disabled={isImporting}
className={cn(
"h-9 min-w-[200px] flex-grow sm:w-65",
!isUrlValid &&
"border-destructive focus-visible:ring-destructive",
)}
/>
{importUrl ? (
<Button
variant="ghost"
size="icon"
title={t("Clear")}
onClick={() => setImportUrl("")}
className="h-9 w-9 flex-shrink-0"
>
<X className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
title={t("Paste")}
onClick={onCopyLink}
className="h-9 w-9 flex-shrink-0"
>
<ClipboardPaste className="h-4 w-4" />
</Button>
)}
</div>
<Button
onClick={handleImport}
disabled={
!importUrl || isCheckingUrl || !isUrlValid || isImporting
}
className="flex-shrink-0 min-w-[5.5rem]"
>
{isCheckingUrl || isImporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t("Import")
)}
</Button>
{!isUrlValid && importUrl && (
<p className="text-sm text-destructive px-1">
{t("Invalid Profile URL")}
</p>
)}
</div>
{/^(vmess|vless|ss|socks|trojan):\/\//.test(importUrl) && (
<div className="space-y-2">
<Label>{t("Template")}</Label>
<Select
value={selectedTemplate}
onValueChange={setSelectedTemplate}
>
<SelectTrigger>
<SelectValue placeholder="Select a template..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{t("Default Template")}
</SelectItem>
<SelectItem value="without_ru">
{t("Template without RU Rules")}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
<Button
variant="outline"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced
? t("Hide Advanced Settings")
: t("Show Advanced Settings")}
</Button>
</div>
)}
{(openType === "edit" || showAdvanced) && (
<Form {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
handleSaveAdvanced();
}}
className="space-y-4 max-h-[60vh] overflow-y-auto px-1 pt-4"
>
<FormField
control={control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Type")}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={openType === "edit"}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="remote">Remote</SelectItem>
<SelectItem value="local">Local</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Name")}</FormLabel>
<FormControl>
<Input placeholder={t("Profile Name")} {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="desc"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Descriptions")}</FormLabel>
<FormControl>
<Input
placeholder={t("Profile Description")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
{isRemote && (
<FormField
control={control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Subscription URL")}</FormLabel>
<FormControl>
<Textarea
placeholder={t("Leave blank to use the URL above")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
)}
{isLocal && openType === "new" && (
<FormItem>
<FormLabel>{t("File")}</FormLabel>
<FormControl>
<Input
type="file"
accept=".yml,.yaml"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setValue(
"name",
form.getValues("name") || file.name,
);
const reader = new FileReader();
reader.onload = (event) => {
fileDataRef.current = event.target
?.result as string;
};
reader.readAsText(file);
}
}}
/>
</FormControl>
</FormItem>
)}
{isRemote && (
<div className="space-y-4 rounded-md border p-4">
<FormField
control={control}
name="option.update_interval"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Update Interval (mins)")}</FormLabel>
<FormControl>
<Input
type="number"
placeholder="1440"
{...field}
onChange={(e) => field.onChange(+e.target.value)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="option.user_agent"
render={({ field }) => (
<FormItem>
<FormLabel>User Agent</FormLabel>
<FormControl>
<Input
placeholder={`koala-clash/v${version}`}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="option.update_always"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<FormLabel>{t("Update on Startup")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="option.with_proxy"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<FormLabel>{t("Use System Proxy")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="option.self_proxy"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<FormLabel>{t("Use Clash Proxy")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="option.danger_accept_invalid_certs"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<FormLabel className="text-destructive">
{t("Accept Invalid Certs (Danger)")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
)}
<button type="submit" className="hidden" />
</form>
</Form>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
{(openType === "edit" || showAdvanced) && (
<Button
type="button"
onClick={handleSaveAdvanced}
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
},
);