rules-editor-viewer.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  1. import { ReactNode, useEffect, useMemo, useState } from "react";
  2. import { useLockFn } from "ahooks";
  3. import yaml from "js-yaml";
  4. import { useTranslation } from "react-i18next";
  5. import {
  6. DndContext,
  7. closestCenter,
  8. KeyboardSensor,
  9. PointerSensor,
  10. useSensor,
  11. useSensors,
  12. DragEndEvent,
  13. } from "@dnd-kit/core";
  14. import {
  15. SortableContext,
  16. sortableKeyboardCoordinates,
  17. } from "@dnd-kit/sortable";
  18. import {
  19. Autocomplete,
  20. Box,
  21. Button,
  22. Dialog,
  23. DialogActions,
  24. DialogContent,
  25. DialogTitle,
  26. List,
  27. ListItem,
  28. ListItemText,
  29. TextField,
  30. styled,
  31. } from "@mui/material";
  32. import { readProfileFile, saveProfileFile } from "@/services/cmds";
  33. import { Notice, Switch } from "@/components/base";
  34. import getSystem from "@/utils/get-system";
  35. import { RuleItem } from "@/components/profile/rule-item";
  36. import { BaseSearchBox } from "../base/base-search-box";
  37. import { Virtuoso } from "react-virtuoso";
  38. import MonacoEditor from "react-monaco-editor";
  39. import { useThemeMode } from "@/services/states";
  40. interface Props {
  41. profileUid: string;
  42. title?: string | ReactNode;
  43. property: string;
  44. open: boolean;
  45. onClose: () => void;
  46. onSave?: (prev?: string, curr?: string) => void;
  47. }
  48. const portValidator = (value: string): boolean => {
  49. return new RegExp(
  50. "^(?:[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])$"
  51. ).test(value);
  52. };
  53. const ipv4CIDRValidator = (value: string): boolean => {
  54. return new RegExp(
  55. "^(?:(?:[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]))$"
  56. ).test(value);
  57. };
  58. const ipv6CIDRValidator = (value: string): boolean => {
  59. return new RegExp(
  60. "^([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])$"
  61. ).test(value);
  62. };
  63. const rules: {
  64. name: string;
  65. required?: boolean;
  66. example?: string;
  67. noResolve?: boolean;
  68. validator?: (value: string) => boolean;
  69. }[] = [
  70. {
  71. name: "DOMAIN",
  72. example: "example.com",
  73. },
  74. {
  75. name: "DOMAIN-SUFFIX",
  76. example: "example.com",
  77. },
  78. {
  79. name: "DOMAIN-KEYWORD",
  80. example: "example",
  81. },
  82. {
  83. name: "DOMAIN-REGEX",
  84. example: "example.*",
  85. },
  86. {
  87. name: "GEOSITE",
  88. example: "youtube",
  89. },
  90. {
  91. name: "GEOIP",
  92. example: "CN",
  93. noResolve: true,
  94. },
  95. {
  96. name: "SRC-GEOIP",
  97. example: "CN",
  98. },
  99. {
  100. name: "IP-ASN",
  101. example: "13335",
  102. noResolve: true,
  103. validator: (value) => (+value ? true : false),
  104. },
  105. {
  106. name: "SRC-IP-ASN",
  107. example: "9808",
  108. validator: (value) => (+value ? true : false),
  109. },
  110. {
  111. name: "IP-CIDR",
  112. example: "127.0.0.0/8",
  113. noResolve: true,
  114. validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
  115. },
  116. {
  117. name: "IP-CIDR6",
  118. example: "2620:0:2d0:200::7/32",
  119. noResolve: true,
  120. validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
  121. },
  122. {
  123. name: "SRC-IP-CIDR",
  124. example: "192.168.1.201/32",
  125. validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
  126. },
  127. {
  128. name: "IP-SUFFIX",
  129. example: "8.8.8.8/24",
  130. noResolve: true,
  131. validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
  132. },
  133. {
  134. name: "SRC-IP-SUFFIX",
  135. example: "192.168.1.201/8",
  136. validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
  137. },
  138. {
  139. name: "SRC-PORT",
  140. example: "7777",
  141. validator: (value) => portValidator(value),
  142. },
  143. {
  144. name: "DST-PORT",
  145. example: "80",
  146. validator: (value) => portValidator(value),
  147. },
  148. {
  149. name: "IN-PORT",
  150. example: "7890",
  151. validator: (value) => portValidator(value),
  152. },
  153. {
  154. name: "DSCP",
  155. example: "4",
  156. },
  157. {
  158. name: "PROCESS-NAME",
  159. example: getSystem() === "windows" ? "chrome.exe" : "curl",
  160. },
  161. {
  162. name: "PROCESS-PATH",
  163. example:
  164. getSystem() === "windows"
  165. ? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
  166. : "/usr/bin/wget",
  167. },
  168. {
  169. name: "PROCESS-NAME-REGEX",
  170. example: ".*telegram.*",
  171. },
  172. {
  173. name: "PROCESS-PATH-REGEX",
  174. example:
  175. getSystem() === "windows" ? "(?i).*Application\\chrome.*" : ".*bin/wget",
  176. },
  177. {
  178. name: "NETWORK",
  179. example: "udp",
  180. validator: (value) => ["tcp", "udp"].includes(value),
  181. },
  182. {
  183. name: "UID",
  184. example: "1001",
  185. validator: (value) => (+value ? true : false),
  186. },
  187. {
  188. name: "IN-TYPE",
  189. example: "SOCKS/HTTP",
  190. },
  191. {
  192. name: "IN-USER",
  193. example: "mihomo",
  194. },
  195. {
  196. name: "IN-NAME",
  197. example: "ss",
  198. },
  199. {
  200. name: "SUB-RULE",
  201. example: "(NETWORK,tcp)",
  202. },
  203. {
  204. name: "RULE-SET",
  205. example: "providername",
  206. noResolve: true,
  207. },
  208. {
  209. name: "AND",
  210. example: "((DOMAIN,baidu.com),(NETWORK,UDP))",
  211. },
  212. {
  213. name: "OR",
  214. example: "((NETWORK,UDP),(DOMAIN,baidu.com))",
  215. },
  216. {
  217. name: "NOT",
  218. example: "((DOMAIN,baidu.com))",
  219. },
  220. {
  221. name: "MATCH",
  222. required: false,
  223. },
  224. ];
  225. const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
  226. export const RulesEditorViewer = (props: Props) => {
  227. const { title, profileUid, property, open, onClose, onSave } = props;
  228. const { t } = useTranslation();
  229. const themeMode = useThemeMode();
  230. const [prevData, setPrevData] = useState("");
  231. const [currData, setCurrData] = useState("");
  232. const [visible, setVisible] = useState(true);
  233. const [match, setMatch] = useState(() => (_: string) => true);
  234. const [ruleType, setRuleType] = useState<(typeof rules)[number]>(rules[0]);
  235. const [ruleContent, setRuleContent] = useState("");
  236. const [noResolve, setNoResolve] = useState(false);
  237. const [proxyPolicy, setProxyPolicy] = useState(builtinProxyPolicies[0]);
  238. const [proxyPolicyList, setProxyPolicyList] = useState<string[]>([]);
  239. const [ruleList, setRuleList] = useState<string[]>([]);
  240. const [ruleSetList, setRuleSetList] = useState<string[]>([]);
  241. const [subRuleList, setSubRuleList] = useState<string[]>([]);
  242. const [prependSeq, setPrependSeq] = useState<string[]>([]);
  243. const [appendSeq, setAppendSeq] = useState<string[]>([]);
  244. const [deleteSeq, setDeleteSeq] = useState<string[]>([]);
  245. const filteredRuleList = useMemo(
  246. () => ruleList.filter((rule) => match(rule)),
  247. [ruleList, match]
  248. );
  249. const sensors = useSensors(
  250. useSensor(PointerSensor),
  251. useSensor(KeyboardSensor, {
  252. coordinateGetter: sortableKeyboardCoordinates,
  253. })
  254. );
  255. const reorder = (list: string[], startIndex: number, endIndex: number) => {
  256. const result = Array.from(list);
  257. const [removed] = result.splice(startIndex, 1);
  258. result.splice(endIndex, 0, removed);
  259. return result;
  260. };
  261. const onPrependDragEnd = async (event: DragEndEvent) => {
  262. const { active, over } = event;
  263. if (over) {
  264. if (active.id !== over.id) {
  265. let activeIndex = prependSeq.indexOf(active.id.toString());
  266. let overIndex = prependSeq.indexOf(over.id.toString());
  267. setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
  268. }
  269. }
  270. };
  271. const onAppendDragEnd = async (event: DragEndEvent) => {
  272. const { active, over } = event;
  273. if (over) {
  274. if (active.id !== over.id) {
  275. let activeIndex = appendSeq.indexOf(active.id.toString());
  276. let overIndex = appendSeq.indexOf(over.id.toString());
  277. setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
  278. }
  279. }
  280. };
  281. const fetchContent = async () => {
  282. let data = await readProfileFile(property);
  283. let obj = yaml.load(data) as { prepend: []; append: []; delete: [] };
  284. setPrependSeq(obj.prepend || []);
  285. setAppendSeq(obj.append || []);
  286. setDeleteSeq(obj.delete || []);
  287. setPrevData(data);
  288. setCurrData(data);
  289. };
  290. useEffect(() => {
  291. if (currData === "") return;
  292. if (visible !== true) return;
  293. let obj = yaml.load(currData) as { prepend: []; append: []; delete: [] };
  294. setPrependSeq(obj.prepend || []);
  295. setAppendSeq(obj.append || []);
  296. setDeleteSeq(obj.delete || []);
  297. }, [visible]);
  298. useEffect(() => {
  299. if (prependSeq && appendSeq && deleteSeq)
  300. setCurrData(
  301. yaml.dump({ prepend: prependSeq, append: appendSeq, delete: deleteSeq })
  302. );
  303. }, [prependSeq, appendSeq, deleteSeq]);
  304. const fetchProfile = async () => {
  305. let data = await readProfileFile(profileUid);
  306. let groupsObj = yaml.load(data) as { "proxy-groups": [] };
  307. let rulesObj = yaml.load(data) as { rules: [] };
  308. let ruleSetObj = yaml.load(data) as { "rule-providers": [] };
  309. let subRuleObj = yaml.load(data) as { "sub-rules": [] };
  310. setProxyPolicyList(
  311. builtinProxyPolicies.concat(
  312. groupsObj["proxy-groups"]
  313. ? groupsObj["proxy-groups"].map((item: any) => item.name)
  314. : []
  315. )
  316. );
  317. setRuleList(rulesObj.rules || []);
  318. setRuleSetList(
  319. ruleSetObj["rule-providers"]
  320. ? Object.keys(ruleSetObj["rule-providers"])
  321. : []
  322. );
  323. setSubRuleList(
  324. subRuleObj["sub-rules"] ? Object.keys(subRuleObj["sub-rules"]) : []
  325. );
  326. };
  327. useEffect(() => {
  328. fetchContent();
  329. fetchProfile();
  330. }, [open]);
  331. const validateRule = () => {
  332. if ((ruleType.required ?? true) && !ruleContent) {
  333. throw new Error(t("Rule Condition Required"));
  334. }
  335. if (ruleType.validator && !ruleType.validator(ruleContent)) {
  336. throw new Error(t("Invalid Rule"));
  337. }
  338. const condition = ruleType.required ?? true ? ruleContent : "";
  339. return `${ruleType.name}${condition ? "," + condition : ""},${proxyPolicy}${
  340. ruleType.noResolve && noResolve ? ",no-resolve" : ""
  341. }`;
  342. };
  343. const handleSave = useLockFn(async () => {
  344. try {
  345. await saveProfileFile(property, currData);
  346. onSave?.(prevData, currData);
  347. onClose();
  348. } catch (err: any) {
  349. Notice.error(err.message || err.toString());
  350. }
  351. });
  352. return (
  353. <Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
  354. <DialogTitle>
  355. {
  356. <Box display="flex" justifyContent="space-between">
  357. {t("Edit Rules")}
  358. <Box>
  359. <Button
  360. variant="contained"
  361. size="small"
  362. onClick={() => {
  363. setVisible((prev) => !prev);
  364. }}
  365. >
  366. {visible ? t("Advanced") : t("Visible")}
  367. </Button>
  368. </Box>
  369. </Box>
  370. }
  371. </DialogTitle>
  372. <DialogContent
  373. sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
  374. >
  375. {visible ? (
  376. <>
  377. <List
  378. sx={{
  379. width: "50%",
  380. padding: "0 10px",
  381. }}
  382. >
  383. <Item>
  384. <ListItemText primary={t("Rule Type")} />
  385. <Autocomplete
  386. size="small"
  387. sx={{ minWidth: "240px" }}
  388. renderInput={(params) => <TextField {...params} />}
  389. options={rules}
  390. value={ruleType}
  391. getOptionLabel={(option) => option.name}
  392. renderOption={(props, option) => (
  393. <li {...props} title={t(option.name)}>
  394. {option.name}
  395. </li>
  396. )}
  397. onChange={(_, value) => value && setRuleType(value)}
  398. />
  399. </Item>
  400. <Item
  401. sx={{ display: !(ruleType.required ?? true) ? "none" : "" }}
  402. >
  403. <ListItemText primary={t("Rule Content")} />
  404. {ruleType.name === "RULE-SET" && (
  405. <Autocomplete
  406. size="small"
  407. sx={{ minWidth: "240px" }}
  408. renderInput={(params) => <TextField {...params} />}
  409. options={ruleSetList}
  410. value={ruleContent}
  411. onChange={(_, value) => value && setRuleContent(value)}
  412. />
  413. )}
  414. {ruleType.name === "SUB-RULE" && (
  415. <Autocomplete
  416. size="small"
  417. sx={{ minWidth: "240px" }}
  418. renderInput={(params) => <TextField {...params} />}
  419. options={subRuleList}
  420. value={ruleContent}
  421. onChange={(_, value) => value && setRuleContent(value)}
  422. />
  423. )}
  424. {ruleType.name !== "RULE-SET" &&
  425. ruleType.name !== "SUB-RULE" && (
  426. <TextField
  427. autoComplete="off"
  428. size="small"
  429. sx={{ minWidth: "240px" }}
  430. value={ruleContent}
  431. required={ruleType.required ?? true}
  432. error={(ruleType.required ?? true) && !ruleContent}
  433. placeholder={ruleType.example}
  434. onChange={(e) => setRuleContent(e.target.value)}
  435. />
  436. )}
  437. </Item>
  438. <Item>
  439. <ListItemText primary={t("Proxy Policy")} />
  440. <Autocomplete
  441. size="small"
  442. sx={{ minWidth: "240px" }}
  443. renderInput={(params) => <TextField {...params} />}
  444. options={proxyPolicyList}
  445. value={proxyPolicy}
  446. renderOption={(props, option) => (
  447. <li {...props} title={t(option)}>
  448. {option}
  449. </li>
  450. )}
  451. onChange={(_, value) => value && setProxyPolicy(value)}
  452. />
  453. </Item>
  454. {ruleType.noResolve && (
  455. <Item>
  456. <ListItemText primary={t("No Resolve")} />
  457. <Switch
  458. checked={noResolve}
  459. onChange={() => setNoResolve(!noResolve)}
  460. />
  461. </Item>
  462. )}
  463. <Item>
  464. <Button
  465. fullWidth
  466. variant="contained"
  467. onClick={() => {
  468. try {
  469. let raw = validateRule();
  470. if (prependSeq.includes(raw)) return;
  471. setPrependSeq([...prependSeq, raw]);
  472. } catch (err: any) {
  473. Notice.error(err.message || err.toString());
  474. }
  475. }}
  476. >
  477. {t("Prepend Rule")}
  478. </Button>
  479. </Item>
  480. <Item>
  481. <Button
  482. fullWidth
  483. variant="contained"
  484. onClick={() => {
  485. try {
  486. let raw = validateRule();
  487. if (appendSeq.includes(raw)) return;
  488. setAppendSeq([...appendSeq, raw]);
  489. } catch (err: any) {
  490. Notice.error(err.message || err.toString());
  491. }
  492. }}
  493. >
  494. {t("Append Rule")}
  495. </Button>
  496. </Item>
  497. </List>
  498. <List
  499. sx={{
  500. width: "50%",
  501. padding: "0 10px",
  502. }}
  503. >
  504. <BaseSearchBox
  505. matchCase={false}
  506. onSearch={(match) => setMatch(() => match)}
  507. />
  508. <Virtuoso
  509. style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
  510. totalCount={
  511. filteredRuleList.length +
  512. (prependSeq.length > 0 ? 1 : 0) +
  513. (appendSeq.length > 0 ? 1 : 0)
  514. }
  515. increaseViewportBy={256}
  516. itemContent={(index) => {
  517. let shift = prependSeq.length > 0 ? 1 : 0;
  518. if (prependSeq.length > 0 && index === 0) {
  519. return (
  520. <DndContext
  521. sensors={sensors}
  522. collisionDetection={closestCenter}
  523. onDragEnd={onPrependDragEnd}
  524. >
  525. <SortableContext
  526. items={prependSeq.map((x) => {
  527. return x;
  528. })}
  529. >
  530. {prependSeq.map((item, index) => {
  531. return (
  532. <RuleItem
  533. key={`${item}-${index}`}
  534. type="prepend"
  535. ruleRaw={item}
  536. onDelete={() => {
  537. setPrependSeq(
  538. prependSeq.filter((v) => v !== item)
  539. );
  540. }}
  541. />
  542. );
  543. })}
  544. </SortableContext>
  545. </DndContext>
  546. );
  547. } else if (index < filteredRuleList.length + shift) {
  548. let newIndex = index - shift;
  549. return (
  550. <RuleItem
  551. key={`${filteredRuleList[newIndex]}-${index}`}
  552. type={
  553. deleteSeq.includes(filteredRuleList[newIndex])
  554. ? "delete"
  555. : "original"
  556. }
  557. ruleRaw={filteredRuleList[newIndex]}
  558. onDelete={() => {
  559. if (deleteSeq.includes(filteredRuleList[newIndex])) {
  560. setDeleteSeq(
  561. deleteSeq.filter(
  562. (v) => v !== filteredRuleList[newIndex]
  563. )
  564. );
  565. } else {
  566. setDeleteSeq((prev) => [
  567. ...prev,
  568. filteredRuleList[newIndex],
  569. ]);
  570. }
  571. }}
  572. />
  573. );
  574. } else {
  575. return (
  576. <DndContext
  577. sensors={sensors}
  578. collisionDetection={closestCenter}
  579. onDragEnd={onAppendDragEnd}
  580. >
  581. <SortableContext
  582. items={appendSeq.map((x) => {
  583. return x;
  584. })}
  585. >
  586. {appendSeq.map((item, index) => {
  587. return (
  588. <RuleItem
  589. key={`${item}-${index}`}
  590. type="append"
  591. ruleRaw={item}
  592. onDelete={() => {
  593. setAppendSeq(
  594. appendSeq.filter((v) => v !== item)
  595. );
  596. }}
  597. />
  598. );
  599. })}
  600. </SortableContext>
  601. </DndContext>
  602. );
  603. }
  604. }}
  605. />
  606. </List>
  607. </>
  608. ) : (
  609. <MonacoEditor
  610. height="100%"
  611. language="yaml"
  612. value={currData}
  613. theme={themeMode === "light" ? "vs" : "vs-dark"}
  614. options={{
  615. tabSize: 2, // 根据语言类型设置缩进大小
  616. minimap: {
  617. enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
  618. },
  619. mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
  620. quickSuggestions: {
  621. strings: true, // 字符串类型的建议
  622. comments: true, // 注释类型的建议
  623. other: true, // 其他类型的建议
  624. },
  625. padding: {
  626. top: 33, // 顶部padding防止遮挡snippets
  627. },
  628. fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
  629. getSystem() === "windows" ? ", twemoji mozilla" : ""
  630. }`,
  631. fontLigatures: true, // 连字符
  632. smoothScrolling: true, // 平滑滚动
  633. }}
  634. onChange={(value) => setCurrData(value)}
  635. />
  636. )}
  637. </DialogContent>
  638. <DialogActions>
  639. <Button onClick={onClose} variant="outlined">
  640. {t("Cancel")}
  641. </Button>
  642. <Button onClick={handleSave} variant="contained">
  643. {t("Save")}
  644. </Button>
  645. </DialogActions>
  646. </Dialog>
  647. );
  648. };
  649. const Item = styled(ListItem)(() => ({
  650. padding: "5px 2px",
  651. }));