Explorar o código

feat: support web ui

GyDi %!s(int64=2) %!d(string=hai) anos
pai
achega
5564c966a5

+ 6 - 0
src-tauri/src/cmds.rs

@@ -257,6 +257,12 @@ pub fn open_logs_dir() -> Result<(), String> {
   wrap_err!(open::that(log_dir))
 }
 
+/// open url
+#[tauri::command]
+pub fn open_web_url(url: String) -> Result<(), String> {
+  wrap_err!(open::that(url))
+}
+
 /// service mode
 #[cfg(windows)]
 pub mod service {

+ 26 - 47
src-tauri/src/core/verge.rs

@@ -46,6 +46,9 @@ pub struct Verge {
   /// theme setting
   pub theme_setting: Option<VergeTheme>,
 
+  /// web ui list
+  pub web_ui_list: Option<Vec<String>>,
+
   /// clash core path
   #[serde(skip_serializing_if = "Option::is_none")]
   pub clash_core: Option<String>,
@@ -84,55 +87,31 @@ impl Verge {
   /// patch verge config
   /// only save to file
   pub fn patch_config(&mut self, patch: Verge) -> Result<()> {
-    // only change it
-    if patch.language.is_some() {
-      self.language = patch.language;
-    }
-    if patch.theme_mode.is_some() {
-      self.theme_mode = patch.theme_mode;
-    }
-    if patch.theme_blur.is_some() {
-      self.theme_blur = patch.theme_blur;
-    }
-    if patch.theme_setting.is_some() {
-      self.theme_setting = patch.theme_setting;
-    }
-    if patch.traffic_graph.is_some() {
-      self.traffic_graph = patch.traffic_graph;
-    }
-    if patch.clash_core.is_some() {
-      self.clash_core = patch.clash_core;
-    }
-
-    // system setting
-    if patch.enable_silent_start.is_some() {
-      self.enable_silent_start = patch.enable_silent_start;
-    }
-    if patch.enable_auto_launch.is_some() {
-      self.enable_auto_launch = patch.enable_auto_launch;
-    }
-
-    // proxy
-    if patch.enable_system_proxy.is_some() {
-      self.enable_system_proxy = patch.enable_system_proxy;
-    }
-    if patch.system_proxy_bypass.is_some() {
-      self.system_proxy_bypass = patch.system_proxy_bypass;
-    }
-    if patch.enable_proxy_guard.is_some() {
-      self.enable_proxy_guard = patch.enable_proxy_guard;
-    }
-    if patch.proxy_guard_duration.is_some() {
-      self.proxy_guard_duration = patch.proxy_guard_duration;
+    macro_rules! patch {
+      ($key: tt) => {
+        if patch.$key.is_some() {
+          self.$key = patch.$key;
+        }
+      };
     }
 
-    // tun mode
-    if patch.enable_tun_mode.is_some() {
-      self.enable_tun_mode = patch.enable_tun_mode;
-    }
-    if patch.enable_service_mode.is_some() {
-      self.enable_service_mode = patch.enable_service_mode;
-    }
+    patch!(language);
+    patch!(theme_mode);
+    patch!(theme_blur);
+    patch!(traffic_graph);
+
+    patch!(enable_tun_mode);
+    patch!(enable_service_mode);
+    patch!(enable_auto_launch);
+    patch!(enable_silent_start);
+    patch!(enable_system_proxy);
+    patch!(enable_proxy_guard);
+    patch!(system_proxy_bypass);
+    patch!(proxy_guard_duration);
+
+    patch!(theme_setting);
+    patch!(web_ui_list);
+    patch!(clash_core);
 
     self.save_file()
   }

+ 1 - 0
src-tauri/src/main.rs

@@ -108,6 +108,7 @@ fn main() -> std::io::Result<()> {
       cmds::get_cur_proxy,
       cmds::open_app_dir,
       cmds::open_logs_dir,
+      cmds::open_web_url,
       cmds::kill_sidecar,
       cmds::restart_sidecar,
       // clash

+ 31 - 0
src/components/base/base-empty.tsx

@@ -0,0 +1,31 @@
+import { alpha, Box, Typography } from "@mui/material";
+import { BlurOnRounded } from "@mui/icons-material";
+
+interface Props {
+  text?: React.ReactNode;
+  extra?: React.ReactNode;
+}
+
+const BaseEmpty = (props: Props) => {
+  const { text = "Empty", extra } = props;
+
+  return (
+    <Box
+      sx={({ palette }) => ({
+        width: "100%",
+        height: "100%",
+        display: "flex",
+        flexDirection: "column",
+        alignItems: "center",
+        justifyContent: "center",
+        color: alpha(palette.text.secondary, 0.75),
+      })}
+    >
+      <BlurOnRounded sx={{ fontSize: "4em" }} />
+      <Typography sx={{ fontSize: "1.25em" }}>{text}</Typography>
+      {extra}
+    </Box>
+  );
+};
+
+export default BaseEmpty;

+ 106 - 0
src/components/setting/mods/web-ui-item.tsx

@@ -0,0 +1,106 @@
+import { useState } from "react";
+import { IconButton, Stack, TextField, Typography } from "@mui/material";
+import {
+  CheckRounded,
+  CloseRounded,
+  DeleteRounded,
+  EditRounded,
+  OpenInNewRounded,
+} from "@mui/icons-material";
+
+interface Props {
+  value?: string;
+  onlyEdit?: boolean;
+  onChange: (value?: string) => void;
+  onOpenUrl?: (value?: string) => void;
+  onDelete?: () => void;
+  onCancel?: () => void;
+}
+
+const WebUIItem = (props: Props) => {
+  const {
+    value,
+    onlyEdit = false,
+    onChange,
+    onDelete,
+    onOpenUrl,
+    onCancel,
+  } = props;
+
+  const [editing, setEditing] = useState(false);
+  const [editValue, setEditValue] = useState(value);
+
+  if (editing || onlyEdit) {
+    return (
+      <Stack spacing={1} direction="row" mt={1} mb={2} alignItems="center">
+        <TextField
+          fullWidth
+          size="small"
+          value={editValue}
+          onChange={(e) => setEditValue(e.target.value)}
+          placeholder={`Support %host %port %secret`}
+          autoComplete="off"
+        />
+        <IconButton
+          size="small"
+          title="Save"
+          onClick={() => {
+            onChange(editValue);
+            setEditing(false);
+          }}
+        >
+          <CheckRounded fontSize="inherit" />
+        </IconButton>
+        <IconButton
+          size="small"
+          title="Cancel"
+          onClick={() => {
+            onCancel?.();
+            setEditing(false);
+          }}
+        >
+          <CloseRounded fontSize="inherit" />
+        </IconButton>
+      </Stack>
+    );
+  }
+
+  return (
+    <Stack spacing={1} direction="row" alignItems="center" mt={1} mb={2}>
+      <Typography
+        component="div"
+        width="100%"
+        title={value}
+        sx={{
+          overflow: "hidden",
+          textOverflow: "ellipsis",
+          whiteSpace: "nowrap",
+        }}
+      >
+        {value || "NULL"}
+      </Typography>
+      <IconButton
+        size="small"
+        title="Open URL"
+        onClick={() => onOpenUrl?.(value)}
+      >
+        <OpenInNewRounded fontSize="inherit" />
+      </IconButton>
+      <IconButton
+        size="small"
+        title="Edit"
+        onClick={() => {
+          setEditing(true);
+          setEditValue(value);
+        }}
+      >
+        <EditRounded fontSize="inherit" />
+      </IconButton>
+      <IconButton size="small" title="Delete" onClick={onDelete}>
+        <DeleteRounded fontSize="inherit" />
+      </IconButton>
+    </Stack>
+  );
+};
+
+export default WebUIItem;

+ 154 - 0
src/components/setting/mods/web-ui-viewer.tsx

@@ -0,0 +1,154 @@
+import useSWR from "swr";
+import { useState } from "react";
+import { useLockFn } from "ahooks";
+import { useTranslation } from "react-i18next";
+import {
+  Button,
+  Dialog,
+  DialogActions,
+  DialogContent,
+  DialogTitle,
+  Typography,
+} from "@mui/material";
+import {
+  getClashInfo,
+  getVergeConfig,
+  openWebUrl,
+  patchVergeConfig,
+} from "@/services/cmds";
+import { ModalHandler } from "@/hooks/use-modal-handler";
+import BaseEmpty from "@/components/base/base-empty";
+import WebUIItem from "./web-ui-item";
+
+interface Props {
+  handler: ModalHandler;
+  onError: (err: Error) => void;
+}
+
+const WebUIViewer = ({ handler, onError }: Props) => {
+  const { t } = useTranslation();
+  const { data: vergeConfig, mutate: mutateVerge } = useSWR(
+    "getVergeConfig",
+    getVergeConfig
+  );
+
+  const webUIList = vergeConfig?.web_ui_list || [];
+
+  const [open, setOpen] = useState(false);
+  const [editing, setEditing] = useState(false);
+
+  if (handler) {
+    handler.current = {
+      open: () => setOpen(true),
+      close: () => setOpen(false),
+    };
+  }
+
+  const handleAdd = useLockFn(async (value: string) => {
+    const newList = [value, ...webUIList];
+    mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
+    await patchVergeConfig({ web_ui_list: newList });
+    await mutateVerge();
+  });
+
+  const handleChange = useLockFn(async (index: number, value?: string) => {
+    const newList = [...webUIList];
+    newList[index] = value ?? "";
+    mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
+    await patchVergeConfig({ web_ui_list: newList });
+    await mutateVerge();
+  });
+
+  const handleDelete = useLockFn(async (index: number) => {
+    const newList = [...webUIList];
+    newList.splice(index, 1);
+    mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
+    await patchVergeConfig({ web_ui_list: newList });
+    await mutateVerge();
+  });
+
+  const { data: clashInfo } = useSWR("getClashInfo", getClashInfo);
+
+  const handleOpenUrl = useLockFn(async (value?: string) => {
+    if (!value) return;
+    try {
+      let url = value.trim().replaceAll("%host", "127.0.0.1");
+
+      if (url.includes("%port") || url.includes("%secret")) {
+        if (!clashInfo) throw new Error("failed to get clash info");
+
+        url = url.replaceAll("%port", clashInfo.port || "9090");
+        url = url.replaceAll("%secret", clashInfo.secret || "");
+      }
+
+      await openWebUrl(url);
+    } catch (e: any) {
+      onError(e);
+    }
+  });
+
+  return (
+    <Dialog open={open} onClose={() => setOpen(false)}>
+      <DialogTitle display="flex" justifyContent="space-between">
+        {t("Web UI")}
+        <Button
+          variant="contained"
+          size="small"
+          disabled={editing}
+          onClick={() => setEditing(true)}
+        >
+          {t("New")}
+        </Button>
+      </DialogTitle>
+
+      <DialogContent
+        sx={{
+          width: 450,
+          height: 300,
+          pb: 1,
+          overflowY: "auto",
+          userSelect: "text",
+        }}
+      >
+        {editing && (
+          <WebUIItem
+            value=""
+            onlyEdit
+            onChange={(v) => {
+              setEditing(false);
+              handleAdd(v || "");
+            }}
+            onCancel={() => setEditing(false)}
+          />
+        )}
+
+        {!editing && webUIList.length === 0 && (
+          <BaseEmpty
+            text="Empty List"
+            extra={
+              <Typography mt={2} sx={{ fontSize: "12px" }}>
+                Replace host, port, secret with "%host" "%port" "%secret"
+              </Typography>
+            }
+          />
+        )}
+
+        {webUIList.map((item, index) => (
+          <WebUIItem
+            key={index}
+            value={item}
+            onChange={(v) => handleChange(index, v)}
+            onDelete={() => handleDelete(index)}
+            onOpenUrl={handleOpenUrl}
+          />
+        ))}
+      </DialogContent>
+
+      <DialogActions>
+        <Button onClick={() => setOpen(false)}>{t("Back")}</Button>
+      </DialogActions>
+    </Dialog>
+  );
+};
+
+export default WebUIViewer;

+ 18 - 7
src/components/setting/setting-clash.tsx

@@ -11,12 +11,14 @@ import {
 } from "@mui/material";
 import { atomClashPort } from "@/services/states";
 import { ArrowForward } from "@mui/icons-material";
-import { openWebUrl, patchClashConfig } from "@/services/cmds";
+import { patchClashConfig } from "@/services/cmds";
 import { SettingList, SettingItem } from "./setting";
 import { getClashConfig, getVersion, updateConfigs } from "@/services/api";
+import useModalHandler from "@/hooks/use-modal-handler";
 import Notice from "../base/base-notice";
 import GuardState from "./mods/guard-state";
 import CoreSwitch from "./mods/core-switch";
+import WebUIViewer from "./mods/web-ui-viewer";
 
 interface Props {
   onError: (err: Error) => void;
@@ -37,6 +39,8 @@ const SettingClash = ({ onError }: Props) => {
 
   const setGlobalClashPort = useSetRecoilState(atomClashPort);
 
+  const webUIHandler = useModalHandler();
+
   const onSwitchFormat = (_e: any, value: boolean) => value;
   const onChangeData = (patch: Partial<ApiType.ConfigData>) => {
     mutate("getClashConfig", { ...clashConfig, ...patch }, false);
@@ -68,6 +72,8 @@ const SettingClash = ({ onError }: Props) => {
 
   return (
     <SettingList title={t("Clash Setting")}>
+      <WebUIViewer handler={webUIHandler} onError={onError} />
+
       <SettingItem label={t("Allow Lan")}>
         <GuardState
           value={allowLan ?? false}
@@ -94,6 +100,17 @@ const SettingClash = ({ onError }: Props) => {
         </GuardState>
       </SettingItem>
 
+      <SettingItem label={t("Web UI")}>
+        <IconButton
+          color="inherit"
+          size="small"
+          sx={{ my: "2px" }}
+          onClick={() => webUIHandler.current.open()}
+        >
+          <ArrowForward />
+        </IconButton>
+      </SettingItem>
+
       <SettingItem label={t("Log Level")}>
         <GuardState
           value={logLevel ?? "info"}
@@ -132,12 +149,6 @@ const SettingClash = ({ onError }: Props) => {
       <SettingItem label={t("Clash Core")} extra={<CoreSwitch />}>
         <Typography sx={{ py: "7px" }}>{clashVer}</Typography>
       </SettingItem>
-
-      {/* <SettingItem label={t("Web UI")}>
-        <IconButton color="inherit" size="small" sx={{ my: "2px" }}>
-          <ArrowForward />
-        </IconButton>
-      </SettingItem> */}
     </SettingList>
   );
 };

+ 14 - 0
src/hooks/use-modal-handler.ts

@@ -0,0 +1,14 @@
+import { MutableRefObject, useRef } from "react";
+
+interface Handler {
+  open: () => void;
+  close: () => void;
+}
+
+export type ModalHandler = MutableRefObject<Handler>;
+
+const useModalHandler = (): ModalHandler => {
+  return useRef({ open: () => {}, close: () => {} });
+};
+
+export default useModalHandler;

+ 1 - 0
src/locales/en.json

@@ -60,6 +60,7 @@
   "theme.dark": "Dark",
   "theme.system": "System",
 
+  "Back": "Back",
   "Save": "Save",
   "Cancel": "Cancel"
 }

+ 1 - 0
src/locales/zh.json

@@ -60,6 +60,7 @@
   "theme.dark": "深色",
   "theme.system": "系统",
 
+  "Back": "返回",
   "Save": "保存",
   "Cancel": "取消"
 }

+ 4 - 0
src/services/cmds.ts

@@ -113,6 +113,10 @@ export async function openLogsDir() {
   );
 }
 
+export async function openWebUrl(url: string) {
+  return invoke<void>("open_web_url", { url });
+}
+
 /// service mode
 
 export async function startService() {

+ 1 - 0
src/services/types.d.ts

@@ -137,6 +137,7 @@ declare namespace CmdType {
     enable_system_proxy?: boolean;
     enable_proxy_guard?: boolean;
     system_proxy_bypass?: string;
+    web_ui_list?: string[];
     theme_setting?: {
       primary_color?: string;
       secondary_color?: string;