feat: support web ui
This commit is contained in:
parent
0891b5e7b7
commit
5564c966a5
@ -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 {
|
||||
|
@ -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;
|
||||
macro_rules! patch {
|
||||
($key: tt) => {
|
||||
if patch.$key.is_some() {
|
||||
self.$key = patch.$key;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
@ -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
src/components/base/base-empty.tsx
Normal file
31
src/components/base/base-empty.tsx
Normal file
@ -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
src/components/setting/mods/web-ui-item.tsx
Normal file
106
src/components/setting/mods/web-ui-item.tsx
Normal file
@ -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
src/components/setting/mods/web-ui-viewer.tsx
Normal file
154
src/components/setting/mods/web-ui-viewer.tsx
Normal file
@ -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;
|
@ -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
src/hooks/use-modal-handler.ts
Normal file
14
src/hooks/use-modal-handler.ts
Normal file
@ -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;
|
@ -60,6 +60,7 @@
|
||||
"theme.dark": "Dark",
|
||||
"theme.system": "System",
|
||||
|
||||
"Back": "Back",
|
||||
"Save": "Save",
|
||||
"Cancel": "Cancel"
|
||||
}
|
||||
|
@ -60,6 +60,7 @@
|
||||
"theme.dark": "深色",
|
||||
"theme.system": "系统",
|
||||
|
||||
"Back": "返回",
|
||||
"Save": "保存",
|
||||
"Cancel": "取消"
|
||||
}
|
||||
|
@ -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
src/services/types.d.ts
vendored
1
src/services/types.d.ts
vendored
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user