Merge branch 'fix-migrate-tauri2-errors'

* fix-migrate-tauri2-errors: (288 commits)

# Conflicts:
#	.github/ISSUE_TEMPLATE/bug_report.yml
This commit is contained in:
huzibaca
2024-11-24 00:14:46 +08:00
123 changed files with 8721 additions and 5789 deletions

View File

@@ -1,3 +1,7 @@
@use "./layout.scss";
@use "./page.scss";
@use "./font.scss";
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
@@ -43,10 +47,6 @@ body {
overflow: hidden;
}
@import "./layout.scss";
@import "./page.scss";
@import "./font.scss";
// @media (prefers-color-scheme: dark) {
// :root {
// background-color: rgba(18, 18, 18, 1);

View File

@@ -10,7 +10,7 @@ type Props = {
};
export const BaseFieldset: React.FC<Props> = (props: Props) => {
const Fieldset = styled(Box)(() => ({
const Fieldset = styled(Box)<{ component?: string }>(() => ({
position: "relative",
border: "1px solid #bbb",
borderRadius: "5px",

View File

@@ -0,0 +1,33 @@
import React from "react";
import { Box, CircularProgress } from "@mui/material";
export interface BaseLoadingOverlayProps {
isLoading: boolean;
}
export const BaseLoadingOverlay: React.FC<BaseLoadingOverlayProps> = ({
isLoading,
}) => {
if (!isLoading) return null;
return (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(255, 255, 255, 0.7)",
zIndex: 1000,
}}
>
<CircularProgress />
</Box>
);
};
export default BaseLoadingOverlay;

View File

@@ -1,7 +1,7 @@
import React, { ReactNode } from "react";
import { Typography } from "@mui/material";
import { BaseErrorBoundary } from "./base-error-boundary";
import { useCustomTheme } from "@/components/layout/use-custom-theme";
import { useTheme } from "@mui/material/styles";
interface Props {
title?: React.ReactNode; // the page title
@@ -13,7 +13,7 @@ interface Props {
export const BasePage: React.FC<Props> = (props) => {
const { title, header, contentStyle, full, children } = props;
const { theme } = useCustomTheme();
const theme = useTheme();
const isDark = theme.palette.mode === "dark";

View File

@@ -7,20 +7,19 @@ import matchCaseIcon from "@/assets/image/component/match_case.svg?react";
import matchWholeWordIcon from "@/assets/image/component/match_whole_word.svg?react";
import useRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react";
export type SearchState = {
text: string;
matchCase: boolean;
matchWholeWord: boolean;
useRegularExpression: boolean;
};
type SearchProps = {
placeholder?: string;
matchCase?: boolean;
matchWholeWord?: boolean;
useRegularExpression?: boolean;
onSearch: (
match: (content: string) => boolean,
state: {
text: string;
matchCase: boolean;
matchWholeWord: boolean;
useRegularExpression: boolean;
}
) => void;
onSearch: (match: (content: string) => boolean, state: SearchState) => void;
};
export const BaseSearchBox = styled((props: SearchProps) => {
@@ -28,10 +27,10 @@ export const BaseSearchBox = styled((props: SearchProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [matchCase, setMatchCase] = useState(props.matchCase ?? false);
const [matchWholeWord, setMatchWholeWord] = useState(
props.matchWholeWord ?? false
props.matchWholeWord ?? false,
);
const [useRegularExpression, setUseRegularExpression] = useState(
props.useRegularExpression ?? false
props.useRegularExpression ?? false,
);
const [errorMessage, setErrorMessage] = useState("");
@@ -60,7 +59,7 @@ export const BaseSearchBox = styled((props: SearchProps) => {
matchCase,
matchWholeWord,
useRegularExpression,
}
},
);
};

View File

@@ -5,3 +5,4 @@ export { BaseLoading } from "./base-loading";
export { BaseErrorBoundary } from "./base-error-boundary";
export { Notice } from "./base-notice";
export { Switch } from "./base-switch";
export { BaseLoadingOverlay } from "./base-loading-overlay";

View File

@@ -30,6 +30,7 @@ export const ConnectionDetail = forwardRef<ConnectionDetailRef>(
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
open={open}
onClose={onClose}
sx={{ maxWidth: "520px" }}
message={
detail ? (
<InnerConnectionDetail data={detail} onClose={onClose} />
@@ -37,7 +38,7 @@ export const ConnectionDetail = forwardRef<ConnectionDetailRef>(
}
/>
);
}
},
);
interface InnerProps {

View File

@@ -8,7 +8,6 @@ import {
import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { TrafficGraph, type TrafficRef } from "./traffic-graph";
import { useLogData } from "@/hooks/use-log-data";
import { useVisibility } from "@/hooks/use-visibility";
import parseTraffic from "@/utils/parse-traffic";
import useSWRSubscription from "swr/subscription";
@@ -39,11 +38,6 @@ export const LayoutTraffic = () => {
return () => {};
}, [isDebug]);
// https://swr.vercel.app/docs/subscription#deduplication
// useSWRSubscription auto deduplicates to one subscription per key per entire app
// So we can simply invoke it here acting as preconnect
useLogData();
const { data: traffic = { up: 0, down: 0 } } = useSWRSubscription<
ITrafficItem,
any,
@@ -65,7 +59,7 @@ export const LayoutTraffic = () => {
this.close();
next(event, { up: 0, down: 0 });
},
}
},
);
return () => {
@@ -75,7 +69,7 @@ export const LayoutTraffic = () => {
{
fallbackData: { up: 0, down: 0 },
keepPreviousData: true,
}
},
);
/* --------- meta memory information --------- */
@@ -102,7 +96,7 @@ export const LayoutTraffic = () => {
this.close();
next(event, { inuse: 0 });
},
}
},
);
return () => {
@@ -112,7 +106,7 @@ export const LayoutTraffic = () => {
{
fallbackData: { inuse: 0 },
keepPreviousData: true,
}
},
);
const [up, upUnit] = parseTraffic(traffic.up);

View File

@@ -0,0 +1,35 @@
import { IconButton, Fade } from "@mui/material";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
interface Props {
onClick: () => void;
show: boolean;
}
export const ScrollTopButton = ({ onClick, show }: Props) => {
return (
<Fade in={show}>
<IconButton
onClick={onClick}
sx={{
position: "absolute",
bottom: "20px",
right: "20px",
backgroundColor: (theme) =>
theme.palette.mode === "dark"
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.1)",
"&:hover": {
backgroundColor: (theme) =>
theme.palette.mode === "dark"
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
},
visibility: show ? "visible" : "hidden",
}}
>
<KeyboardArrowUpIcon />
</IconButton>
</Fade>
);
};

View File

@@ -4,6 +4,7 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useSetThemeMode, useThemeMode } from "@/services/states";
import { defaultTheme, defaultDarkTheme } from "@/pages/_theme";
import { useVerge } from "@/hooks/use-verge";
import { useTheme } from "@mui/material/styles";
const appWindow = getCurrentWebviewWindow();
/**
@@ -103,7 +104,7 @@ export const useCustomTheme = () => {
rootEle.style.setProperty("--primary-main", theme.palette.primary.main);
rootEle.style.setProperty(
"--background-color-alpha",
alpha(theme.palette.primary.main, 0.1)
alpha(theme.palette.primary.main, 0.1),
);
// inject css

View File

@@ -1,4 +1,5 @@
import { styled, Box } from "@mui/material";
import { SearchState } from "@/components/base/base-search-box";
const Item = styled(Box)(({ theme: { palette, typography } }) => ({
padding: "8px 0",
@@ -32,14 +33,54 @@ const Item = styled(Box)(({ theme: { palette, typography } }) => ({
color: palette.text.primary,
overflowWrap: "anywhere",
},
"& .highlight": {
backgroundColor: palette.mode === "dark" ? "#ffeb3b40" : "#ffeb3b90",
borderRadius: 2,
padding: "0 2px",
},
}));
interface Props {
value: ILogItem;
searchState?: SearchState;
}
const LogItem = (props: Props) => {
const { value } = props;
const LogItem = ({ value, searchState }: Props) => {
const renderHighlightText = (text: string) => {
if (!searchState?.text.trim()) return text;
try {
const searchText = searchState.text;
let pattern: string;
if (searchState.useRegularExpression) {
try {
new RegExp(searchText);
pattern = searchText;
} catch {
pattern = searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
} else {
const escaped = searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
pattern = searchState.matchWholeWord ? `\\b${escaped}\\b` : escaped;
}
const flags = searchState.matchCase ? "g" : "gi";
const parts = text.split(new RegExp(`(${pattern})`, flags));
return parts.map((part, index) => {
return index % 2 === 1 ? (
<span key={index} className="highlight">
{part}
</span>
) : (
part
);
});
} catch {
return text;
}
};
return (
<Item>
@@ -50,7 +91,7 @@ const LogItem = (props: Props) => {
</span>
</div>
<div>
<span className="data">{value.payload}</span>
<span className="data">{renderHighlightText(value.payload)}</span>
</div>
</Item>
);

View File

@@ -34,7 +34,7 @@ export const ProviderButton = () => {
const hasProvider = Object.keys(data || {}).length > 0;
const [updating, setUpdating] = useState(
Object.keys(data || {}).map(() => false)
Object.keys(data || {}).map(() => false),
);
const setUpdatingAt = (status: boolean, index: number) => {
@@ -107,7 +107,7 @@ export const ProviderButton = () => {
const expire = sub?.Expire || 0;
const progress = Math.min(
Math.round(((download + upload) * 100) / (total + 0.01)) + 1,
100
100,
);
return (
<>
@@ -190,7 +190,7 @@ export const ProviderButton = () => {
</>
);
};
const TypeBox = styled(Box)(({ theme }) => ({
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.secondary.main, 0.5),
@@ -202,17 +202,19 @@ const TypeBox = styled(Box)(({ theme }) => ({
lineHeight: 1.25,
}));
const StyledTypeBox = styled(Box)(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
marginRight: "4px",
padding: "0 2px",
lineHeight: 1.25,
}));
const StyledTypeBox = styled(Box)<{ component?: React.ElementType }>(
({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
marginRight: "4px",
padding: "0 2px",
lineHeight: 1.25,
}),
);
const boxStyle = {
height: 26,

View File

@@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef, useState, useEffect } from "react";
import { useLockFn } from "ahooks";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import {
@@ -15,6 +15,7 @@ import { useRenderList } from "./use-render-list";
import { ProxyRender } from "./proxy-render";
import delayManager from "@/services/delay";
import { useTranslation } from "react-i18next";
import { ScrollTopButton } from "../layout/scroll-top-button";
interface Props {
mode: string;
@@ -32,6 +33,22 @@ export const ProxyGroups = (props: Props) => {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [showScrollTop, setShowScrollTop] = useState(false);
// 添加滚动处理函数
const handleScroll = (e: any) => {
const scrollTop = e.target.scrollTop;
setShowScrollTop(scrollTop > 100);
};
// 滚动到顶部
const scrollToTop = () => {
virtuosoRef.current?.scrollTo?.({
top: 0,
behavior: "smooth",
});
};
// 切换分组的节点代理
const handleChangeProxy = useLockFn(
async (group: IProxyGroupItem, proxy: IProxyItem) => {
@@ -57,7 +74,7 @@ export const ProxyGroups = (props: Props) => {
if (!current.selected) current.selected = [];
const index = current.selected.findIndex(
(item) => item.name === group.name
(item) => item.name === group.name,
);
if (index < 0) {
@@ -66,14 +83,14 @@ export const ProxyGroups = (props: Props) => {
current.selected[index] = { name, now: proxy.name };
}
await patchCurrent({ selected: current.selected });
}
},
);
// 测全部延迟
const handleCheckAll = useLockFn(async (groupName: string) => {
const proxies = renderList
.filter(
(e) => e.group?.name === groupName && (e.type === 2 || e.type === 4)
(e) => e.group?.name === groupName && (e.type === 2 || e.type === 4),
)
.flatMap((e) => e.proxyCol || e.proxy!)
.filter(Boolean);
@@ -82,7 +99,7 @@ export const ProxyGroups = (props: Props) => {
if (providers.size) {
Promise.allSettled(
[...providers].map((p) => providerHealthCheck(p))
[...providers].map((p) => providerHealthCheck(p)),
).then(() => onProxies());
}
@@ -105,7 +122,7 @@ export const ProxyGroups = (props: Props) => {
(e) =>
e.group?.name === name &&
((e.type === 2 && e.proxy?.name === now) ||
(e.type === 4 && e.proxyCol?.some((p) => p.name === now)))
(e.type === 4 && e.proxyCol?.some((p) => p.name === now))),
);
if (index >= 0) {
@@ -122,22 +139,33 @@ export const ProxyGroups = (props: Props) => {
}
return (
<Virtuoso
ref={virtuosoRef}
style={{ height: "calc(100% - 16px)" }}
totalCount={renderList.length}
increaseViewportBy={256}
itemContent={(index) => (
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
)}
/>
<div style={{ position: "relative", height: "100%" }}>
<Virtuoso
ref={virtuosoRef}
style={{ height: "calc(100% - 16px)" }}
totalCount={renderList.length}
increaseViewportBy={256}
scrollerRef={(ref) => {
if (ref) {
ref.addEventListener("scroll", handleScroll);
}
}}
itemContent={(index) => (
<>
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
</>
)}
/>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</div>
);
};

View File

@@ -160,6 +160,16 @@ export const ProxyItemMini = (props: Props) => {
TFO
</TypeBox>
)}
{proxy.mptcp && (
<TypeBox color="text.secondary" component="span">
MPTCP
</TypeBox>
)}
{proxy.smux && (
<TypeBox color="text.secondary" component="span">
SMUX
</TypeBox>
)}
</Box>
)}
</Box>
@@ -239,7 +249,9 @@ const Widget = styled(Box)(({ theme: { typography } }) => ({
borderRadius: "4px",
}));
const TypeBox = styled(Box)(({ theme: { palette, typography } }) => ({
const TypeBox = styled(Box, {
shouldForwardProp: (prop) => prop !== "component",
})<{ component?: React.ElementType }>(({ theme: { palette, typography } }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: "text.secondary",

View File

@@ -31,7 +31,7 @@ const Widget = styled(Box)(() => ({
borderRadius: "4px",
}));
const TypeBox = styled(Box)(({ theme }) => ({
const TypeBox = styled("span")(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.text.secondary, 0.36),
@@ -121,14 +121,14 @@ export const ProxyItem = (props: Props) => {
{showType && proxy.now && ` - ${proxy.now}`}
</Box>
{showType && !!proxy.provider && (
<TypeBox component="span">{proxy.provider}</TypeBox>
<TypeBox>{proxy.provider}</TypeBox>
)}
{showType && <TypeBox component="span">{proxy.type}</TypeBox>}
{showType && proxy.udp && <TypeBox component="span">UDP</TypeBox>}
{showType && proxy.xudp && (
<TypeBox component="span">XUDP</TypeBox>
)}
{showType && proxy.tfo && <TypeBox component="span">TFO</TypeBox>}
{showType && <TypeBox>{proxy.type}</TypeBox>}
{showType && proxy.udp && <TypeBox>UDP</TypeBox>}
{showType && proxy.xudp && <TypeBox>XUDP</TypeBox>}
{showType && proxy.tfo && <TypeBox>TFO</TypeBox>}
{showType && proxy.mptcp && <TypeBox>MPTCP</TypeBox>}
{showType && proxy.smux && <TypeBox>SMUX</TypeBox>}
</>
}
/>

View File

@@ -59,7 +59,7 @@ export const ProxyRender = (props: RenderProps) => {
return url.substring(url.lastIndexOf("/") + 1);
}
if (type === 0 && !group.hidden) {
if (type === 0) {
return (
<ListItemButton
dense
@@ -125,7 +125,7 @@ export const ProxyRender = (props: RenderProps) => {
);
}
if (type === 1 && !group.hidden) {
if (type === 1) {
return (
<ProxyHead
sx={{ pl: 2, pr: 3, mt: indent ? 1 : 0.5, mb: 1 }}
@@ -139,7 +139,7 @@ export const ProxyRender = (props: RenderProps) => {
);
}
if (type === 2 && !group.hidden) {
if (type === 2) {
return (
<ProxyItem
group={group}
@@ -152,7 +152,7 @@ export const ProxyRender = (props: RenderProps) => {
);
}
if (type === 3 && !group.hidden) {
if (type === 3) {
return (
<Box
sx={{
@@ -170,7 +170,7 @@ export const ProxyRender = (props: RenderProps) => {
);
}
if (type === 4 && !group.hidden) {
if (type === 4) {
const proxyColItemsMemo = useMemo(() => {
return proxyCol?.map((proxy) => (
<ProxyItemMini

View File

@@ -25,7 +25,7 @@ export const useRenderList = (mode: string) => {
const { data: proxiesData, mutate: mutateProxies } = useSWR(
"getProxies",
getProxies,
{ refreshInterval: 45000 }
{ refreshInterval: 45000 },
);
const { verge } = useVerge();
@@ -78,7 +78,7 @@ export const useRenderList = (mode: string) => {
group.all,
group.name,
headState.filterText,
headState.sortType
headState.sortType,
);
ret.push({ type: 1, key: `head-${group.name}`, group, headState });
@@ -97,7 +97,7 @@ export const useRenderList = (mode: string) => {
headState,
col,
proxyCol,
}))
})),
);
}
@@ -108,14 +108,14 @@ export const useRenderList = (mode: string) => {
group,
proxy,
headState,
}))
})),
);
}
return ret;
});
if (!useRule) return retList.slice(1);
return retList;
return retList.filter((item) => item.group.hidden === false);
}, [headStates, proxiesData, mode, col]);
return {

View File

@@ -32,7 +32,7 @@ export const ProviderButton = () => {
const hasProvider = Object.keys(data || {}).length > 0;
const [updating, setUpdating] = useState(
Object.keys(data || {}).map(() => false)
Object.keys(data || {}).map(() => false),
);
const setUpdatingAt = (status: boolean, index: number) => {
@@ -162,7 +162,9 @@ export const ProviderButton = () => {
</>
);
};
const TypeBox = styled(Box)(({ theme }) => ({
const TypeBox = styled(Box, {
shouldForwardProp: (prop) => prop !== "component",
})<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.secondary.main, 0.5),
@@ -174,7 +176,9 @@ const TypeBox = styled(Box)(({ theme }) => ({
lineHeight: 1.25,
}));
const StyledTypeBox = styled(Box)(({ theme }) => ({
const StyledTypeBox = styled(Box, {
shouldForwardProp: (prop) => prop !== "component",
})<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.primary.main, 0.5),

View File

@@ -0,0 +1,245 @@
import { useState, useRef, memo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { useVerge } from "@/hooks/use-verge";
import { Notice } from "@/components/base";
import { isValidUrl } from "@/utils/helper";
import { useLockFn } from "ahooks";
import {
TextField,
Button,
Grid2,
Box,
Stack,
IconButton,
InputAdornment,
} from "@mui/material";
import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import { saveWebdavConfig, createWebdavBackup } from "@/services/cmds";
export interface BackupConfigViewerProps {
onBackupSuccess: () => Promise<void>;
onSaveSuccess: () => Promise<void>;
onRefresh: () => Promise<void>;
onInit: () => Promise<void>;
setLoading: (loading: boolean) => void;
}
export const BackupConfigViewer = memo(
({
onBackupSuccess,
onSaveSuccess,
onRefresh,
onInit,
setLoading,
}: BackupConfigViewerProps) => {
const { t } = useTranslation();
const { verge } = useVerge();
const { webdav_url, webdav_username, webdav_password } = verge || {};
const [showPassword, setShowPassword] = useState(false);
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const urlRef = useRef<HTMLInputElement>(null);
const { register, handleSubmit, watch } = useForm<IWebDavConfig>({
defaultValues: {
url: webdav_url,
username: webdav_username,
password: webdav_password,
},
});
const url = watch("url");
const username = watch("username");
const password = watch("password");
const webdavChanged =
webdav_url !== url ||
webdav_username !== username ||
webdav_password !== password;
console.log(
"webdavChanged",
webdavChanged,
webdav_url,
webdav_username,
webdav_password,
);
const handleClickShowPassword = () => {
setShowPassword((prev) => !prev);
};
useEffect(() => {
if (webdav_url && webdav_username && webdav_password) {
onInit();
}
}, []);
const checkForm = () => {
const username = usernameRef.current?.value;
const password = passwordRef.current?.value;
const url = urlRef.current?.value;
if (!url) {
urlRef.current?.focus();
Notice.error(t("WebDAV URL Required"));
throw new Error(t("WebDAV URL Required"));
} else if (!isValidUrl(url)) {
urlRef.current?.focus();
Notice.error(t("Invalid WebDAV URL"));
throw new Error(t("Invalid WebDAV URL"));
}
if (!username) {
usernameRef.current?.focus();
Notice.error(t("WebDAV URL Required"));
throw new Error(t("Username Required"));
}
if (!password) {
passwordRef.current?.focus();
Notice.error(t("WebDAV URL Required"));
throw new Error(t("Password Required"));
}
};
const save = useLockFn(async (data: IWebDavConfig) => {
checkForm();
try {
setLoading(true);
await saveWebdavConfig(
data.url.trim(),
data.username.trim(),
data.password,
).then(() => {
Notice.success(t("WebDAV Config Saved"));
onSaveSuccess();
});
} catch (error) {
Notice.error(t("WebDAV Config Save Failed", { error }), 3000);
} finally {
setLoading(false);
}
});
const handleBackup = useLockFn(async () => {
checkForm();
try {
setLoading(true);
await createWebdavBackup().then(async () => {
await onBackupSuccess();
Notice.success(t("Backup Created"));
});
} catch (error) {
Notice.error(t("Backup Failed", { error }));
} finally {
setLoading(false);
}
});
return (
<form onSubmit={(e) => e.preventDefault()}>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12, sm: 9 }}>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12 }}>
<TextField
fullWidth
label={t("WebDAV Server URL")}
variant="outlined"
size="small"
{...register("url")}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={urlRef}
/>
</Grid2>
<Grid2 size={{ xs: 6 }}>
<TextField
label={t("Username")}
variant="outlined"
size="small"
{...register("username")}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={usernameRef}
/>
</Grid2>
<Grid2 size={{ xs: 6 }}>
<TextField
label={t("Password")}
type={showPassword ? "text" : "password"}
variant="outlined"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={passwordRef}
{...register("password")}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
/>
</Grid2>
</Grid2>
</Grid2>
<Grid2 size={{ xs: 12, sm: 3 }}>
<Stack
direction="column"
justifyContent="space-between"
alignItems="stretch"
sx={{ height: "100%" }}
>
{webdavChanged ||
webdav_url === undefined ||
webdav_username === undefined ||
webdav_password === undefined ? (
<Button
variant="contained"
color={"primary"}
sx={{ height: "100%" }}
type="button"
onClick={handleSubmit(save)}
>
{t("Save")}
</Button>
) : (
<>
<Button
variant="contained"
color="success"
onClick={handleBackup}
type="button"
size="large"
>
{t("Backup")}
</Button>
<Button
variant="outlined"
onClick={onRefresh}
type="button"
size="large"
>
{t("Refresh")}
</Button>
</>
)}
</Stack>
</Grid2>
</Grid2>
</form>
);
},
);

View File

@@ -0,0 +1,266 @@
import { SVGProps, memo } from "react";
import {
Box,
Paper,
IconButton,
Divider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
} from "@mui/material";
import { Notice } from "@/components/base";
import { Typography } from "@mui/material";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { Dayjs } from "dayjs";
import {
deleteWebdavBackup,
restoreWebDavBackup,
restartApp,
} from "@/services/cmds";
import DeleteIcon from "@mui/icons-material/Delete";
import RestoreIcon from "@mui/icons-material/Restore";
export type BackupFile = IWebDavFile & {
platform: string;
backup_time: Dayjs;
allow_apply: boolean;
};
export const DEFAULT_ROWS_PER_PAGE = 5;
export interface BackupTableViewerProps {
datasource: BackupFile[];
page: number;
onPageChange: (
event: React.MouseEvent<HTMLButtonElement> | null,
page: number,
) => void;
total: number;
onRefresh: () => Promise<void>;
}
export const BackupTableViewer = memo(
({
datasource,
page,
onPageChange,
total,
onRefresh,
}: BackupTableViewerProps) => {
const { t } = useTranslation();
const handleDelete = useLockFn(async (filename: string) => {
await deleteWebdavBackup(filename);
await onRefresh();
});
const handleRestore = useLockFn(async (filename: string) => {
await restoreWebDavBackup(filename).then(() => {
Notice.success(t("Restore Success, App will restart in 1s"));
});
await restartApp();
});
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Filename")}</TableCell>
<TableCell>{t("Backup Time")}</TableCell>
<TableCell align="right">{t("Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{datasource.length > 0 ? (
datasource?.map((file, index) => (
<TableRow key={index}>
<TableCell component="th" scope="row">
{file.platform === "windows" ? (
<WindowsIcon className="h-full w-full" />
) : file.platform === "linux" ? (
<LinuxIcon className="h-full w-full" />
) : (
<MacIcon className="h-full w-full" />
)}
{file.filename}
</TableCell>
<TableCell align="center">
{file.backup_time.fromNow()}
</TableCell>
<TableCell align="right">
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
>
<IconButton
color="secondary"
aria-label={t("Delete")}
size="small"
title={t("Delete Backup")}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
const confirmed = await window.confirm(
t("Confirm to delete this backup file?"),
);
if (confirmed) {
await handleDelete(file.filename);
}
}}
>
<DeleteIcon />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{ mx: 1, height: 24 }}
/>
<IconButton
color="primary"
aria-label={t("Restore")}
size="small"
title={t("Restore Backup")}
disabled={!file.allow_apply}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
const confirmed = await window.confirm(
t("Confirm to restore this backup file?"),
);
if (confirmed) {
await handleRestore(file.filename);
}
}}
>
<RestoreIcon />
</IconButton>
</Box>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} align="center">
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: 150,
}}
>
<Typography
variant="body1"
color="textSecondary"
align="center"
>
{t("No Backups")}
</Typography>
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[]}
component="div"
count={total}
rowsPerPage={DEFAULT_ROWS_PER_PAGE}
page={page}
onPageChange={onPageChange}
labelRowsPerPage={t("Rows per page")}
/>
</TableContainer>
);
},
);
function LinuxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 48 48"
{...props}
>
<path
fill="#ECEFF1"
d="m20.1 16.2l.1 2.3l-1.6 3l-2.5 4.9l-.5 4.1l1.8 5.8l4.1 2.3h6.2l5.8-4.4l2.6-6.9l-6-7.3l-1.7-4.1z"
/>
<path
fill="#263238"
d="M34.3 21.9c-1.6-2.3-2.9-3.7-3.6-6.6s.2-2.1-.4-4.6c-.3-1.3-.8-2.2-1.3-2.9c-.6-.7-1.3-1.1-1.7-1.2c-.9-.5-3-1.3-5.6.1c-2.7 1.4-2.4 4.4-1.9 10.5c0 .4-.1.9-.3 1.3c-.4.9-1.1 1.7-1.7 2.4c-.7 1-1.4 2-1.9 3.1c-1.2 2.3-2.3 5.2-2 6.3c.5-.1 6.8 9.5 6.8 9.7c.4-.1 2.1-.1 3.6-.1c2.1-.1 3.3-.2 5 .2c0-.3-.1-.6-.1-.9c0-.6.1-1.1.2-1.8c.1-.5.2-1 .3-1.6c-1 .9-2.8 1.9-4.5 2.2c-1.5.3-4-.2-5.2-1.7c.1 0 .3 0 .4-.1c.3-.1.6-.2.7-.4c.3-.5.1-1-.1-1.3s-1.7-1.4-2.4-2s-1.1-.9-1.5-1.3l-.8-.8c-.2-.2-.3-.4-.4-.5c-.2-.5-.3-1.1-.2-1.9c.1-1.1.5-2 1-3c.2-.4.7-1.2.7-1.2s-1.7 4.2-.8 5.5c0 0 .1-1.3.5-2.6c.3-.9.8-2.2 1.4-2.9s2.1-3.3 2.2-4.9c0-.7.1-1.4.1-1.9c-.4-.4 6.6-1.4 7-.3c.1.4 1.5 4 2.3 5.9c.4.9.9 1.7 1.2 2.7c.3 1.1.5 2.6.5 4.1c0 .3 0 .8-.1 1.3c.2 0 4.1-4.2-.5-7.7c0 0 2.8 1.3 2.9 3.9c.1 2.1-.8 3.8-1 4.1c.1 0 2.1.9 2.2.9c.4 0 1.2-.3 1.2-.3c.1-.3.4-1.1.4-1.4c.7-2.3-1-6-2.6-8.3"
/>
<g fill="#ECEFF1" transform="translate(0 -2)">
<ellipse cx="21.6" cy="15.3" rx="1.3" ry="2" />
<ellipse cx="26.1" cy="15.2" rx="1.7" ry="2.3" />
</g>
<g fill="#212121" transform="translate(0 -2)">
<ellipse
cx="21.7"
cy="15.5"
rx="1.2"
ry=".7"
transform="rotate(-97.204 21.677 15.542)"
/>
<ellipse cx="26" cy="15.6" rx="1" ry="1.3" />
</g>
<path
fill="#FFC107"
d="M39.3 35.6c-.4-.2-1.1-.5-1.7-1.4c-.3-.5-.2-1.9-.7-2.5c-.3-.4-.7-.2-.8-.2c-.9.2-3 1.6-4.4 0c-.2-.2-.5-.5-1-.5s-.7.2-.9.6s-.2.7-.2 1.7c0 .8 0 1.7-.1 2.4c-.2 1.7-.5 2.7-.5 3.7c0 1.1.3 1.8.7 2.1c.3.3.8.5 1.9.5s1.8-.4 2.5-1.1c.5-.5.9-.7 2.3-1.7c1.1-.7 2.8-1.6 3.1-1.9c.2-.2.5-.3.5-.9c0-.5-.4-.7-.7-.8m-20.1.3c-1-1.6-1.1-1.9-1.8-2.9c-.6-1-1.9-2.9-2.7-2.9c-.6 0-.9.3-1.3.7s-.8 1.3-1.5 1.8c-.6.5-2.3.4-2.7 1s.4 1.5.4 3c0 .6-.5 1-.6 1.4c-.1.5-.2.8 0 1.2c.4.6.9.8 4.3 1.5c1.8.4 3.5 1.4 4.6 1.5s3 0 3-2.7c.1-1.6-.8-2-1.7-3.6m1.9-18.1c-.6-.4-1.1-.8-1.1-1.4s.4-.8 1-1.3c.1-.1 1.2-1.1 2.3-1.1s2.4.7 2.9.9c.9.2 1.8.4 1.7 1.1c-.1 1-.2 1.2-1.2 1.7c-.7.2-2 1.3-2.9 1.3c-.4 0-1 0-1.4-.1c-.3-.1-.8-.6-1.3-1.1"
/>
<path
fill="#634703"
d="M20.9 17c.2.2.5.4.8.5c.2.1.5.2.5.2h.9c.5 0 1.2-.2 1.9-.6c.7-.3.8-.5 1.3-.7c.5-.3 1-.6.8-.7s-.4 0-1.1.4c-.6.4-1.1.6-1.7.9c-.3.1-.7.3-1 .3h-.9c-.3 0-.5-.1-.8-.2c-.2-.1-.3-.2-.4-.2c-.2-.1-.6-.5-.8-.6c0 0-.2 0-.1.1zm3-2.2c.1.2.3.2.4.3s.2.1.2.1c.1-.1 0-.3-.1-.3c0-.2-.5-.2-.5-.1m-1.6.2c0 .1.2.2.2.1c.1-.1.2-.2.3-.2c.2-.1.1-.2-.2-.2c-.2.1-.2.2-.3.3"
/>
<path
fill="#455A64"
d="M32 32.7v.3c.2.4.7.5 1.1.5c.6 0 1.2-.4 1.5-.8c0-.1.1-.2.2-.3c.2-.3.3-.5.4-.6c0 0-.1-.1-.1-.2c-.1-.2-.4-.4-.8-.5c-.3-.1-.8-.2-1-.2c-.9-.1-1.4.2-1.7.5c0 0 .1 0 .1.1c.2.2.3.4.3.7c.1.2 0 .3 0 .5"
/>
</svg>
);
}
function WindowsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 16 16"
{...props}
>
<path
fill="#0284c7"
d="M6.555 1.375L0 2.237v5.45h6.555zM0 13.795l6.555.933V8.313H0zm7.278-5.4l.026 6.378L16 16V8.395zM16 0L7.33 1.244v6.414H16z"
/>
</svg>
);
}
function MacIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 26 26"
{...props}
>
<path
fill="#000"
d="M23.934 18.947c-.598 1.324-.884 1.916-1.652 3.086c-1.073 1.634-2.588 3.673-4.461 3.687c-1.666.014-2.096-1.087-4.357-1.069c-2.261.011-2.732 1.089-4.4 1.072c-1.873-.017-3.307-1.854-4.381-3.485c-3.003-4.575-3.32-9.937-1.464-12.79C4.532 7.425 6.61 6.237 8.561 6.237c1.987 0 3.236 1.092 4.879 1.092c1.594 0 2.565-1.095 4.863-1.095c1.738 0 3.576.947 4.889 2.581c-4.296 2.354-3.598 8.49.742 10.132M16.559 4.408c.836-1.073 1.47-2.587 1.24-4.131c-1.364.093-2.959.964-3.891 2.092c-.844 1.027-1.544 2.553-1.271 4.029c1.488.048 3.028-.839 3.922-1.99"
/>
</svg>
);
}

View File

@@ -0,0 +1,144 @@
import {
forwardRef,
useImperativeHandle,
useState,
useCallback,
useEffect,
} from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef } from "@/components/base";
import getSystem from "@/utils/get-system";
import { BaseLoadingOverlay } from "@/components/base";
import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import {
BackupTableViewer,
BackupFile,
DEFAULT_ROWS_PER_PAGE,
} from "./backup-table-viewer";
import { BackupConfigViewer } from "./backup-config-viewer";
import { Box, Paper, Divider } from "@mui/material";
import { listWebDavBackup } from "@/services/cmds";
dayjs.extend(customParseFormat);
const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss";
const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);
const [dataSource, setDataSource] = useState<BackupFile[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const OS = getSystem();
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
},
close: () => setOpen(false),
}));
// Handle page change
const handleChangePage = useCallback(
(_: React.MouseEvent<HTMLButtonElement> | null, page: number) => {
setPage(page);
},
[],
);
const fetchAndSetBackupFiles = async () => {
try {
setIsLoading(true);
const files = await getAllBackupFiles();
setBackupFiles(files);
setTotal(files.length);
} catch (error) {
setBackupFiles([]);
setTotal(0);
console.error(error);
// Notice.error(t("Failed to fetch backup files"));
} finally {
setIsLoading(false);
}
};
const getAllBackupFiles = async () => {
const files = await listWebDavBackup();
return files
.map((file) => {
const platform = file.filename.split("-")[0];
const fileBackupTimeStr = file.filename.match(FILENAME_PATTERN)!;
if (fileBackupTimeStr === null) {
return null;
}
const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT);
const allowApply = OS === platform;
return {
...file,
platform,
backup_time: backupTime,
allow_apply: allowApply,
} as BackupFile;
})
.filter((item) => item !== null)
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
};
useEffect(() => {
setDataSource(
backupFiles.slice(
page * DEFAULT_ROWS_PER_PAGE,
page * DEFAULT_ROWS_PER_PAGE + DEFAULT_ROWS_PER_PAGE,
),
);
}, [page, backupFiles]);
return (
<BaseDialog
open={open}
title={t("Backup Setting")}
contentSx={{ width: 600, maxHeight: 800 }}
okBtn={t("")}
cancelBtn={t("Close")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
disableOk
>
<Box sx={{ maxWidth: 800 }}>
<BaseLoadingOverlay isLoading={isLoading} />
<Paper elevation={2} sx={{ padding: 2 }}>
<BackupConfigViewer
setLoading={setIsLoading}
onBackupSuccess={async () => {
fetchAndSetBackupFiles();
}}
onSaveSuccess={async () => {
fetchAndSetBackupFiles();
}}
onRefresh={async () => {
fetchAndSetBackupFiles();
}}
onInit={async () => {
fetchAndSetBackupFiles();
}}
/>
<Divider sx={{ marginY: 2 }} />
<BackupTableViewer
datasource={dataSource}
page={page}
onPageChange={handleChangePage}
total={total}
onRefresh={fetchAndSetBackupFiles}
/>
</Paper>
</Box>
</BaseDialog>
);
});

View File

@@ -17,7 +17,7 @@ import {
ListItemButton,
ListItemText,
} from "@mui/material";
import { changeClashCore, restartSidecar } from "@/services/cmds";
import { changeClashCore, restartCore } from "@/services/cmds";
import { closeAllConnections, upgradeCore } from "@/services/api";
const VALID_CORE = [
@@ -59,7 +59,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
const onRestart = useLockFn(async () => {
try {
await restartSidecar();
await restartCore();
Notice.success(t(`Clash Core Restarted`), 1000);
} catch (err: any) {
Notice.error(err?.message || err.toString());

View File

@@ -41,7 +41,6 @@ export function GuardState<T>(props: Props<T>) {
childProps[onChangeProps] = async (...args: any[]) => {
// 多次操作无效
if (lockRef.current) return;
lockRef.current = true;
try {

View File

@@ -196,8 +196,8 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
},
],
});
if (selected?.path.length) {
await copyIconFile(`${selected.path}`, "common");
if (selected) {
await copyIconFile(`${selected}`, "common");
await initIconPath();
onChangeData({ common_tray_icon: true });
patchVerge({ common_tray_icon: true });
@@ -242,8 +242,8 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
},
],
});
if (selected?.path.length) {
await copyIconFile(`${selected.path}`, "sysproxy");
if (selected) {
await copyIconFile(`${selected}`, "sysproxy");
await initIconPath();
onChangeData({ sysproxy_tray_icon: true });
patchVerge({ sysproxy_tray_icon: true });
@@ -281,13 +281,13 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
multiple: false,
filters: [
{
name: "Tray Icon Image",
name: "Tun Icon Image",
extensions: ["png", "ico"],
},
],
});
if (selected?.path.length) {
await copyIconFile(`${selected.path}`, "tun");
if (selected) {
await copyIconFile(`${selected}`, "tun");
await initIconPath();
onChangeData({ tun_tray_icon: true });
patchVerge({ tun_tray_icon: true });

View File

@@ -205,7 +205,7 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
spellCheck="false"
sx={{ width: 250, marginLeft: "auto" }}
value={values.defaultLatencyTest}
placeholder="http://1.1.1.1"
placeholder="http://cp.cloudflare.com/generate_204"
onChange={(e) =>
setValues((v) => ({ ...v, defaultLatencyTest: e.target.value }))
}

View File

@@ -1,134 +0,0 @@
import { KeyedMutator } from "swr";
import { useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { installService, uninstallService } from "@/services/cmds";
import { Notice } from "@/components/base";
import { LoadingButton } from "@mui/lab";
import { PasswordInput } from "./password-input";
import getSystem from "@/utils/get-system";
interface Props {
status: "active" | "installed" | "unknown" | "uninstall";
mutate: KeyedMutator<"active" | "installed" | "unknown" | "uninstall">;
patchVerge: (value: Partial<IVergeConfig>) => Promise<void>;
onChangeData: (patch: Partial<IVergeConfig>) => void;
}
export const ServiceSwitcher = (props: Props) => {
const { status, mutate, patchVerge, onChangeData } = props;
const isWindows = getSystem() === "windows";
const isActive = status === "active";
const isInstalled = status === "installed";
const isUninstall = status === "uninstall" || status === "unknown";
const { t } = useTranslation();
const [serviceLoading, setServiceLoading] = useState(false);
const [uninstallServiceLoaing, setUninstallServiceLoading] = useState(false);
const [openInstall, setOpenInstall] = useState(false);
const [openUninstall, setOpenUninstall] = useState(false);
async function install(passwd: string) {
try {
setOpenInstall(false);
await installService(passwd);
await mutate();
setTimeout(() => {
mutate();
}, 2000);
Notice.success(t("Service Installed Successfully"));
setServiceLoading(false);
} catch (err: any) {
await mutate();
setTimeout(() => {
mutate();
}, 2000);
Notice.error(err.message || err.toString());
setServiceLoading(false);
}
}
async function uninstall(passwd: string) {
try {
setOpenUninstall(false);
await uninstallService(passwd);
await mutate();
setTimeout(() => {
mutate();
}, 2000);
Notice.success(t("Service Uninstalled Successfully"));
setUninstallServiceLoading(false);
} catch (err: any) {
await mutate();
setTimeout(() => {
mutate();
}, 2000);
Notice.error(err.message || err.toString());
setUninstallServiceLoading(false);
}
}
const onInstallOrEnableService = useLockFn(async () => {
setServiceLoading(true);
if (isUninstall) {
// install service
if (isWindows) {
await install("");
} else {
setOpenInstall(true);
}
} else {
try {
// enable or disable service
await patchVerge({ enable_service_mode: !isActive });
onChangeData({ enable_service_mode: !isActive });
await mutate();
setTimeout(() => {
mutate();
}, 2000);
setServiceLoading(false);
} catch (err: any) {
await mutate();
Notice.error(err.message || err.toString());
setServiceLoading(false);
}
}
});
const onUninstallService = useLockFn(async () => {
setUninstallServiceLoading(true);
if (isWindows) {
await uninstall("");
} else {
setOpenUninstall(true);
}
});
return (
<>
{openInstall && <PasswordInput onConfirm={install} />}
{openUninstall && <PasswordInput onConfirm={uninstall} />}
<LoadingButton
size="small"
variant={isUninstall ? "outlined" : "contained"}
onClick={onInstallOrEnableService}
loading={serviceLoading}
>
{isActive ? t("Disable") : isInstalled ? t("Enable") : t("Install")}
</LoadingButton>
{isInstalled && (
<LoadingButton
size="small"
variant="outlined"
color="error"
sx={{ ml: 1 }}
onClick={onUninstallService}
loading={uninstallServiceLoaing}
>
{t("Uninstall")}
</LoadingButton>
)}
</>
);
};

View File

@@ -28,7 +28,7 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
setTheme({ ...theme_setting } || {});
setTheme({ ...theme_setting });
},
close: () => setOpen(false),
}));

View File

@@ -7,10 +7,11 @@ import { relaunch } from "@tauri-apps/plugin-process";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { BaseDialog, DialogRef, Notice } from "@/components/base";
import { useUpdateState, useSetUpdateState } from "@/services/states";
import { listen, Event, UnlistenFn } from "@tauri-apps/api/event";
import { Event, UnlistenFn } from "@tauri-apps/api/event";
import { portableFlag } from "@/pages/_layout";
import { open as openUrl } from "@tauri-apps/plugin-shell";
import ReactMarkdown from "react-markdown";
import { useListen } from "@/hooks/use-listen";
let eventListener: UnlistenFn | null = null;
@@ -21,6 +22,7 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
const updateState = useUpdateState();
const setUpdateState = useSetUpdateState();
const { addListener } = useListen();
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2,
@@ -66,7 +68,7 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
if (eventListener !== null) {
eventListener();
}
eventListener = await listen(
eventListener = await addListener(
"tauri://update-download-progress",
(e: Event<any>) => {
setTotal(e.payload.contentLength);
@@ -74,7 +76,7 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
setDownloaded((a) => {
return a + e.payload.chunkLength;
});
}
},
);
try {
await updateInfo.install();
@@ -98,7 +100,7 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
size="small"
onClick={() => {
openUrl(
`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`
`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`,
);
}}
>

View File

@@ -1,7 +1,6 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { TextField, Select, MenuItem, Typography } from "@mui/material";
import {
SettingsRounded,
ShuffleRounded,
@@ -34,7 +33,12 @@ const SettingClash = ({ onError }: Props) => {
const { clash, version, mutateClash, patchClash } = useClash();
const { verge, mutateVerge, patchVerge } = useVerge();
const { ipv6, "allow-lan": allowLan, "log-level": logLevel } = clash ?? {};
const {
ipv6,
"allow-lan": allowLan,
"log-level": logLevel,
"unified-delay": unifiedDelay,
} = clash ?? {};
const { enable_random_port = false, verge_mixed_port } = verge ?? {};
@@ -106,10 +110,36 @@ const SettingClash = ({ onError }: Props) => {
</GuardState>
</SettingItem>
<SettingItem label={t("Log Level")}>
<SettingItem
label={t("Unified Delay")}
extra={
<TooltipIcon
title={t("Unified Delay Info")}
sx={{ opacity: "0.7" }}
/>
}
>
<GuardState
value={unifiedDelay ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ "unified-delay": e })}
onGuard={(e) => patchClash({ "unified-delay": e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem
label={t("Log Level")}
extra={
<TooltipIcon title={t("Log Level Info")} sx={{ opacity: "0.7" }} />
}
>
<GuardState
// clash premium 2022.08.26 值为warn
value={logLevel === "warn" ? "warning" : logLevel ?? "info"}
value={logLevel === "warn" ? "warning" : (logLevel ?? "info")}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ "log-level": e })}
@@ -135,7 +165,7 @@ const SettingClash = ({ onError }: Props) => {
onClick={() => {
Notice.success(
t("Restart Application to Apply Modifications"),
1000
1000,
);
onChangeVerge({ enable_random_port: !enable_random_port });
patchVerge({ enable_random_port: !enable_random_port });

View File

@@ -2,12 +2,10 @@ import useSWR from "swr";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { SettingsRounded } from "@mui/icons-material";
import { checkService } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { DialogRef, Notice, Switch } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp";
import { GuardState } from "./mods/guard-state";
import { ServiceSwitcher } from "./mods/service-switcher";
import { SysproxyViewer } from "./mods/sysproxy-viewer";
import { TunViewer } from "./mods/tun-viewer";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
@@ -20,16 +18,6 @@ const SettingSystem = ({ onError }: Props) => {
const { t } = useTranslation();
const { verge, mutateVerge, patchVerge } = useVerge();
// service mode
const { data: serviceStatus, mutate: mutateServiceStatus } = useSWR(
"checkService",
checkService,
{
revalidateIfStale: false,
shouldRetryOnError: false,
focusThrottleInterval: 36e5, // 1 hour
}
);
const sysproxyRef = useRef<DialogRef>(null);
const tunRef = useRef<DialogRef>(null);
@@ -67,34 +55,15 @@ const SettingSystem = ({ onError }: Props) => {
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => {
if (serviceStatus !== "active") {
onChangeData({ enable_tun_mode: false });
} else {
onChangeData({ enable_tun_mode: e });
}
onChangeData({ enable_tun_mode: e });
}}
onGuard={(e) => {
if (serviceStatus !== "active" && e) {
Notice.error(t("Please Enable Service Mode"));
return Promise.resolve();
} else {
return patchVerge({ enable_tun_mode: e });
}
return patchVerge({ enable_tun_mode: e });
}}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem label={t("Service Mode")}>
<ServiceSwitcher
status={serviceStatus ?? "unknown"}
mutate={mutateServiceStatus}
patchVerge={patchVerge}
onChangeData={onChangeData}
/>
</SettingItem>
<SettingItem
label={t("System Proxy")}
extra={
@@ -134,7 +103,9 @@ const SettingSystem = ({ onError }: Props) => {
<SettingItem
label={t("Silent Start")}
extra={<TooltipIcon title={t("Silent Start Info")} />}
extra={
<TooltipIcon title={t("Silent Start Info")} sx={{ opacity: "0.7" }} />
}
>
<GuardState
value={enable_silent_start ?? false}

View File

@@ -23,6 +23,7 @@ import { ThemeViewer } from "./mods/theme-viewer";
import { GuardState } from "./mods/guard-state";
import { LayoutViewer } from "./mods/layout-viewer";
import { UpdateViewer } from "./mods/update-viewer";
import { BackupViewer } from "./mods/backup-viewer";
import getSystem from "@/utils/get-system";
import { routers } from "@/pages/_routers";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
@@ -52,6 +53,7 @@ const SettingVerge = ({ onError }: Props) => {
const themeRef = useRef<DialogRef>(null);
const layoutRef = useRef<DialogRef>(null);
const updateRef = useRef<DialogRef>(null);
const backupRef = useRef<DialogRef>(null);
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
@@ -83,6 +85,7 @@ const SettingVerge = ({ onError }: Props) => {
<MiscViewer ref={miscRef} />
<LayoutViewer ref={layoutRef} />
<UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} />
<SettingItem label={t("Language")}>
<GuardState
@@ -194,9 +197,9 @@ const SettingVerge = ({ onError }: Props) => {
},
],
});
if (selected?.path.length) {
onChangeData({ startup_script: `${selected.path}` });
patchVerge({ startup_script: `${selected.path}` });
if (selected) {
onChangeData({ startup_script: `${selected}` });
patchVerge({ startup_script: `${selected}` });
}
}}
>
@@ -238,12 +241,23 @@ const SettingVerge = ({ onError }: Props) => {
label={t("Hotkey Setting")}
/>
<SettingItem
onClick={() => backupRef.current?.open()}
label={t("Backup Setting")}
extra={
<TooltipIcon
title={t("Backup Setting Info")}
sx={{ opacity: "0.7" }}
/>
}
/>
<SettingItem
onClick={() => configRef.current?.open()}
label={t("Runtime Config")}
/>
<SettingItem onClick={openAppDir} label={t("Open App Dir")} />
<SettingItem onClick={openAppDir} label={t("Open Conf Dir")} />
<SettingItem onClick={openCoreDir} label={t("Open Core Dir")} />

View File

@@ -18,9 +18,9 @@ import { Notice } from "@/components/base";
import { TestBox } from "./test-box";
import delayManager from "@/services/delay";
import { cmdTestDelay, downloadIconCache } from "@/services/cmds";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { UnlistenFn } from "@tauri-apps/api/event";
import { convertFileSrc } from "@tauri-apps/api/core";
import { useListen } from "@/hooks/use-listen";
interface Props {
id: string;
itemData: IVergeTestItem;
@@ -47,6 +47,7 @@ export const TestItem = (props: Props) => {
const [delay, setDelay] = useState(-1);
const { uid, name, icon, url } = itemData;
const [iconCachePath, setIconCachePath] = useState("");
const { addListener } = useListen();
useEffect(() => {
initIconCachePath();
@@ -91,7 +92,7 @@ export const TestItem = (props: Props) => {
const listenTsetEvent = async () => {
eventListener();
eventListener = await listen("verge://test-all", () => {
eventListener = await addListener("verge://test-all", () => {
onDelay();
});
};

View File

@@ -67,6 +67,16 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
let newList;
let uid;
if (form.icon && form.icon.startsWith("<svg")) {
const doc = new DOMParser().parseFromString(
form.icon,
"image/svg+xml",
);
if (doc.querySelector("parsererror")) {
throw new Error("`Icon`svg format error");
}
}
if (openType === "new") {
uid = nanoid();
const item = { ...form, uid };
@@ -87,7 +97,7 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
Notice.error(err.message || err.toString());
setLoading(false);
}
})
}),
);
const handleClose = () => {

View File

@@ -1,6 +1,6 @@
import useSWR, { mutate } from "swr";
import { useLockFn } from "ahooks";
import { getAxios, getVersion, updateConfigs } from "@/services/api";
import { getAxios, getVersion } from "@/services/api";
import {
getClashInfo,
patchClashConfig,
@@ -10,16 +10,15 @@ import {
export const useClash = () => {
const { data: clash, mutate: mutateClash } = useSWR(
"getRuntimeConfig",
getRuntimeConfig
getRuntimeConfig,
);
const { data: versionData, mutate: mutateVersion } = useSWR(
"getVersion",
getVersion
getVersion,
);
const patchClash = useLockFn(async (patch: Partial<IConfigData>) => {
await updateConfigs(patch);
await patchClashConfig(patch);
mutateClash();
});
@@ -27,8 +26,8 @@ export const useClash = () => {
const version = versionData?.premium
? `${versionData.version} Premium`
: versionData?.meta
? `${versionData.version} Mihomo`
: versionData?.version || "-";
? `${versionData.version} Mihomo`
: versionData?.version || "-";
return {
clash,
@@ -42,7 +41,7 @@ export const useClash = () => {
export const useClashInfo = () => {
const { data: clashInfo, mutate: mutateInfo } = useSWR(
"getClashInfo",
getClashInfo
getClashInfo,
);
const patchInfo = async (
@@ -57,7 +56,7 @@ export const useClashInfo = () => {
| "external-controller"
| "secret"
>
>
>,
) => {
const hasInfo =
patch["redir-port"] != null ||

31
src/hooks/use-listen.ts Normal file
View File

@@ -0,0 +1,31 @@
import { listen, UnlistenFn, EventCallback } from "@tauri-apps/api/event";
import { event } from "@tauri-apps/api";
import { useRef } from "react";
export const useListen = () => {
const unlistenFns = useRef<UnlistenFn[]>([]);
const addListener = async <T>(
eventName: string,
handler: EventCallback<T>,
) => {
const unlisten = await listen(eventName, handler);
unlistenFns.current.push(unlisten);
return unlisten;
};
const removeAllListeners = () => {
unlistenFns.current.forEach((unlisten) => unlisten());
unlistenFns.current = [];
};
const setupCloseListener = async function () {
await event.once("tauri://close-requested", async () => {
removeAllListeners();
});
};
return {
addListener,
setupCloseListener,
};
};

View File

@@ -1,57 +1,105 @@
import useSWRSubscription from "swr/subscription";
import { useEffect } from "react";
import { useEnableLog } from "../services/states";
import { createSockette } from "../utils/websocket";
import { useClashInfo } from "./use-clash";
import dayjs from "dayjs";
import { getClashLogs } from "../services/cmds";
import { create } from "zustand";
const MAX_LOG_NUM = 1000;
export const useLogData = () => {
const { clashInfo } = useClashInfo();
export type LogLevel = "warning" | "info" | "debug" | "error" | "all";
const [enableLog] = useEnableLog();
!enableLog || !clashInfo;
interface ILogItem {
time?: string;
type: string;
payload: string;
[key: string]: any;
}
return useSWRSubscription<ILogItem[], any, "getClashLog" | null>(
enableLog && clashInfo ? "getClashLog" : null,
(_key, { next }) => {
const { server = "", secret = "" } = clashInfo!;
const buildWSUrl = (server: string, secret: string, logLevel: LogLevel) => {
const baseUrl = `ws://${server}/logs`;
const params = new URLSearchParams();
// populate the initial logs
getClashLogs().then(
(logs) => next(null, logs),
(err) => next(err)
);
const s = createSockette(
`ws://${server}/logs?token=${encodeURIComponent(secret)}`,
{
onmessage(event) {
const data = JSON.parse(event.data) as ILogItem;
// append new log item on socket message
next(null, (l = []) => {
const time = dayjs().format("MM-DD HH:mm:ss");
if (l.length >= MAX_LOG_NUM) l.shift();
return [...l, { ...data, time }];
});
},
onerror(event) {
this.close();
next(event);
},
}
);
return () => {
s.close();
};
},
{
fallbackData: [],
keepPreviousData: true,
}
);
if (secret) {
params.append("token", encodeURIComponent(secret));
}
if (logLevel === "all") {
params.append("level", "debug");
} else {
params.append("level", logLevel);
}
const queryString = params.toString();
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
};
interface LogStore {
logs: Record<LogLevel, ILogItem[]>;
clearLogs: (level?: LogLevel) => void;
appendLog: (level: LogLevel, log: ILogItem) => void;
}
const useLogStore = create<LogStore>(
(set: (fn: (state: LogStore) => Partial<LogStore>) => void) => ({
logs: {
warning: [],
info: [],
debug: [],
error: [],
all: [],
},
clearLogs: (level?: LogLevel) =>
set((state: LogStore) => ({
logs: level
? { ...state.logs, [level]: [] }
: { warning: [], info: [], debug: [], error: [], all: [] },
})),
appendLog: (level: LogLevel, log: ILogItem) =>
set((state: LogStore) => {
const currentLogs = state.logs[level];
const newLogs =
currentLogs.length >= MAX_LOG_NUM
? [...currentLogs.slice(1), log]
: [...currentLogs, log];
return { logs: { ...state.logs, [level]: newLogs } };
}),
}),
);
export const useLogData = (logLevel: LogLevel) => {
const { clashInfo } = useClashInfo();
const [enableLog] = useEnableLog();
const { logs, appendLog } = useLogStore();
useEffect(() => {
if (!enableLog || !clashInfo) return;
const { server = "", secret = "" } = clashInfo;
const wsUrl = buildWSUrl(server, secret, logLevel);
let isActive = true;
const socket = createSockette(wsUrl, {
onmessage(event) {
if (!isActive) return;
const data = JSON.parse(event.data) as ILogItem;
const time = dayjs().format("MM-DD HH:mm:ss");
appendLog(logLevel, { ...data, time });
},
onerror() {
if (!isActive) return;
socket.close();
},
});
return () => {
isActive = false;
socket.close();
};
}, [clashInfo, enableLog, logLevel]);
return logs[logLevel];
};
// 导出清空日志的方法
export const clearLogs = (level?: LogLevel) => {
useLogStore.getState().clearLogs(level);
};

View File

@@ -4,7 +4,10 @@ import { getVergeConfig, patchVergeConfig } from "@/services/cmds";
export const useVerge = () => {
const { data: verge, mutate: mutateVerge } = useSWR(
"getVergeConfig",
getVergeConfig
async () => {
const config = await getVergeConfig();
return config;
},
);
const patchVerge = async (value: Partial<IVergeConfig>) => {

View File

@@ -193,9 +193,9 @@
"Test URL": "Test URL",
"Settings": "Settings",
"System Setting": "System Setting",
"Tun Mode": "Tun Mode",
"Tun Mode": "Tun (Virtual NIC) Mode",
"Reset to Default": "Reset to Default",
"Tun Mode Info": "The Tun mode requires granting core-related permissions. Please enable service mode before using it",
"Tun Mode Info": "Tun (Virtual NIC) mode: Captures all system traffic, when enabled, there is no need to enable system proxy.",
"Stack": "Tun Stack",
"System and Mixed Can Only be Used in Service Mode": "System and Mixed Can Only be Used in Service Mode",
"Device": "Device Name",
@@ -205,7 +205,7 @@
"DNS Hijack": "DNS Hijack",
"MTU": "Max Transmission Unit",
"Service Mode": "Service Mode",
"Service Mode Info": "The core launched by the service can obtain corresponding permissions after installation and authorization of the service",
"Service Mode Info": "Please install the service mode before enabling TUN mode. The kernel process started by the service can obtain the permission to install the virtual network card (TUN mode)",
"Current State": "Current State",
"pending": "pending",
"installed": "installed",
@@ -246,6 +246,8 @@
"Ip Address": "IP Address",
"Mac Address": "MAC Address",
"IPv6": "IPv6",
"Unified Delay": "Unified Delay",
"Unified Delay Info": "When unified delay is turned on, two delay tests will be performed to eliminate the delay differences between different types of nodes caused by connection handshakes, etc",
"Log Level": "Log Level",
"Port Config": "Port Config",
"Random Port": "Random Port",
@@ -330,15 +332,16 @@
"clash_mode_direct": "Direct Mode",
"toggle_system_proxy": "Enable/Disable System Proxy",
"toggle_tun_mode": "Enable/Disable Tun Mode",
"Backup Setting": "Backup Setting",
"Runtime Config": "Runtime Config",
"Open App Dir": "Open App Dir",
"Open Conf Dir": "Open Conf Dir",
"Open Core Dir": "Open Core Dir",
"Open Logs Dir": "Open Logs Dir",
"Check for Updates": "Check for Updates",
"Go to Release Page": "Go to Release Page",
"Portable Updater Error": "The portable version does not support in-app updates. Please manually download and replace it",
"Break Change Update Error": "This version is a major update and does not support in-app updates. Please uninstall it and manually download and install the new version",
"Open Dev Tools": "Open Dev Tools",
"Open Dev Tools": "Dev Tools",
"Exit": "Exit",
"Verge Version": "Verge Version",
"ReadOnly": "ReadOnly",
@@ -367,5 +370,30 @@
"Clash Core Restarted": "Clash Core Restarted",
"Switched to _clash Core": "Switched to {{core}} Core",
"GeoData Updated": "GeoData Updated",
"Currently on the Latest Version": "Currently on the Latest Version"
"Currently on the Latest Version": "Currently on the Latest Version",
"Import Subscription Successful": "Import subscription successful",
"WebDAV Server URL": "WebDAV Server URL",
"Username": "Username",
"Password": "Password",
"Backup": "Backup",
"Filename": "Filename",
"Actions": "Actions",
"Restore": "Restore",
"No Backups": "No backups available",
"WebDAV URL Required": "WebDAV URL cannot be empty",
"Invalid WebDAV URL": "Invalid WebDAV URL format",
"Username Required": "Username cannot be empty",
"Password Required": "Password cannot be empty",
"Failed to Fetch Backups": "Failed to fetch backup files",
"WebDAV Config Saved": "WebDAV configuration saved successfully",
"WebDAV Config Save Failed": "Failed to save WebDAV configuration: {{error}}",
"Backup Created": "Backup created successfully",
"Backup Failed": "Backup failed: {{error}}",
"Delete Backup": "Delete Backup",
"Restore Backup": "Restore Backup",
"Backup Time": "Backup Time",
"Confirm to delete this backup file?": "Confirm to delete this backup file?",
"Confirm to restore this backup file?": "Confirm to restore this backup file?",
"Restore Success, App will restart in 1s": "Restore Success, App will restart in 1s",
"Failed to fetch backup files": "Failed to fetch backup files"
}

View File

@@ -65,6 +65,8 @@
"Append Rule": "اضافه کردن قانون به انتها",
"Prepend Group": "اضافه کردن گروه به ابتدا",
"Append Group": "اضافه کردن گروه به انتها",
"Prepend Proxy": "پیش‌افزودن پراکسی",
"Append Proxy": "پس‌افزودن پراکسی",
"Rule Condition Required": "شرط قانون الزامی است",
"Invalid Rule": "قانون نامعتبر",
"Advanced": "پیشرفته",
@@ -111,7 +113,7 @@
"select": "انتخاب پروکسی به صورت دستی",
"url-test": "انتخاب پروکسی بر اساس تأخیر آزمایش URL",
"fallback": "تعویض به پروکسی دیگر در صورت بروز خطا",
"load-balance": "توزیع پروکسی بر اساس توازن بار",
"load-balance": "توزیع <EFBFBD><EFBFBD>روکسی بر اساس توازن بار",
"relay": "عبور از زنجیره پروکسی تعریف شده",
"Group Name": "نام گروه",
"Use Proxies": "استفاده از پروکسی‌ها",
@@ -191,9 +193,9 @@
"Test URL": "آدرس آزمون",
"Settings": "تنظیمات",
"System Setting": "تنظیمات سیستم",
"Tun Mode": "حالت Tun",
"Tun Mode": "Tun (کارت شبکه مجازی)",
"Reset to Default": "بازنشانی به پیش‌فرض",
"Tun Mode Info": "حالت Tun نیاز به اعطای مجوزهای مربوط به هسته دارد. لطفاً قبل از استفاده، حالت سرویس را فعال کنید",
"Tun Mode Info": "حالت Tun (NIC مجازی): تمام ترافیک سیستم را ضبط می کند، وقتی فعال باشد، نیازی به فعال کردن پروکسی سیستم نیست.",
"Stack": "انباشته Tun",
"System and Mixed Can Only be Used in Service Mode": "سیستم و ترکیبی تنها می‌توانند در حالت سرویس استفاده شوند",
"Device": "نام دستگاه",
@@ -203,7 +205,7 @@
"DNS Hijack": "ربایش DNS",
"MTU": "واحد حداکثر انتقال",
"Service Mode": "حالت سرویس",
"Service Mode Info": "هسته راه اندازی شده توسط سرویس می تواند مجوزهای مربوطه را پس از نصب و مجوز سرویس دریافت کند",
"Service Mode Info": "لطفاً قبل از فعال کردن حالت TUN، حالت سرویس را نصب کنید.",
"Current State": "وضعیت فعلی",
"pending": "در انتظار",
"installed": "نصب شده",
@@ -235,13 +237,19 @@
"Auto Launch": "راه‌اندازی خودکار",
"Silent Start": "شروع بی‌صدا",
"Silent Start Info": "برنامه را در حالت پس‌زمینه بدون نمایش پانل اجرا کنید",
"TG Channel": "کانال تلگرام",
"Manual": "راهنما",
"Github Repo": "مخزن GitHub",
"Clash Setting": "تنظیمات Clash",
"Allow Lan": "اجازه LAN",
"Network Interface": "رابط شبکه",
"Ip Address": "آدرس IP",
"Mac Address": "آدرس MAC",
"IPv6": "IPv6",
"Unified Delay": "معادلDELAY",
"Unified Delay Info": "معادلDELAY را فعال کنید تا ترافیک شبکه به سرعت رسید",
"Log Level": "سطح لاگ",
"Log Level Info": "این فقط روی فایل‌های لاگ هسته تحت فایل سرویس در فهرست ورود اثر می‌گذارد.",
"Port Config": "پیکربندی پورت",
"Random Port": "پورت تصادفی",
"Mixed Port": "پورت پروکسی ترکیبی",
@@ -267,9 +275,6 @@
"Open UWP tool": "باز کردن ابزار UWP",
"Open UWP tool Info": "از ویندوز 8 به بعد، برنامه‌های UWP (مانند Microsoft Store) از دسترسی مستقیم به خدمات شبکه محلی محدود شده‌اند و این ابزار می‌تواند برای دور زدن این محدودیت استفاده شود",
"Update GeoData": "به‌روزرسانی GeoData",
"TG Channel": "کانال تلگرام",
"Manual": "راهنما",
"Github Repo": "مخزن GitHub",
"Verge Setting": "تنظیمات Verge",
"Language": "زبان",
"Theme Mode": "حالت تم",
@@ -328,8 +333,10 @@
"clash_mode_direct": "حالت مستقیم",
"toggle_system_proxy": "فعال/غیرفعال کردن پراکسی سیستم",
"toggle_tun_mode": "فعال/غیرفعال کردن حالت Tun",
"Backup Setting": "تنظیمات پشتیبان گیری",
"Backup Setting Info": "از فایل های پیکربندی پشتیبان WebDAV پشتیبانی می کند",
"Runtime Config": "پیکربندی زمان اجرا",
"Open App Dir": "باز کردن پوشه برنامه",
"Open Conf Dir": "باز کردن پوشه برنامه",
"Open Core Dir": "باز کردن پوشه هسته",
"Open Logs Dir": "باز کردن پوشه لاگ‌ها",
"Check for Updates": "بررسی برای به‌روزرسانی‌ها",
@@ -365,5 +372,30 @@
"Clash Core Restarted": "هسته Clash مجدداً راه‌اندازی شد",
"Switched to _clash Core": "تغییر به هسته {{core}}",
"GeoData Updated": "GeoData به‌روزرسانی شد",
"Currently on the Latest Version": "در حال حاضر در آخرین نسخه"
"Currently on the Latest Version": "در حال حاضر در آخرین نسخه",
"Import Subscription Successful": "وارد کردن اشتراک با موفقیت انجام شد",
"WebDAV Server URL": "http(s):// URL سرور WebDAV",
"Username": "نام کاربری",
"Password": "رمز عبور",
"Backup": "پشتیبان‌گیری",
"Filename": "نام فایل",
"Actions": "عملیات",
"Restore": "بازیابی",
"No Backups": "هیچ پشتیبانی موجود نیست",
"WebDAV URL Required": "آدرس WebDAV نمی‌تواند خالی باشد",
"Invalid WebDAV URL": "فرمت آدرس WebDAV نامعتبر است",
"Username Required": "نام کاربری نمی‌تواند خالی باشد",
"Password Required": "رمز عبور نمی‌تواند خالی باشد",
"Failed to Fetch Backups": "دریافت فایل‌های پشتیبان ناموفق بود",
"WebDAV Config Saved": "پیکربندی WebDAV با موفقیت ذخیره شد",
"WebDAV Config Save Failed": "خطا در ذخیره تنظیمات WebDAV: {{error}}",
"Backup Created": "پشتیبان‌گیری با موفقیت ایجاد شد",
"Backup Failed": "خطا در پشتیبان‌گیری: {{error}}",
"Delete Backup": "حذف پشتیبان",
"Restore Backup": "بازیابی پشتیبان",
"Backup Time": "زمان پشتیبان‌گیری",
"Confirm to delete this backup file?": "آیا از حذف این فایل پشتیبان اطمینان دارید؟",
"Confirm to restore this backup file?": "آیا از بازیابی این فایل پشتیبان اطمینان دارید؟",
"Restore Success, App will restart in 1s": "بازیابی با موفقیت انجام شد، برنامه در 1 ثانیه راه‌اندازی مجدد می‌شود",
"Failed to fetch backup files": "دریافت فایل‌های پشتیبان ناموفق بود"
}

View File

@@ -65,6 +65,8 @@
"Append Rule": "Добавить правило в конец",
"Prepend Group": "Добавить группу в начало",
"Append Group": "Добавить группу в конец",
"Prepend Proxy": "Добавить прокси в начало",
"Append Proxy": "Добавить прокси в конец",
"Rule Condition Required": "Требуется условие правила",
"Invalid Rule": "Недействительное правило",
"Advanced": "Дополнительно",
@@ -191,9 +193,9 @@
"Test URL": "Тестовый URL",
"Settings": "Настройки",
"System Setting": "Настройки системы",
"Tun Mode": "Режим туннеля",
"Tun Mode": "Tun (виртуальный сетевой адаптер) режим",
"Reset to Default": "Сбросить настройки по умолчанию",
"Tun Mode Info": "Режим туннеля требует предоставления разрешений, связанных с ядрам. Пожалуйста, включите сервисный режим перед его использованием",
"Tun Mode Info": "Режим Tun (виртуальный сетевой адаптер): захватывает весь системный трафик, при включении нет необходимости включать системный прокси-сервер.",
"Stack": "Стек",
"System and Mixed Can Only be Used in Service Mode": "Система и смешанные могут использоваться только в сервисном режиме",
"Device": "Имя устройства",
@@ -203,7 +205,7 @@
"DNS Hijack": "DNS-перехват",
"MTU": "Максимальная единица передачи",
"Service Mode": "Режим сервиса",
"Service Mode Info": "Ядро, запущенное сервисом, может получить соответствующие разрешения после установки и авторизации сервиса",
"Service Mode Info": "Установите сервисный режим перед включением режима TUN. Процесс ядра, запущенный службой, может получить разрешение на установку виртуальной сетевой карты (режим TUN).",
"Current State": "Текущее состояние",
"pending": "Ожидающий",
"installed": "Установленный",
@@ -244,7 +246,10 @@
"Ip Address": "IP адрес",
"Mac Address": "MAC адрес",
"IPv6": "IPv6",
"Unified Delay": "Общий задержка",
"Unified Delay Info": "Когда унифицированная задержка включена, будут выполнены два теста задержки, чтобы устранить различия в задержке между разными типами узлов, вызванные подтверждением соединения и т. д",
"Log Level": "Уровень логов",
"Log Level Info": "Это действует только на файлы журнала ядра в служебном файле в каталоге журналов.",
"Port Config": "Настройка порта",
"Random Port": "Случайный порт",
"Mixed Port": "Смешанный прокси-порт",
@@ -328,8 +333,10 @@
"clash_mode_direct": "Прямой режим",
"toggle_system_proxy": "Включить/Отключить системный прокси",
"toggle_tun_mode": "Включить/Отключить режим туннеля",
"Backup Setting": "Настройки резервного копирования",
"Backup Setting Info": "Поддерживает файлы конфигурации резервного копирования WebDAV",
"Runtime Config": "Используемый конфиг",
"Open App Dir": "Открыть папку приложения",
"Open Conf Dir": "Открыть папку приложения",
"Open Core Dir": "Открыть папку ядра",
"Open Logs Dir": "Открыть папку логов",
"Check for Updates": "Проверить обновления",
@@ -365,5 +372,30 @@
"Clash Core Restarted": "Clash ядра перезапущено",
"Switched to _clash Core": "Переключено на ядра {{core}}",
"GeoData Updated": "GeoData Обновлена",
"Currently on the Latest Version": "В настоящее время используется последняя версия"
"Currently on the Latest Version": "В настоящее время используется последняя версия",
"Import subscription successful": "Импорт подписки успешно",
"WebDAV Server URL": "URL-адрес сервера WebDAV http(s)://",
"Username": "Имя пользователя",
"Password": "Пароль",
"Backup": "Резервное копирование",
"Filename": "Имя файла",
"Actions": "Действия",
"Restore": "Восстановить",
"No Backups": "Нет доступных резервных копий",
"WebDAV URL Required": "URL-адрес WebDAV не может быть пустым",
"Invalid WebDAV URL": "Неверный формат URL-адреса WebDAV",
"Username Required": "Имя пользователя не может быть пустым",
"Password Required": "Пароль не может быть пустым",
"Failed to Fetch Backups": "Не удалось получить файлы резервных копий",
"WebDAV Config Saved": "Конфигурация WebDAV успешно сохранена",
"WebDAV Config Save Failed": "Не удалось сохранить конфигурацию WebDAV: {{error}}",
"Backup Created": "Резервная копия успешно создана",
"Backup Failed": "Ошибка резервного копирования: {{error}}",
"Delete Backup": "Удалить резервную копию",
"Restore Backup": "Восстановить резервную копию",
"Backup Time": "Время резервного копирования",
"Confirm to delete this backup file?": "Вы уверены, что хотите удалить этот файл резервной копии?",
"Confirm to restore this backup file?": "Вы уверены, что хотите восстановить этот файл резервной копии?",
"Restore Success, App will restart in 1s": "Восстановление успешно выполнено, приложение перезапустится через 1 секунду",
"Failed to fetch backup files": "Не удалось получить файлы резервных копий"
}

View File

@@ -193,9 +193,9 @@
"Test URL": "测试地址",
"Settings": "设置",
"System Setting": "系统设置",
"Tun Mode": "Tun 模式",
"Tun Mode": "Tun(虚拟网卡)模式",
"Reset to Default": "重置为默认值",
"Tun Mode Info": "Tun模式需要授予内核相关权限,使用前请先开启服务模式",
"Tun Mode Info": "Tun(虚拟网卡)模式接管系统所有流量,启用时无须打开系统代理",
"Stack": "Tun 模式堆栈",
"System and Mixed Can Only be Used in Service Mode": "System 和 Mixed 只能在服务模式下使用",
"Device": "Tun 网卡名称",
@@ -205,7 +205,7 @@
"DNS Hijack": "DNS 劫持",
"MTU": "最大传输单元",
"Service Mode": "服务模式",
"Service Mode Info": "安装并授权服务后,由该服务启动的内核进程可获得相关权限",
"Service Mode Info": "启用TUN模式前请安装服务模式该服务启动的内核进程可获得安装虚拟网卡(TUN模式)的权限",
"Current State": "当前状态",
"pending": "等待中",
"installed": "已安装",
@@ -246,7 +246,10 @@
"Ip Address": "IP 地址",
"Mac Address": "MAC 地址",
"IPv6": "IPv6",
"Unified Delay": "统一延迟",
"Unified Delay Info": "开启统一延迟时,会进行两次延迟测试,以消除连接握手等带来的不同类型节点的延迟差异",
"Log Level": "日志等级",
"Log Level Info": "仅对日志目录Service文件夹下的内核日志文件生效",
"Port Config": "端口设置",
"Random Port": "随机端口",
"Mixed Port": "混合代理端口",
@@ -330,15 +333,17 @@
"clash_mode_direct": "直连模式",
"toggle_system_proxy": "打开/关闭系统代理",
"toggle_tun_mode": "打开/关闭 Tun 模式",
"Backup Setting": "备份设置",
"Backup Setting Info": "支持WebDAV备份配置文件",
"Runtime Config": "当前配置",
"Open App Dir": "应用目录",
"Open Conf Dir": "配置目录",
"Open Core Dir": "内核目录",
"Open Logs Dir": "日志目录",
"Check for Updates": "检查更新",
"Go to Release Page": "前往发布页",
"Portable Updater Error": "便携版不支持应用内更新,请手动下载替换",
"Break Change Update Error": "此版本为重大更新,不支持应用内更新,请卸载后手动下载安装",
"Open Dev Tools": "打开开发者工具",
"Open Dev Tools": "开发者工具",
"Exit": "退出",
"Verge Version": "Verge 版本",
"ReadOnly": "只读",
@@ -360,12 +365,37 @@
"Invalid Bypass Format": "无效的代理绕过格式",
"Clash Port Modified": "Clash 端口已修改",
"Port Conflict": "端口冲突",
"Restart Application to Apply Modifications": "重启应用程序以应用修改",
"Restart Application to Apply Modifications": "重启Verge以应用修改",
"External Controller Address Modified": "外部控制器监听地址已修改",
"Permissions Granted Successfully for _clash Core": "{{core}} 内核授权成功",
"Core Version Updated": "内核版本已更新",
"Clash Core Restarted": "已重启 Clash 内核",
"Switched to _clash Core": "已切换至 {{core}} 内核",
"GeoData Updated": "已更新 GeoData",
"Currently on the Latest Version": "当前已是最新版本"
"Currently on the Latest Version": "当前已是最新版本",
"Import Subscription Successful": "导入订阅成功",
"WebDAV Server URL": "WebDAV服务器地址 http(s)://",
"Username": "用户名",
"Password": "密码",
"Backup": "备份",
"Filename": "文件名称",
"Actions": "操作",
"Restore": "恢复",
"No Backups": "暂无备份",
"WebDAV URL Required": "WebDAV 服务器地址不能为空",
"Invalid WebDAV URL": "无效的 WebDAV 服务器地址格式",
"Username Required": "用户名不能为空",
"Password Required": "密码不能为空",
"Failed to Fetch Backups": "获取备份文件失败",
"WebDAV Config Saved": "WebDAV 配置保存成功",
"WebDAV Config Save Failed": "保存 WebDAV 配置失败: {{error}}",
"Backup Created": "备份创建成功",
"Backup Failed": "备份失败: {{error}}",
"Delete Backup": "删除备份",
"Restore Backup": "恢复备份",
"Backup Time": "备份时间",
"Confirm to delete this backup file?": "确认删除此备份文件吗?",
"Confirm to restore this backup file?": "确认恢复此 份文件吗?",
"Restore Success, App will restart in 1s": "恢复成功应用将在1秒后重启",
"Failed to fetch backup files": "获取备份文件失败"
}

View File

@@ -4,9 +4,8 @@ import relativeTime from "dayjs/plugin/relativeTime";
import { SWRConfig, mutate } from "swr";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useRoutes } from "react-router-dom";
import { useLocation, useRoutes, useNavigate } from "react-router-dom";
import { List, Paper, ThemeProvider, SvgIcon } from "@mui/material";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { routers } from "./_routers";
import { getAxios } from "@/services/api";
@@ -25,9 +24,10 @@ import getSystem from "@/utils/get-system";
import "dayjs/locale/ru";
import "dayjs/locale/zh-cn";
import { getPortableFlag } from "@/services/cmds";
import { useNavigate } from "react-router-dom";
import React from "react";
import { TransitionGroup, CSSTransition } from "react-transition-group";
import { useListen } from "@/hooks/use-listen";
const appWindow = getCurrentWebviewWindow();
export let portableFlag = false;
@@ -46,17 +46,13 @@ const Layout = () => {
const navigate = useNavigate();
const location = useLocation();
const routersEles = useRoutes(routers);
const { addListener, setupCloseListener } = useListen();
if (!routersEles) return null;
useEffect(() => {
window.addEventListener("keydown", (e) => {
// macOS有cmd+w
if (e.key === "Escape" && OS !== "macos") {
appWindow.close();
}
});
setupCloseListener();
listen("verge://refresh-clash-config", async () => {
useEffect(() => {
addListener("verge://refresh-clash-config", async () => {
// the clash info may be updated
await getAxios(true);
mutate("getProxies");
@@ -66,12 +62,21 @@ const Layout = () => {
});
// update the verge config
listen("verge://refresh-verge-config", () => mutate("getVergeConfig"));
addListener("verge://refresh-verge-config", () => mutate("getVergeConfig"));
// 设置提示监听
listen("verge://notice-message", ({ payload }) => {
addListener("verge://notice-message", ({ payload }) => {
const [status, msg] = payload as [string, string];
switch (status) {
case "import_sub_url::ok":
navigate("/profile", { state: { current: msg } });
Notice.success(t("Import Subscription Successful"));
break;
case "import_sub_url::error":
navigate("/profile");
Notice.error(msg);
break;
case "set_config::ok":
Notice.success(t("Clash Config Updated"));
break;

View File

@@ -15,11 +15,11 @@ import {
ConnectionDetailRef,
} from "@/components/connection/connection-detail";
import parseTraffic from "@/utils/parse-traffic";
import { useCustomTheme } from "@/components/layout/use-custom-theme";
import { BaseSearchBox } from "@/components/base/base-search-box";
import { BaseStyledSelect } from "@/components/base/base-styled-select";
import useSWRSubscription from "swr/subscription";
import { createSockette } from "@/utils/websocket";
import { useTheme } from "@mui/material/styles";
const initConn: IConnections = {
uploadTotal: 0,
@@ -32,7 +32,8 @@ type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
const ConnectionsPage = () => {
const { t } = useTranslation();
const { clashInfo } = useClashInfo();
const { theme } = useCustomTheme();
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const [match, setMatch] = useState(() => (_: string) => true);
const [curOrderOpt, setOrderOpt] = useState("Default");
@@ -46,7 +47,7 @@ const ConnectionsPage = () => {
list.sort(
(a, b) =>
new Date(b.start || "0").getTime()! -
new Date(a.start || "0").getTime()!
new Date(a.start || "0").getTime()!,
),
"Upload Speed": (list) => list.sort((a, b) => b.curUpload! - a.curUpload!),
"Download Speed": (list) =>
@@ -102,7 +103,7 @@ const ConnectionsPage = () => {
next(event);
},
},
3
3,
);
return () => {
@@ -113,7 +114,7 @@ const ConnectionsPage = () => {
const [filterConn, download, upload] = useMemo(() => {
const orderFunc = orderOpts[curOrderOpt];
let connections = connData.connections.filter((conn) =>
match(conn.metadata.host || conn.metadata.destinationIP || "")
match(conn.metadata.host || conn.metadata.destinationIP || ""),
);
if (orderFunc) connections = orderFunc(connections);
@@ -151,7 +152,7 @@ const ConnectionsPage = () => {
setSetting((o) =>
o?.layout !== "table"
? { ...o, layout: "table" }
: { ...o, layout: "list" }
: { ...o, layout: "list" },
)
}
>

View File

@@ -2,35 +2,43 @@ import { useMemo, useState } from "react";
import { Box, Button, IconButton, MenuItem } from "@mui/material";
import { Virtuoso } from "react-virtuoso";
import { useTranslation } from "react-i18next";
import { useLocalStorage } from "foxact/use-local-storage";
import {
PlayCircleOutlineRounded,
PauseCircleOutlineRounded,
} from "@mui/icons-material";
import { useLogData } from "@/hooks/use-log-data";
import { useLogData, LogLevel, clearLogs } from "@/hooks/use-log-data";
import { useEnableLog } from "@/services/states";
import { BaseEmpty, BasePage } from "@/components/base";
import LogItem from "@/components/log/log-item";
import { useCustomTheme } from "@/components/layout/use-custom-theme";
import { useTheme } from "@mui/material/styles";
import { BaseSearchBox } from "@/components/base/base-search-box";
import { BaseStyledSelect } from "@/components/base/base-styled-select";
import { mutate } from "swr";
import { SearchState } from "@/components/base/base-search-box";
const LogPage = () => {
const { t } = useTranslation();
const { data: logData = [] } = useLogData();
const [enableLog, setEnableLog] = useEnableLog();
const { theme } = useCustomTheme();
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const [logState, setLogState] = useState("all");
const [logLevel, setLogLevel] = useLocalStorage<LogLevel>(
"log:log-level",
"info",
);
const [match, setMatch] = useState(() => (_: string) => true);
const logData = useLogData(logLevel);
const [searchState, setSearchState] = useState<SearchState>();
const filterLogs = useMemo(() => {
return logData.filter(
(data) =>
(logState === "all" ? true : data.type.includes(logState)) &&
match(data.payload)
);
}, [logData, logState, match]);
return logData
? logData.filter((data) =>
logLevel === "all"
? match(data.payload)
: data.type.includes(logLevel) && match(data.payload),
)
: [];
}, [logData, logLevel, match]);
return (
<BasePage
@@ -52,15 +60,17 @@ const LogPage = () => {
)}
</IconButton>
<Button
size="small"
variant="contained"
// useSWRSubscription adds a prefix "$sub$" to the cache key
// https://github.com/vercel/swr/blob/1585a3e37d90ad0df8097b099db38f1afb43c95d/src/subscription/index.ts#L37
onClick={() => mutate("$sub$getClashLog", [])}
>
{t("Clear")}
</Button>
{enableLog === true && (
<Button
size="small"
variant="contained"
onClick={() => {
clearLogs(logLevel);
}}
>
{t("Clear")}
</Button>
)}
</Box>
}
>
@@ -75,15 +85,21 @@ const LogPage = () => {
}}
>
<BaseStyledSelect
value={logState}
onChange={(e) => setLogState(e.target.value)}
value={logLevel}
onChange={(e) => setLogLevel(e.target.value as LogLevel)}
>
<MenuItem value="all">ALL</MenuItem>
<MenuItem value="inf">INFO</MenuItem>
<MenuItem value="warn">WARN</MenuItem>
<MenuItem value="err">ERROR</MenuItem>
<MenuItem value="info">INFO</MenuItem>
<MenuItem value="warning">WARNING</MenuItem>
<MenuItem value="error">ERROR</MenuItem>
<MenuItem value="debug">DEBUG</MenuItem>
</BaseStyledSelect>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<BaseSearchBox
onSearch={(matcher, state) => {
setMatch(() => matcher);
setSearchState(state);
}}
/>
</Box>
<Box
@@ -98,7 +114,9 @@ const LogPage = () => {
<Virtuoso
initialTopMostItemIndex={999}
data={filterLogs}
itemContent={(index, item) => <LogItem value={item} />}
itemContent={(index, item) => (
<LogItem value={item} searchState={searchState} />
)}
followOutput={"smooth"}
/>
) : (

View File

@@ -47,13 +47,15 @@ import { useProfiles } from "@/hooks/use-profiles";
import { ConfigViewer } from "@/components/setting/mods/config-viewer";
import { throttle } from "lodash-es";
import { BaseStyledTextField } from "@/components/base/base-styled-text-field";
import { listen } from "@tauri-apps/api/event";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { readText } from "@tauri-apps/plugin-clipboard-manager";
import { useLocation } from "react-router-dom";
import { useListen } from "@/hooks/use-listen";
const ProfilePage = () => {
const { t } = useTranslation();
const location = useLocation();
const { addListener } = useListen();
const [url, setUrl] = useState("");
const [disabled, setDisabled] = useState(false);
const [activatings, setActivatings] = useState<string[]>([]);
@@ -62,11 +64,12 @@ const ProfilePage = () => {
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
const { current } = location.state || {};
useEffect(() => {
const unlisten = listen("tauri://file-drop", async (event) => {
const unlisten = addListener("tauri://file-drop", async (event) => {
const fileList = event.payload as string[];
for (let file of fileList) {
if (!file.endsWith(".yaml") && !file.endsWith(".yml")) {
@@ -102,7 +105,7 @@ const ProfilePage = () => {
const { data: chainLogs = {}, mutate: mutateLogs } = useSWR(
"getRuntimeLogs",
getRuntimeLogs
getRuntimeLogs,
);
const viewerRef = useRef<ProfileViewerRef>(null);
@@ -132,23 +135,8 @@ const ProfilePage = () => {
Notice.success(t("Profile Imported Successfully"));
setUrl("");
setLoading(false);
getProfiles().then(async (newProfiles) => {
mutate("getProfiles", newProfiles);
const remoteItem = newProfiles.items?.find((e) => e.type === "remote");
const profilesCount = newProfiles.items?.filter(
(e) => e.type === "remote" || e.type === "local"
).length as number;
if (remoteItem && (profilesCount == 1 || !newProfiles.current)) {
const current = remoteItem.uid;
await patchProfiles({ current });
mutateLogs();
setTimeout(() => activateSelected(), 2000);
}
});
mutateProfiles();
await onEnhance(false);
} catch (err: any) {
Notice.error(err.message || err.toString());
setLoading(false);
@@ -168,33 +156,49 @@ const ProfilePage = () => {
}
};
const onSelect = useLockFn(async (current: string, force: boolean) => {
if (!force && current === profiles.current) return;
const activateProfile = async (profile: string, notifySuccess: boolean) => {
// 避免大多数情况下loading态闪烁
const reset = setTimeout(() => {
setActivatings([...currentActivatings(), current]);
setActivatings((prev) => [...prev, profile]);
}, 100);
try {
await patchProfiles({ current });
await patchProfiles({ current: profile });
await mutateLogs();
closeAllConnections();
activateSelected().then(() => {
await activateSelected();
if (notifySuccess) {
Notice.success(t("Profile Switched"), 1000);
});
}
} catch (err: any) {
Notice.error(err?.message || err.toString(), 4000);
} finally {
clearTimeout(reset);
setActivatings([]);
}
};
const onSelect = useLockFn(async (current: string, force: boolean) => {
if (!force && current === profiles.current) return;
await activateProfile(current, true);
});
const onEnhance = useLockFn(async () => {
useEffect(() => {
(async () => {
if (current) {
mutateProfiles();
await activateProfile(current, false);
}
})();
}, current);
const onEnhance = useLockFn(async (notifySuccess: boolean) => {
setActivatings(currentActivatings());
try {
await enhanceProfiles();
mutateLogs();
Notice.success(t("Profile Reactivated"), 1000);
if (notifySuccess) {
Notice.success(t("Profile Reactivated"), 1000);
}
} catch (err: any) {
Notice.error(err.message || err.toString(), 3000);
} finally {
@@ -209,7 +213,7 @@ const ProfilePage = () => {
await deleteProfile(uid);
mutateProfiles();
mutateLogs();
current && (await onEnhance());
current && (await onEnhance(false));
} catch (err: any) {
Notice.error(err?.message || err.toString());
} finally {
@@ -236,7 +240,7 @@ const ProfilePage = () => {
setLoadingCache((cache) => {
// 获取没有正在更新的订阅
const items = profileItems.filter(
(e) => e.type === "remote" && !cache[e.uid]
(e) => e.type === "remote" && !cache[e.uid],
);
const change = Object.fromEntries(items.map((e) => [e.uid, true]));
@@ -286,7 +290,7 @@ const ProfilePage = () => {
size="small"
color="primary"
title={t("Reactivate Profiles")}
onClick={onEnhance}
onClick={() => onEnhance(true)}
>
<LocalFireDepartmentRounded />
</IconButton>
@@ -385,7 +389,7 @@ const ProfilePage = () => {
onEdit={() => viewerRef.current?.edit(item)}
onSave={async (prev, curr) => {
if (prev !== curr && profiles.current === item.uid) {
await onEnhance();
await onEnhance(false);
}
}}
onDelete={() => onDelete(item.uid)}
@@ -408,7 +412,7 @@ const ProfilePage = () => {
id="Merge"
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance();
await onEnhance(false);
}
}}
/>
@@ -419,7 +423,7 @@ const ProfilePage = () => {
logInfo={chainLogs["Script"]}
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance();
await onEnhance(false);
}
}}
/>
@@ -428,7 +432,13 @@ const ProfilePage = () => {
</Box>
</Box>
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
<ProfileViewer
ref={viewerRef}
onChange={async () => {
mutateProfiles();
await onEnhance(false);
}}
/>
<ConfigViewer ref={configRef} />
</BasePage>
);

View File

@@ -3,11 +3,7 @@ import { useEffect } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { Box, Button, ButtonGroup } from "@mui/material";
import {
closeAllConnections,
getClashConfig,
updateConfigs,
} from "@/services/api";
import { closeAllConnections, getClashConfig } from "@/services/api";
import { patchClashConfig } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { BasePage } from "@/components/base";
@@ -19,7 +15,7 @@ const ProxyPage = () => {
const { data: clashConfig, mutate: mutateClash } = useSWR(
"getClashConfig",
getClashConfig
getClashConfig,
);
const { verge } = useVerge();
@@ -33,7 +29,6 @@ const ProxyPage = () => {
if (mode !== curMode && verge?.auto_close_connection) {
closeAllConnections();
}
await updateConfigs({ mode });
await patchClashConfig({ mode });
mutateClash();
});

View File

@@ -1,26 +1,40 @@
import useSWR from "swr";
import { useState, useMemo } from "react";
import { useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Virtuoso } from "react-virtuoso";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { Box } from "@mui/material";
import { getRules } from "@/services/api";
import { BaseEmpty, BasePage } from "@/components/base";
import RuleItem from "@/components/rule/rule-item";
import { ProviderButton } from "@/components/rule/provider-button";
import { useCustomTheme } from "@/components/layout/use-custom-theme";
import { BaseSearchBox } from "@/components/base/base-search-box";
import { useTheme } from "@mui/material/styles";
import { ScrollTopButton } from "@/components/layout/scroll-top-button";
const RulesPage = () => {
const { t } = useTranslation();
const { data = [] } = useSWR("getRules", getRules);
const { theme } = useCustomTheme();
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const [match, setMatch] = useState(() => (_: string) => true);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [showScrollTop, setShowScrollTop] = useState(false);
const rules = useMemo(() => {
return data.filter((item) => match(item.payload));
}, [data, match]);
const scrollToTop = () => {
virtuosoRef.current?.scrollTo({
top: 0,
behavior: "smooth",
});
};
const handleScroll = (e: any) => {
setShowScrollTop(e.target.scrollTop > 100);
};
return (
<BasePage
full
@@ -51,16 +65,24 @@ const RulesPage = () => {
margin: "10px",
borderRadius: "8px",
bgcolor: isDark ? "#282a36" : "#ffffff",
position: "relative",
}}
>
{rules.length > 0 ? (
<Virtuoso
data={rules}
itemContent={(index, item) => (
<RuleItem index={index + 1} value={item} />
)}
followOutput={"smooth"}
/>
<>
<Virtuoso
ref={virtuosoRef}
data={rules}
itemContent={(index, item) => (
<RuleItem index={index + 1} value={item} />
)}
followOutput={"smooth"}
scrollerRef={(ref) => {
if (ref) ref.addEventListener("scroll", handleScroll);
}}
/>
<ScrollTopButton onClick={scrollToTop} show={showScrollTop} />
</>
) : (
<BaseEmpty />
)}

View File

@@ -49,12 +49,6 @@ export const getClashConfig = async () => {
return instance.get("/configs") as Promise<IConfigData>;
};
/// Update current configs
export const updateConfigs = async (config: Partial<IConfigData>) => {
const instance = await getAxios();
return instance.patch("/configs", config);
};
/// Update geo data
export const updateGeoData = async () => {
const instance = await getAxios();
@@ -78,16 +72,16 @@ export const getRules = async () => {
export const getProxyDelay = async (
name: string,
url?: string,
timeout?: number
timeout?: number,
) => {
const params = {
timeout: timeout || 10000,
url: url || "http://1.1.1.1",
url: url || "http://cp.cloudflare.com/generate_204",
};
const instance = await getAxios();
const result = await instance.get(
`/proxies/${encodeURIComponent(name)}/delay`,
{ params }
{ params },
);
return result as any as { delay: number };
};
@@ -114,8 +108,8 @@ export const getProxies = async () => {
// provider name map
const providerMap = Object.fromEntries(
Object.entries(providerRecord).flatMap(([provider, item]) =>
item.proxies.map((p) => [p.name, { ...p, provider }])
)
item.proxies.map((p) => [p.name, { ...p, provider }]),
),
);
// compatible with proxy-providers
@@ -128,6 +122,8 @@ export const getProxies = async () => {
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
history: [],
};
};
@@ -158,7 +154,7 @@ export const getProxies = async () => {
}
return acc;
},
[]
[],
);
let globalNames = new Set(globalGroups.map((each) => each.name));
@@ -171,8 +167,8 @@ export const getProxies = async () => {
const proxies = [direct, reject].concat(
Object.values(proxyRecord).filter(
(p) => !p.all?.length && p.name !== "DIRECT" && p.name !== "REJECT"
)
(p) => !p.all?.length && p.name !== "DIRECT" && p.name !== "REJECT",
),
);
const _global: IProxyGroupItem = {
@@ -197,7 +193,7 @@ export const getProxyProviders = async () => {
Object.entries(providers).filter(([key, item]) => {
const type = item.vehicleType.toLowerCase();
return type === "http" || type === "file";
})
}),
);
};
@@ -214,7 +210,7 @@ export const getRuleProviders = async () => {
Object.entries(providers).filter(([key, item]) => {
const type = item.vehicleType.toLowerCase();
return type === "http" || type === "file";
})
}),
);
};
@@ -222,7 +218,7 @@ export const getRuleProviders = async () => {
export const providerHealthCheck = async (name: string) => {
const instance = await getAxios();
return instance.get(
`/providers/proxies/${encodeURIComponent(name)}/healthcheck`
`/providers/proxies/${encodeURIComponent(name)}/healthcheck`,
);
};
@@ -258,16 +254,16 @@ export const closeAllConnections = async () => {
export const getGroupProxyDelays = async (
groupName: string,
url?: string,
timeout?: number
timeout?: number,
) => {
const params = {
timeout: timeout || 10000,
url: url || "http://1.1.1.1",
url: url || "http://cp.cloudflare.com/generate_204",
};
const instance = await getAxios();
const result = await instance.get(
`/group/${encodeURIComponent(groupName)}/delay`,
{ params }
{ params },
);
return result as any as Record<string, number>;
};

View File

@@ -6,29 +6,6 @@ export async function copyClashEnv() {
return invoke<void>("copy_clash_env");
}
export async function getClashLogs() {
const regex = /time="(.+?)"\s+level=(.+?)\s+msg="(.+?)"/;
const newRegex = /(.+?)\s+(.+?)\s+(.+)/;
const logs = await invoke<string[]>("get_clash_logs");
return logs.reduce<ILogItem[]>((acc, log) => {
const result = log.match(regex);
if (result) {
const [_, _time, type, payload] = result;
const time = dayjs(_time).format("MM-DD HH:mm:ss");
acc.push({ time, type, payload });
return acc;
}
const result2 = log.match(newRegex);
if (result2) {
const [_, time, type, payload] = result2;
acc.push({ time, type, payload });
}
return acc;
}, []);
}
export async function getProfiles() {
return invoke<IProfilesConfig>("get_profiles");
}
@@ -43,7 +20,7 @@ export async function patchProfilesConfig(profiles: IProfilesConfig) {
export async function createProfile(
item: Partial<IProfileItem>,
fileData?: string | null
fileData?: string | null,
) {
return invoke<void>("create_profile", { item, fileData });
}
@@ -84,7 +61,7 @@ export async function deleteProfile(index: string) {
export async function patchProfile(
index: string,
profile: Partial<IProfileItem>
profile: Partial<IProfileItem>,
) {
return invoke<void>("patch_profile", { index, profile });
}
@@ -141,8 +118,12 @@ export async function changeClashCore(clashCore: string) {
return invoke<any>("change_clash_core", { clashCore });
}
export async function restartSidecar() {
return invoke<void>("restart_sidecar");
export async function restartCore() {
return invoke<void>("restart_core");
}
export async function restartApp() {
return invoke<void>("restart_app");
}
export async function getAppDir() {
@@ -151,19 +132,19 @@ export async function getAppDir() {
export async function openAppDir() {
return invoke<void>("open_app_dir").catch((err) =>
Notice.error(err?.message || err.toString(), 1500)
Notice.error(err?.message || err.toString(), 1500),
);
}
export async function openCoreDir() {
return invoke<void>("open_core_dir").catch((err) =>
Notice.error(err?.message || err.toString(), 1500)
Notice.error(err?.message || err.toString(), 1500),
);
}
export async function openLogsDir() {
return invoke<void>("open_logs_dir").catch((err) =>
Notice.error(err?.message || err.toString(), 1500)
Notice.error(err?.message || err.toString(), 1500),
);
}
@@ -174,7 +155,7 @@ export async function openWebUrl(url: string) {
export async function cmdGetProxyDelay(
name: string,
timeout: number,
url?: string
url?: string,
) {
name = encodeURIComponent(name);
return invoke<{ delay: number }>("clash_api_get_proxy_delay", {
@@ -188,30 +169,9 @@ export async function cmdTestDelay(url: string) {
return invoke<number>("test_delay", { url });
}
/// service mode
export async function checkService() {
try {
const result = await invoke<any>("check_service");
if (result?.code === 0) return "active";
if (result?.code === 400) return "installed";
return "unknown";
} catch (err: any) {
return "uninstall";
}
}
export async function installService(passwd: string) {
return invoke<void>("install_service", { passwd });
}
export async function uninstallService(passwd: string) {
return invoke<void>("uninstall_service", { passwd });
}
export async function invoke_uwp_tool() {
return invoke<void>("invoke_uwp_tool").catch((err) =>
Notice.error(err?.message || err.toString(), 1500)
Notice.error(err?.message || err.toString(), 1500),
);
}
@@ -229,7 +189,7 @@ export async function exitApp() {
export async function copyIconFile(
path: string,
name: "common" | "sysproxy" | "tun"
name: "common" | "sysproxy" | "tun",
) {
return invoke<void>("copy_icon_file", { path, name });
}
@@ -245,3 +205,35 @@ export async function getNetworkInterfaces() {
export async function getNetworkInterfacesInfo() {
return invoke<INetworkInterface[]>("get_network_interfaces_info");
}
export async function createWebdavBackup() {
return invoke<void>("create_webdav_backup");
}
export async function deleteWebdavBackup(filename: string) {
return invoke<void>("delete_webdav_backup", { filename });
}
export async function restoreWebDavBackup(filename: string) {
return invoke<void>("restore_webdav_backup", { filename });
}
export async function saveWebdavConfig(
url: string,
username: string,
password: String,
) {
return invoke<void>("save_webdav_config", {
url,
username,
password,
});
}
export async function listWebDavBackup() {
let list: IWebDavFile[] = await invoke<IWebDavFile[]>("list_webdav_backup");
list.map((item) => {
item.filename = item.href.split("/").pop() as string;
});
return list;
}

View File

@@ -5,7 +5,7 @@ const [ThemeModeProvider, useThemeMode, useSetThemeMode] = createContextState<
"light" | "dark"
>("light");
export const useEnableLog = () => useLocalStorage("enable-log", true);
export const useEnableLog = () => useLocalStorage("enable-log", false);
interface IConnectionSetting {
layout: "table" | "list";
@@ -20,7 +20,7 @@ export const useConnectionSetting = () =>
{
serializer: JSON.stringify,
deserializer: JSON.parse,
}
},
);
// save the state of each profile item loading

View File

@@ -32,6 +32,7 @@ interface IConfigData {
"tproxy-port": number;
"external-controller": string;
secret: string;
"unified-delay": boolean;
tun: {
stack: string;
device: string;
@@ -55,6 +56,8 @@ interface IProxyItem {
udp: boolean;
xudp: boolean;
tfo: boolean;
mptcp: boolean;
smux: boolean;
history: {
time: string;
delay: number;
@@ -467,6 +470,7 @@ interface IProxyVlessConfig extends IProxyBaseConfig {
fingerprint?: string;
servername?: string;
"client-fingerprint"?: ClientFingerprint;
smux?: boolean;
}
// vmess
interface IProxyVmessConfig extends IProxyBaseConfig {
@@ -495,6 +499,7 @@ interface IProxyVmessConfig extends IProxyBaseConfig {
"global-padding"?: boolean;
"authenticated-length"?: boolean;
"client-fingerprint"?: ClientFingerprint;
smux?: boolean;
}
interface WireGuardPeerOptions {
server?: string;
@@ -603,6 +608,7 @@ interface IProxyShadowsocksConfig extends IProxyBaseConfig {
"udp-over-tcp"?: boolean;
"udp-over-tcp-version"?: number;
"client-fingerprint"?: ClientFingerprint;
smux?: boolean;
}
// shadowsocksR
interface IProxyshadowsocksRConfig extends IProxyBaseConfig {
@@ -702,7 +708,6 @@ interface IVergeConfig {
tun_tray_icon?: boolean;
enable_tun_mode?: boolean;
enable_auto_launch?: boolean;
enable_service_mode?: boolean;
enable_silent_start?: boolean;
enable_system_proxy?: boolean;
proxy_auto_config?: boolean;
@@ -743,4 +748,22 @@ interface IVergeConfig {
auto_log_clean?: 0 | 1 | 2 | 3;
proxy_layout_column?: number;
test_list?: IVergeTestItem[];
webdav_url?: string;
webdav_username?: string;
webdav_password?: string;
}
interface IWebDavFile {
filename: string;
href: string;
last_modified: string;
content_length: number;
content_type: string;
tag: string;
}
interface IWebDavConfig {
url: string;
username: string;
password: string;
}

9
src/utils/helper.ts Normal file
View File

@@ -0,0 +1,9 @@
export const isValidUrl = (url: string) => {
try {
new URL(url);
return true;
} catch (e) {
console.log(e);
return false;
}
};

View File

@@ -1,27 +1,63 @@
const KEY_MAP: Record<string, string> = {
'"': "'",
":": ";",
"?": "/",
">": ".",
"<": ",",
"{": "[",
"}": "]",
"|": "\\",
"!": "1",
"@": "2",
"#": "3",
$: "4",
"%": "5",
"^": "6",
"&": "7",
"*": "8",
"(": "9",
")": "0",
"~": "`",
// 特殊字符映射
"-": "Minus",
"=": "Equal",
"[": "BracketLeft",
"]": "BracketRight",
"\\": "Backslash",
";": "Semicolon",
"'": "Quote",
",": "Comma",
".": "Period",
"/": "Slash",
// Option + 特殊字符映射
"": "Minus", // Option + -
"": "Equal", // Option + =
"\u201C": "BracketLeft", // Option + [
"\u2019": "BracketRight", // Option + ]
"«": "Backslash", // Option + \
"": "Semicolon", // Option + ;
æ: "Quote", // Option + '
"≤": "Comma", // Option + ,
"≥": "Period", // Option + .
"÷": "Slash", // Option + /
// Option组合键映射
Å: "A",
"∫": "B",
Ç: "C",
"∂": "D",
"´": "E",
ƒ: "F",
"©": "G",
"˙": "H",
ˆ: "I",
"∆": "J",
"˚": "K",
"¬": "L",
µ: "M",
"˜": "N",
Ø: "O",
π: "P",
Œ: "Q",
"®": "R",
ß: "S",
"†": "T",
"¨": "U",
"√": "V",
"∑": "W",
"≈": "X",
"¥": "Y",
Ω: "Z",
};
const mapKeyCombination = (key: string): string => {
const mappedKey = KEY_MAP[key] || key;
return `${mappedKey}`;
};
export const parseHotkey = (key: string) => {
let temp = key.toUpperCase();
console.log(temp);
if (temp.startsWith("ARROW")) {
temp = temp.slice(5);
@@ -34,6 +70,7 @@ export const parseHotkey = (key: string) => {
} else if (temp.endsWith("RIGHT")) {
temp = temp.slice(0, -5);
}
console.log(temp, mapKeyCombination(temp));
switch (temp) {
case "CONTROL":

View File

@@ -6,7 +6,7 @@ import Sockette, { type SocketteOptions } from "sockette";
export const createSockette = (
url: string,
opt: SocketteOptions,
maxError = 10
maxError = 10,
) => {
let remainRetryCount = maxError;
@@ -23,8 +23,10 @@ export const createSockette = (
remainRetryCount -= 1;
if (remainRetryCount >= 0) {
this.close();
this.reconnect();
if (this instanceof Sockette) {
this.close();
this.reconnect();
}
} else {
opt.onerror?.call(this, ev);
}