123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679 |
- import { ReactNode, useEffect, useMemo, useState } from "react";
- import { useLockFn } from "ahooks";
- import yaml from "js-yaml";
- import { useTranslation } from "react-i18next";
- import {
- DndContext,
- closestCenter,
- KeyboardSensor,
- PointerSensor,
- useSensor,
- useSensors,
- DragEndEvent,
- } from "@dnd-kit/core";
- import {
- SortableContext,
- sortableKeyboardCoordinates,
- } from "@dnd-kit/sortable";
- import {
- Autocomplete,
- Box,
- Button,
- Dialog,
- DialogActions,
- DialogContent,
- DialogTitle,
- List,
- ListItem,
- ListItemText,
- TextField,
- styled,
- } from "@mui/material";
- import { readProfileFile, saveProfileFile } from "@/services/cmds";
- import { Notice, Switch } from "@/components/base";
- import getSystem from "@/utils/get-system";
- import { RuleItem } from "@/components/profile/rule-item";
- import { BaseSearchBox } from "../base/base-search-box";
- import { Virtuoso } from "react-virtuoso";
- import MonacoEditor from "react-monaco-editor";
- import { useThemeMode } from "@/services/states";
- interface Props {
- profileUid: string;
- title?: string | ReactNode;
- property: string;
- open: boolean;
- onClose: () => void;
- onSave?: (prev?: string, curr?: string) => void;
- }
- const portValidator = (value: string): boolean => {
- return new RegExp(
- "^(?:[1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$"
- ).test(value);
- };
- const ipv4CIDRValidator = (value: string): boolean => {
- return new RegExp(
- "^(?:(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))\\.){3}(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))(?:\\/(?:[12]?[0-9]|3[0-2]))$"
- ).test(value);
- };
- const ipv6CIDRValidator = (value: string): boolean => {
- return new RegExp(
- "^([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){7}|::|:(?::[0-9a-fA-F]{1,4}){1,6}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,5}|(?:[0-9a-fA-F]{1,4}:){2}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){3}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){4}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){5}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,6}:)\\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])$"
- ).test(value);
- };
- const rules: {
- name: string;
- required?: boolean;
- example?: string;
- noResolve?: boolean;
- validator?: (value: string) => boolean;
- }[] = [
- {
- name: "DOMAIN",
- example: "example.com",
- },
- {
- name: "DOMAIN-SUFFIX",
- example: "example.com",
- },
- {
- name: "DOMAIN-KEYWORD",
- example: "example",
- },
- {
- name: "DOMAIN-REGEX",
- example: "example.*",
- },
- {
- name: "GEOSITE",
- example: "youtube",
- },
- {
- name: "GEOIP",
- example: "CN",
- noResolve: true,
- },
- {
- name: "SRC-GEOIP",
- example: "CN",
- },
- {
- name: "IP-ASN",
- example: "13335",
- noResolve: true,
- validator: (value) => (+value ? true : false),
- },
- {
- name: "SRC-IP-ASN",
- example: "9808",
- validator: (value) => (+value ? true : false),
- },
- {
- name: "IP-CIDR",
- example: "127.0.0.0/8",
- noResolve: true,
- validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
- },
- {
- name: "IP-CIDR6",
- example: "2620:0:2d0:200::7/32",
- noResolve: true,
- validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
- },
- {
- name: "SRC-IP-CIDR",
- example: "192.168.1.201/32",
- validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
- },
- {
- name: "IP-SUFFIX",
- example: "8.8.8.8/24",
- noResolve: true,
- validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
- },
- {
- name: "SRC-IP-SUFFIX",
- example: "192.168.1.201/8",
- validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
- },
- {
- name: "SRC-PORT",
- example: "7777",
- validator: (value) => portValidator(value),
- },
- {
- name: "DST-PORT",
- example: "80",
- validator: (value) => portValidator(value),
- },
- {
- name: "IN-PORT",
- example: "7890",
- validator: (value) => portValidator(value),
- },
- {
- name: "DSCP",
- example: "4",
- },
- {
- name: "PROCESS-NAME",
- example: getSystem() === "windows" ? "chrome.exe" : "curl",
- },
- {
- name: "PROCESS-PATH",
- example:
- getSystem() === "windows"
- ? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
- : "/usr/bin/wget",
- },
- {
- name: "PROCESS-NAME-REGEX",
- example: ".*telegram.*",
- },
- {
- name: "PROCESS-PATH-REGEX",
- example:
- getSystem() === "windows" ? "(?i).*Application\\chrome.*" : ".*bin/wget",
- },
- {
- name: "NETWORK",
- example: "udp",
- validator: (value) => ["tcp", "udp"].includes(value),
- },
- {
- name: "UID",
- example: "1001",
- validator: (value) => (+value ? true : false),
- },
- {
- name: "IN-TYPE",
- example: "SOCKS/HTTP",
- },
- {
- name: "IN-USER",
- example: "mihomo",
- },
- {
- name: "IN-NAME",
- example: "ss",
- },
- {
- name: "SUB-RULE",
- example: "(NETWORK,tcp)",
- },
- {
- name: "RULE-SET",
- example: "providername",
- noResolve: true,
- },
- {
- name: "AND",
- example: "((DOMAIN,baidu.com),(NETWORK,UDP))",
- },
- {
- name: "OR",
- example: "((NETWORK,UDP),(DOMAIN,baidu.com))",
- },
- {
- name: "NOT",
- example: "((DOMAIN,baidu.com))",
- },
- {
- name: "MATCH",
- required: false,
- },
- ];
- const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
- export const RulesEditorViewer = (props: Props) => {
- const { title, profileUid, property, open, onClose, onSave } = props;
- const { t } = useTranslation();
- const themeMode = useThemeMode();
- const [prevData, setPrevData] = useState("");
- const [currData, setCurrData] = useState("");
- const [visible, setVisible] = useState(true);
- const [match, setMatch] = useState(() => (_: string) => true);
- const [ruleType, setRuleType] = useState<(typeof rules)[number]>(rules[0]);
- const [ruleContent, setRuleContent] = useState("");
- const [noResolve, setNoResolve] = useState(false);
- const [proxyPolicy, setProxyPolicy] = useState(builtinProxyPolicies[0]);
- const [proxyPolicyList, setProxyPolicyList] = useState<string[]>([]);
- const [ruleList, setRuleList] = useState<string[]>([]);
- const [ruleSetList, setRuleSetList] = useState<string[]>([]);
- const [subRuleList, setSubRuleList] = useState<string[]>([]);
- const [prependSeq, setPrependSeq] = useState<string[]>([]);
- const [appendSeq, setAppendSeq] = useState<string[]>([]);
- const [deleteSeq, setDeleteSeq] = useState<string[]>([]);
- const filteredRuleList = useMemo(
- () => ruleList.filter((rule) => match(rule)),
- [ruleList, match]
- );
- const sensors = useSensors(
- useSensor(PointerSensor),
- useSensor(KeyboardSensor, {
- coordinateGetter: sortableKeyboardCoordinates,
- })
- );
- const reorder = (list: string[], startIndex: number, endIndex: number) => {
- const result = Array.from(list);
- const [removed] = result.splice(startIndex, 1);
- result.splice(endIndex, 0, removed);
- return result;
- };
- const onPrependDragEnd = async (event: DragEndEvent) => {
- const { active, over } = event;
- if (over) {
- if (active.id !== over.id) {
- let activeIndex = prependSeq.indexOf(active.id.toString());
- let overIndex = prependSeq.indexOf(over.id.toString());
- setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
- }
- }
- };
- const onAppendDragEnd = async (event: DragEndEvent) => {
- const { active, over } = event;
- if (over) {
- if (active.id !== over.id) {
- let activeIndex = appendSeq.indexOf(active.id.toString());
- let overIndex = appendSeq.indexOf(over.id.toString());
- setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
- }
- }
- };
- const fetchContent = async () => {
- let data = await readProfileFile(property);
- let obj = yaml.load(data) as { prepend: []; append: []; delete: [] };
- setPrependSeq(obj.prepend || []);
- setAppendSeq(obj.append || []);
- setDeleteSeq(obj.delete || []);
- setPrevData(data);
- setCurrData(data);
- };
- useEffect(() => {
- if (currData === "") return;
- if (visible !== true) return;
- let obj = yaml.load(currData) as { prepend: []; append: []; delete: [] };
- setPrependSeq(obj.prepend || []);
- setAppendSeq(obj.append || []);
- setDeleteSeq(obj.delete || []);
- }, [visible]);
- useEffect(() => {
- if (prependSeq && appendSeq && deleteSeq)
- setCurrData(
- yaml.dump({ prepend: prependSeq, append: appendSeq, delete: deleteSeq })
- );
- }, [prependSeq, appendSeq, deleteSeq]);
- const fetchProfile = async () => {
- let data = await readProfileFile(profileUid);
- let groupsObj = yaml.load(data) as { "proxy-groups": [] };
- let rulesObj = yaml.load(data) as { rules: [] };
- let ruleSetObj = yaml.load(data) as { "rule-providers": [] };
- let subRuleObj = yaml.load(data) as { "sub-rules": [] };
- setProxyPolicyList(
- builtinProxyPolicies.concat(
- groupsObj["proxy-groups"]
- ? groupsObj["proxy-groups"].map((item: any) => item.name)
- : []
- )
- );
- setRuleList(rulesObj.rules || []);
- setRuleSetList(
- ruleSetObj["rule-providers"]
- ? Object.keys(ruleSetObj["rule-providers"])
- : []
- );
- setSubRuleList(
- subRuleObj["sub-rules"] ? Object.keys(subRuleObj["sub-rules"]) : []
- );
- };
- useEffect(() => {
- fetchContent();
- fetchProfile();
- }, [open]);
- const validateRule = () => {
- if ((ruleType.required ?? true) && !ruleContent) {
- throw new Error(t("Rule Condition Required"));
- }
- if (ruleType.validator && !ruleType.validator(ruleContent)) {
- throw new Error(t("Invalid Rule"));
- }
- const condition = ruleType.required ?? true ? ruleContent : "";
- return `${ruleType.name}${condition ? "," + condition : ""},${proxyPolicy}${
- ruleType.noResolve && noResolve ? ",no-resolve" : ""
- }`;
- };
- const handleSave = useLockFn(async () => {
- try {
- await saveProfileFile(property, currData);
- onSave?.(prevData, currData);
- onClose();
- } catch (err: any) {
- Notice.error(err.message || err.toString());
- }
- });
- return (
- <Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
- <DialogTitle>
- {
- <Box display="flex" justifyContent="space-between">
- {t("Edit Rules")}
- <Box>
- <Button
- variant="contained"
- size="small"
- onClick={() => {
- setVisible((prev) => !prev);
- }}
- >
- {visible ? t("Advanced") : t("Visible")}
- </Button>
- </Box>
- </Box>
- }
- </DialogTitle>
- <DialogContent
- sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
- >
- {visible ? (
- <>
- <List
- sx={{
- width: "50%",
- padding: "0 10px",
- }}
- >
- <Item>
- <ListItemText primary={t("Rule Type")} />
- <Autocomplete
- size="small"
- sx={{ minWidth: "240px" }}
- renderInput={(params) => <TextField {...params} />}
- options={rules}
- value={ruleType}
- getOptionLabel={(option) => option.name}
- renderOption={(props, option) => (
- <li {...props} title={t(option.name)}>
- {option.name}
- </li>
- )}
- onChange={(_, value) => value && setRuleType(value)}
- />
- </Item>
- <Item
- sx={{ display: !(ruleType.required ?? true) ? "none" : "" }}
- >
- <ListItemText primary={t("Rule Content")} />
- {ruleType.name === "RULE-SET" && (
- <Autocomplete
- size="small"
- sx={{ minWidth: "240px" }}
- renderInput={(params) => <TextField {...params} />}
- options={ruleSetList}
- value={ruleContent}
- onChange={(_, value) => value && setRuleContent(value)}
- />
- )}
- {ruleType.name === "SUB-RULE" && (
- <Autocomplete
- size="small"
- sx={{ minWidth: "240px" }}
- renderInput={(params) => <TextField {...params} />}
- options={subRuleList}
- value={ruleContent}
- onChange={(_, value) => value && setRuleContent(value)}
- />
- )}
- {ruleType.name !== "RULE-SET" &&
- ruleType.name !== "SUB-RULE" && (
- <TextField
- autoComplete="off"
- size="small"
- sx={{ minWidth: "240px" }}
- value={ruleContent}
- required={ruleType.required ?? true}
- error={(ruleType.required ?? true) && !ruleContent}
- placeholder={ruleType.example}
- onChange={(e) => setRuleContent(e.target.value)}
- />
- )}
- </Item>
- <Item>
- <ListItemText primary={t("Proxy Policy")} />
- <Autocomplete
- size="small"
- sx={{ minWidth: "240px" }}
- renderInput={(params) => <TextField {...params} />}
- options={proxyPolicyList}
- value={proxyPolicy}
- renderOption={(props, option) => (
- <li {...props} title={t(option)}>
- {option}
- </li>
- )}
- onChange={(_, value) => value && setProxyPolicy(value)}
- />
- </Item>
- {ruleType.noResolve && (
- <Item>
- <ListItemText primary={t("No Resolve")} />
- <Switch
- checked={noResolve}
- onChange={() => setNoResolve(!noResolve)}
- />
- </Item>
- )}
- <Item>
- <Button
- fullWidth
- variant="contained"
- onClick={() => {
- try {
- let raw = validateRule();
- if (prependSeq.includes(raw)) return;
- setPrependSeq([...prependSeq, raw]);
- } catch (err: any) {
- Notice.error(err.message || err.toString());
- }
- }}
- >
- {t("Prepend Rule")}
- </Button>
- </Item>
- <Item>
- <Button
- fullWidth
- variant="contained"
- onClick={() => {
- try {
- let raw = validateRule();
- if (appendSeq.includes(raw)) return;
- setAppendSeq([...appendSeq, raw]);
- } catch (err: any) {
- Notice.error(err.message || err.toString());
- }
- }}
- >
- {t("Append Rule")}
- </Button>
- </Item>
- </List>
- <List
- sx={{
- width: "50%",
- padding: "0 10px",
- }}
- >
- <BaseSearchBox
- matchCase={false}
- onSearch={(match) => setMatch(() => match)}
- />
- <Virtuoso
- style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
- totalCount={
- filteredRuleList.length +
- (prependSeq.length > 0 ? 1 : 0) +
- (appendSeq.length > 0 ? 1 : 0)
- }
- increaseViewportBy={256}
- itemContent={(index) => {
- let shift = prependSeq.length > 0 ? 1 : 0;
- if (prependSeq.length > 0 && index === 0) {
- return (
- <DndContext
- sensors={sensors}
- collisionDetection={closestCenter}
- onDragEnd={onPrependDragEnd}
- >
- <SortableContext
- items={prependSeq.map((x) => {
- return x;
- })}
- >
- {prependSeq.map((item, index) => {
- return (
- <RuleItem
- key={`${item}-${index}`}
- type="prepend"
- ruleRaw={item}
- onDelete={() => {
- setPrependSeq(
- prependSeq.filter((v) => v !== item)
- );
- }}
- />
- );
- })}
- </SortableContext>
- </DndContext>
- );
- } else if (index < filteredRuleList.length + shift) {
- let newIndex = index - shift;
- return (
- <RuleItem
- key={`${filteredRuleList[newIndex]}-${index}`}
- type={
- deleteSeq.includes(filteredRuleList[newIndex])
- ? "delete"
- : "original"
- }
- ruleRaw={filteredRuleList[newIndex]}
- onDelete={() => {
- if (deleteSeq.includes(filteredRuleList[newIndex])) {
- setDeleteSeq(
- deleteSeq.filter(
- (v) => v !== filteredRuleList[newIndex]
- )
- );
- } else {
- setDeleteSeq((prev) => [
- ...prev,
- filteredRuleList[newIndex],
- ]);
- }
- }}
- />
- );
- } else {
- return (
- <DndContext
- sensors={sensors}
- collisionDetection={closestCenter}
- onDragEnd={onAppendDragEnd}
- >
- <SortableContext
- items={appendSeq.map((x) => {
- return x;
- })}
- >
- {appendSeq.map((item, index) => {
- return (
- <RuleItem
- key={`${item}-${index}`}
- type="append"
- ruleRaw={item}
- onDelete={() => {
- setAppendSeq(
- appendSeq.filter((v) => v !== item)
- );
- }}
- />
- );
- })}
- </SortableContext>
- </DndContext>
- );
- }
- }}
- />
- </List>
- </>
- ) : (
- <MonacoEditor
- height="100%"
- language="yaml"
- value={currData}
- theme={themeMode === "light" ? "vs" : "vs-dark"}
- options={{
- tabSize: 2, // 根据语言类型设置缩进大小
- minimap: {
- enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
- },
- mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
- quickSuggestions: {
- strings: true, // 字符串类型的建议
- comments: true, // 注释类型的建议
- other: true, // 其他类型的建议
- },
- padding: {
- top: 33, // 顶部padding防止遮挡snippets
- },
- fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
- getSystem() === "windows" ? ", twemoji mozilla" : ""
- }`,
- fontLigatures: true, // 连字符
- smoothScrolling: true, // 平滑滚动
- }}
- onChange={(value) => setCurrData(value)}
- />
- )}
- </DialogContent>
- <DialogActions>
- <Button onClick={onClose} variant="outlined">
- {t("Cancel")}
- </Button>
- <Button onClick={handleSave} variant="contained">
- {t("Save")}
- </Button>
- </DialogActions>
- </Dialog>
- );
- };
- const Item = styled(ListItem)(() => ({
- padding: "5px 2px",
- }));
|