feat: support web ui

This commit is contained in:
GyDi 2022-08-06 21:56:54 +08:00
parent 0891b5e7b7
commit 5564c966a5
No known key found for this signature in database
GPG Key ID: 1C95E0D3467B3084
12 changed files with 361 additions and 52 deletions

View File

@ -257,6 +257,12 @@ pub fn open_logs_dir() -> Result<(), String> {
wrap_err!(open::that(log_dir)) 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 /// service mode
#[cfg(windows)] #[cfg(windows)]
pub mod service { pub mod service {

View File

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

View File

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

View 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;

View 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;

View 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;

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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