diff --git a/src-tauri/src/cmds.rs b/src-tauri/src/cmds.rs index 4d30c85..0fcd861 100644 --- a/src-tauri/src/cmds.rs +++ b/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 { diff --git a/src-tauri/src/core/verge.rs b/src-tauri/src/core/verge.rs index a03bde4..d206dbb 100644 --- a/src-tauri/src/core/verge.rs +++ b/src-tauri/src/core/verge.rs @@ -46,6 +46,9 @@ pub struct Verge { /// theme setting pub theme_setting: Option, + /// web ui list + pub web_ui_list: Option>, + /// clash core path #[serde(skip_serializing_if = "Option::is_none")] pub clash_core: Option, @@ -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; + macro_rules! patch { + ($key: tt) => { + if patch.$key.is_some() { + self.$key = patch.$key; + } + }; } - // 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; - } + patch!(language); + patch!(theme_mode); + patch!(theme_blur); + patch!(traffic_graph); - // 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; - } + 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); - // 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!(theme_setting); + patch!(web_ui_list); + patch!(clash_core); self.save_file() } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 28eeff3..c1891ac 100644 --- a/src-tauri/src/main.rs +++ b/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 diff --git a/src/components/base/base-empty.tsx b/src/components/base/base-empty.tsx new file mode 100644 index 0000000..dc3f132 --- /dev/null +++ b/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 ( + ({ + width: "100%", + height: "100%", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + color: alpha(palette.text.secondary, 0.75), + })} + > + + {text} + {extra} + + ); +}; + +export default BaseEmpty; diff --git a/src/components/setting/mods/web-ui-item.tsx b/src/components/setting/mods/web-ui-item.tsx new file mode 100644 index 0000000..94056dd --- /dev/null +++ b/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 ( + + setEditValue(e.target.value)} + placeholder={`Support %host %port %secret`} + autoComplete="off" + /> + { + onChange(editValue); + setEditing(false); + }} + > + + + { + onCancel?.(); + setEditing(false); + }} + > + + + + ); + } + + return ( + + + {value || "NULL"} + + onOpenUrl?.(value)} + > + + + { + setEditing(true); + setEditValue(value); + }} + > + + + + + + + ); +}; + +export default WebUIItem; diff --git a/src/components/setting/mods/web-ui-viewer.tsx b/src/components/setting/mods/web-ui-viewer.tsx new file mode 100644 index 0000000..ca9ce1e --- /dev/null +++ b/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 ( + setOpen(false)}> + + {t("Web UI")} + + + + + {editing && ( + { + setEditing(false); + handleAdd(v || ""); + }} + onCancel={() => setEditing(false)} + /> + )} + + {!editing && webUIList.length === 0 && ( + + Replace host, port, secret with "%host" "%port" "%secret" + + } + /> + )} + + {webUIList.map((item, index) => ( + handleChange(index, v)} + onDelete={() => handleDelete(index)} + onOpenUrl={handleOpenUrl} + /> + ))} + + + + + + + ); +}; + +export default WebUIViewer; diff --git a/src/components/setting/setting-clash.tsx b/src/components/setting/setting-clash.tsx index 5f13838..0f24891 100644 --- a/src/components/setting/setting-clash.tsx +++ b/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) => { mutate("getClashConfig", { ...clashConfig, ...patch }, false); @@ -68,6 +72,8 @@ const SettingClash = ({ onError }: Props) => { return ( + + { + + webUIHandler.current.open()} + > + + + + { }> {clashVer} - - {/* - - - - */} ); }; diff --git a/src/hooks/use-modal-handler.ts b/src/hooks/use-modal-handler.ts new file mode 100644 index 0000000..dbcf889 --- /dev/null +++ b/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; + +const useModalHandler = (): ModalHandler => { + return useRef({ open: () => {}, close: () => {} }); +}; + +export default useModalHandler; diff --git a/src/locales/en.json b/src/locales/en.json index 7b75b85..d852586 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -60,6 +60,7 @@ "theme.dark": "Dark", "theme.system": "System", + "Back": "Back", "Save": "Save", "Cancel": "Cancel" } diff --git a/src/locales/zh.json b/src/locales/zh.json index ea560ae..10bac9d 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -60,6 +60,7 @@ "theme.dark": "深色", "theme.system": "系统", + "Back": "返回", "Save": "保存", "Cancel": "取消" } diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 456fab0..1841134 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -113,6 +113,10 @@ export async function openLogsDir() { ); } +export async function openWebUrl(url: string) { + return invoke("open_web_url", { url }); +} + /// service mode export async function startService() { diff --git a/src/services/types.d.ts b/src/services/types.d.ts index 6aafd11..96a1620 100644 --- a/src/services/types.d.ts +++ b/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;