Update On Thu Jun 13 20:31:37 CEST 2024

This commit is contained in:
github-action[bot]
2024-06-13 20:31:37 +02:00
parent 794afacdf7
commit c66b76b626
280 changed files with 14579 additions and 9845 deletions
+1
View File
@@ -671,3 +671,4 @@ Update On Sun Jun 9 20:29:25 CEST 2024
Update On Mon Jun 10 20:32:26 CEST 2024
Update On Tue Jun 11 20:31:29 CEST 2024
Update On Wed Jun 12 20:32:18 CEST 2024
Update On Thu Jun 13 20:31:26 CEST 2024
+12
View File
@@ -0,0 +1,12 @@
package dns
// export functions from tunnel module
import "github.com/metacubex/mihomo/tunnel"
const RespectRules = tunnel.DnsRespectRules
type dialHandler = tunnel.DnsDialHandler
var getDialHandler = tunnel.GetDnsDialHandler
var listenPacket = tunnel.DnsListenPacket
@@ -1,4 +1,6 @@
package dns
package tunnel
// WARNING: all function in this file should only be using in dns module
import (
"context"
@@ -11,15 +13,14 @@ import (
"github.com/metacubex/mihomo/component/dialer"
"github.com/metacubex/mihomo/component/resolver"
C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/tunnel"
"github.com/metacubex/mihomo/tunnel/statistic"
)
const RespectRules = "RULES"
const DnsRespectRules = "RULES"
type dialHandler func(ctx context.Context, network, addr string) (net.Conn, error)
type DnsDialHandler func(ctx context.Context, network, addr string) (net.Conn, error)
func getDialHandler(r *Resolver, proxyAdapter C.ProxyAdapter, proxyName string, opts ...dialer.Option) dialHandler {
func GetDnsDialHandler(r resolver.Resolver, proxyAdapter C.ProxyAdapter, proxyName string, opts ...dialer.Option) DnsDialHandler {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
if len(proxyName) == 0 && proxyAdapter == nil {
opts = append(opts, dialer.WithResolver(r))
@@ -47,22 +48,22 @@ func getDialHandler(r *Resolver, proxyAdapter C.ProxyAdapter, proxyName string,
var rule C.Rule
if proxyAdapter == nil {
if proxyName == RespectRules {
if proxyName == DnsRespectRules {
if !metadata.Resolved() {
// resolve here before ResolveMetadata to avoid its inner resolver.ResolveIP
// resolve here before resolveMetadata to avoid its inner resolver.ResolveIP
dstIP, err := resolver.ResolveIPWithResolver(ctx, metadata.Host, r)
if err != nil {
return nil, err
}
metadata.DstIP = dstIP
}
proxyAdapter, rule, err = tunnel.ResolveMetadata(metadata)
proxyAdapter, rule, err = resolveMetadata(metadata)
if err != nil {
return nil, err
}
} else {
var ok bool
proxyAdapter, ok = tunnel.Proxies()[proxyName]
proxyAdapter, ok = Proxies()[proxyName]
if !ok {
opts = append(opts, dialer.WithInterface(proxyName))
}
@@ -88,8 +89,10 @@ func getDialHandler(r *Resolver, proxyAdapter C.ProxyAdapter, proxyName string,
conn, err := proxyAdapter.DialContext(ctx, metadata, opts...)
if err != nil {
logMetadataErr(metadata, rule, proxyAdapter, err)
return nil, err
}
logMetadata(metadata, rule, conn)
conn = statistic.NewTCPTracker(conn, statistic.DefaultManager, metadata, rule, 0, 0, false)
@@ -105,8 +108,10 @@ func getDialHandler(r *Resolver, proxyAdapter C.ProxyAdapter, proxyName string,
packetConn, err := proxyAdapter.ListenPacketContext(ctx, metadata, opts...)
if err != nil {
logMetadataErr(metadata, rule, proxyAdapter, err)
return nil, err
}
logMetadata(metadata, rule, packetConn)
packetConn = statistic.NewUDPTracker(packetConn, statistic.DefaultManager, metadata, rule, 0, 0, false)
@@ -116,7 +121,7 @@ func getDialHandler(r *Resolver, proxyAdapter C.ProxyAdapter, proxyName string,
}
}
func listenPacket(ctx context.Context, proxyAdapter C.ProxyAdapter, proxyName string, network string, addr string, r *Resolver, opts ...dialer.Option) (net.PacketConn, error) {
func DnsListenPacket(ctx context.Context, proxyAdapter C.ProxyAdapter, proxyName string, network string, addr string, r resolver.Resolver, opts ...dialer.Option) (net.PacketConn, error) {
metadata := &C.Metadata{
NetWork: C.UDP,
Type: C.INNER,
@@ -136,14 +141,14 @@ func listenPacket(ctx context.Context, proxyAdapter C.ProxyAdapter, proxyName st
var rule C.Rule
if proxyAdapter == nil {
if proxyName == RespectRules {
proxyAdapter, rule, err = tunnel.ResolveMetadata(metadata)
if proxyName == DnsRespectRules {
proxyAdapter, rule, err = resolveMetadata(metadata)
if err != nil {
return nil, err
}
} else {
var ok bool
proxyAdapter, ok = tunnel.Proxies()[proxyName]
proxyAdapter, ok = Proxies()[proxyName]
if !ok {
opts = append(opts, dialer.WithInterface(proxyName))
}
@@ -160,8 +165,10 @@ func listenPacket(ctx context.Context, proxyAdapter C.ProxyAdapter, proxyName st
packetConn, err := proxyAdapter.ListenPacketContext(ctx, metadata, opts...)
if err != nil {
logMetadataErr(metadata, rule, proxyAdapter, err)
return nil, err
}
logMetadata(metadata, rule, packetConn)
packetConn = statistic.NewUDPTracker(packetConn, statistic.DefaultManager, metadata, rule, 0, 0, false)
+38 -65
View File
@@ -8,6 +8,7 @@ import (
"net/netip"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
@@ -278,7 +279,7 @@ func preHandleMetadata(metadata *C.Metadata) error {
return nil
}
func ResolveMetadata(metadata *C.Metadata) (proxy C.Proxy, rule C.Rule, err error) {
func resolveMetadata(metadata *C.Metadata) (proxy C.Proxy, rule C.Rule, err error) {
if metadata.SpecialProxy != "" {
var exist bool
proxy, exist = proxies[metadata.SpecialProxy]
@@ -375,7 +376,7 @@ func handleUDPConn(packet C.PacketAdapter) {
cond.Broadcast()
}()
proxy, rule, err := ResolveMetadata(metadata)
proxy, rule, err := resolveMetadata(metadata)
if err != nil {
log.Warnln("[UDP] Parse metadata failed: %s", err.Error())
return
@@ -386,43 +387,18 @@ func handleUDPConn(packet C.PacketAdapter) {
rawPc, err := retry(ctx, func(ctx context.Context) (C.PacketConn, error) {
return proxy.ListenPacketContext(ctx, metadata.Pure())
}, func(err error) {
if rule == nil {
log.Warnln(
"[UDP] dial %s %s --> %s error: %s",
proxy.Name(),
metadata.SourceDetail(),
metadata.RemoteAddress(),
err.Error(),
)
} else {
log.Warnln("[UDP] dial %s (match %s/%s) %s --> %s error: %s", proxy.Name(), rule.RuleType().String(), rule.Payload(), metadata.SourceDetail(), metadata.RemoteAddress(), err.Error())
}
logMetadataErr(metadata, rule, proxy, err)
})
if err != nil {
return
}
logMetadata(metadata, rule, rawPc)
pc := statistic.NewUDPTracker(rawPc, statistic.DefaultManager, metadata, rule, 0, 0, true)
switch true {
case metadata.SpecialProxy != "":
log.Infoln("[UDP] %s --> %s using %s", metadata.SourceDetail(), metadata.RemoteAddress(), metadata.SpecialProxy)
case rule != nil:
if rule.Payload() != "" {
log.Infoln("[UDP] %s --> %s match %s using %s", metadata.SourceDetail(), metadata.RemoteAddress(), fmt.Sprintf("%s(%s)", rule.RuleType().String(), rule.Payload()), rawPc.Chains().String())
if rawPc.Chains().Last() == "REJECT-DROP" {
pc.Close()
return
}
} else {
log.Infoln("[UDP] %s --> %s match %s using %s", metadata.SourceDetail(), metadata.RemoteAddress(), rule.Payload(), rawPc.Chains().String())
}
case mode == Global:
log.Infoln("[UDP] %s --> %s using GLOBAL", metadata.SourceDetail(), metadata.RemoteAddress())
case mode == Direct:
log.Infoln("[UDP] %s --> %s using DIRECT", metadata.SourceDetail(), metadata.RemoteAddress())
default:
log.Infoln("[UDP] %s --> %s doesn't match any rule using DIRECT", metadata.SourceDetail(), metadata.RemoteAddress())
if rawPc.Chains().Last() == "REJECT-DROP" {
pc.Close()
return
}
oAddrPort := metadata.AddrPort()
@@ -486,7 +462,7 @@ func handleTCPConn(connCtx C.ConnContext) {
}()
}
proxy, rule, err := ResolveMetadata(metadata)
proxy, rule, err := resolveMetadata(metadata)
if err != nil {
log.Warnln("[Metadata] parse failed: %s", err.Error())
return
@@ -539,48 +515,18 @@ func handleTCPConn(connCtx C.ConnContext) {
}
return
}, func(err error) {
if rule == nil {
log.Warnln(
"[TCP] dial %s %s --> %s error: %s",
proxy.Name(),
metadata.SourceDetail(),
metadata.RemoteAddress(),
err.Error(),
)
} else {
log.Warnln("[TCP] dial %s (match %s/%s) %s --> %s error: %s", proxy.Name(), rule.RuleType().String(), rule.Payload(), metadata.SourceDetail(), metadata.RemoteAddress(), err.Error())
}
logMetadataErr(metadata, rule, proxy, err)
})
if err != nil {
return
}
logMetadata(metadata, rule, remoteConn)
remoteConn = statistic.NewTCPTracker(remoteConn, statistic.DefaultManager, metadata, rule, 0, int64(peekLen), true)
defer func(remoteConn C.Conn) {
_ = remoteConn.Close()
}(remoteConn)
switch true {
case metadata.SpecialProxy != "":
log.Infoln("[TCP] %s --> %s using %s", metadata.SourceDetail(), metadata.RemoteAddress(), metadata.SpecialProxy)
case rule != nil:
if rule.Payload() != "" {
log.Infoln("[TCP] %s --> %s match %s using %s", metadata.SourceDetail(), metadata.RemoteAddress(), fmt.Sprintf("%s(%s)", rule.RuleType().String(), rule.Payload()), remoteConn.Chains().String())
} else {
log.Infoln("[TCP] %s --> %s match %s using %s", metadata.SourceDetail(), metadata.RemoteAddress(), rule.RuleType().String(), remoteConn.Chains().String())
}
case mode == Global:
log.Infoln("[TCP] %s --> %s using GLOBAL", metadata.SourceDetail(), metadata.RemoteAddress())
case mode == Direct:
log.Infoln("[TCP] %s --> %s using DIRECT", metadata.SourceDetail(), metadata.RemoteAddress())
default:
log.Infoln(
"[TCP] %s --> %s doesn't match any rule using DIRECT",
metadata.SourceDetail(),
metadata.RemoteAddress(),
)
}
_ = conn.SetReadDeadline(time.Now()) // stop unfinished peek
peekMutex.Lock()
defer peekMutex.Unlock()
@@ -588,6 +534,33 @@ func handleTCPConn(connCtx C.ConnContext) {
handleSocket(conn, remoteConn)
}
func logMetadataErr(metadata *C.Metadata, rule C.Rule, proxy C.ProxyAdapter, err error) {
if rule == nil {
log.Warnln("[%s] dial %s %s --> %s error: %s", strings.ToUpper(metadata.NetWork.String()), proxy.Name(), metadata.SourceDetail(), metadata.RemoteAddress(), err.Error())
} else {
log.Warnln("[%s] dial %s (match %s/%s) %s --> %s error: %s", strings.ToUpper(metadata.NetWork.String()), proxy.Name(), rule.RuleType().String(), rule.Payload(), metadata.SourceDetail(), metadata.RemoteAddress(), err.Error())
}
}
func logMetadata(metadata *C.Metadata, rule C.Rule, remoteConn C.Connection) {
switch {
case metadata.SpecialProxy != "":
log.Infoln("[%s] %s --> %s using %s", strings.ToUpper(metadata.NetWork.String()), metadata.SourceDetail(), metadata.RemoteAddress(), metadata.SpecialProxy)
case rule != nil:
if rule.Payload() != "" {
log.Infoln("[%s] %s --> %s match %s using %s", strings.ToUpper(metadata.NetWork.String()), metadata.SourceDetail(), metadata.RemoteAddress(), fmt.Sprintf("%s(%s)", rule.RuleType().String(), rule.Payload()), remoteConn.Chains().String())
} else {
log.Infoln("[%s] %s --> %s match %s using %s", strings.ToUpper(metadata.NetWork.String()), metadata.SourceDetail(), metadata.RemoteAddress(), rule.RuleType().String(), remoteConn.Chains().String())
}
case mode == Global:
log.Infoln("[%s] %s --> %s using GLOBAL", strings.ToUpper(metadata.NetWork.String()), metadata.SourceDetail(), metadata.RemoteAddress())
case mode == Direct:
log.Infoln("[%s] %s --> %s using DIRECT", strings.ToUpper(metadata.NetWork.String()), metadata.SourceDetail(), metadata.RemoteAddress())
default:
log.Infoln("[%s] %s --> %s doesn't match any rule using %s", strings.ToUpper(metadata.NetWork.String()), metadata.SourceDetail(), metadata.RemoteAddress(), remoteConn.Chains().Last())
}
}
func shouldResolveIP(rule C.Rule, metadata *C.Metadata) bool {
return rule.ShouldResolveIP() && metadata.Host != "" && !metadata.DstIP.IsValid()
}
@@ -78,8 +78,8 @@ pub struct IVerge {
/// show memory info (only for Clash Meta)
pub enable_memory_usage: Option<bool>,
/// page transition animation, default is `slide`
pub page_transition_animation: Option<String>,
/// global ui framer motion effects
pub lighten_animation_effects: Option<bool>,
/// clash tun mode
pub enable_tun_mode: Option<bool>,
@@ -219,6 +219,10 @@ impl IVerge {
config.max_log_files = template.max_log_files;
}
if config.lighten_animation_effects.is_none() {
config.lighten_animation_effects = template.lighten_animation_effects;
}
config
}
@@ -244,7 +248,7 @@ impl IVerge {
auto_close_connection: Some(true),
enable_builtin_enhanced: Some(true),
enable_clash_fields: Some(true),
page_transition_animation: Some("slide".into()),
lighten_animation_effects: Some(false),
// auto_log_clean: Some(60 * 24 * 7), // 7 days 自动清理日记
max_log_files: Some(7), // 7 days
enable_auto_check_update: Some(true),
@@ -275,7 +279,7 @@ impl IVerge {
patch!(theme_blur);
patch!(traffic_graph);
patch!(enable_memory_usage);
patch!(page_transition_animation);
patch!(lighten_animation_effects);
patch!(enable_auto_check_update);
patch!(enable_tun_mode);
+5 -5
View File
@@ -50,11 +50,11 @@ fn main() -> std::io::Result<()> {
commands::parse().unwrap();
// Should be in first place in order prevent single instance check block everything
tauri_plugin_deep_link::prepare(if cfg!(feature = "verge-dev") {
"moe.elaina.clash.nyanpasu.dev"
} else {
"moe.elaina.clash.nyanpasu"
});
#[cfg(feature = "verge-dev")]
tauri_plugin_deep_link::prepare("moe.elaina.clash.nyanpasu.dev");
#[cfg(not(feature = "verge-dev"))]
tauri_plugin_deep_link::prepare("moe.elaina.clash.nyanpasu");
// 单例检测
let single_instance_result = utils::init::check_singleton();
@@ -182,3 +182,7 @@ export const selectProxy = async (group: string, name: string) => {
export const updateProxyProvider = async (name: string) => {
return await invoke<void>("update_proxy_provider", { name });
};
export const save_window_size_state = async () => {
return await invoke<void>("save_window_size_state");
};
@@ -8,7 +8,7 @@ export interface VergeConfig {
theme_blur?: boolean;
traffic_graph?: boolean;
enable_memory_usage?: boolean;
page_transition_animation?: string;
lighten_animation_effects?: boolean;
enable_auto_check_update?: boolean;
enable_tun_mode?: boolean;
enable_auto_launch?: boolean;
@@ -16,9 +16,9 @@
"@generouted/react-router": "1.19.5",
"@juggle/resize-observer": "3.4.0",
"@material/material-color-utilities": "0.2.7",
"@mui/icons-material": "5.15.19",
"@mui/icons-material": "5.15.20",
"@mui/lab": "5.0.0-alpha.170",
"@mui/material": "5.15.19",
"@mui/material": "5.15.20",
"@mui/x-data-grid": "7.6.2",
"@nyanpasu/interface": "workspace:^",
"@nyanpasu/ui": "workspace:^",
@@ -58,7 +58,7 @@
"@vitejs/plugin-react": "4.3.1",
"sass": "1.77.5",
"shiki": "1.6.4",
"vite": "5.2.13",
"vite": "5.3.0",
"vite-plugin-monaco-editor": "1.1.3",
"vite-plugin-sass-dts": "1.3.22",
"vite-plugin-svgr": "4.2.0",
@@ -1,5 +1,4 @@
@import "./layout.scss";
@import "./page.scss";
@import "./fonts.scss";
body {
@@ -1,121 +1,9 @@
.layout {
display: flex;
width: 100%;
height: 100vh;
overflow: hidden;
&__left {
position: relative;
box-sizing: border-box;
display: flex;
flex: 1 0 25%;
flex-direction: column;
min-width: 125px;
max-width: 225px;
height: 100%;
padding: 36px 0 28px;
overflow: hidden;
user-select: none;
background-color: var(--background-color-alpha);
$maxLogo: 100px;
.the-logo {
position: relative;
box-sizing: border-box;
flex: 0 1 $maxLogo;
width: 100%;
max-width: $maxLogo + 32px;
max-height: $maxLogo;
padding: 0 16px;
margin: 0 auto;
text-align: center;
img,
svg {
width: 100%;
height: 100%;
pointer-events: none;
fill: var(--primary-main);
#bg {
fill: var(--background-color);
}
}
.the-newbtn {
position: absolute;
right: 10px;
bottom: 0;
transform: scale(0.8);
}
}
.the-menu {
flex: 1 1 80%;
margin-bottom: 8px;
overflow-y: auto;
}
.the-traffic {
flex: 0 0 60px;
> div {
margin: 0 auto;
}
}
}
&__right {
position: relative;
flex: 1 1 75%;
height: 100%;
background-color: var(--background-color-alpha);
.the-bar {
position: absolute;
top: 8px;
right: 28px;
z-index: 2;
}
.the-content {
position: absolute;
inset: 0 28px 28px 0;
}
.drag-mask {
height: 38px;
}
}
.the-content {
position: absolute;
inset: 36px calc(var(--border-radius) * 2) calc(var(--border-radius) * 2) 0;
}
.linux,
.windows,
.unknown {
&.layout {
$maxLogo: 115px;
.layout__left .the-logo {
flex: 0 1 $maxLogo;
max-width: $maxLogo + 32px;
max-height: $maxLogo;
}
.layout__right .the-content {
top: 30px;
}
}
}
.macos {
&.layout {
.layout__left {
padding-top: 24px;
}
.layout__right .the-content {
top: 20px;
}
}
.the-content-small {
position: absolute;
inset: 36px calc(var(--border-radius) * 2) calc(var(--border-radius) * 2);
}
@@ -1,52 +0,0 @@
.base-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
> header {
box-sizing: border-box;
display: flex;
flex: 0 0 58px;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 4px;
// max-width: 850px;
margin: 0 auto;
}
.base-container {
height: 100%;
overflow: hidden;
border-radius: var(--border-radius);
> section {
position: relative;
box-sizing: border-box;
flex: 1 1 100%;
width: 100%;
height: 100%;
padding: 28px 0;
overflow: auto;
background-color: var(--background-color);
scrollbar-gutter: stable;
.base-content {
width: calc(100% - 28px * 2);
margin: 0 auto;
}
}
&.no-padding {
> section {
padding: 0;
overflow: visible;
.base-content {
width: 100%;
}
}
}
}
}
@@ -0,0 +1,14 @@
.layout {
display: flex;
width: 100%;
height: 100vh;
overflow: hidden;
background-color: var(--background-color);
.container {
position: relative;
flex: 1 1 75%;
height: 100%;
background-color: var(--background-color-alpha);
}
}
@@ -0,0 +1,5 @@
declare const classNames: {
readonly layout: "layout";
readonly container: "container";
};
export default classNames;
@@ -0,0 +1,47 @@
import getSystem from "@/utils/get-system";
import { LayoutControl } from "../layout/layout-control";
import AppDrawer from "./app-drawer";
import { ReactNode } from "react";
import styles from "./app-container.module.scss";
import { appWindow } from "@tauri-apps/api/window";
import Paper from "@mui/material/Paper";
const OS = getSystem();
export const AppContainer = ({
children,
isDrawer,
}: {
children?: ReactNode;
isDrawer?: boolean;
}) => {
return (
<Paper
square
elevation={0}
className={styles.layout}
onPointerDown={(e: any) => {
if (e.target?.dataset?.windrag) {
appWindow.startDragging();
}
}}
onContextMenu={(e) => {
e.preventDefault();
}}
>
<AppDrawer isDrawer={isDrawer} data-windrag />
<div className={styles.container}>
{OS === "windows" && (
<LayoutControl className="fixed right-6 top-1.5 !z-50" />
)}
<div className="h-9" data-windrag />
{children}
</div>
</Paper>
);
};
export default AppContainer;
@@ -0,0 +1,122 @@
import { classNames } from "@/utils";
import { getRoutesWithIcon } from "@/utils/routes-utils";
import { Backdrop, IconButton, alpha, useTheme } from "@mui/material";
import AnimatedLogo from "../layout/animated-logo";
import RouteListItem from "./modules/route-list-item";
import { MenuOpen } from "@mui/icons-material";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
export const AppDrawer = ({ isDrawer }: { isDrawer?: boolean }) => {
const { palette } = useTheme();
const routes = getRoutesWithIcon();
const [open, setOpen] = useState(false);
const Content = ({ className }: { className?: string }) => {
return (
<div
className={classNames(
"max-w-60",
"min-w-28",
"p-4",
"pt-8",
"h-full",
"flex",
"flex-col",
"gap-4",
className,
)}
style={{
backgroundColor: "var(--background-color-alpha)",
}}
data-windrag
>
<div
className="w-full h-full max-w-32 max-h-32 ml-auto mr-auto"
data-windrag
>
<AnimatedLogo className="w-full h-full" data-windrag />
</div>
<div className="flex flex-col gap-2">
{Object.entries(routes).map(([name, { path, icon }]) => {
return (
<RouteListItem key={name} name={name} path={path} icon={icon} />
);
})}
</div>
</div>
);
};
const DrawerTitle = () => {
return (
<div
className="flex items-center gap-2 fixed z-10 top-1.5 left-6"
data-windrag
>
<IconButton
className="!size-8 !min-w-0"
sx={{
backgroundColor: alpha(palette.primary.main, 0.1),
svg: { transform: "scale(0.9)" },
}}
onClick={() => setOpen(true)}
>
<MenuOpen />
</IconButton>
<div className="size-5" data-windrag>
<AnimatedLogo className="w-full h-full" data-windrag />
</div>
<div className="text-lg" data-windrag>
Clash Nyanpasu
</div>
</div>
);
};
return isDrawer ? (
<>
<DrawerTitle />
<Backdrop
className="z-20 backdrop-blur-xl"
sx={{
backgroundColor: alpha(palette.primary[palette.mode], 0.1),
}}
open={open}
onClick={() => setOpen(false)}
>
<AnimatePresence initial={false}>
<div className="w-full h-full">
<motion.div
className="h-full"
animate={open ? "open" : "closed"}
variants={{
open: {
x: 0,
},
closed: {
x: -240,
},
}}
transition={{
type: "tween",
}}
>
<Content />
</motion.div>
</div>
</AnimatePresence>
</Backdrop>
</>
) : (
<Content />
);
};
export default AppDrawer;
@@ -0,0 +1,22 @@
import { useNyanpasu } from "@nyanpasu/interface";
import { locale } from "dayjs";
import { changeLanguage } from "i18next";
import { useEffect } from "react";
export const LocalesProvider = () => {
const { nyanpasuConfig } = useNyanpasu();
useEffect(() => {
if (nyanpasuConfig?.language) {
locale(
nyanpasuConfig?.language === "zh" ? "zh-cn" : nyanpasuConfig?.language,
);
changeLanguage(nyanpasuConfig?.language);
}
}, [nyanpasuConfig?.language]);
return null;
};
export default LocalesProvider;
@@ -0,0 +1,54 @@
import { SvgIconComponent } from "@mui/icons-material";
import { ListItemButton, ListItemIcon, alpha, useTheme } from "@mui/material";
import { createElement } from "react";
import { useTranslation } from "react-i18next";
import { useMatch, useNavigate } from "react-router-dom";
export const RouteListItem = ({
name,
path,
icon,
}: {
name: string;
path: string;
icon: SvgIconComponent;
}) => {
const { t } = useTranslation();
const { palette } = useTheme();
const match = useMatch({ path: path, end: true });
const navigate = useNavigate();
return (
<ListItemButton
className="!pr-12 !rounded-full"
sx={{
backgroundColor: match ? alpha(palette.primary.main, 0.3) : undefined,
"&:hover": {
backgroundColor: match ? alpha(palette.primary.main, 0.5) : undefined,
},
}}
onClick={() => navigate(path)}
>
<ListItemIcon>
{createElement(icon, {
sx: {
fill: match ? palette.primary.main : undefined,
},
})}
</ListItemIcon>
<div
className="pt-1 pb-1"
style={{ color: match ? palette.primary.main : undefined }}
>
{t(`label_${name}`)}
</div>
</ListItemButton>
);
};
export default RouteListItem;
@@ -1,5 +0,0 @@
.basePageTransition {
:global(.MuiDialog-paper) {
max-height: calc(100vh - 64px);
}
}
@@ -1,5 +0,0 @@
declare const classNames: {
readonly basePageTransition: "basePageTransition";
readonly "MuiDialog-paper": "MuiDialog-paper";
};
export default classNames;
@@ -1,141 +0,0 @@
import { classNames } from "@/utils";
import { LoadingButton } from "@mui/lab";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
type SxProps,
type Theme,
} from "@mui/material";
import { TransitionProps } from "@mui/material/transitions";
import { AnimatePresence, motion } from "framer-motion";
import React, { ReactNode } from "react";
import styles from "./base-dialog.module.scss";
interface Props {
title: ReactNode;
open: boolean;
okBtn?: ReactNode;
okBtnDisabled?: boolean;
cancelBtn?: ReactNode;
disableOk?: boolean;
disableCancel?: boolean;
disableFooter?: boolean;
contentSx?: SxProps<Theme>;
children?: ReactNode;
loading?: boolean;
onOk?: () => void;
onCancel?: () => void;
onClose?: () => void;
}
export interface DialogRef {
open: () => void;
close: () => void;
}
export function BaseDialog(props: Props) {
const {
open,
title,
children,
okBtn,
okBtnDisabled,
cancelBtn,
contentSx,
disableCancel,
disableOk,
disableFooter,
loading,
} = props;
return (
<Dialog
open={open}
onClose={props.onClose}
keepMounted
TransitionComponent={BaseDialogTransition}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent sx={contentSx}>{children}</DialogContent>
{!disableFooter && (
<DialogActions>
{!disableCancel && (
<Button variant="outlined" onClick={props.onCancel}>
{cancelBtn}
</Button>
)}
{!disableOk && (
<LoadingButton
disabled={loading || okBtnDisabled}
loading={loading}
variant="contained"
onClick={props.onOk}
>
{okBtn}
</LoadingButton>
)}
</DialogActions>
)}
</Dialog>
);
}
const BaseDialogTransition = React.forwardRef(function BaseDialogTransition(
props: TransitionProps,
ref,
) {
const { in: inProp, children } = props;
return (
<AnimatePresence>
{inProp && (
<motion.div
className={classNames(styles.basePageTransition)}
style={{
width: "fit-content",
height: "fit-content",
// margin: "auto",
maxHeight: "100vh",
position: "fixed",
}}
initial={{
opacity: 0,
scale: 0,
top: "50%",
left: "50%",
translateX: "-50%",
translateY: "-50%",
}}
animate={{
opacity: 1,
scale: 1,
}}
exit={{
opacity: 0,
scale: 0,
}}
>
{children &&
React.cloneElement(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
React.Children.only(children as unknown as any),
{
style: {
opacity: 1,
visibility: "visible",
},
// TODO: 也许 framer motion 就不会产生这个,手动设定一下。等弄清楚了再说。
tabIndex: -1,
ref: ref,
},
)}
</motion.div>
)}
</AnimatePresence>
);
});
@@ -1,48 +0,0 @@
import { styled } from "@mui/material";
const Loading = styled("div")`
position: relative;
display: flex;
height: 100%;
min-height: 18px;
box-sizing: border-box;
align-items: center;
& > div {
box-sizing: border-box;
width: 6px;
height: 6px;
margin: 2px;
border-radius: 100%;
animation: loading 0.7s -0.15s infinite linear;
}
& > div:nth-child(2n-1) {
animation-delay: -0.5s;
}
@keyframes loading {
50% {
opacity: 0.2;
transform: scale(0.75);
}
100% {
opacity: 1;
transform: scale(1);
}
}
`;
const LoadingItem = styled("div")(({ theme }) => ({
background: theme.palette.text.secondary,
}));
export const BaseLoading = () => {
return (
<Loading>
<LoadingItem />
<LoadingItem />
<LoadingItem />
</Loading>
);
};
@@ -1,38 +0,0 @@
import React, { ReactNode } from "react";
import { Typography } from "@mui/material";
import { BaseErrorBoundary } from "./base-error-boundary";
interface Props {
title?: React.ReactNode; // the page title
header?: React.ReactNode; // something behind title
contentStyle?: React.CSSProperties;
sectionStyle?: React.CSSProperties;
children?: ReactNode;
full?: boolean;
}
export const BasePage: React.FC<Props> = (props) => {
const { title, header, contentStyle, full, children, sectionStyle } = props;
return (
<BaseErrorBoundary>
<div className="base-page" data-windrag>
<header data-windrag style={{ userSelect: "none" }}>
<Typography variant="h4" component="h1" data-windrag>
{title}
</Typography>
{header}
</header>
<div className={full ? "base-container no-padding" : "base-container"}>
<section style={sectionStyle}>
<div className="base-content" style={contentStyle} data-windrag>
{children}
</div>
</section>
</div>
</div>
</BaseErrorBoundary>
);
};
@@ -0,0 +1,36 @@
import { classNames } from "@/utils";
import { Public } from "@mui/icons-material";
import { ReactNode } from "react";
export interface ContentDisplayProps {
className?: string;
message?: string;
children?: ReactNode;
}
export const ContentDisplay = ({
message,
children,
className,
}: ContentDisplayProps) => (
<div
className={classNames(
"h-full w-full flex items-center justify-center",
className,
)}
>
<div className="flex flex-col items-center gap-4">
{children ? (
children
) : (
<>
<Public className="!size-16" />
<b>{message}</b>
</>
)}
</div>
</div>
);
export default ContentDisplay;
@@ -1,6 +1,3 @@
export { BaseDialog, type DialogRef } from "./base-dialog";
export { BasePage } from "./base-page";
export { BaseEmpty } from "./base-empty";
export { BaseLoading } from "./base-loading";
export { BaseErrorBoundary } from "./base-error-boundary";
export { Notice } from "./base-notice";
@@ -1,21 +0,0 @@
import { styled } from "@mui/material/styles";
const Kbd = styled("kbd")(({ theme }) => ({
backgroundColor:
theme.palette.mode === "dark" ? "rgb(255 255 255 / 0.06)" : "#edf2f7;",
borderColor:
theme.palette.mode === "dark" ? "rgb(255 255 255 / 0.16)" : "#e2e8f0",
paddingRight: "0.4em",
paddingLeft: "0.4em",
fontFamily: "SFMono-Regular, Menlo, Monaco, Consolas, monospace",
// font-size: 1em,
fontSize: "0.8em",
fontWeight: "bold",
lineHeight: "normal",
whiteSpace: "nowrap",
borderWidth: "1px",
borderBottomWidth: "3px",
borderRadius: "0.375rem",
borderStyle: "solid",
}));
export default Kbd;
@@ -1,15 +0,0 @@
.MDYSwitch-container {
position: relative;
.MDYSwitch-CircularProgress {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
&.checked {
right: 8px;
left: unset;
}
}
}
@@ -1,89 +0,0 @@
import { styled } from "@mui/material/styles";
import Switch, { SwitchProps } from "@mui/material/Switch";
import CircularProgress from "@mui/material/CircularProgress";
import "./mdy-switch.scss";
interface MDYSwitchProps extends SwitchProps {
loading?: boolean;
}
const MDYSwitch = styled((props: MDYSwitchProps) => {
const { loading = false, checked, disabled, ...nativeProps } = props;
return (
<div className="MDYSwitch-container">
{loading && (
<CircularProgress
className={"MDYSwitch-CircularProgress " + (checked ? "checked" : "")}
aria-labelledby={props.id}
color="inherit"
size={16}
/>
)}
<Switch
{...nativeProps}
checked={checked}
disabled={loading || disabled}
/>
</div>
);
})(({ theme, checked, loading, disabled }) => ({
height: "32px",
padding: 0,
margin: 0,
borderRadius: 24,
opacity: loading || disabled ? 0.5 : 1,
"& .MuiSwitch-track": {
borderRadius: 24,
opacity: checked
? "1 !important"
: theme.palette.mode === "dark"
? "0.3 !important"
: "0.7 !important",
backgroundColor: checked
? theme.palette.primary.main
: theme.palette.mode === "dark"
? theme.palette.grey.A700
: theme.palette.grey.A200,
"&::before": {
content: '""',
border: `solid 2px ${theme.palette.grey.A700}`,
width: "100%",
height: "100%",
opacity: checked ? 0 : 1,
position: "absolute",
borderRadius: "inherit",
boxSizing: "border-box",
transitionProperty: "opacity, background-color",
transitionTimingFunction: "linear",
transitionDuration: "67ms",
},
},
"& .MuiSwitch-switchBase": {
padding: "6px",
},
"& .MuiSwitch-thumb": {
boxShadow: "none",
width: loading ? 24 : 15,
height: loading ? 24 : 15,
margin: loading ? -2 : 3,
color: checked
? theme.palette.getContrastText(theme.palette.primary.main)
: theme.palette.mode === "dark"
? theme.palette.grey.A200
: theme.palette.grey.A700,
opacity: checked ? 1 : 0.7,
},
"& .Mui-checked": {
"&.MuiSwitch-switchBase": {
padding: "6px 9px 6px 12px",
},
"& .MuiSwitch-thumb": {
width: 24,
height: 24,
margin: -2,
},
},
}));
export default MDYSwitch;
@@ -1,106 +0,0 @@
import { deleteConnection } from "@/services/api";
import parseTraffic from "@/utils/parse-traffic";
import { truncateStr } from "@/utils/truncate-str";
import { Box, Button, Snackbar } from "@mui/material";
import { useLockFn } from "ahooks";
import dayjs from "dayjs";
import { forwardRef, useImperativeHandle, useState } from "react";
export interface ConnectionDetailRef {
open: (detail: IConnectionsItem) => void;
}
export const ConnectionDetail = forwardRef<ConnectionDetailRef>(
(props, ref) => {
const [open, setOpen] = useState(false);
const [detail, setDetail] = useState<IConnectionsItem>(null!);
useImperativeHandle(ref, () => ({
open: (detail: IConnectionsItem) => {
if (open) return;
setOpen(true);
setDetail(detail);
},
}));
const onClose = () => setOpen(false);
return (
<Snackbar
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
open={open}
onClose={onClose}
message={
detail ? (
<InnerConnectionDetail data={detail} onClose={onClose} />
) : null
}
/>
);
},
);
ConnectionDetail.displayName = "ConnectionDetail";
interface InnerProps {
data: IConnectionsItem;
onClose?: () => void;
}
const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
const { metadata, rulePayload } = data;
const chains = [...data.chains].reverse().join(" / ");
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule;
const host = metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.destinationIP}:${metadata.destinationPort}`;
const information = [
{ label: "Host", value: host },
{ label: "Download", value: parseTraffic(data.download).join(" ") },
{ label: "Upload", value: parseTraffic(data.upload).join(" ") },
{
label: "DL Speed",
value: parseTraffic(data.curDownload ?? -1).join(" ") + "/s",
},
{
label: "UL Speed",
value: parseTraffic(data.curUpload ?? -1).join(" ") + "/s",
},
{ label: "Chains", value: chains },
{ label: "Rule", value: rule },
{
label: "Process",
value: truncateStr(metadata.process || metadata.processPath),
},
{ label: "Time", value: dayjs(data.start).fromNow() },
{ label: "Source", value: `${metadata.sourceIP}:${metadata.sourcePort}` },
{ label: "Destination IP", value: metadata.destinationIP },
{ label: "Type", value: `${metadata.type}(${metadata.network})` },
];
const onDelete = useLockFn(async () => deleteConnection(data.id));
return (
<Box sx={{ userSelect: "text" }}>
{information.map((each) => (
<div key={each.label}>
<b>{each.label}</b>: <span>{each.value}</span>
</div>
))}
<Box sx={{ textAlign: "right" }}>
<Button
variant="contained"
title="Close Connection"
onClick={() => {
onDelete();
onClose?.();
}}
>
Close
</Button>
</Box>
</Box>
);
};
@@ -1,75 +0,0 @@
import dayjs from "dayjs";
import { useLockFn } from "ahooks";
import {
styled,
ListItem,
IconButton,
ListItemText,
Box,
alpha,
} from "@mui/material";
import { CloseRounded } from "@mui/icons-material";
import { deleteConnection } from "@/services/api";
import parseTraffic from "@/utils/parse-traffic";
const Tag = styled("span")(({ theme }) => ({
fontSize: "10px",
padding: "0 4px",
lineHeight: 1.375,
border: "1px solid",
borderRadius: 4,
borderColor: alpha(theme.palette.text.secondary, 0.35),
marginRight: "4px",
}));
interface Props {
value: IConnectionsItem;
onShowDetail?: () => void;
}
export const ConnectionItem = (props: Props) => {
const { value, onShowDetail } = props;
const { id, metadata, chains, start, curUpload, curDownload } = value;
const onDelete = useLockFn(async () => deleteConnection(id));
const showTraffic = curUpload! >= 100 || curDownload! >= 100;
return (
<ListItem
dense
secondaryAction={
<IconButton edge="end" color="inherit" onClick={onDelete}>
<CloseRounded />
</IconButton>
}
>
<ListItemText
sx={{ userSelect: "text", cursor: "pointer" }}
primary={metadata.host || metadata.destinationIP}
onClick={onShowDetail}
secondary={
<Box sx={{ display: "flex", flexWrap: "wrap" }}>
<Tag sx={{ textTransform: "uppercase", color: "success" }}>
{metadata.network}
</Tag>
<Tag>{metadata.type}</Tag>
{!!metadata.process && <Tag>{metadata.process}</Tag>}
{chains.length > 0 && <Tag>{chains[value.chains.length - 1]}</Tag>}
<Tag>{dayjs(start).fromNow()}</Tag>
{showTraffic && (
<Tag>
{parseTraffic(curUpload!)} / {parseTraffic(curDownload!)}
</Tag>
)}
</Box>
}
/>
</ListItem>
);
};
@@ -1,114 +0,0 @@
import parseTraffic from "@/utils/parse-traffic";
import { truncateStr } from "@/utils/truncate-str";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import dayjs from "dayjs";
import { useMemo, useState } from "react";
interface Props {
connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void;
}
export const ConnectionTable = (props: Props) => {
const { connections, onShowDetail } = props;
const [columnVisible, setColumnVisible] = useState<
Partial<Record<keyof IConnectionsItem, boolean>>
>({});
const columns: GridColDef[] = [
{ field: "host", headerName: "Host", flex: 220, minWidth: 220 },
{
field: "download",
headerName: "Download",
width: 88,
align: "right",
headerAlign: "right",
valueFormatter: (value) => parseTraffic(value).join(" "),
},
{
field: "upload",
headerName: "Upload",
width: 88,
align: "right",
headerAlign: "right",
valueFormatter: (value) => parseTraffic(value).join(" "),
},
{
field: "dlSpeed",
headerName: "DL Speed",
width: 88,
align: "right",
headerAlign: "right",
valueFormatter: (value) => parseTraffic(value).join(" ") + "/s",
},
{
field: "ulSpeed",
headerName: "UL Speed",
width: 88,
align: "right",
headerAlign: "right",
valueFormatter: (value) => parseTraffic(value).join(" ") + "/s",
},
{ field: "chains", headerName: "Chains", flex: 360, minWidth: 360 },
{ field: "rule", headerName: "Rule", flex: 300, minWidth: 250 },
{ field: "process", headerName: "Process", flex: 480, minWidth: 480 },
{
field: "time",
headerName: "Time",
flex: 120,
minWidth: 100,
align: "right",
headerAlign: "right",
},
{ field: "source", headerName: "Source", flex: 200, minWidth: 130 },
{
field: "destinationIP",
headerName: "Destination IP",
flex: 200,
minWidth: 130,
},
{ field: "type", headerName: "Type", flex: 160, minWidth: 100 },
];
const connRows = useMemo(() => {
return connections.map((each) => {
const { metadata, rulePayload } = each;
const chains = [...each.chains].reverse().join(" / ");
const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
return {
id: each.id,
host: metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.destinationIP}:${metadata.destinationPort}`,
download: each.download,
upload: each.upload,
dlSpeed: each.curDownload,
ulSpeed: each.curUpload,
chains,
rule,
process: truncateStr(metadata.process || metadata.processPath),
time: dayjs(each.start).fromNow(),
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
destinationIP: metadata.destinationIP,
type: `${metadata.type}(${metadata.network})`,
connectionData: each,
};
});
}, [connections]);
return (
<DataGrid
hideFooter
rows={connRows}
columns={columns}
onRowClick={(e) => onShowDetail(e.row.connectionData)}
density="compact"
sx={{ border: "none", "div:focus": { outline: "none !important" } }}
columnVisibilityModel={columnVisible}
onColumnVisibilityModelChange={(e) => setColumnVisible(e)}
/>
);
};
@@ -2,7 +2,7 @@ import parseTraffic from "@/utils/parse-traffic";
import { GridColDef, DataGrid } from "@mui/x-data-grid";
import { useClashWS, Connection } from "@nyanpasu/interface";
import dayjs from "dayjs";
import { useRef, useMemo, useEffect } from "react";
import { useRef, useMemo } from "react";
import { useTranslation } from "react-i18next";
import HeaderSearch from "./header-search";
@@ -20,7 +20,7 @@ export const ConnectionsTable = () => {
const { t } = useTranslation();
const {
connections: { readyState, latestMessage },
connections: { latestMessage },
} = useClashWS();
const historyMessage = useRef<TableMessage | undefined>(undefined);
@@ -61,8 +61,18 @@ export const ConnectionsTable = () => {
}, [latestMessage?.data]);
const columns: GridColDef[] = [
{ field: "host", headerName: t("Host"), flex: 240, minWidth: 240 },
{ field: "process", headerName: t("Process"), flex: 100, minWidth: 100 },
{
field: "host",
headerName: t("Host"),
flex: 240,
minWidth: 240,
},
{
field: "process",
headerName: t("Process"),
flex: 100,
minWidth: 100,
},
{
field: "download",
headerName: t("Download"),
@@ -94,7 +104,12 @@ export const ConnectionsTable = () => {
minWidth: 360,
valueFormatter: (value) => [...value].reverse().join(" / "),
},
{ field: "rule", headerName: "Rule", flex: 300, minWidth: 250 },
{
field: "rule",
headerName: "Rule",
flex: 300,
minWidth: 250,
},
{
field: "start",
headerName: t("Time"),
@@ -102,14 +117,24 @@ export const ConnectionsTable = () => {
minWidth: 100,
valueFormatter: (value) => dayjs(value).fromNow(),
},
{ field: "source", headerName: "Source", flex: 200, minWidth: 130 },
{
field: "source",
headerName: "Source",
flex: 200,
minWidth: 130,
},
{
field: "destinationIP",
headerName: t("Destination IP"),
flex: 200,
minWidth: 130,
},
{ field: "type", headerName: t("Type"), flex: 160, minWidth: 100 },
{
field: "type",
headerName: t("Type"),
flex: 160,
minWidth: 100,
},
];
return (
@@ -118,6 +143,7 @@ export const ConnectionsTable = () => {
rows={connectionsMessage.connections}
columns={columns}
density="compact"
autosizeOnMount
hideFooter
disableColumnFilter
disableColumnSelector
@@ -0,0 +1,7 @@
.LogoSchema {
fill: var(--primary-main);
:global(#bg) {
fill: var(--background-color);
}
}
@@ -0,0 +1,4 @@
declare const classNames: {
readonly LogoSchema: "LogoSchema";
};
export default classNames;
@@ -1,34 +1,67 @@
import LogoSvg from "@/assets/image/logo.svg?react";
import getSystem from "@/utils/get-system";
import { motion } from "framer-motion";
import { useRef } from "react";
import { UpdateButton } from "./update-button";
const OS = getSystem();
import { AnimatePresence, Variants, motion } from "framer-motion";
import { classNames } from "@/utils";
import { CSSProperties } from "react";
import styles from "./animated-logo.module.scss";
import { useNyanpasu } from "@nyanpasu/interface";
const Logo = motion(LogoSvg);
export default function AnimatedLogo() {
const constraintsRef = useRef<HTMLDivElement>(null);
const transition = {
type: "spring",
stiffness: 260,
damping: 20,
};
const motionVariants: { [name: string]: Variants } = {
default: {
initial: {
opacity: 0,
scale: 0.5,
transition,
},
animate: {
opacity: 1,
scale: 1,
transition,
},
exit: {
opacity: 0,
scale: 0.5,
transition,
},
whileHover: {
scale: 1.1,
transition,
},
},
none: {
initial: {},
animate: {},
exit: {},
},
};
export default function AnimatedLogo({
className,
style,
disbaleMotion,
}: {
className?: string;
style?: CSSProperties;
disbaleMotion?: boolean;
}) {
const { nyanpasuConfig } = useNyanpasu();
const disbale = disbaleMotion ?? nyanpasuConfig?.lighten_animation_effects;
return (
<div className="the-logo" ref={constraintsRef}>
<AnimatePresence initial={false}>
<Logo
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.1 }}
transition={{
type: "spring",
stiffness: 260,
damping: 20,
}}
drag
dragConstraints={constraintsRef}
className={classNames(styles.LogoSchema, className)}
variants={motionVariants[disbale ? "none" : "default"]}
style={style}
/>
{!(OS === "windows" && WIN_PORTABLE) && (
<UpdateButton className="the-newbtn" />
)}
</div>
</AnimatePresence>
);
}
@@ -1,5 +1,4 @@
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { save_window_size_state } from "@/services/cmds";
import { classNames } from "@/utils";
import {
CloseRounded,
@@ -8,6 +7,7 @@ import {
HorizontalRuleRounded,
} from "@mui/icons-material";
import { alpha, Button, ButtonProps, useTheme } from "@mui/material";
import { save_window_size_state } from "@nyanpasu/interface";
import { platform, type Platform } from "@tauri-apps/api/os";
import { appWindow } from "@tauri-apps/api/window";
import { debounce } from "lodash-es";
@@ -1,42 +0,0 @@
import { useNavigate, type Params, type Path } from "@/router";
import type { LinkProps } from "@generouted/react-router/client";
import { ListItem, ListItemButton, ListItemText, alpha } from "@mui/material";
import { useMatch, useResolvedPath } from "react-router-dom";
export const LayoutItem = (props: LinkProps<Path, Params>) => {
const { to, children } = props;
const resolved = useResolvedPath(to);
const match = useMatch({ path: resolved.pathname, end: true });
const navigate = useNavigate();
return (
<ListItem sx={{ py: 0.5, maxWidth: 250, mx: "auto" }}>
<ListItemButton
selected={!!match}
sx={[
{
borderRadius: 8,
textAlign: "center",
"& .MuiListItemText-primary": { color: "text.secondary" },
},
({ palette: { mode, primary } }) => {
const bgcolor =
mode === "light"
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35);
const color = mode === "light" ? primary.main : primary.light;
return {
"&.Mui-selected": { bgcolor },
"&.Mui-selected:hover": { bgcolor },
"&.Mui-selected .MuiListItemText-primary": { color },
};
},
]}
onClick={() => navigate(to)}
>
<ListItemText primary={children} />
</ListItemButton>
</ListItem>
);
};
@@ -1,137 +0,0 @@
import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { useVisibility } from "@/hooks/use-visibility";
import { useWebsocket } from "@/hooks/use-websocket";
import parseTraffic from "@/utils/parse-traffic";
import {
ArrowDownward,
ArrowUpward,
MemoryOutlined,
} from "@mui/icons-material";
import { Box, Typography } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import { TrafficGraph, type TrafficRef } from "./traffic-graph";
import { useLogSetup } from "./use-log-setup";
// setup the traffic
export const LayoutTraffic = () => {
const { clashInfo } = useClashInfo();
const { verge } = useVerge();
// whether hide traffic graph
const trafficGraph = verge?.traffic_graph ?? true;
const trafficRef = useRef<TrafficRef>(null);
const [traffic, setTraffic] = useState({ up: 0, down: 0 });
const [memory, setMemory] = useState({ inuse: 0 });
const pageVisible = useVisibility();
// setup log ws during layout
useLogSetup();
const { connect, disconnect } = useWebsocket((event) => {
const data = JSON.parse(event.data) as ITrafficItem;
trafficRef.current?.appendData(data);
setTraffic(data);
});
useEffect(() => {
if (!clashInfo || !pageVisible) return;
const { server = "", secret = "" } = clashInfo;
connect(`ws://${server}/traffic?token=${encodeURIComponent(secret)}`);
return () => {
disconnect();
};
}, [clashInfo, pageVisible]);
/* --------- meta memory information --------- */
const isMetaCore =
verge?.clash_core === "mihomo" || verge?.clash_core === "mihomo-alpha";
const displayMemory = isMetaCore && (verge?.enable_memory_usage ?? true);
const memoryWs = useWebsocket(
(event) => {
setMemory(JSON.parse(event.data));
},
{ onError: () => setMemory({ inuse: 0 }) },
);
useEffect(() => {
if (!clashInfo || !pageVisible || !displayMemory) return;
const { server = "", secret = "" } = clashInfo;
memoryWs.connect(
`ws://${server}/memory?token=${encodeURIComponent(secret)}`,
);
return () => memoryWs.disconnect();
}, [clashInfo, pageVisible, displayMemory]);
const [up, upUnit] = parseTraffic(traffic.up);
const [down, downUnit] = parseTraffic(traffic.down);
const [inuse, inuseUnit] = parseTraffic(memory.inuse);
const iconStyle: any = {
sx: { mr: "8px", fontSize: 16 },
};
const valStyle: any = {
component: "span",
color: "primary",
textAlign: "center",
sx: { flex: "1 1 56px", userSelect: "none" },
};
const unitStyle: any = {
component: "span",
color: "grey.500",
fontSize: "12px",
textAlign: "right",
sx: { flex: "0 1 27px", userSelect: "none" },
};
return (
<Box
width="110px"
position="relative"
onClick={trafficRef.current?.toggleStyle}
>
{trafficGraph && pageVisible && (
<div style={{ width: "100%", height: 60, marginBottom: 6 }}>
<TrafficGraph ref={trafficRef} />
</div>
)}
<Box display="flex" flexDirection="column" gap={0.75}>
<Box display="flex" alignItems="center" whiteSpace="nowrap">
<ArrowUpward
{...iconStyle}
color={+up > 0 ? "primary" : "disabled"}
/>
<Typography {...valStyle}>{up}</Typography>
<Typography {...unitStyle}>{upUnit}/s</Typography>
</Box>
<Box display="flex" alignItems="center" whiteSpace="nowrap">
<ArrowDownward
{...iconStyle}
color={+down > 0 ? "primary" : "disabled"}
/>
<Typography {...valStyle}>{down}</Typography>
<Typography {...unitStyle}>{downUnit}/s</Typography>
</Box>
{displayMemory && (
<Box
display="flex"
alignItems="center"
whiteSpace="nowrap"
title="Memory Usage"
>
<MemoryOutlined {...iconStyle} color="disabled" />
<Typography {...valStyle}>{inuse}</Typography>
<Typography {...unitStyle}>{inuseUnit}</Typography>
</Box>
)}
</Box>
</Box>
);
};
@@ -0,0 +1,38 @@
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { listen } from "@tauri-apps/api/event";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
export const NoticeProvider = () => {
const { t } = useTranslation();
useEffect(() => {
listen("verge://notice-message", ({ payload }) => {
const [status, msg] = payload as [string, string];
switch (status) {
case "set_config::ok":
useNotification({
title: t("Success"),
body: "Refresh Clash Config",
type: NotificationType.Success,
});
break;
case "set_config::error":
useNotification({
title: t("Error"),
body: msg,
type: NotificationType.Error,
});
break;
default:
break;
}
});
}, []);
return null;
};
export default NoticeProvider;
@@ -1,29 +1,21 @@
import { useVerge } from "@/hooks/use-verge";
import { classNames } from "@/utils";
import { motion, type HTMLMotionProps } from "framer-motion";
import { useState } from "react";
import { useOutlet } from "react-router-dom";
import { useNyanpasu } from "@nyanpasu/interface";
import { AnimatePresence, Variant, motion } from "framer-motion";
import { useLocation, useOutlet } from "react-router-dom";
type Props = {
children?: React.ReactNode;
type PageVariantKey = "initial" | "visible" | "hidden";
type PageVariant = {
[key in PageVariantKey]: Variant;
};
interface PageTransitionVariant {
initial: HTMLMotionProps<"div">["initial"];
visible: HTMLMotionProps<"div">["animate"];
hidden: HTMLMotionProps<"div">["exit"];
transition?: HTMLMotionProps<"div">["transition"];
}
const commonTransition = {
type: "spring",
bounce: 0.3,
duration: 0.5,
delayChildren: 0.2,
staggerChildren: 0.05,
bounce: 0,
duration: 0.35,
};
export const pageTransitionVariants = {
export const pageTransitionVariants: { [name: string]: PageVariant } = {
blur: {
initial: { opacity: 0, filter: "blur(10px)" },
visible: { opacity: 1, filter: "blur(0px)" },
@@ -31,9 +23,9 @@ export const pageTransitionVariants = {
},
slide: {
initial: {
translateY: "50%",
translateY: "30%",
opacity: 0,
scale: 0.9,
scale: 0.95,
},
visible: {
translateY: "0%",
@@ -42,7 +34,6 @@ export const pageTransitionVariants = {
transition: commonTransition,
},
hidden: {
translateY: "-50%",
opacity: 0,
scale: 0.9,
transition: commonTransition,
@@ -53,66 +44,33 @@ export const pageTransitionVariants = {
visible: { opacity: 1 },
hidden: { opacity: 0 },
},
none: {
initial: {},
visible: {},
hidden: {},
},
} satisfies Record<string, PageTransitionVariant>;
function overrideVariantsTransition(
variants: Record<string, PageTransitionVariant>,
transition?: HTMLMotionProps<"div">["transition"],
) {
if (!transition) return variants;
return Object.keys(variants).reduce(
(acc, cur) => {
acc[cur] = Object.entries(variants[cur]).reduce((acc, [key, value]) => {
if (key === "initial") {
acc[key] = value;
return acc;
}
// @ts-expect-error ts(7053) - 懒得针对工具方法做类型体操了
acc[key] = {
...value,
transition,
};
return acc;
}, {} as PageTransitionVariant);
return acc;
},
{} as Record<string, PageTransitionVariant>,
);
}
const AnimatedOutlet: React.FC = () => {
const o = useOutlet();
const [outlet] = useState(o);
return <>{outlet}</>;
};
export default function PageTransition() {
const { verge } = useVerge();
const { theme_setting } = verge ?? {};
const variants = overrideVariantsTransition(
pageTransitionVariants,
theme_setting?.page_transition_duration
? {
duration: theme_setting.page_transition_duration,
}
: undefined,
) as typeof pageTransitionVariants;
export default function PageTransition({ className }: { className?: string }) {
const { nyanpasuConfig } = useNyanpasu();
const outlet = useOutlet();
const hashkey = useLocation().pathname;
const variants = nyanpasuConfig?.lighten_animation_effects
? pageTransitionVariants.transparent
: pageTransitionVariants.slide;
return (
<motion.div
className={classNames("page-transition", "the-content")}
key={location.pathname}
variants={variants[verge?.page_transition_animation ?? "slide"]}
initial="initial"
animate="visible"
exit="hidden"
>
<AnimatedOutlet />
</motion.div>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
className={classNames("page-transition", className)}
key={hashkey}
layout
layoutId={hashkey}
variants={variants}
initial="initial"
animate="visible"
exit="hidden"
>
{outlet}
</motion.div>
</AnimatePresence>
);
}
@@ -0,0 +1,44 @@
import { listen } from "@tauri-apps/api/event";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
export const SchemeProvider = () => {
const navigate = useNavigate();
useEffect(() => {
listen("scheme-request-received", (req) => {
const message: string = req.payload as string;
const url = new URL(message);
if (url.pathname.endsWith("/")) {
url.pathname = url.pathname.slice(0, -1);
}
if (url.pathname.startsWith("//")) {
url.pathname = url.pathname.slice(1);
}
switch (url.pathname) {
case "/subscribe-remote-profile":
navigate("/profiles", {
state: {
subscribe: {
url: url.searchParams.get("url"),
name: url.searchParams.has("name")
? decodeURIComponent(url.searchParams.get("name")!)
: undefined,
desc: url.searchParams.has("desc")
? decodeURIComponent(url.searchParams.get("desc")!)
: undefined,
},
},
});
}
});
}, []);
return null;
};
export default SchemeProvider;
@@ -1,197 +0,0 @@
import { useTheme } from "@mui/material";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
const maxPoint = 30;
const refLineAlpha = 1;
const refLineWidth = 2;
const upLineAlpha = 0.6;
const upLineWidth = 4;
const downLineAlpha = 1;
const downLineWidth = 4;
const defaultList = Array(maxPoint + 2).fill({ up: 0, down: 0 });
type TrafficData = { up: number; down: number };
export interface TrafficRef {
appendData: (data: TrafficData) => void;
toggleStyle: () => void;
}
/**
* draw the traffic graph
*/
export const TrafficGraph = forwardRef<TrafficRef>((props, ref) => {
const countRef = useRef(0);
const styleRef = useRef(true);
const listRef = useRef<TrafficData[]>(defaultList);
const canvasRef = useRef<HTMLCanvasElement>(null!);
const cacheRef = useRef<TrafficData | null>(null);
const { palette } = useTheme();
useImperativeHandle(ref, () => ({
appendData: (data: TrafficData) => {
cacheRef.current = data;
},
toggleStyle: () => {
styleRef.current = !styleRef.current;
},
}));
useEffect(() => {
let timer: any;
const zero = { up: 0, down: 0 };
const handleData = () => {
const data = cacheRef.current ? cacheRef.current : zero;
cacheRef.current = null;
const list = listRef.current;
if (list.length > maxPoint + 2) list.shift();
list.push(data);
countRef.current = 0;
timer = setTimeout(handleData, 1000);
};
handleData();
return () => {
if (timer) clearTimeout(timer);
};
}, []);
useEffect(() => {
let raf = 0;
const canvas = canvasRef.current!;
if (!canvas) return;
const context = canvas.getContext("2d")!;
if (!context) return;
const { primary, secondary, divider } = palette;
const refLineColor = divider || "rgba(0, 0, 0, 0.12)";
const upLineColor = secondary.main || "#9c27b0";
const downLineColor = primary.main || "#5b5c9d";
const width = canvas.width;
const height = canvas.height;
const dx = width / maxPoint;
const dy = height / 7;
const l1 = dy;
const l2 = dy * 4;
const countY = (v: number) => {
const h = height;
if (v == 0) return h - 1;
if (v <= 10) return h - (v / 10) * dy;
if (v <= 100) return h - (v / 100 + 1) * dy;
if (v <= 1024) return h - (v / 1024 + 2) * dy;
if (v <= 10240) return h - (v / 10240 + 3) * dy;
if (v <= 102400) return h - (v / 102400 + 4) * dy;
if (v <= 1048576) return h - (v / 1048576 + 5) * dy;
if (v <= 10485760) return h - (v / 10485760 + 6) * dy;
return 1;
};
const drawBezier = (list: number[], offset: number) => {
const points = list.map((y, i) => [
(dx * (i - 1) - offset + 3) | 0,
countY(y),
]);
let x = points[0][0];
let y = points[0][1];
context.moveTo(x, y);
for (let i = 1; i < points.length; i++) {
const p1 = points[i];
const p2 = points[i + 1] || p1;
const x1 = (p1[0] + p2[0]) / 2;
const y1 = (p1[1] + p2[1]) / 2;
context.quadraticCurveTo(p1[0], p1[1], x1, y1);
x = x1;
y = y1;
}
};
const drawLine = (list: number[], offset: number) => {
const points = list.map((y, i) => [
(dx * (i - 1) - offset) | 0,
countY(y),
]);
context.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) {
const p = points[i];
context.lineTo(p[0], p[1]);
}
};
const drawGraph = (lastTime: number) => {
const listUp = listRef.current.map((v) => v.up);
const listDown = listRef.current.map((v) => v.down);
const lineStyle = styleRef.current;
const now = Date.now();
const diff = now - lastTime;
const temp = Math.min((diff / 1000) * dx + countRef.current, dx);
const offset = countRef.current === 0 ? 0 : temp;
countRef.current = temp;
context.clearRect(0, 0, width, height);
// Reference lines
context.beginPath();
context.globalAlpha = refLineAlpha;
context.lineWidth = refLineWidth;
context.strokeStyle = refLineColor;
context.moveTo(0, l1);
context.lineTo(width, l1);
context.moveTo(0, l2);
context.lineTo(width, l2);
context.stroke();
context.closePath();
context.beginPath();
context.globalAlpha = upLineAlpha;
context.lineWidth = upLineWidth;
context.strokeStyle = upLineColor;
lineStyle ? drawBezier(listUp, offset) : drawLine(listUp, offset);
context.stroke();
context.closePath();
context.beginPath();
context.globalAlpha = downLineAlpha;
context.lineWidth = downLineWidth;
context.strokeStyle = downLineColor;
lineStyle ? drawBezier(listDown, offset) : drawLine(listDown, offset);
context.stroke();
context.closePath();
raf = requestAnimationFrame(() => drawGraph(now));
};
drawGraph(Date.now());
return () => {
cancelAnimationFrame(raf);
};
}, [palette]);
return <canvas ref={canvasRef} style={{ width: "100%", height: "100%" }} />;
});
TrafficGraph.displayName = "TrafficGraph";
@@ -1,49 +0,0 @@
import { useVerge } from "@/hooks/use-verge";
import { Button } from "@mui/material";
import { checkUpdate } from "@tauri-apps/api/updater";
import { useRef } from "react";
import useSWR from "swr";
import { DialogRef } from "../base";
import { UpdateViewer } from "../setting/mods/update-viewer";
interface Props {
className?: string;
}
export const UpdateButton = (props: Props) => {
const { className } = props;
const viewerRef = useRef<DialogRef>(null);
const { verge } = useVerge();
const { disable_auto_check_update } = verge ?? {};
const { data: updateInfo } = useSWR(
disable_auto_check_update ? null : "checkUpdate",
disable_auto_check_update ? null : checkUpdate,
{
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
},
);
if (!updateInfo?.shouldUpdate) return null;
return (
<>
<UpdateViewer ref={viewerRef} />
<Button
color="error"
variant="contained"
size="small"
className={className}
onClick={() => viewerRef.current?.open()}
>
New
</Button>
</>
);
};
@@ -6,6 +6,7 @@ import { useNyanpasu } from "@nyanpasu/interface";
import { createMDYTheme } from "@nyanpasu/ui";
import { useAtomValue, useSetAtom } from "jotai";
import { themeMode as themeModeAtom } from "@/store";
import { useWhyDidYouUpdate } from "ahooks";
const applyRootStyleVar = (mode: "light" | "dark", theme: Theme) => {
const root = document.documentElement;
@@ -37,6 +38,8 @@ export const useCustomTheme = () => {
const { nyanpasuConfig } = useNyanpasu();
const themeMode = useAtomValue(themeModeAtom);
useWhyDidYouUpdate("useCustomTheme", { nyanpasuConfig, themeMode });
const theme = useMemo(() => {
const mergedTheme = createMDYTheme({
...defaultTheme,
@@ -1,39 +0,0 @@
import dayjs from "dayjs";
import { useEffect } from "react";
import { getClashLogs } from "@/services/cmds";
import { useClashInfo } from "@/hooks/use-clash";
import { useWebsocket } from "@/hooks/use-websocket";
import { useAtomValue, useSetAtom } from "jotai";
import { atomEnableLog, atomLogData } from "@/store";
const MAX_LOG_NUM = 1000;
// setup the log websocket
export const useLogSetup = () => {
const { clashInfo } = useClashInfo();
const enableLog = useAtomValue(atomEnableLog);
const setLogData = useSetAtom(atomLogData);
const { connect, disconnect } = useWebsocket((event) => {
const data = JSON.parse(event.data) as ILogItem;
const time = dayjs().format("MM-DD HH:mm:ss");
setLogData((l) => {
if (l.length >= MAX_LOG_NUM) l.shift();
return [...l, { ...data, time }];
});
});
useEffect(() => {
if (!enableLog || !clashInfo) return;
getClashLogs().then(setLogData);
const { server = "", secret = "" } = clashInfo;
connect(`ws://${server}/logs?token=${encodeURIComponent(secret)}`);
return () => {
disconnect();
};
}, [clashInfo, enableLog]);
};
@@ -1,23 +0,0 @@
.item {
:global(.shiki) {
margin-top: 0.25em;
margin-bottom: 0;
background-color: transparent !important;
* {
font-family: var(--item-font);
}
span {
white-space: normal;
}
}
&.dark {
:global(.shiki) {
span {
color: var(--shiki-dark) !important;
}
}
}
}
@@ -1,6 +0,0 @@
declare const classNames: {
readonly item: "item";
readonly shiki: "shiki";
readonly dark: "dark";
};
export default classNames;
@@ -1,92 +0,0 @@
import { classNames } from "@/utils";
import { formatAnsi } from "@/utils/shiki";
import { Box, styled, useTheme } from "@mui/material";
import React, { useEffect, useState } from "react";
import styles from "./log-item.module.scss";
const Item = styled(Box)(({ theme: { palette, typography } }) => ({
padding: "8px 0",
margin: "0 12px",
lineHeight: 1.35,
borderBottom: `1px solid ${palette.divider}`,
fontSize: "0.875rem",
fontFamily: typography.fontFamily,
userSelect: "text",
"& .time": {
color: palette.text.secondary,
fontWeight: "thin",
},
"& .type": {
display: "inline-block",
marginLeft: 8,
textAlign: "center",
borderRadius: 2,
textTransform: "uppercase",
fontWeight: "600",
},
'& .type[data-type="error"], & .type[data-type="err"]': {
color: palette.error.main,
},
'& .type[data-type="warning"], & .type[data-type="warn"]': {
color: palette.warning.main,
},
'& .type[data-type="info"], & .type[data-type="inf"]': {
color: palette.info.main,
},
"& .data": {
color: palette.text.primary,
},
}));
interface Props {
value: ILogItem;
index: number;
}
const LogItem = (props: Props) => {
const theme = useTheme();
const { value, index } = props;
const [payload, setPayload] = useState(value.payload);
useEffect(() => {
formatAnsi(value.payload).then((res) => {
setPayload(res);
});
}, [value.payload]);
return (
<Item
sx={{
ml: 3.5,
mr: 3.5,
// pt: index === 0 ? 8 : 0,
}}
>
<div>
<span className="time">{value.time}</span>
<span className="type" data-type={value.type.toLowerCase()}>
{value.type}
</span>
</div>
<div
style={
{
"--item-font": theme.typography.fontFamily as string,
} as React.CSSProperties
}
>
<span
className={classNames(
styles.item,
theme.palette.mode === "dark" && styles.dark,
"data",
)}
dangerouslySetInnerHTML={{
__html: payload,
}}
/>
</div>
</Item>
);
};
export default LogItem;
@@ -1,94 +0,0 @@
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from "@mui/material";
import { useLockFn } from "ahooks";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { monaco } from "@/services/monaco";
import { useAtomValue } from "jotai";
import { themeMode as atomThemeMode } from "@/store";
interface Props {
uid: string;
open: boolean;
mode: "yaml" | "javascript";
onClose: () => void;
onChange?: () => void;
}
export const EditorViewer = (props: Props) => {
const { uid, open, mode, onClose, onChange } = props;
const { t } = useTranslation();
const editorRef = useRef<any>();
const instanceRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const themeMode = useAtomValue(atomThemeMode);
useEffect(() => {
if (!open) return;
readProfileFile(uid).then((data) => {
const dom = editorRef.current;
if (!dom) return;
if (instanceRef.current) instanceRef.current.dispose();
instanceRef.current = monaco.editor.create(editorRef.current, {
value: data,
language: mode,
theme: themeMode === "light" ? "vs" : "vs-dark",
minimap: { enabled: false },
});
});
return () => {
if (instanceRef.current) {
instanceRef.current.dispose();
instanceRef.current = null;
}
};
}, [open]);
const onSave = useLockFn(async () => {
const value = instanceRef.current?.getValue();
if (value == null) return;
try {
await saveProfileFile(uid, value);
onChange?.();
onClose();
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t("Edit File")}</DialogTitle>
<DialogContent sx={{ width: 520, pb: 1, userSelect: "text" }}>
<div style={{ width: "100%", height: "420px" }} ref={editorRef} />
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("Cancel")}
</Button>
<Button onClick={onSave} variant="contained">
{t("Save")}
</Button>
</DialogActions>
</Dialog>
);
};
@@ -1,61 +0,0 @@
import { useRef, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { Box, Button, Typography } from "@mui/material";
interface Props {
onChange: (value: string) => void;
}
export const FileInput = (props: Props) => {
const { onChange } = props;
const { t } = useTranslation();
// file input
const inputRef = useRef<any>();
const [loading, setLoading] = useState(false);
const [fileName, setFileName] = useState("");
const onFileInput = useLockFn(async (e: any) => {
const file = e.target.files?.[0] as File;
if (!file) return;
setFileName(file.name);
setLoading(true);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
resolve(null);
onChange(event.target?.result as string);
};
reader.onerror = reject;
reader.readAsText(file);
}).finally(() => setLoading(false));
});
return (
<Box sx={{ mt: 2, mb: 1, display: "flex", alignItems: "center" }}>
<Button
variant="outlined"
sx={{ flex: "none" }}
onClick={() => inputRef.current?.click()}
>
{t("Choose File")}
</Button>
<input
type="file"
accept=".yaml,.yml"
ref={inputRef}
style={{ display: "none" }}
onChange={onFileInput}
/>
<Typography noWrap sx={{ ml: 1 }}>
{loading ? "Loading..." : fileName}
</Typography>
</Box>
);
};
@@ -1,69 +0,0 @@
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Typography,
} from "@mui/material";
import { BaseEmpty } from "@/components/base";
interface Props {
open: boolean;
logInfo: [string, string][];
onClose: () => void;
}
export const LogViewer = (props: Props) => {
const { open, logInfo, onClose } = props;
const { t } = useTranslation();
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t("Script Console")}</DialogTitle>
<DialogContent
sx={{
width: 400,
height: 300,
overflowX: "hidden",
userSelect: "text",
pb: 1,
}}
>
{logInfo.map(([level, log], index) => (
<Fragment key={index.toString()}>
<Typography color="text.secondary" component="div">
<Chip
label={level}
size="small"
variant="outlined"
color={
level === "error" || level === "exception"
? "error"
: "default"
}
sx={{ mr: 1 }}
/>
{log}
</Typography>
<Divider sx={{ my: 0.5 }} />
</Fragment>
))}
{logInfo.length === 0 && <BaseEmpty />}
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("Back")}
</Button>
</DialogActions>
</Dialog>
);
};
@@ -1,45 +0,0 @@
import { alpha, Box, styled } from "@mui/material";
export const ProfileBox = styled(Box)(({
theme,
"aria-selected": selected,
}) => {
const { mode, primary, text, grey, background } = theme.palette;
const key = `${mode}-${!!selected}`;
const backgroundColor = {
"light-true": alpha(primary.main, 0.2),
"light-false": alpha(background.paper, 0.75),
"dark-true": alpha(primary.main, 0.45),
"dark-false": alpha(grey[700], 0.45),
}[key]!;
const color = {
"light-true": text.secondary,
"light-false": text.secondary,
"dark-true": alpha(text.secondary, 0.85),
"dark-false": alpha(text.secondary, 0.65),
}[key]!;
const h2color = {
"light-true": primary.main,
"light-false": text.primary,
"dark-true": primary.light,
"dark-false": text.primary,
}[key]!;
return {
position: "relative",
width: "100%",
display: "block",
cursor: "pointer",
textAlign: "left",
borderRadius: 14,
boxShadow: theme.shadows[2],
padding: "10px 14px",
boxSizing: "border-box",
backgroundColor,
color,
"& h2": { color: h2color },
};
});
@@ -1,389 +0,0 @@
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { deleteProfile, updateProfile, viewProfile } from "@/services/cmds";
import parseTraffic from "@/utils/parse-traffic";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { DragIndicator, RefreshRounded } from "@mui/icons-material";
import {
Box,
CircularProgress,
IconButton,
LinearProgress,
Menu,
MenuItem,
Paper,
Typography,
keyframes,
} from "@mui/material";
import { useLockFn } from "ahooks";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { mutate } from "swr";
import { EditorViewer } from "./editor-viewer";
import { ProfileBox } from "./profile-box";
import { useAtom } from "jotai";
import { atomLoadingCache } from "@/store";
const round = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
interface Props {
id: string;
selected: boolean;
activating: boolean;
itemData: IProfileItem;
onSelect: (force: boolean) => void;
onEdit: () => void;
}
export const ProfileItem = (props: Props) => {
const { selected, activating, itemData, onSelect, onEdit } = props;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: props.id });
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [loadingCache, setLoadingCache] = useAtom(atomLoadingCache);
const { uid, name = "Profile", extra, updated = 0 } = itemData;
// local file mode
// remote file mode
const hasUrl = !!itemData.url;
const hasExtra = !!extra; // only subscription url has extra info
const { upload = 0, download = 0, total = 0 } = extra ?? {};
const from = parseUrl(itemData.url);
const expire = parseExpire(extra?.expire);
const progress = Math.round(((download + upload) * 100) / (total + 0.1));
const loading = loadingCache[itemData.uid] ?? false;
// interval update fromNow field
const [, setRefresh] = useState({});
useEffect(() => {
if (!hasUrl) return;
let timer: any = null;
const handler = () => {
const now = Date.now();
const lastUpdate = updated * 1000;
// 大于一天的不管
if (now - lastUpdate >= 24 * 36e5) return;
const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4;
timer = setTimeout(() => {
setRefresh({});
handler();
}, wait);
};
handler();
return () => {
if (timer) clearTimeout(timer);
};
}, [hasUrl, updated]);
const [fileOpen, setFileOpen] = useState(false);
const onEditInfo = () => {
setAnchorEl(null);
onEdit();
};
const onEditFile = () => {
setAnchorEl(null);
setFileOpen(true);
};
const onForceSelect = () => {
setAnchorEl(null);
onSelect(true);
};
const onOpenFile = useLockFn(async () => {
setAnchorEl(null);
try {
await viewProfile(itemData.uid);
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
/// 0 不使用任何代理
/// 1 使用配置好的代理
/// 2 至少使用一个代理,根据配置,如果没配置,默认使用系统代理
const onUpdate = useLockFn(async (type: 0 | 1 | 2) => {
setAnchorEl(null);
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
const option: Partial<IProfileOption> = {};
if (type === 0) {
option.with_proxy = false;
option.self_proxy = false;
} else if (type === 1) {
// nothing
} else if (type === 2) {
if (itemData.option?.self_proxy) {
option.with_proxy = false;
option.self_proxy = true;
} else {
option.with_proxy = true;
option.self_proxy = false;
}
}
try {
await updateProfile(itemData.uid, option);
mutate("getProfiles");
} catch (err: any) {
const errmsg = err?.message || err.toString();
useNotification({
title: t("Error"),
body:
err.message ||
errmsg.replace(/error sending request for url (\S+?): /, ""),
type: NotificationType.Error,
});
} finally {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
}
});
const onDelete = useLockFn(async () => {
setAnchorEl(null);
try {
await deleteProfile(itemData.uid);
mutate("getProfiles");
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
const urlModeMenu = [
{ label: "Select", handler: onForceSelect },
{ label: "Edit Info", handler: onEditInfo },
{ label: "Edit File", handler: onEditFile },
{ label: "Open File", handler: onOpenFile },
{ label: "Update", handler: () => onUpdate(0) },
{ label: "Update(Proxy)", handler: () => onUpdate(2) },
{ label: "Delete", handler: onDelete },
];
const fileModeMenu = [
{ label: "Select", handler: onForceSelect },
{ label: "Edit Info", handler: onEditInfo },
{ label: "Edit File", handler: onEditFile },
{ label: "Open File", handler: onOpenFile },
{ label: "Delete", handler: onDelete },
];
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
};
return (
<Box
sx={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<ProfileBox
aria-selected={selected}
onClick={() => onSelect(false)}
onContextMenu={(event) => {
const { clientX, clientY } = event;
setPosition({ top: clientY, left: clientX });
setAnchorEl(event.currentTarget);
event.preventDefault();
}}
sx={{
borderRadius: 4,
}}
>
{activating && (
<Box
sx={{
position: "absolute",
display: "flex",
justifyContent: "center",
alignItems: "center",
top: 10,
left: 10,
right: 10,
bottom: 2,
zIndex: 10,
backdropFilter: "blur(2px)",
}}
>
<CircularProgress size={20} />
</Box>
)}
<Box position="relative">
<Paper
ref={setNodeRef}
sx={{
width: "28px",
height: "28px",
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "absolute",
top: "-24px",
left: "-28px",
}}
{...attributes}
{...listeners}
>
<DragIndicator sx={{ cursor: "grab" }} />
</Paper>
<Box sx={{ display: "flex", justifyContent: "start" }}>
<Typography
width="calc(100% - 40px)"
variant="h6"
component="h2"
noWrap
title={name}
>
{name}
</Typography>
</Box>
{/* only if has url can it be updated */}
{hasUrl && (
<IconButton
sx={{
position: "absolute",
p: "3px",
top: -1,
right: -5,
animation: loading ? `1s linear infinite ${round}` : "none",
}}
size="small"
color="inherit"
disabled={loading}
onClick={(e) => {
e.stopPropagation();
onUpdate(1);
}}
>
<RefreshRounded color="inherit" />
</IconButton>
)}
</Box>
{/* the second line show url's info or description */}
<Box sx={boxStyle}>
{hasUrl ? (
<>
<Typography noWrap title={`From: ${from}`}>
{from}
</Typography>
<Typography
noWrap
flex="1 0 auto"
fontSize={14}
textAlign="right"
title={`Updated Time: ${parseExpire(updated)}`}
>
{updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
</Typography>
</>
) : (
<Typography noWrap title={itemData.desc}>
{itemData.desc}
</Typography>
)}
</Box>
{/* the third line show extra info or last updated time */}
{hasExtra ? (
<Box sx={{ ...boxStyle, fontSize: 14 }}>
<span title="Used / Total">
{parseTraffic(upload + download)} / {parseTraffic(total)}
</span>
<span title="Expire Time">{expire}</span>
</Box>
) : (
<Box sx={{ ...boxStyle, fontSize: 14, justifyContent: "flex-end" }}>
<span title="Updated Time">{parseExpire(updated)}</span>
</Box>
)}
<LinearProgress
variant="determinate"
value={progress}
color="inherit"
/>
</ProfileBox>
<Menu
open={!!anchorEl}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorPosition={position}
anchorReference="anchorPosition"
transitionDuration={225}
MenuListProps={{ sx: { py: 0.5 } }}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
}}
>
{(hasUrl ? urlModeMenu : fileModeMenu).map((item) => (
<MenuItem
key={item.label}
onClick={item.handler}
sx={{ minWidth: 120 }}
dense
>
{t(item.label)}
</MenuItem>
))}
</Menu>
<EditorViewer
uid={uid}
open={fileOpen}
mode="yaml"
onClose={() => setFileOpen(false)}
/>
</Box>
);
};
function parseUrl(url?: string) {
if (!url) return "";
const regex = /https?:\/\/(.+?)\//;
const result = url.match(regex);
return result ? result[1] : "local file";
}
function parseExpire(expire?: number) {
if (!expire) return "-";
return dayjs(expire * 1000).format("YYYY-MM-DD");
}
@@ -1,247 +0,0 @@
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { viewProfile } from "@/services/cmds";
import { FeaturedPlayListRounded } from "@mui/icons-material";
import {
Badge,
Box,
Chip,
IconButton,
Menu,
MenuItem,
Typography,
} from "@mui/material";
import { useLockFn } from "ahooks";
import dayjs from "dayjs";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { EditorViewer } from "./editor-viewer";
import { LogViewer } from "./log-viewer";
import { ProfileBox } from "./profile-box";
interface Props {
selected: boolean;
itemData: IProfileItem;
enableNum: number;
logInfo?: [string, string][];
onEnable: () => void;
onDisable: () => void;
onMoveTop: () => void;
onMoveEnd: () => void;
onDelete: () => void;
onEdit: () => void;
}
// profile enhanced item
export const ProfileMore = (props: Props) => {
const {
selected,
itemData,
enableNum,
logInfo = [],
onEnable,
onDisable,
onMoveTop,
onMoveEnd,
onDelete,
onEdit,
} = props;
const { uid, type } = itemData;
const { t, i18n } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [fileOpen, setFileOpen] = useState(false);
const [logOpen, setLogOpen] = useState(false);
const onEditInfo = () => {
setAnchorEl(null);
onEdit();
};
const onEditFile = () => {
setAnchorEl(null);
setFileOpen(true);
};
const onOpenFile = useLockFn(async () => {
setAnchorEl(null);
try {
await viewProfile(itemData.uid);
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
const fnWrapper = (fn: () => void) => () => {
setAnchorEl(null);
return fn();
};
const hasError = !!logInfo.find((e) => e[0] === "exception");
const showMove = enableNum > 1 && !hasError;
const enableMenu = [
{ label: "Disable", handler: fnWrapper(onDisable) },
{ label: "Edit Info", handler: onEditInfo },
{ label: "Edit File", handler: onEditFile },
{ label: "Open File", handler: onOpenFile },
{ label: "To Top", show: showMove, handler: fnWrapper(onMoveTop) },
{ label: "To End", show: showMove, handler: fnWrapper(onMoveEnd) },
{ label: "Delete", handler: fnWrapper(onDelete) },
];
const disableMenu = [
{ label: "Enable", handler: fnWrapper(onEnable) },
{ label: "Edit Info", handler: onEditInfo },
{ label: "Edit File", handler: onEditFile },
{ label: "Open File", handler: onOpenFile },
{ label: "Delete", handler: fnWrapper(onDelete) },
];
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
lineHeight: 1,
};
return (
<>
<ProfileBox
aria-selected={selected}
onDoubleClick={onEditFile}
// onClick={() => onSelect(false)}
onContextMenu={(event) => {
const { clientX, clientY } = event;
setPosition({ top: clientY, left: clientX });
setAnchorEl(event.currentTarget);
event.preventDefault();
}}
>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
mb={0.5}
>
<Typography
width="calc(100% - 52px)"
variant="h6"
component="h2"
noWrap
title={itemData.name}
>
{itemData.name}
</Typography>
<Chip
label={type}
color="primary"
size="small"
variant="outlined"
sx={{ height: 20, textTransform: "capitalize" }}
/>
</Box>
<Box sx={boxStyle}>
{selected && type === "script" ? (
hasError ? (
<Badge color="error" variant="dot" overlap="circular">
<IconButton
size="small"
edge="start"
color="error"
title="Console"
onClick={() => setLogOpen(true)}
>
<FeaturedPlayListRounded fontSize="inherit" />
</IconButton>
</Badge>
) : (
<IconButton
size="small"
edge="start"
color="inherit"
title="Console"
onClick={() => setLogOpen(true)}
>
<FeaturedPlayListRounded fontSize="inherit" />
</IconButton>
)
) : (
<Typography
noWrap
title={itemData.desc}
sx={i18n.language === "zh" ? { width: "calc(100% - 75px)" } : {}}
>
{itemData.desc}
</Typography>
)}
<Typography
noWrap
component="span"
title={`Updated Time: ${parseExpire(itemData.updated)}`}
style={{ fontSize: 14 }}
>
{!!itemData.updated
? dayjs(itemData.updated! * 1000).fromNow()
: ""}
</Typography>
</Box>
</ProfileBox>
<Menu
open={!!anchorEl}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorPosition={position}
anchorReference="anchorPosition"
transitionDuration={225}
MenuListProps={{ sx: { py: 0.5 } }}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
}}
>
{(selected ? enableMenu : disableMenu)
.filter((item: any) => item.show !== false)
.map((item) => (
<MenuItem
key={item.label}
onClick={item.handler}
sx={{ minWidth: 120 }}
dense
>
{t(item.label)}
</MenuItem>
))}
</Menu>
<EditorViewer
uid={uid}
open={fileOpen}
mode={type === "merge" ? "yaml" : "javascript"}
onClose={() => setFileOpen(false)}
/>
{selected && (
<LogViewer
open={logOpen}
logInfo={logInfo}
onClose={() => setLogOpen(false)}
/>
)}
</>
);
};
function parseExpire(expire?: number) {
if (!expire) return "-";
return dayjs(expire * 1000).format("YYYY-MM-DD");
}
@@ -1,294 +0,0 @@
import { BaseDialog } from "@/components/base";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { createProfile, patchProfile } from "@/services/cmds";
import {
Box,
FormControl,
InputAdornment,
InputLabel,
MenuItem,
Select,
TextField,
styled,
} from "@mui/material";
import { useLockFn } from "ahooks";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { version } from "~/package.json";
import MDYSwitch from "../common/mdy-switch";
import { FileInput } from "./file-input";
interface Props {
onChange: () => void;
url?: string;
name?: string;
desc?: string;
}
export interface ProfileViewerRef {
create: () => void;
edit: (item: IProfileItem) => void;
}
// create or edit the profile
// remote / local / merge / script
export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
(props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [openType, setOpenType] = useState<"new" | "edit">("new");
const [loading, setLoading] = useState(false);
// file input
const fileDataRef = useRef<string | null>(null);
const { control, watch, register, ...formIns } = useForm<IProfileItem>({
defaultValues: {
type: "remote",
name: "Remote File",
desc: "",
url: props.url ?? "",
option: {
// user_agent: "",
with_proxy: false,
self_proxy: false,
},
},
});
useImperativeHandle(ref, () => ({
create: () => {
setOpenType("new");
setOpen(true);
},
edit: (item) => {
if (item) {
Object.entries(item).forEach(([key, value]) => {
formIns.setValue(key as any, value);
});
}
setOpenType("edit");
setOpen(true);
},
}));
const selfProxy = watch("option.self_proxy");
const withProxy = watch("option.with_proxy");
useEffect(() => {
if (selfProxy) formIns.setValue("option.with_proxy", false);
}, [selfProxy]);
useEffect(() => {
if (withProxy) formIns.setValue("option.self_proxy", false);
}, [withProxy]);
const handleOk = useLockFn(
formIns.handleSubmit(async (form) => {
setLoading(true);
try {
if (!form.type) throw new Error("`Type` should not be null");
if (form.type === "remote" && !form.url) {
throw new Error("The URL should not be null");
}
if (form.type !== "remote" && form.type !== "local") {
delete form.option;
}
if (form.option?.update_interval) {
form.option.update_interval = +form.option.update_interval;
}
const name = form.name || `${form.type} file`;
const item = { ...form, name };
// 创建
if (openType === "new") {
await createProfile(item, fileDataRef.current);
}
// 编辑
else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, item);
}
setOpen(false);
setLoading(false);
setTimeout(() => formIns.reset(), 500);
fileDataRef.current = null;
props.onChange();
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
setLoading(false);
}
}),
);
const handleClose = () => {
setOpen(false);
fileDataRef.current = null;
setTimeout(() => formIns.reset(), 500);
};
const text = {
fullWidth: true,
size: "small",
margin: "normal",
variant: "outlined",
autoComplete: "off",
autoCorrect: "off",
} as const;
const formType = watch("type");
const isRemote = formType === "remote";
const isLocal = formType === "local";
return (
<BaseDialog
open={open}
title={openType === "new" ? t("Create Profile") : t("Edit Profile")}
contentSx={{ width: 375, pb: 0, maxHeight: "80%" }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={handleClose}
onCancel={handleClose}
onOk={handleOk}
loading={loading}
>
<Controller
name="type"
control={control}
render={({ field }) => (
<FormControl size="small" fullWidth sx={{ mt: 1, mb: 1 }}>
<InputLabel>{t("Type")}</InputLabel>
<Select {...field} autoFocus label={t("Type")}>
<MenuItem value="remote">Remote</MenuItem>
<MenuItem value="local">Local</MenuItem>
<MenuItem value="script">Script</MenuItem>
<MenuItem value="merge">Merge</MenuItem>
</Select>
</FormControl>
)}
/>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField {...text} {...field} label={t("Name")} />
)}
/>
<Controller
name="desc"
control={control}
render={({ field }) => (
<TextField {...text} {...field} label={t("Descriptions")} />
)}
/>
{isRemote && (
<>
<Controller
name="url"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
multiline
label={t("Subscription URL")}
/>
)}
/>
<Controller
name="option.user_agent"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
placeholder={`clash-nyanpasu/v${version}`}
label="User Agent"
/>
)}
/>
</>
)}
{(isRemote || isLocal) && (
<Controller
name="option.update_interval"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
onChange={(e) => {
e.target.value = e.target.value
?.replace(/\D/, "")
.slice(0, 10);
field.onChange(e);
}}
label={t("Update Interval")}
InputProps={{
endAdornment: (
<InputAdornment position="end">mins</InputAdornment>
),
}}
/>
)}
/>
)}
{isLocal && openType === "new" && (
<FileInput onChange={(val) => (fileDataRef.current = val)} />
)}
{isRemote && (
<>
<Controller
name="option.with_proxy"
control={control}
render={({ field }) => (
<StyledBox>
<InputLabel>{t("Use System Proxy")}</InputLabel>
<MDYSwitch checked={field.value} {...field} color="primary" />
</StyledBox>
)}
/>
<Controller
name="option.self_proxy"
control={control}
render={({ field }) => (
<StyledBox>
<InputLabel>{t("Use Clash Proxy")}</InputLabel>
<MDYSwitch checked={field.value} {...field} color="primary" />
</StyledBox>
)}
/>
</>
)}
</BaseDialog>
);
},
);
ProfileViewer.displayName = "ProfileViewer";
const StyledBox = styled(Box)(() => ({
margin: "8px 0 8px 8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}));
@@ -0,0 +1,69 @@
import { memo, useState } from "react";
import FeatureChip from "./feature-chip";
import { getColorForDelay } from "./utils";
import { classNames } from "@/utils";
import { CircularProgress } from "@mui/material";
export const DelayChip = memo(function DelayChip({
delay,
onClick,
}: {
delay: number;
onClick: () => Promise<void>;
}) {
const [loading, setLoading] = useState(false);
const handleClick = async () => {
try {
setLoading(true);
await onClick();
} finally {
setLoading(false);
}
};
return (
<FeatureChip
sx={{
ml: "auto",
color: getColorForDelay(delay),
}}
label={
<>
<span
className={classNames(
"transition-opacity",
loading ? "opacity-0" : "opacity-1",
)}
>
{delay ? `${delay} ms` : "timeout"}
</span>
<CircularProgress
size={12}
className={classNames(
"transition-opacity",
"absolute",
"animate-spin",
"top-0",
"bottom-0",
"left-0",
"right-0",
"m-auto",
loading ? "opacity-1" : "opacity-0",
)}
/>
</>
}
variant="filled"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleClick();
}}
/>
);
});
export default DelayChip;
@@ -0,0 +1,24 @@
import { Chip, ChipProps } from "@mui/material";
import { memo } from "react";
export const FeatureChip = memo(function FeatureChip(props: ChipProps) {
return (
<Chip
variant="outlined"
size="small"
{...props}
sx={{
fontSize: 10,
height: 16,
padding: 0,
"& .MuiChip-label": {
padding: "0 4px",
},
...props.sx,
}}
/>
);
});
export default FeatureChip;
@@ -44,7 +44,11 @@ export const GroupList = (listItemButtonProps: ListItemButtonProps) => {
>
{group.icon && <IconRender icon={group.icon} />}
<ListItemText primary={group.name} secondary={group.now} />
<ListItemText
className="!truncate"
primary={group.name}
secondary={group.now}
/>
</ListItemButton>
</ListItem>
);
@@ -0,0 +1,45 @@
import { Clash } from "@nyanpasu/interface";
import { CSSProperties, memo, useMemo } from "react";
import { filterDelay } from "./utils";
import { PaperSwitchButton } from "../setting/modules/system-proxy";
import Box from "@mui/material/Box";
import FeatureChip from "./feature-chip";
import DelayChip from "./delay-chip";
export const NodeCard = memo(function NodeCard({
node,
now,
disabled,
onClick,
onClickDelay,
style,
}: {
node: Clash.Proxy<string>;
now?: string;
disabled?: boolean;
onClick: () => void;
onClickDelay: () => Promise<void>;
style?: CSSProperties;
}) {
const delay = useMemo(() => filterDelay(node.history), [node.history]);
return (
<PaperSwitchButton
label={node.name}
checked={node.name === now}
onClick={onClick}
disabled={disabled}
style={style}
>
<Box width="100%" display="flex" gap={0.5}>
<FeatureChip label={node.type} />
{node.udp && <FeatureChip label="UDP" />}
<DelayChip delay={delay} onClick={onClickDelay} />
</Box>
</PaperSwitchButton>
);
});
export default NodeCard;
@@ -1,180 +1,16 @@
import {
Box,
Chip,
ChipProps,
CircularProgress,
useTheme,
} from "@mui/material";
import { PaperSwitchButton } from "../setting/modules/system-proxy";
import { Clash, useClashCore, useNyanpasu } from "@nyanpasu/interface";
import { useBreakpoint } from "@nyanpasu/ui";
import { useAtom, useAtomValue } from "jotai";
import { proxyGroupAtom, proxyGroupSortAtom } from "@/store";
import {
CSSProperties,
memo,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useCallback, useEffect, useState, useTransition } from "react";
import { classNames } from "@/utils";
import { VList } from "virtua";
import { AnimatePresence, motion } from "framer-motion";
type History = Clash.Proxy["history"];
import { filterDelay } from "./utils";
import NodeCard from "./node-card";
type RenderClashProxy = Clash.Proxy<string> & { renderLayoutKey: string };
const filterDelay = (history?: History): number => {
if (!history || history.length == 0) {
return 0;
} else {
return history[history.length - 1].delay;
}
};
const getColorForDelay = (delay: number): string => {
const { palette } = useTheme();
const delayColorMapping: { [key: string]: string } = {
"0": palette.error.main,
"1": palette.text.secondary,
"100": palette.success.main,
"500": palette.warning.main,
"10000": palette.error.main,
};
let color: string = palette.text.secondary;
for (const key in delayColorMapping) {
if (delay <= parseInt(key)) {
color = delayColorMapping[key];
break;
}
}
return color;
};
const FeatureChip = memo(function FeatureChip(props: ChipProps) {
return (
<Chip
variant="outlined"
size="small"
{...props}
sx={{
fontSize: 10,
height: 16,
padding: 0,
"& .MuiChip-label": {
padding: "0 4px",
},
...props.sx,
}}
/>
);
});
const DelayChip = memo(function DelayChip({
delay,
onClick,
}: {
delay: number;
onClick: () => Promise<void>;
}) {
const [loading, setLoading] = useState(false);
const handleClick = async () => {
try {
setLoading(true);
await onClick();
} finally {
setLoading(false);
}
};
return (
<FeatureChip
sx={{
ml: "auto",
color: getColorForDelay(delay),
}}
label={
<>
<span
className={classNames(
"transition-opacity",
loading ? "opacity-0" : "opacity-1",
)}
>
{delay ? `${delay} ms` : "timeout"}
</span>
<CircularProgress
size={12}
className={classNames(
"transition-opacity",
"absolute",
"animate-spin",
"top-0",
"bottom-0",
"left-0",
"right-0",
"m-auto",
loading ? "opacity-1" : "opacity-0",
)}
/>
</>
}
variant="filled"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleClick();
}}
/>
);
});
const NodeCard = memo(function NodeCard({
node,
now,
disabled,
onClick,
onClickDelay,
style,
}: {
node: Clash.Proxy<string>;
now?: string;
disabled?: boolean;
onClick: () => void;
onClickDelay: () => Promise<void>;
style?: CSSProperties;
}) {
const delay = useMemo(() => filterDelay(node.history), [node.history]);
return (
<PaperSwitchButton
label={node.name}
checked={node.name === now}
onClick={onClick}
disabled={disabled}
style={style}
>
<Box width="100%" display="flex" gap={0.5}>
<FeatureChip label={node.type} />
{node.udp && <FeatureChip label="UDP" />}
<DelayChip delay={delay} onClick={onClickDelay} />
</Box>
</PaperSwitchButton>
);
});
export const NodeList = () => {
const { data, setGroupProxy, setGlobalProxy, updateProxiesDelay } =
useClashCore();
@@ -189,7 +25,7 @@ export const NodeList = () => {
const [group, setGroup] = useState<Clash.Proxy<Clash.Proxy<string>>>();
useEffect(() => {
const sortGroup = useCallback(() => {
if (!getCurrentMode.global) {
if (proxyGroup.selector !== null) {
const selectedGroup = data?.groups[proxyGroup.selector];
@@ -225,7 +61,17 @@ export const NodeList = () => {
} else {
setGroup(data?.global);
}
}, [data?.groups, proxyGroup.selector, getCurrentMode, proxyGroupSort]);
}, [
data?.groups,
proxyGroup.selector,
getCurrentMode,
proxyGroupSort,
setGroup,
]);
useEffect(() => {
sortGroup();
}, [sortGroup]);
const { column } = useBreakpoint({
sm: 1,
@@ -287,6 +133,10 @@ export const NodeList = () => {
}
};
const { nyanpasuConfig } = useNyanpasu();
const disableMotion = nyanpasuConfig?.lighten_animation_effects;
return (
<AnimatePresence initial={false} mode="sync">
<VList
@@ -305,7 +155,23 @@ export const NodeList = () => {
style={{ gridTemplateColumns: `repeat(${column} , 1fr)` }}
>
{node.map((render) => {
return (
const Card = () => (
<NodeCard
node={render}
now={group?.now}
disabled={group?.type !== "Selector"}
onClick={() => hendleClick(render.name)}
onClickDelay={async () => {
await updateProxiesDelay(render.name);
}}
/>
);
return disableMotion ? (
<div className="relative overflow-hidden">
<Card />
</div>
) : (
<motion.div
key={render.name}
layoutId={`node-${render.renderLayoutKey}`}
@@ -315,15 +181,7 @@ export const NodeList = () => {
animate={{ scale: 1, opacity: 1 }}
exit={{ opacity: 0 }}
>
<NodeCard
node={render}
now={group?.now}
disabled={group?.type !== "Selector"}
onClick={() => hendleClick(render.name)}
onClickDelay={async () => {
await updateProxiesDelay(render.name);
}}
/>
<Card />
</motion.div>
);
})}
@@ -0,0 +1,31 @@
import { useNyanpasu } from "@nyanpasu/interface";
import { AnimatePresence, motion } from "framer-motion";
import { memo } from "react";
export const ProxyGroupName = memo(function ProxyGroupName({
name,
}: {
name: string;
}) {
const { nyanpasuConfig } = useNyanpasu();
const disbaleMotion = nyanpasuConfig?.lighten_animation_effects;
return disbaleMotion ? (
<>{name}</>
) : (
<AnimatePresence mode="sync" initial={false}>
<motion.div
key={`group-name-${name}`}
className="absolute"
initial={{ x: 100, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -100, opacity: 0 }}
>
{name}
</motion.div>
</AnimatePresence>
);
});
export default ProxyGroupName;
@@ -0,0 +1,55 @@
import { proxyGroupSortAtom } from "@/store";
import { Button, Menu, MenuItem } from "@mui/material";
import { useAtom } from "jotai";
import { memo, useState } from "react";
import { useTranslation } from "react-i18next";
export const SortSelector = memo(function SortSelector() {
const { t } = useTranslation();
const [proxyGroupSort, setProxyGroupSort] = useAtom(proxyGroupSortAtom);
type SortType = typeof proxyGroupSort;
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const handleClick = (sort: SortType) => {
setAnchorEl(null);
setProxyGroupSort(sort);
};
const tmaps: { [key: string]: string } = {
default: "Sort by default",
delay: "Sort by delay",
name: "Sort by name",
};
return (
<>
<Button
size="small"
variant="outlined"
sx={{ textTransform: "none" }}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
{t(tmaps[proxyGroupSort])}
</Button>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
{Object.entries(tmaps).map(([key, value], index) => {
return (
<MenuItem key={index} onClick={() => handleClick(key as SortType)}>
{t(value)}
</MenuItem>
);
})}
</Menu>
</>
);
});
export default SortSelector;
@@ -0,0 +1,35 @@
import { useTheme } from "@mui/material";
import { Clash } from "@nyanpasu/interface";
export type History = Clash.Proxy["history"];
export const filterDelay = (history?: History): number => {
if (!history || history.length == 0) {
return 0;
} else {
return history[history.length - 1].delay;
}
};
export const getColorForDelay = (delay: number): string => {
const { palette } = useTheme();
const delayColorMapping: { [key: string]: string } = {
"0": palette.error.main,
"1": palette.text.secondary,
"100": palette.success.main,
"500": palette.warning.main,
"10000": palette.error.main,
};
let color: string = palette.text.secondary;
for (const key in delayColorMapping) {
if (delay <= parseInt(key)) {
color = delayColorMapping[key];
break;
}
}
return color;
};
@@ -1,87 +0,0 @@
import { getProviders } from "@/services/api";
import { updateProxyProvider } from "@/services/cmds";
import { RefreshRounded } from "@mui/icons-material";
import {
Button,
IconButton,
List,
ListItem,
ListItemText,
} from "@mui/material";
import { useLockFn } from "ahooks";
import dayjs from "dayjs";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR, { mutate } from "swr";
import { BaseDialog } from "../base";
export const ProviderButton = () => {
const { t } = useTranslation();
const { data } = useSWR("getProviders", getProviders);
const [open, setOpen] = useState(false);
const hasProvider = Object.keys(data || {}).length > 0;
const handleUpdate = useLockFn(async (key: string) => {
await updateProxyProvider(key);
await mutate("getProxies");
await mutate("getProviders");
});
if (!hasProvider) return null;
return (
<>
<Button
size="small"
variant="outlined"
sx={{ textTransform: "capitalize" }}
onClick={() => setOpen(true)}
>
{t("Provider")}
</Button>
<BaseDialog
open={open}
title={t("Proxy Provider")}
contentSx={{ width: 400 }}
disableOk
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<List sx={{ py: 0, minHeight: 250 }}>
{Object.entries(data || {}).map(([key, item]) => {
const time = dayjs(item.updatedAt);
return (
<ListItem sx={{ p: 0 }} key={key}>
<ListItemText
primary={key}
secondary={
<>
<span style={{ marginRight: "4em" }}>
Type: {item.vehicleType}
</span>
<span title={time.format("YYYY-MM-DD HH:mm:ss")}>
Updated: {time.fromNow()}
</span>
</>
}
/>
<IconButton
size="small"
color="inherit"
title="Update Provider"
onClick={() => handleUpdate(key)}
>
<RefreshRounded />
</IconButton>
</ListItem>
);
})}
</List>
</BaseDialog>
</>
);
};
@@ -1,7 +0,0 @@
.proxyVirtuoso {
:global(div[data-viewport-type="element"]) {
width: calc(100% - 28px * 2) !important;
height: calc(100% - 28px) !important;
padding: 14px 28px;
}
}
@@ -1,4 +0,0 @@
declare const classNames: {
readonly proxyVirtuoso: "proxyVirtuoso";
};
export = classNames;
@@ -1,140 +0,0 @@
import { useProfiles } from "@/hooks/use-profiles";
import { useVerge } from "@/hooks/use-verge";
import {
// updateProxy,
deleteConnection,
getConnections,
providerHealthCheck,
} from "@/services/api";
import { selectProxy } from "@/services/cmds";
import delayManager from "@/services/delay";
import { classNames } from "@/utils";
import { useLockFn } from "ahooks";
import { useRef } from "react";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { BaseEmpty } from "../base";
import styles from "./proxy-group.module.scss";
import { ProxyRender } from "./proxy-render";
import { useRenderList } from "./use-render-list";
interface Props {
mode: string;
}
export const ProxyGroups = (props: Props) => {
const { mode } = props;
const { renderList, onProxies, onHeadState } = useRenderList(mode);
const { verge } = useVerge();
const { current, patchCurrent } = useProfiles();
const virtuosoRef = useRef<VirtuosoHandle>(null);
// 切换分组的节点代理
const handleChangeProxy = useLockFn(
async (group: IProxyGroupItem, proxy: IProxyItem) => {
if (group.type !== "Selector" && group.type !== "Fallback") return;
const { name, now } = group;
await selectProxy(name, proxy.name);
onProxies();
// 断开连接
if (verge?.auto_close_connection) {
getConnections().then(({ connections }) => {
connections.forEach((conn) => {
if (conn.chains.includes(now!)) {
deleteConnection(conn.id);
}
});
});
}
// 保存到selected中
if (!current) return;
if (!current.selected) current.selected = [];
const index = current.selected.findIndex(
(item) => item.name === group.name,
);
if (index < 0) {
current.selected.push({ name, now: proxy.name });
} else {
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),
)
.flatMap((e) => e.proxyCol || e.proxy!)
.filter(Boolean);
const providers = new Set(proxies.map((p) => p!.provider!).filter(Boolean));
if (providers.size) {
Promise.allSettled(
[...providers].map((p) => providerHealthCheck(p)),
).then(() => onProxies());
}
const names = proxies.filter((p) => !p!.provider).map((p) => p!.name);
await delayManager.checkListDelay(names, groupName);
onProxies();
});
// 滚到对应的节点
const handleLocation = (group: IProxyGroupItem) => {
if (!group) return;
const { name, now } = group;
const index = renderList.findIndex(
(e) =>
e.group?.name === name &&
((e.type === 2 && e.proxy?.name === now) ||
(e.type === 4 && e.proxyCol?.some((p) => p.name === now))),
);
if (index >= 0) {
virtuosoRef.current?.scrollToIndex?.({
index,
align: "center",
behavior: "smooth",
});
}
};
if (mode === "direct") {
return <BaseEmpty text="Direct Mode" />;
}
return (
<Virtuoso
ref={virtuosoRef}
style={{ height: "100%" }}
className={classNames(styles.proxyVirtuoso)}
totalCount={renderList.length}
increaseViewportBy={256}
overscan={1200}
itemContent={(index) => (
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
)}
/>
);
};
@@ -1,162 +0,0 @@
import { useVerge } from "@/hooks/use-verge";
import delayManager from "@/services/delay";
import {
AccessTimeRounded,
FilterAltOffRounded,
FilterAltRounded,
MyLocationRounded,
NetworkCheckRounded,
SortByAlphaRounded,
SortRounded,
VisibilityOffRounded,
VisibilityRounded,
WifiTetheringOffRounded,
WifiTetheringRounded,
} from "@mui/icons-material";
import { Box, IconButton, SxProps, TextField } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ProxySortType } from "./use-filter-sort";
import type { HeadState } from "./use-head-state";
interface Props {
sx?: SxProps;
groupName: string;
headState: HeadState;
onLocation: () => void;
onCheckDelay: () => void;
onHeadState: (val: Partial<HeadState>) => void;
}
export const ProxyHead = (props: Props) => {
const { sx = {}, groupName, headState, onHeadState } = props;
const { showType, sortType, filterText, textState, testUrl } = headState;
const { t } = useTranslation();
const [autoFocus, setAutoFocus] = useState(false);
useEffect(() => {
// fix the focus conflict
const timer = setTimeout(() => setAutoFocus(true), 100);
return () => clearTimeout(timer);
}, []);
const { verge } = useVerge();
useEffect(() => {
delayManager.setUrl(groupName, testUrl || verge!.default_latency_test!);
}, [groupName, testUrl, verge?.default_latency_test]);
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, ...sx }}>
<IconButton
size="small"
color="inherit"
title={t("Location")}
onClick={props.onLocation}
>
<MyLocationRounded />
</IconButton>
<IconButton
size="small"
color="inherit"
title={t("Delay check")}
onClick={() => {
// Remind the user that it is custom test url
if (testUrl?.trim() && textState !== "filter") {
onHeadState({ textState: "url" });
}
props.onCheckDelay();
}}
>
<NetworkCheckRounded />
</IconButton>
<IconButton
size="small"
color="inherit"
title={
[t("Sort by default"), t("Sort by delay"), t("Sort by name")][
sortType
]
}
onClick={() =>
onHeadState({ sortType: ((sortType + 1) % 3) as ProxySortType })
}
>
{sortType !== 1 && sortType !== 2 && <SortRounded />}
{sortType === 1 && <AccessTimeRounded />}
{sortType === 2 && <SortByAlphaRounded />}
</IconButton>
<IconButton
size="small"
color="inherit"
title={t("Delay check URL")}
onClick={() =>
onHeadState({ textState: textState === "url" ? null : "url" })
}
>
{textState === "url" ? (
<WifiTetheringRounded />
) : (
<WifiTetheringOffRounded />
)}
</IconButton>
<IconButton
size="small"
color="inherit"
title={t("Proxy detail")}
onClick={() => onHeadState({ showType: !showType })}
>
{showType ? <VisibilityRounded /> : <VisibilityOffRounded />}
</IconButton>
<IconButton
size="small"
color="inherit"
title={t("Filter")}
onClick={() =>
onHeadState({ textState: textState === "filter" ? null : "filter" })
}
>
{textState === "filter" ? (
<FilterAltRounded />
) : (
<FilterAltOffRounded />
)}
</IconButton>
{textState === "filter" && (
<TextField
autoFocus={autoFocus}
hiddenLabel
value={filterText}
size="small"
variant="outlined"
placeholder={t("Filter conditions")}
onChange={(e) => onHeadState({ filterText: e.target.value })}
sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }}
/>
)}
{textState === "url" && (
<TextField
autoFocus={autoFocus}
hiddenLabel
autoSave="off"
autoComplete="off"
value={testUrl}
size="small"
variant="outlined"
placeholder={t("Delay check URL")}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }}
/>
)}
</Box>
);
};
@@ -1,181 +0,0 @@
import { BaseLoading } from "@/components/base";
import delayManager from "@/services/delay";
import { CheckCircleOutlineRounded } from "@mui/icons-material";
import { Box, ListItemButton, Typography, alpha, styled } from "@mui/material";
import { useLockFn } from "ahooks";
import { useEffect, useState } from "react";
interface Props {
groupName: string;
proxy: IProxyItem;
selected: boolean;
showType?: boolean;
onClick?: (name: string) => void;
}
// 多列布局
export const ProxyItemMini = (props: Props) => {
const { groupName, proxy, selected, showType = true, onClick } = props;
// -1/<=0 为 不显示
// -2 为 loading
const [delay, setDelay] = useState(-1);
useEffect(() => {
delayManager.setListener(proxy.name, groupName, setDelay);
return () => {
delayManager.removeListener(proxy.name, groupName);
};
}, [proxy.name, groupName]);
useEffect(() => {
if (!proxy) return;
setDelay(delayManager.getDelayFix(proxy, groupName));
}, [proxy]);
const onDelay = useLockFn(async () => {
setDelay(-2);
setDelay(await delayManager.checkDelay(proxy.name, groupName));
});
return (
<ListItemButton
dense
selected={selected}
onClick={() => onClick?.(proxy.name)}
sx={[
{
height: 56,
borderRadius: 7,
pl: 2,
pr: 1,
justifyContent: "space-between",
alignItems: "center",
},
({ palette: { mode, primary } }) => {
const bgcolor =
mode === "light"
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35);
const color = mode === "light" ? primary.main : primary.light;
const showDelay = delay > 0;
const shadowColor =
mode === "light" ? "rgba(0,0,0,0.04)" : "rgba(255,255,255,0.08)";
return {
"&:hover .the-check": { display: !showDelay ? "block" : "none" },
"&:hover .the-delay": { display: showDelay ? "block" : "none" },
"&:hover .the-icon": { display: "none" },
"&.Mui-selected": { bgcolor, boxShadow: `0 0 0 1px ${bgcolor}` },
"&.Mui-selected .MuiListItemText-secondary": { color },
boxShadow: `0 0 0 1px ${shadowColor}`,
};
},
]}
>
<Box title={proxy.name} sx={{ overflow: "hidden" }}>
<Typography
variant="body2"
component="div"
color="text.secondary"
sx={{
display: "block",
textOverflow: "ellipsis",
wordBreak: "break-all",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{proxy.name}
</Typography>
{showType && (
<Box sx={{ display: "flex", flexWrap: "nowrap", flex: "none" }}>
{!!proxy.provider && (
<TypeBox component="span">{proxy.provider}</TypeBox>
)}
<TypeBox component="span">{proxy.type}</TypeBox>
{proxy.udp && <TypeBox component="span">UDP</TypeBox>}
</Box>
)}
</Box>
<Box sx={{ ml: 0.5, color: "primary.main" }}>
{delay === -2 && (
<Widget>
<BaseLoading />
</Widget>
)}
{!proxy.provider && delay !== -2 && (
// provider的节点不支持检测
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
}}
sx={({ palette }) => ({
display: "none", // hover才显示
":hover": { bgcolor: alpha(palette.primary.main, 0.15) },
})}
>
Check
</Widget>
)}
{delay > 0 && (
// 显示延迟
<Widget
className="the-delay"
onClick={(e) => {
if (proxy.provider) return;
e.preventDefault();
e.stopPropagation();
onDelay();
}}
color={delayManager.formatDelayColor(delay)}
sx={({ palette }) =>
!proxy.provider
? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } }
: {}
}
>
{delayManager.formatDelay(delay)}
</Widget>
)}
{delay !== -2 && delay <= 0 && selected && (
// 展示已选择的icon
<CheckCircleOutlineRounded
className="the-icon"
sx={{ fontSize: 16, mr: 0.5, display: "block" }}
/>
)}
</Box>
</ListItemButton>
);
};
const Widget = styled(Box)(({ theme: { typography } }) => ({
padding: "3px 6px",
fontSize: 14,
fontFamily: typography.fontFamily,
borderRadius: "4px",
}));
const TypeBox = styled(Box)(({ theme: { palette, typography } }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(palette.text.secondary, 0.36),
color: alpha(palette.text.secondary, 0.42),
borderRadius: 4,
fontSize: 10,
fontFamily: typography.fontFamily,
marginRight: "4px",
padding: "0 2px",
lineHeight: 1.25,
}));
@@ -1,170 +0,0 @@
import { BaseLoading } from "@/components/base";
import delayManager from "@/services/delay";
import { CheckCircleOutlineRounded } from "@mui/icons-material";
import {
Box,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
SxProps,
Theme,
alpha,
styled,
} from "@mui/material";
import { useLockFn } from "ahooks";
import { useEffect, useState } from "react";
interface Props {
groupName: string;
proxy: IProxyItem;
selected: boolean;
showType?: boolean;
sx?: SxProps<Theme>;
onClick?: (name: string) => void;
}
const Widget = styled(Box)(() => ({
padding: "3px 6px",
fontSize: 14,
borderRadius: "4px",
}));
const TypeBox = styled(Box)(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.text.secondary, 0.36),
color: alpha(theme.palette.text.secondary, 0.42),
borderRadius: 4,
fontSize: 10,
marginRight: "4px",
padding: "0 2px",
lineHeight: 1.25,
}));
export const ProxyItem = (props: Props) => {
const { groupName, proxy, selected, showType = true, sx, onClick } = props;
// -1/<=0 为 不显示
// -2 为 loading
const [delay, setDelay] = useState(-1);
useEffect(() => {
delayManager.setListener(proxy.name, groupName, setDelay);
return () => {
delayManager.removeListener(proxy.name, groupName);
};
}, [proxy.name, groupName]);
useEffect(() => {
if (!proxy) return;
setDelay(delayManager.getDelayFix(proxy, groupName));
}, [proxy]);
const onDelay = useLockFn(async () => {
setDelay(-2);
setDelay(await delayManager.checkDelay(proxy.name, groupName));
});
return (
<ListItem sx={sx}>
<ListItemButton
dense
selected={selected}
onClick={() => onClick?.(proxy.name)}
sx={[
{ borderRadius: 7 },
({ palette: { mode, primary } }) => {
const bgcolor =
mode === "light"
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35);
const color = mode === "light" ? primary.main : primary.light;
const showDelay = delay > 0;
return {
"&:hover .the-check": { display: !showDelay ? "block" : "none" },
"&:hover .the-delay": { display: showDelay ? "block" : "none" },
"&:hover .the-icon": { display: "none" },
"&.Mui-selected": { bgcolor },
"&.Mui-selected .MuiListItemText-secondary": { color },
};
},
]}
>
<ListItemText
title={proxy.name}
secondary={
<>
<span style={{ marginRight: 4 }}>{proxy.name}</span>
{showType && !!proxy.provider && (
<TypeBox component="span">{proxy.provider}</TypeBox>
)}
{showType && <TypeBox component="span">{proxy.type}</TypeBox>}
{showType && proxy.udp && <TypeBox component="span">UDP</TypeBox>}
</>
}
/>
<ListItemIcon
sx={{ justifyContent: "flex-end", color: "primary.main" }}
>
{delay === -2 && (
<Widget>
<BaseLoading />
</Widget>
)}
{!proxy.provider && delay !== -2 && (
// provider的节点不支持检测
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
}}
sx={({ palette }) => ({
display: "none", // hover才显示
":hover": { bgcolor: alpha(palette.primary.main, 0.15) },
})}
>
Check
</Widget>
)}
{delay > 0 && (
// 显示延迟
<Widget
className="the-delay"
onClick={(e) => {
if (proxy.provider) return;
e.preventDefault();
e.stopPropagation();
onDelay();
}}
color={delayManager.formatDelayColor(delay)}
sx={({ palette }) =>
!proxy.provider
? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } }
: {}
}
>
{delayManager.formatDelay(delay)}
</Widget>
)}
{delay !== -2 && delay <= 0 && selected && (
// 展示已选择的icon
<CheckCircleOutlineRounded
className="the-icon"
sx={{ fontSize: 16 }}
/>
)}
</ListItemIcon>
</ListItemButton>
</ListItem>
);
};
@@ -1,236 +0,0 @@
import {
alpha,
Box,
ListItemText,
ListItemButton,
Typography,
styled,
Paper,
Divider,
} from "@mui/material";
import {
ExpandLessRounded,
ExpandMoreRounded,
InboxRounded,
} from "@mui/icons-material";
import { HeadState } from "./use-head-state";
import { ProxyHead } from "./proxy-head";
import { ProxyItem } from "./proxy-item";
import { ProxyItemMini } from "./proxy-item-mini";
import type { IRenderItem } from "./use-render-list";
interface RenderProps {
item: IRenderItem;
indent: boolean;
onLocation: (group: IProxyGroupItem) => void;
onCheckAll: (groupName: string) => void;
onHeadState: (groupName: string, patch: Partial<HeadState>) => void;
onChangeProxy: (group: IProxyGroupItem, proxy: IProxyItem) => void;
}
export const ProxyRender = (props: RenderProps) => {
const { indent, item, onLocation, onCheckAll, onHeadState, onChangeProxy } =
props;
const { type, group, headState, proxy, proxyCol } = item;
if (type === 0) {
return (
<Paper
sx={{
boxShadow: 2,
borderRadius: 7,
marginTop: 2,
marginBottom: 2,
}}
>
<ListItemButton
dense
onClick={() => onHeadState(group!.name, { open: !headState?.open })}
sx={{
borderRadius: 7,
}}
>
<ListItemText
primary={group!.name}
secondary={
<ListItemTextChild
sx={{
overflow: "hidden",
display: "flex",
alignItems: "center",
pt: "2px",
}}
>
<StyledTypeBox>{group!.type}</StyledTypeBox>
<StyledSubtitle>{group!.now}</StyledSubtitle>
</ListItemTextChild>
}
secondaryTypographyProps={{
sx: { display: "flex", alignItems: "center" },
}}
/>
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
</ListItemButton>
</Paper>
);
}
if (type === 1) {
return (
<Paper
sx={{
boxShadow: 2,
borderTopLeftRadius: "28px",
borderTopRightRadius: "28px",
borderBottomLeftRadius: "0",
borderBottomRightRadius: "0",
}}
>
<ProxyHead
sx={{
pl: 2,
pr: 2,
pt: 1,
pb: 1,
mt: indent ? 1 : 0.5,
}}
groupName={group!.name}
headState={headState!}
onLocation={() => onLocation(group!)}
onCheckDelay={() => onCheckAll(group!.name)}
onHeadState={(p) => onHeadState(group!.name, p)}
/>
<Divider />
</Paper>
);
}
if (type === 2) {
return (
<Paper
sx={{
boxShadow:
"0px 1px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 5px 5px 0px rgba(0,0,0,0.12)",
borderRadius: "0",
}}
>
<ProxyItem
groupName={group!.name}
proxy={proxy!}
selected={group!.now === proxy?.name}
showType={headState?.showType}
sx={{ py: 0, pl: 2, pb: 1 }}
onClick={() => onChangeProxy(group!, proxy!)}
/>
</Paper>
);
}
if (type === 3) {
return (
<Box
sx={{
py: 2,
pl: indent ? 4.5 : 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<InboxRounded sx={{ fontSize: "2.5em", color: "inherit" }} />
<Typography sx={{ color: "inherit" }}>No Proxies</Typography>
</Box>
);
}
if (type === 4) {
return (
<Paper
sx={{
boxShadow:
"0px 1px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 5px 5px 0px rgba(0,0,0,0.12)",
borderRadius: "0",
}}
>
<Box
sx={{
height: 56,
display: "grid",
gap: 1.5,
pl: 2,
pr: 2,
pt: 1,
pb: 1,
gridTemplateColumns: `repeat(${item.col! || 2}, 1fr)`,
}}
>
{proxyCol?.map((proxy) => (
<ProxyItemMini
key={item.key + proxy.name}
groupName={group!.name}
proxy={proxy!}
selected={group!.now === proxy.name}
showType={headState?.showType}
onClick={() => onChangeProxy(group!, proxy!)}
/>
))}
</Box>
</Paper>
);
}
if (type === 5) {
return (
<Paper
sx={{
boxShadow:
"0px 1px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 5px 5px 0px rgba(0,0,0,0.12)",
borderTopLeftRadius: "0",
borderTopRightRadius: "0",
borderBottomLeftRadius: "28px",
borderBottomRightRadius: "28px",
height: 28,
}}
>
<Divider />
</Paper>
);
}
if (type === 6) {
return (
<Box
sx={{
height: 28,
}}
/>
);
}
return null;
};
const StyledSubtitle = styled("span")`
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ListItemTextChild = styled("span")`
display: block;
`;
const StyledTypeBox = styled(ListItemTextChild)(({ 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,
padding: "0 2px",
lineHeight: 1.25,
marginRight: "4px",
}));
@@ -1,125 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import delayManager from "@/services/delay";
// default | delay | alphabet
export type ProxySortType = 0 | 1 | 2;
export default function useFilterSort(
proxies: IProxyItem[],
groupName: string,
filterText: string,
sortType: ProxySortType,
) {
const [refresh, setRefresh] = useState({});
useEffect(() => {
let last = 0;
delayManager.setGroupListener(groupName, () => {
// 简单节流
const now = Date.now();
if (now - last > 666) {
last = now;
setRefresh({});
}
});
return () => {
delayManager.removeGroupListener(groupName);
};
}, [groupName]);
return useMemo(() => {
const fp = filterProxies(proxies, groupName, filterText);
const sp = sortProxies(fp, groupName, sortType);
return sp;
}, [proxies, groupName, filterText, sortType, refresh]);
}
export function filterSort(
proxies: IProxyItem[],
groupName: string,
filterText: string,
sortType: ProxySortType,
) {
const fp = filterProxies(proxies, groupName, filterText);
const sp = sortProxies(fp, groupName, sortType);
return sp;
}
/**
* 可以通过延迟数/节点类型 过滤
*/
const regex1 = /delay([=<>])(\d+|timeout|error)/i;
const regex2 = /type=(.*)/i;
/**
* filter the proxy
* according to the regular conditions
*/
function filterProxies(
proxies: IProxyItem[],
groupName: string,
filterText: string,
) {
if (!filterText) return proxies;
const res1 = regex1.exec(filterText);
if (res1) {
const symbol = res1[1];
const symbol2 = res1[2].toLowerCase();
const value =
symbol2 === "error" ? 1e5 : symbol2 === "timeout" ? 3000 : +symbol2;
return proxies.filter((p) => {
const delay = delayManager.getDelayFix(p, groupName);
if (delay < 0) return false;
if (symbol === "=" && symbol2 === "error") return delay >= 1e5;
if (symbol === "=" && symbol2 === "timeout")
return delay < 1e5 && delay >= 3000;
if (symbol === "=") return delay == value;
if (symbol === "<") return delay <= value;
if (symbol === ">") return delay >= value;
return false;
});
}
const res2 = regex2.exec(filterText);
if (res2) {
const type = res2[1].toLowerCase();
return proxies.filter((p) => p.type.toLowerCase().includes(type));
}
return proxies.filter((p) => p.name.includes(filterText.trim()));
}
/**
* sort the proxy
*/
function sortProxies(
proxies: IProxyItem[],
groupName: string,
sortType: ProxySortType,
) {
if (!proxies) return [];
if (sortType === 0) return proxies;
const list = proxies.slice();
if (sortType === 1) {
list.sort((a, b) => {
const ad = delayManager.getDelayFix(a, groupName);
const bd = delayManager.getDelayFix(b, groupName);
if (ad === -1 || ad === -2) return 1;
if (bd === -1 || bd === -2) return -1;
return ad - bd;
});
} else {
list.sort((a, b) => a.name.localeCompare(b.name));
}
return list;
}
@@ -1,81 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { ProxySortType } from "./use-filter-sort";
import { useProfiles } from "@/hooks/use-profiles";
export interface HeadState {
open?: boolean;
showType: boolean;
sortType: ProxySortType;
filterText: string;
textState: "url" | "filter" | null;
testUrl: string;
}
type HeadStateStorage = Record<string, Record<string, HeadState>>;
const HEAD_STATE_KEY = "proxy-head-state";
export const DEFAULT_STATE: HeadState = {
open: false,
showType: false,
sortType: 0,
filterText: "",
textState: null,
testUrl: "",
};
export function useHeadStateNew() {
const { profiles } = useProfiles();
const current = profiles?.current || "";
const [state, setState] = useState<Record<string, HeadState>>({});
useEffect(() => {
if (!current) {
setState({});
return;
}
try {
const data = JSON.parse(
localStorage.getItem(HEAD_STATE_KEY)!,
) as HeadStateStorage;
const value = data[current] || {};
if (value && typeof value === "object") {
setState(value);
} else {
setState({});
}
} catch {}
}, [current]);
const setHeadState = useCallback(
(groupName: string, obj: Partial<HeadState>) => {
setState((old) => {
const state = old[groupName] || DEFAULT_STATE;
const ret = { ...old, [groupName]: { ...state, ...obj } };
// 保存到存储中
setTimeout(() => {
try {
const item = localStorage.getItem(HEAD_STATE_KEY);
let data = (item ? JSON.parse(item) : {}) as HeadStateStorage;
if (!data || typeof data !== "object") data = {};
data[current] = ret;
localStorage.setItem(HEAD_STATE_KEY, JSON.stringify(data));
} catch {}
});
return ret;
});
},
[current],
);
return [state, setHeadState] as const;
}
@@ -1,171 +0,0 @@
import { useEffect, useMemo } from "react";
import useSWR from "swr";
// import { getProxies } from "@/services/api";
import { useVerge } from "@/hooks/use-verge";
import { getProxies } from "@/services/cmds";
import { filterSort } from "./use-filter-sort";
import {
DEFAULT_STATE,
useHeadStateNew,
type HeadState,
} from "./use-head-state";
import { useWindowWidth } from "./use-window-width";
export interface IRenderItem {
// 组 head item empty | item col | item bottom | empty-padding
type: 0 | 1 | 2 | 3 | 4 | 5 | 6;
key: string;
group?: IProxyGroupItem;
proxy?: IProxyItem;
col?: number;
proxyCol?: IProxyItem[];
headState?: HeadState;
}
export const useRenderList = (mode: string) => {
const { data: proxiesData, mutate: mutateProxies } = useSWR(
"getProxies",
async () => {
const res = await getProxies();
console.log(res);
return res;
},
{ refreshInterval: 45000 },
);
const { verge } = useVerge();
const { width } = useWindowWidth();
let col = Math.floor(verge?.proxy_layout_column || 6);
// 自适应
if (col >= 6 || col <= 0) {
if (width > 1450) col = 4;
else if (width > 1024) col = 3;
else if (width > 900) col = 2;
else if (width >= 600) col = 1;
else col = 1;
}
const [headStates, setHeadState] = useHeadStateNew();
// make sure that fetch the proxies successfully
useEffect(() => {
if (!proxiesData) return;
const { groups, proxies } = proxiesData;
if (
(mode === "rule" && !groups.length) ||
(mode === "global" && proxies.length < 2)
) {
setTimeout(() => mutateProxies(), 500);
}
}, [proxiesData, mode]);
const renderList: IRenderItem[] = useMemo(() => {
if (!proxiesData) return [];
// global 和 direct 使用展开的样式
const useRule = mode === "rule" || mode === "script";
const renderGroups =
(useRule && proxiesData.groups.length
? proxiesData.groups
: [proxiesData.global!]) || [];
const retList = renderGroups.flatMap((group) => {
const headState = headStates[group.name] || DEFAULT_STATE;
const ret: IRenderItem[] = [
{ type: 0, key: group.name, group, headState },
];
if (headState?.open || !useRule) {
const proxies = filterSort(
group.all,
group.name,
headState.filterText,
headState.sortType,
);
ret.push({ type: 1, key: `head-${group.name}`, group, headState });
if (!proxies.length) {
ret.push({ type: 3, key: `empty-${group.name}`, group, headState });
}
// 支持多列布局
if (col > 1) {
const lists = ret.concat(
groupList(proxies, col).map((proxyCol) => ({
type: 4,
key: `col-${group.name}-${proxyCol[0].name}`,
group,
headState,
col,
proxyCol,
})),
);
lists.push({
type: 5,
key: `footer-${group.name}`,
group,
headState,
});
return lists;
}
const lists = ret.concat(
proxies.map((proxy) => ({
type: 2,
key: `${group.name}-${proxy!.name}`,
group,
proxy,
headState,
})),
);
lists.push({
type: 5,
key: `footer-${group.name}`,
});
return lists;
}
return ret;
});
if (!useRule) return retList.slice(1);
console.log(retList);
retList.push({
type: 6,
key: `empty-padding`,
});
return retList;
}, [headStates, proxiesData, mode, col]);
return {
renderList,
onProxies: mutateProxies,
onHeadState: setHeadState,
};
};
function groupList<T = any>(list: T[], size: number): T[][] {
return list.reduce((p, n) => {
if (!p.length) return [[n]];
const i = p.length - 1;
if (p[i].length < size) {
p[i].push(n);
return p;
}
p.push([n]);
return p;
}, [] as T[][]);
}
@@ -1,16 +0,0 @@
import { useEffect, useState } from "react";
export const useWindowWidth = () => {
const [width, setWidth] = useState(() => document.body.clientWidth);
useEffect(() => {
const handleResize = () => setWidth(document.body.clientWidth);
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return { width };
};
@@ -1,78 +0,0 @@
import { styled, Box, Typography } from "@mui/material";
const Item = styled(Box)(({ theme }) => ({
display: "flex",
padding: "4px 16px",
color: theme.palette.text.primary,
}));
const COLOR = [
"primary",
"secondary",
"info.main",
"warning.main",
"success.main",
];
interface Props {
index: number;
value: IRuleItem;
}
const parseColor = (text: string) => {
if (text === "REJECT" || text === "REJECT-DROP") return "error.main";
if (text === "DIRECT") return "text.primary";
let sum = 0;
for (let i = 0; i < text.length; i++) {
sum += text.charCodeAt(i);
}
return COLOR[sum % COLOR.length];
};
const RuleItem = (props: Props) => {
const { index, value } = props;
return (
<Item
sx={{
pl: 3.5,
pr: 3.5,
pt: index === 0 ? 8 : 0,
}}
>
<Typography
color="text.secondary"
variant="body2"
sx={{ lineHeight: 2, minWidth: 30, mr: 2.25, textAlign: "center" }}
>
{index + 1}
</Typography>
<Box sx={{ userSelect: "text" }}>
<Typography component="h6" variant="subtitle1" color="text.primary">
{value.payload || "-"}
</Typography>
<Typography
component="span"
variant="body2"
color="text.secondary"
sx={{ mr: 3, minWidth: 120, display: "inline-block" }}
>
{value.type}
</Typography>
<Typography
component="span"
variant="body2"
color={parseColor(value.proxy)}
>
{value.proxy}
</Typography>
</Box>
</Item>
);
};
export default RuleItem;
@@ -1,407 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import { closeAllConnections } from "@/services/api";
import {
changeClashCore,
fetchLatestCoreVersions,
getCoreVersion,
grantPermission,
restartSidecar,
updateCore,
} from "@/services/cmds";
import getSystem from "@/utils/get-system";
import { FiberManualRecord, Lock, Update } from "@mui/icons-material";
import { LoadingButton } from "@mui/lab";
import {
Box,
CircularProgress,
IconButton,
List,
ListItemButton,
ListItemText,
alpha,
useTheme,
} from "@mui/material";
import { useAsyncEffect, useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { mutate } from "swr";
type ClashCore = Required<IVergeConfig>["clash_core"];
interface Core {
name: string;
core: ClashCore;
version?: string;
latest?: string;
}
const VALID_CORE: Core[] = [
{ name: "Clash Premium", core: "clash" },
{ name: "Mihomo", core: "mihomo" },
{ name: "Mihomo Alpha", core: "mihomo-alpha" },
{ name: "Clash Rust", core: "clash-rs" },
];
const OS = getSystem();
export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { verge } = useVerge();
const [open, setOpen] = useState(false);
const [lock, setLock] = useState(false);
const [validCores, setValidCores] = useState<Core[]>(VALID_CORE);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}));
const { clash_core = "clash" } = verge ?? {};
const [checkUpdatesLoading, setCheckUpdatesLoading] = useState(false);
const onCheckUpdates = useLockFn(async () => {
try {
setCheckUpdatesLoading(true);
const results = await fetchLatestCoreVersions();
const buf = validCores.map((each) => ({
...each,
latest:
each.core === "clash"
? `n${results["clash_premium"]}`
: results[each.core.replace(/-/g, "_") as keyof typeof results],
}));
setValidCores(buf);
useNotification({
title: t("Success"),
body: t("Successfully check updates"),
type: NotificationType.Success,
});
} catch (e) {
if (e instanceof Error) {
useNotification({
title: t("Error"),
body: e.message,
type: NotificationType.Error,
});
} else if (typeof e === "string") {
useNotification({
title: t("Error"),
body: e,
type: NotificationType.Error,
});
} else {
console.error(e);
}
} finally {
setCheckUpdatesLoading(false);
}
});
const [restartLoading, setRestartLoading] = useState(false);
const onRestart = useLockFn(async () => {
try {
setRestartLoading(true);
await restartSidecar();
useNotification({
title: t("Success"),
body: t("Successfully restart core"),
type: NotificationType.Success,
});
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
} finally {
setRestartLoading(false);
}
});
const getCoreVersions = async () => {
try {
const versions = await Promise.all(
VALID_CORE.reduce(
(acc, each) => acc.concat(getCoreVersion(each.core)),
[] as Promise<string>[],
),
);
setValidCores(
VALID_CORE.map((each, idx) => ({
...each,
version: !isNaN(Number(versions[idx][0]))
? `v${versions[idx]}`
: versions[idx],
})),
);
} catch (e) {
if (e instanceof Error) {
useNotification({
title: t("Error"),
body: `Failed to get core version: ${e.message}`,
type: NotificationType.Error,
});
} else if (typeof e === "string") {
useNotification({
title: t("Error"),
body: `Failed to get core version: ${e}`,
type: NotificationType.Error,
});
} else {
console.error(e);
}
}
};
useAsyncEffect(getCoreVersions, []);
return (
<BaseDialog
open={open}
title={
<Box display="flex" gap={2}>
{t("Clash Core")}
<div style={{ flex: 1 }} />
<LoadingButton
variant="outlined"
size="small"
onClick={onCheckUpdates}
loading={checkUpdatesLoading}
disabled={checkUpdatesLoading}
>
{t("Check Updates")}
</LoadingButton>
<LoadingButton
variant="contained"
size="small"
loading={restartLoading}
onClick={onRestart}
disabled={restartLoading}
>
{t("Restart")}
</LoadingButton>
</Box>
}
contentSx={{
pb: 0,
width: 380,
height: 310,
overflowY: "auto",
userSelect: "text",
marginTop: "-8px",
}}
disableOk
cancelBtn={t("Back")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<List component="nav">
{validCores.map((each) => (
<CoreElement
lock={lock}
key={each.core}
selected={each.core === clash_core}
core={each}
onCoreChanged={(_, state) => {
if (state === "start") setLock(true);
else setLock(false);
}}
onCoreUpdated={() => {
getCoreVersions();
}}
/>
))}
</List>
</BaseDialog>
);
});
ClashCoreViewer.displayName = "ClashCoreViewer";
function CoreElement({
selected,
core,
lock,
onCoreChanged,
onCoreUpdated,
}: {
selected: boolean;
core: Core;
lock: boolean;
onCoreChanged: (core: string, state: "start" | "finish") => void;
onCoreUpdated?: (core: string) => void;
}) {
const { t } = useTranslation();
const { mutateVerge } = useVerge();
const theme = useTheme();
const [loading, setLoading] = useState(false);
const needUpdate = core.latest && core.version !== core.latest;
const onCoreChange = useLockFn(async (core: ClashCore) => {
if (selected || lock) return;
try {
setLoading(true);
onCoreChanged(core, "start");
closeAllConnections();
await changeClashCore(core);
mutateVerge();
setTimeout(() => {
mutate("getClashConfig");
mutate("getVersion");
}, 100);
useNotification({
title: t("Success"),
body: `Successfully switch to ${core}`,
type: NotificationType.Success,
});
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
} finally {
setLoading(false);
onCoreChanged(core, "finish");
}
});
const onGrant = useLockFn(async (core: string) => {
try {
await grantPermission(core);
// 自动重启
if (selected) await restartSidecar();
useNotification({
title: t("Success"),
body: `Successfully grant permission to ${core}`,
type: NotificationType.Success,
});
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
const [updateCoreLoading, setUpdateCoreLoading] = useState(false);
const onUpdateCore = useLockFn(
async (core: Required<IVergeConfig>["clash_core"]) => {
try {
setUpdateCoreLoading(true);
await updateCore(core);
mutateVerge();
setTimeout(() => {
mutate("getClashConfig");
mutate("getVersion");
}, 100);
useNotification({
title: t("Success"),
body: `Successfully update core ${core}`,
type: NotificationType.Success,
});
onCoreUpdated?.(core);
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
} finally {
setUpdateCoreLoading(false);
}
},
);
return (
<ListItemButton
selected={selected}
onClick={() => onCoreChange(core.core)}
style={{
position: "relative",
}}
sx={{
backgroundColor: loading
? alpha(theme.palette.action.focus, 0.03)
: undefined,
}}
>
<CircularProgress
style={{
position: "absolute",
left: "50%",
}}
size="1.5em"
color="primary"
thickness={4}
disableShrink={true}
sx={{
visibility: loading ? "visible" : "hidden",
}}
/>
<ListItemText
primary={
<div
style={{
display: "flex",
alignItems: "center",
}}
>
<span>{core.name}</span>
{needUpdate && (
<FiberManualRecord
fontSize="small"
color="secondary"
style={{
transform: "scale(0.5)",
}}
/>
)}
</div>
}
secondary={
needUpdate
? `${core.version} (${core.latest})`
: core.version ?? `/${core.core}`
}
/>
{needUpdate && (
<IconButton
color="inherit"
size="small"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUpdateCore(core.core);
}}
>
{updateCoreLoading ? (
<CircularProgress size="1em" />
) : (
<Update fontSize="inherit" />
)}
</IconButton>
)}
{(OS === "macos" || OS === "linux") && (
<IconButton
color="inherit"
size="small"
edge="end"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onGrant(core.core);
}}
>
<Lock fontSize="inherit" />
</IconButton>
)}
</ListItemButton>
);
}
@@ -1,129 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useProfiles } from "@/hooks/use-profiles";
import { getRuntimeExists } from "@/services/cmds";
import {
DEFAULT_FIELDS,
HANDLE_FIELDS,
OTHERS_FIELDS,
} from "@/utils/clash-fields";
import { InfoRounded } from "@mui/icons-material";
import { Checkbox, Divider, Stack, Tooltip, Typography } from "@mui/material";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
const otherFields = [...OTHERS_FIELDS];
const handleFields = [...HANDLE_FIELDS, ...DEFAULT_FIELDS];
export const ClashFieldViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { profiles = {}, patchProfiles } = useProfiles();
const { data: existsKeys = [], mutate: mutateExists } = useSWR(
"getRuntimeExists",
getRuntimeExists,
);
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
useImperativeHandle(ref, () => ({
open: () => {
mutateExists();
setSelected(profiles.valid || []);
setOpen(true);
},
close: () => setOpen(false),
}));
const handleChange = (item: string) => {
if (!item) return;
setSelected((old) =>
old.includes(item) ? old.filter((e) => e !== item) : [...old, item],
);
};
const handleSave = async () => {
setOpen(false);
const oldSet = new Set(profiles.valid || []);
const curSet = new Set(selected);
const joinSet = new Set(selected.concat([...oldSet]));
if (curSet.size === oldSet.size && curSet.size === joinSet.size) return;
try {
await patchProfiles({ valid: [...curSet] });
// Notice.success("Refresh clash config", 1000);
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
};
return (
<BaseDialog
open={open}
title={t("Clash Field")}
contentSx={{
pb: 0,
width: 320,
height: 300,
overflowY: "auto",
userSelect: "text",
}}
okBtn={t("Save")}
cancelBtn={t("Back")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={handleSave}
>
{otherFields.map((item) => {
const inSelect = selected.includes(item);
const inConfig = existsKeys.includes(item);
return (
<Stack key={item} mb={0.5} direction="row" alignItems="center">
<Checkbox
checked={inSelect}
size="small"
sx={{ p: 0.5 }}
onChange={() => handleChange(item)}
/>
<Typography width="100%">{item}</Typography>
{!inSelect && inConfig && <WarnIcon />}
</Stack>
);
})}
<Divider sx={{ my: 1 }}>
<Typography color="text.secondary" fontSize={14}>
Clash Nyanpasu Control Fields
</Typography>
</Divider>
{handleFields.map((item) => (
<Stack key={item} mb={0.5} direction="row" alignItems="center">
<Checkbox defaultChecked disabled size="small" sx={{ p: 0.5 }} />
<Typography>{item}</Typography>
</Stack>
))}
</BaseDialog>
);
});
ClashFieldViewer.displayName = "ClashFieldViewer";
function WarnIcon() {
return (
<Tooltip title="The field exists in the config but not enabled.">
<InfoRounded color="warning" sx={{ cursor: "pointer", opacity: 0.5 }} />
</Tooltip>
);
}
@@ -1,81 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { useClashInfo } from "@/hooks/use-clash";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import { List, ListItem, ListItemText, TextField } from "@mui/material";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { clashInfo, patchInfo } = useClashInfo();
const { verge, patchVerge } = useVerge();
const [open, setOpen] = useState(false);
const [port, setPort] = useState(
verge?.verge_mixed_port ?? clashInfo?.port ?? 7890,
);
useImperativeHandle(ref, () => ({
open: () => {
if (verge?.verge_mixed_port) setPort(verge?.verge_mixed_port);
setOpen(true);
},
close: () => setOpen(false),
}));
const onSave = useLockFn(async () => {
if (port === verge?.verge_mixed_port) {
setOpen(false);
return;
}
try {
await patchInfo({ "mixed-port": port });
await patchVerge({ verge_mixed_port: port });
setOpen(false);
useNotification({
title: t("Success"),
body: t("Change Clash Port successfully!"),
type: NotificationType.Success,
});
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
return (
<BaseDialog
open={open}
title={t("Clash Port")}
contentSx={{ width: 300 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary="Mixed Port" />
<TextField
size="small"
autoComplete="off"
sx={{ width: 135 }}
value={port}
onChange={(e) =>
setPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
/>
</ListItem>
</List>
</BaseDialog>
);
});
ClashPortViewer.displayName = "ClashPortViewer";
@@ -1,74 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { getRuntimeYaml } from "@/services/cmds";
import { Chip } from "@mui/material";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { monaco } from "@/services/monaco";
import { themeMode as atomThemeMode } from "@/store";
import { useAtomValue } from "jotai";
export const ConfigViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const editorRef = useRef<any>();
const instanceRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const themeMode = useAtomValue(atomThemeMode);
useEffect(() => {
return () => {
if (instanceRef.current) {
instanceRef.current.dispose();
instanceRef.current = null;
}
};
}, []);
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
getRuntimeYaml().then((data) => {
const dom = editorRef.current;
if (!dom) return;
if (instanceRef.current) instanceRef.current.dispose();
instanceRef.current = monaco.editor.create(editorRef.current, {
value: data ?? "# Error\n",
language: "yaml",
theme: themeMode === "light" ? "vs" : "vs-dark",
minimap: { enabled: false },
readOnly: true,
});
});
},
close: () => setOpen(false),
}));
return (
<BaseDialog
open={open}
title={
<>
{t("Runtime Config")} <Chip label={t("ReadOnly")} size="small" />
</>
}
contentSx={{ width: 520, pb: 1, userSelect: "text" }}
cancelBtn={t("Back")}
disableOk
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<div style={{ width: "100%", height: "420px" }} ref={editorRef} />
</BaseDialog>
);
});
ConfigViewer.displayName = "ConfigViewer";
@@ -1,122 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { useClashInfo } from "@/hooks/use-clash";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import {
List,
ListItem,
ListItemText,
MenuItem,
Select,
TextField,
} from "@mui/material";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { clashInfo, patchInfo } = useClashInfo();
const { verge, patchVerge } = useVerge();
const [controller, setController] = useState(clashInfo?.server || "");
const [secret, setSecret] = useState(clashInfo?.secret || "");
const [portStrategy, setPortStrategy] = useState(
verge?.clash_strategy?.external_controller_port_strategy ||
"allow_fallback",
);
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
setController(clashInfo?.server || "");
setSecret(clashInfo?.secret || "");
},
close: () => setOpen(false),
}));
const onSave = useLockFn(async () => {
try {
setLoading(true);
await patchVerge({
clash_strategy: { external_controller_port_strategy: portStrategy },
});
await patchInfo({ "external-controller": controller, secret });
useNotification({
title: t("Success"),
body: t("Change Clash Config successfully!"),
type: NotificationType.Success,
});
setOpen(false);
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
} finally {
setLoading(false);
}
});
return (
<BaseDialog
open={open}
title={t("Clash External Controll")}
contentSx={{ width: 400 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
loading={loading}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary="External Controller" />
<TextField
size="small"
autoComplete="off"
sx={{ width: 175 }}
value={controller}
placeholder="Required"
onChange={(e) => setController(e.target.value)}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary="Port Strategy" />
<Select
size="small"
sx={{ width: 175 }}
defaultValue="allow_fallback"
value={portStrategy}
onChange={(e) =>
setPortStrategy(e.target.value as typeof portStrategy)
}
>
<MenuItem value="allow_fallback">Allow Fallback</MenuItem>
<MenuItem value="fixed">Fixed</MenuItem>
<MenuItem value="random">Random</MenuItem>
</Select>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary="Core Secret" />
<TextField
size="small"
autoComplete="off"
sx={{ width: 175 }}
value={secret}
placeholder="Recommended"
onChange={(e) => setSecret(e.target.value)}
/>
</ListItem>
</List>
</BaseDialog>
);
});
ControllerViewer.displayName = "ControllerViewer";
@@ -1,47 +0,0 @@
import { cloneElement, isValidElement, ReactNode, useRef } from "react";
import noop from "@/utils/noop";
interface Props<Value> {
value?: Value;
valueProps?: string;
loading?: boolean;
onChangeProps?: string;
waitTime?: number;
onFormat?: (...args: any[]) => Value;
onGuard?: (value: Value) => Promise<void>;
onCatch?: (error: Error) => void;
children: ReactNode;
}
export function GuardState<T>(props: Props<T>) {
const {
value,
children,
valueProps = "value",
loading,
onChangeProps = "onChange",
onGuard = noop,
onCatch = noop,
onFormat = (v: T) => v,
} = props;
if (!isValidElement(children)) {
return children as any;
}
const childProps = { ...children.props, loading };
childProps[valueProps] = value;
childProps[onChangeProps] = async (...args: any[]) => {
try {
const newValue = (onFormat as any)(...args);
await onGuard(newValue);
} catch (err: any) {
onCatch(err);
}
};
return cloneElement(children, childProps);
}
@@ -1,107 +0,0 @@
import Kbd from "@/components/common/kbd";
import { parseHotkey } from "@/utils/parse-hotkey";
import { DeleteRounded } from "@mui/icons-material";
import { Box, IconButton, alpha, styled } from "@mui/material";
import { FocusEvent, useRef, useState } from "react";
const KeyWrapper = styled("div")<{
isDuplicate?: boolean;
}>(({ theme, isDuplicate }) => ({
position: "relative",
width: 165,
minHeight: 36,
"> input": {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 1,
opacity: 0,
},
"> input:focus + .list": {
borderColor: alpha(theme.palette.primary.main, 0.75),
},
".list": {
display: "flex",
alignItems: "center",
flexWrap: "wrap",
width: "100%",
height: "100%",
minHeight: 36,
boxSizing: "border-box",
padding: "2px 5px",
border: "1px solid",
borderRadius: 4,
gap: 4,
borderColor: isDuplicate
? theme.palette.error.main
: alpha(theme.palette.text.secondary, 0.15),
"&:last-child": {
marginRight: 0,
},
},
}));
interface Props {
func: string;
isDuplicate: boolean;
value: string[];
onChange: (value: string[]) => void;
onBlur?: (e: FocusEvent, func: string) => void;
}
export const HotkeyInput = (props: Props) => {
const { value, onChange, func, isDuplicate } = props;
const changeRef = useRef<string[]>([]);
const [keys, setKeys] = useState(value);
return (
<Box sx={{ display: "flex", alignItems: "center" }}>
<KeyWrapper isDuplicate={isDuplicate}>
<input
onKeyUp={() => {
const ret = changeRef.current.slice();
if (ret.length) {
onChange(ret);
changeRef.current = [];
}
}}
onKeyDown={(e) => {
const evt = e.nativeEvent;
e.preventDefault();
e.stopPropagation();
const key = parseHotkey(evt.key);
if (key === "UNIDENTIFIED") return;
changeRef.current = [...new Set([...changeRef.current, key])];
setKeys(changeRef.current);
}}
onBlur={(e) => props.onBlur && props.onBlur(e, func)}
/>
<div className="list">
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</div>
</KeyWrapper>
<IconButton
size="small"
title="Delete"
color="inherit"
onClick={() => {
onChange([]);
setKeys([]);
props.onBlur && props.onBlur({} as never, func);
}}
>
<DeleteRounded fontSize="inherit" />
</IconButton>
</Box>
);
};
@@ -1,154 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import { Typography, styled } from "@mui/material";
import { useLatest, useLockFn } from "ahooks";
import {
FocusEvent,
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { HotkeyInput } from "./hotkey-input";
const ItemWrapper = styled("div")`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
`;
const HOTKEY_FUNC = [
"open_or_close_dashboard",
"clash_mode_rule",
"clash_mode_global",
"clash_mode_direct",
"clash_mode_script",
"toggle_system_proxy",
"enable_system_proxy",
"disable_system_proxy",
"toggle_tun_mode",
"enable_tun_mode",
"disable_tun_mode",
];
export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { verge, patchVerge } = useVerge();
const [hotkeyMap, setHotkeyMap] = useState<Record<string, string[]>>({});
const hotkeyMapRef = useLatest(hotkeyMap);
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
const map = {} as typeof hotkeyMap;
verge?.hotkeys?.forEach((text) => {
const [func, key] = text.split(",").map((e) => e.trim());
if (!func || !key) return;
map[func] = key
.split("+")
.map((e) => e.trim())
.map((k) => (k === "PLUS" ? "+" : k));
});
setHotkeyMap(map);
setDuplicateItems([]);
},
close: () => setOpen(false),
}));
// 检查是否有快捷键重复
const [duplicateItems, setDuplicateItems] = useState<string[]>([]);
const isDuplicate = !!duplicateItems.length;
const onBlur = (e: FocusEvent, func: string) => {
console.log(func);
const keys = Object.values(hotkeyMapRef.current).flat().filter(Boolean);
const set = new Set(keys);
if (keys.length !== set.size) {
setDuplicateItems([...duplicateItems, func]);
} else {
setDuplicateItems(duplicateItems.filter((e) => e !== func));
}
};
const saveState = useLockFn(async () => {
const hotkeys = Object.entries(hotkeyMap)
.map(([func, keys]) => {
if (!func || !keys?.length) return "";
const key = keys
.map((k) => k.trim())
.filter(Boolean)
.map((k) => (k === "+" ? "PLUS" : k))
.join("+");
if (!key) return "";
return `${func},${key}`;
})
.filter(Boolean);
try {
await patchVerge({ hotkeys });
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
useEffect(() => {
if (!duplicateItems.length && open) {
saveState();
}
}, [hotkeyMap, duplicateItems, open]);
const onSave = () => {
saveState().then(() => {
setOpen(false);
});
};
return (
<BaseDialog
open={open}
title={t("Hotkey Viewer")}
contentSx={{ width: 450, maxHeight: 330 }}
okBtn={t("Save")}
okBtnDisabled={isDuplicate}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
{HOTKEY_FUNC.map((func) => (
<ItemWrapper key={func}>
<Typography>{t(func)}</Typography>
<HotkeyInput
func={func}
isDuplicate={duplicateItems.includes(func)}
onBlur={onBlur}
value={hotkeyMap[func] ?? []}
onChange={(v) => {
const map = { ...hotkeyMapRef.current, [func]: v };
hotkeyMapRef.current = map;
setHotkeyMap(map);
}}
/>
</ItemWrapper>
))}
</BaseDialog>
);
});
HotkeyViewer.displayName = "HotkeyViewer";
@@ -1,123 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { pageTransitionVariants } from "@/components/layout/page-transition";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import { List, MenuItem, Select } from "@mui/material";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { GuardState } from "./guard-state";
import { SettingItem } from "./setting-comp";
import MDYSwitch from "@/components/common/mdy-switch";
export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState({
theme_blur: false,
traffic_graph: false,
});
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}));
const onSwitchFormat = (_e: any, value: boolean) => value;
const onError = (err: any) => {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
};
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
};
return (
<BaseDialog
open={open}
title={t("Layout Setting")}
contentSx={{ width: 450 }}
disableOk
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<List>
<SettingItem label={t("Theme Blur")}>
<GuardState
value={verge?.theme_blur ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => patchVerge({ theme_blur: e })}
loading={loading["theme_blur"]}
>
<MDYSwitch edge="end" />
</GuardState>
</SettingItem>
<SettingItem label={t("Traffic Graph")}>
<GuardState
value={verge?.traffic_graph ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => patchVerge({ traffic_graph: e })}
loading={loading["traffic_graph"]}
>
<MDYSwitch edge="end" />
</GuardState>
</SettingItem>
<SettingItem label={t("Memory Usage")}>
<GuardState
value={verge?.enable_memory_usage ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => patchVerge({ enable_memory_usage: e })}
>
<MDYSwitch edge="end" />
</GuardState>
</SettingItem>
{/* TODO: 将 select 单独开一个 Modal 以符合 Material Design 的设计 */}
<SettingItem label={t("Page Transition Animation")}>
<Select
value={verge?.page_transition_animation ?? "slide"}
style={{ width: 100 }}
onChange={(e) => {
onChangeData({
page_transition_animation: e.target
.value as keyof typeof pageTransitionVariants,
});
patchVerge({
page_transition_animation: e.target
.value as keyof typeof pageTransitionVariants,
});
}}
>
<MenuItem value="slide">
{t("Page Transition Animation Slide")}
</MenuItem>
<MenuItem value="blur">
{t("Page Transition Animation Blur")}
</MenuItem>
<MenuItem value="transparent">
{t("Page Transition Animation Transparent")}
</MenuItem>
<MenuItem value="none">
{t("Page Transition Animation None")}
</MenuItem>
</Select>
</SettingItem>
</List>
</BaseDialog>
);
});
LayoutViewer.displayName = "LayoutViewer";
@@ -1,191 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import MDYSwitch from "@/components/common/mdy-switch";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import {
List,
ListItem,
ListItemText,
MenuItem,
Select,
TextField,
} from "@mui/material";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { verge, patchVerge } = useVerge();
const [open, setOpen] = useState(false);
const [values, setValues] = useState({
appLogLevel: "info",
autoCloseConnection: false,
enableClashFields: true,
enableBuiltinEnhanced: true,
clashTraySelector: true,
proxyLayoutColumn: 6,
defaultLatencyTest: "",
});
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
setValues({
appLogLevel: verge?.app_log_level ?? "info",
autoCloseConnection: verge?.auto_close_connection ?? false,
enableClashFields: verge?.enable_clash_fields ?? true,
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
clashTraySelector: verge?.clash_tray_selector ?? true,
proxyLayoutColumn: verge?.proxy_layout_column || 6,
defaultLatencyTest: verge?.default_latency_test || "",
});
},
close: () => setOpen(false),
}));
const onSave = useLockFn(async () => {
try {
await patchVerge({
app_log_level: values.appLogLevel,
auto_close_connection: values.autoCloseConnection,
enable_clash_fields: values.enableClashFields,
enable_builtin_enhanced: values.enableBuiltinEnhanced,
proxy_layout_column: values.proxyLayoutColumn,
default_latency_test: values.defaultLatencyTest,
clash_tray_selector: values.clashTraySelector,
});
setOpen(false);
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
return (
<BaseDialog
open={open}
title={t("Miscellaneous")}
contentSx={{ width: 450 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("App Log Level")} />
<Select
size="small"
sx={{ width: 100, "> div": { py: "7.5px" } }}
value={values.appLogLevel}
onChange={(e) => {
setValues((v) => ({
...v,
appLogLevel: e.target.value as string,
}));
}}
>
{["trace", "debug", "info", "warn", "error", "silent"].map((i) => (
<MenuItem value={i} key={i}>
{i[0].toUpperCase() + i.slice(1).toLowerCase()}
</MenuItem>
))}
</Select>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Close Connections")} />
<MDYSwitch
edge="end"
checked={values.autoCloseConnection}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoCloseConnection: c }))
}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Enable Clash Fields Filter")} />
<MDYSwitch
edge="end"
checked={values.enableClashFields}
onChange={(_, c) =>
setValues((v) => ({ ...v, enableClashFields: c }))
}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Enable Builtin Enhanced")} />
<MDYSwitch
edge="end"
checked={values.enableBuiltinEnhanced}
onChange={(_, c) =>
setValues((v) => ({ ...v, enableBuiltinEnhanced: c }))
}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Enable Tray Proxies Selector")} />
<MDYSwitch
edge="end"
checked={values.clashTraySelector}
onChange={(_, c) =>
setValues((v) => ({ ...v, clashTraySelector: c }))
}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Proxy Layout Column")} />
<Select
size="small"
sx={{ width: 100, "> div": { py: "7.5px" } }}
value={values.proxyLayoutColumn}
onChange={(e) => {
setValues((v) => ({
...v,
proxyLayoutColumn: e.target.value as number,
}));
}}
>
<MenuItem value={6} key={6}>
Auto
</MenuItem>
{[1, 2, 3, 4, 5].map((i) => (
<MenuItem value={i} key={i}>
{i}
</MenuItem>
))}
</Select>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Default Latency Test")} />
<TextField
size="small"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
value={values.defaultLatencyTest}
placeholder="http://www.gstatic.com/generate_204"
onChange={(e) =>
setValues((v) => ({ ...v, defaultLatencyTest: e.target.value }))
}
/>
</ListItem>
</List>
</BaseDialog>
);
});
MiscViewer.displayName = "MiscViewer";
@@ -1,146 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import {
checkService,
installService,
patchVergeConfig,
uninstallService,
} from "@/services/cmds";
import { Button, Stack, Typography } from "@mui/material";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
interface Props {
enable: boolean;
}
export const ServiceViewer = forwardRef<DialogRef, Props>((props, ref) => {
const { enable } = props;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { data: status, mutate: mutateCheck } = useSWR(
"checkService",
checkService,
{
revalidateIfStale: false,
shouldRetryOnError: false,
focusThrottleInterval: 36e5, // 1 hour
},
);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}));
const state = status != null ? status : "pending";
const onInstall = useLockFn(async () => {
try {
await installService();
mutateCheck();
setOpen(false);
useNotification({
title: t("Success"),
body: "Service installed successfully",
type: NotificationType.Success,
});
} catch (err: any) {
mutateCheck();
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
const onUninstall = useLockFn(async () => {
try {
if (enable) {
await patchVergeConfig({ enable_service_mode: false });
}
await uninstallService();
mutateCheck();
setOpen(false);
useNotification({
title: t("Success"),
body: "Service uninstalled successfully",
type: NotificationType.Success,
});
} catch (err: any) {
mutateCheck();
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
// fix unhandled error of the service mode
const onDisable = useLockFn(async () => {
try {
await patchVergeConfig({ enable_service_mode: false });
mutateCheck();
setOpen(false);
} catch (err: any) {
mutateCheck();
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
return (
<BaseDialog
open={open}
title={t("Service Mode")}
contentSx={{ width: 360, userSelect: "text" }}
disableFooter
onClose={() => setOpen(false)}
>
<Typography>Current State: {state}</Typography>
{(state === "unknown" || state === "uninstall") && (
<Typography>
Information: Please make sure that the Clash Nyanpasu Service is
installed and enabled
</Typography>
)}
<Stack
direction="row"
spacing={1}
sx={{ mt: 4, justifyContent: "flex-end" }}
>
{state === "uninstall" && enable && (
<Button variant="contained" onClick={onDisable}>
Disable Service Mode
</Button>
)}
{state === "uninstall" && (
<Button variant="contained" onClick={onInstall}>
Install
</Button>
)}
{(state === "active" || state === "installed") && (
<Button variant="outlined" onClick={onUninstall}>
Uninstall
</Button>
)}
</Stack>
</BaseDialog>
);
});
ServiceViewer.displayName = "ServiceViewer";
@@ -1,54 +0,0 @@
import {
Box,
List,
ListItem,
ListItemText,
ListSubheader,
} from "@mui/material";
import React, { ReactNode } from "react";
interface ItemProps {
label: ReactNode;
extra?: ReactNode;
children?: ReactNode;
secondary?: ReactNode;
}
export const SettingItem: React.FC<ItemProps> = (props) => {
const { label, extra, children, secondary } = props;
const primary = !extra ? (
label
) : (
<Box sx={{ display: "flex", alignItems: "center" }}>
<span>{label}</span>
<span
style={{
marginLeft: "8px",
}}
>
{extra}
</span>
</Box>
);
return (
<ListItem sx={{ pt: "5px", pb: "5px" }}>
<ListItemText primary={primary} secondary={secondary} />
{children}
</ListItem>
);
};
export const SettingList: React.FC<{
title: string;
children: ReactNode;
}> = (props) => (
<List>
<ListSubheader sx={{ background: "transparent" }} disableSticky>
{props.title}
</ListSubheader>
{props.children}
</List>
);
@@ -1,184 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import MDYSwitch from "@/components/common/mdy-switch";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import { getSystemProxy } from "@/services/cmds";
import {
Box,
InputAdornment,
List,
ListItem,
ListItemText,
TextField,
Typography,
styled,
} from "@mui/material";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { verge, patchVerge } = useVerge();
type SysProxy = Awaited<ReturnType<typeof getSystemProxy>>;
const [sysproxy, setSysproxy] = useState<SysProxy>();
const {
enable_system_proxy: enabled,
enable_proxy_guard,
system_proxy_bypass,
proxy_guard_duration,
} = verge ?? {};
const [value, setValue] = useState({
guard: enable_proxy_guard,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
});
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
setValue({
guard: enable_proxy_guard,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
});
getSystemProxy().then((p) => setSysproxy(p));
},
close: () => setOpen(false),
}));
const onSave = useLockFn(async () => {
if (value.duration < 1) {
useNotification({
title: t("Error"),
body: "Proxy guard duration at least 1 seconds",
type: NotificationType.Error,
});
return;
}
const patch: Partial<IVergeConfig> = {};
if (value.guard !== enable_proxy_guard) {
patch.enable_proxy_guard = value.guard;
}
if (value.duration !== proxy_guard_duration) {
patch.proxy_guard_duration = value.duration;
}
if (value.bypass !== system_proxy_bypass) {
patch.system_proxy_bypass = value.bypass;
}
try {
await patchVerge(patch);
setOpen(false);
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
return (
<BaseDialog
open={open}
title={t("System Proxy Setting")}
contentSx={{ width: 450, maxHeight: 300 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Proxy Guard")} />
<MDYSwitch
edge="end"
disabled={!enabled}
checked={value.guard}
onChange={(_, e) => setValue((v) => ({ ...v, guard: e }))}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Guard Duration")} />
<TextField
disabled={!enabled}
size="small"
value={value.duration}
sx={{ width: 100 }}
InputProps={{
endAdornment: <InputAdornment position="end">s</InputAdornment>,
}}
onChange={(e) => {
setValue((v) => ({
...v,
duration: +e.target.value.replace(/\D/, ""),
}));
}}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px", alignItems: "start" }}>
<ListItemText primary={t("Proxy Bypass")} sx={{ padding: "3px 0" }} />
<TextField
disabled={!enabled}
size="small"
autoComplete="off"
multiline
rows={3}
sx={{ width: 280 }}
value={value.bypass}
onChange={(e) =>
setValue((v) => ({ ...v, bypass: e.target.value }))
}
/>
</ListItem>
</List>
<Box sx={{ mt: 2.5 }}>
<Typography variant="body1" sx={{ fontSize: "18px", mb: 1 }}>
{t("Current System Proxy")}
</Typography>
<FlexBox>
<Typography className="label">Enable:</Typography>
<Typography className="value">
{(!!sysproxy?.enable).toString()}
</Typography>
</FlexBox>
<FlexBox>
<Typography className="label">Server:</Typography>
<Typography className="value">{sysproxy?.server || "-"}</Typography>
</FlexBox>
<FlexBox>
<Typography className="label">Bypass:</Typography>
<Typography className="value">{sysproxy?.bypass || "-"}</Typography>
</FlexBox>
</Box>
</BaseDialog>
);
});
SysproxyViewer.displayName = "SysproxyViewer";
const FlexBox = styled("div")`
display: flex;
margin-top: 4px;
.label {
flex: none;
width: 80px;
}
`;
@@ -1,79 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import { List, ListItem, ListItemText, TextField } from "@mui/material";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
export const TasksViewer = forwardRef<DialogRef>(
function TasksViewer(props, ref) {
const { t } = useTranslation();
const { verge, patchVerge } = useVerge();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [values, setValues] = useState({
max_log_files: 0,
});
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
setValues({
max_log_files: verge?.max_log_files ?? 7,
});
},
close: () => setOpen(false),
}));
const onSave = useLockFn(async () => {
setLoading(true);
try {
await patchVerge({
max_log_files: values.max_log_files,
});
setOpen(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
} finally {
setLoading(false);
}
});
return (
<BaseDialog
title={t("Tasks")}
open={open}
contentSx={{ width: 450 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
loading={loading}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Max Log Files")} />
<TextField
size="small"
type="number"
value={values.max_log_files}
sx={{ width: 100 }}
onChange={(e) => {
setValues({
...values,
max_log_files: Number.parseInt(e.target.value, 10),
});
}}
/>
</ListItem>
</List>
</BaseDialog>
);
},
);
@@ -1,31 +0,0 @@
import { useTranslation } from "react-i18next";
import { Button, ButtonGroup } from "@mui/material";
type ThemeValue = IVergeConfig["theme_mode"];
interface Props {
value?: ThemeValue;
onChange?: (value: ThemeValue) => void;
}
export const ThemeModeSwitch = (props: Props) => {
const { value, onChange } = props;
const { t } = useTranslation();
const modes = ["light", "dark", "system"] as const;
return (
<ButtonGroup size="small" sx={{ my: "4px" }}>
{modes.map((mode) => (
<Button
key={mode}
variant={mode === value ? "contained" : "outlined"}
onClick={() => onChange?.(mode)}
sx={{ textTransform: "capitalize" }}
>
{t(`theme.${mode}`)}
</Button>
))}
</ButtonGroup>
);
};
@@ -1,185 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import { defaultDarkTheme, defaultTheme } from "@/pages/_theme";
import {
List,
ListItem,
ListItemText,
TextField,
styled,
useTheme,
} from "@mui/material";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { MuiColorInput } from "mui-color-input";
import React from "react";
export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { verge, patchVerge } = useVerge();
const { theme_setting } = verge ?? {};
const [theme, setTheme] = useState(theme_setting || {});
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
setTheme({ ...theme_setting } || {});
},
close: () => setOpen(false),
}));
const textProps = {
size: "small",
autoComplete: "off",
sx: { width: 135 },
} as const;
const handleChange = (field: keyof typeof theme) => (e: any) => {
setTheme((t) => ({ ...t, [field]: e.target.value }));
};
const onSave = useLockFn(async () => {
try {
const msgs = (Object.keys(theme) as Array<keyof typeof theme>).reduce(
(acc, cur) => {
if (theme[cur] === "") {
return acc;
}
// theme.page_transition_duration should be string here
if (cur === "page_transition_duration") {
acc[cur] = parseFloat(
theme.page_transition_duration as unknown as string,
);
} else {
acc[cur] = theme[cur];
}
return acc;
},
{} as Exclude<IVergeConfig["theme_setting"], undefined>,
);
await patchVerge({ theme_setting: msgs });
setOpen(false);
} catch (err: any) {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
}
});
// default theme
const { palette } = useTheme();
const dt = palette.mode === "light" ? defaultTheme : defaultDarkTheme;
type ThemeKey = keyof typeof theme & keyof typeof defaultTheme;
const renderItem = (label: string, key: ThemeKey) => {
const [color, setColor] = React.useState(theme[key] || dt[key]);
const onChange = (color: string) => {
if (!color) {
color = dt[key];
}
setColor(color);
setTheme((t) => ({ ...t, [key]: color }));
};
return (
<Item>
<ListItemText primary={label} />
<MuiColorInput
{...textProps}
value={color}
fallbackValue={dt[key]}
isAlphaHidden
format="hex"
onChange={onChange}
/>
</Item>
);
};
return (
<BaseDialog
open={open}
title={t("Theme Setting")}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
contentSx={{ width: 400, maxHeight: "80%", overflow: "auto", pb: 0 }}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List sx={{ pt: 0 }}>
{renderItem("Primary Color", "primary_color")}
{renderItem("Secondary Color", "secondary_color")}
{renderItem("Primary Text", "primary_text")}
{renderItem("Secondary Text", "secondary_text")}
{renderItem("Info Color", "info_color")}
{renderItem("Error Color", "error_color")}
{renderItem("Warning Color", "warning_color")}
{renderItem("Success Color", "success_color")}
<Item>
<ListItemText primary="Font Family" />
<TextField
{...textProps}
value={theme.font_family ?? ""}
onChange={handleChange("font_family")}
onKeyDown={(e) => e.key === "Enter" && onSave()}
/>
</Item>
<Item>
<ListItemText primary="CSS Injection" />
<TextField
{...textProps}
value={theme.css_injection ?? ""}
onChange={handleChange("css_injection")}
onKeyDown={(e) => e.key === "Enter" && onSave()}
/>
</Item>
<Item>
{/* 单位为秒,内容为浮点数 */}
<ListItemText primary="Page Transition Duration" />
<TextField
{...textProps}
type="number"
value={theme.page_transition_duration ?? ""}
onChange={handleChange("page_transition_duration")}
onKeyDown={(e) => e.key === "Enter" && onSave()}
/>
</Item>
</List>
</BaseDialog>
);
});
ThemeViewer.displayName = "ThemeViewer";
const Item = styled(ListItem)(() => ({
padding: "5px 2px",
}));
const Round = styled("div")(() => ({
width: "24px",
height: "24px",
borderRadius: "18px",
display: "inline-block",
marginRight: "8px",
}));
@@ -1,102 +0,0 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { useMessage } from "@/hooks/use-notification";
import { isPortable } from "@/services/cmds";
import { relaunch } from "@tauri-apps/api/process";
import { checkUpdate, installUpdate } from "@tauri-apps/api/updater";
import { open as openWebUrl } from "@tauri-apps/api/shell";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import Markdown, { Components } from "react-markdown";
import useSWR from "swr";
import { Chip, Tooltip } from "@mui/material";
import { useAtom } from "jotai";
import { atomUpdateState } from "@/store";
export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [updateState, setUpdateState] = useAtom(atomUpdateState);
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
});
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}));
const components: Components = {
a: ({ children, href }) => {
const click = () => openWebUrl(href || "");
return (
<Tooltip title="Show on GitHub">
<Chip
label={children}
size="small"
sx={{ height: "20px" }}
onClick={click}
/>
</Tooltip>
);
},
};
const updatePreprocess = () => {
const context = updateInfo?.manifest?.body;
if (!context) {
return "New Version is available";
}
return context.replace(/@(\w+)/g, "[@$1](https://github.com/$1)");
};
const onUpdate = useLockFn(async () => {
const portable = await isPortable();
if (portable) {
useMessage(t("Portable Update Error"), {
type: "error",
title: t("Error"),
});
return;
}
if (updateState) return;
setUpdateState(true);
try {
await installUpdate();
await relaunch();
} catch (err: any) {
useMessage(err.message || err.toString(), {
type: "error",
title: t("Error"),
});
} finally {
setUpdateState(false);
}
});
return (
<BaseDialog
open={open}
title={`New Version v${updateInfo?.manifest?.version}`}
contentSx={{ minWidth: 360, maxWidth: 400, maxHeight: "80%" }}
okBtn={t("Update")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onUpdate}
>
{/* <UpdateLog dangerouslySetInnerHTML={{ __html: parseContent }} /> */}
<Markdown components={components}>{updatePreprocess()}</Markdown>
</BaseDialog>
);
});
UpdateViewer.displayName = "UpdateViewer";
@@ -1,130 +0,0 @@
import { useState } from "react";
import {
Divider,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
CheckRounded,
CloseRounded,
DeleteRounded,
EditRounded,
OpenInNewRounded,
} from "@mui/icons-material";
interface Props {
value?: string;
onlyEdit?: boolean;
onChange: (value?: string) => void;
onOpenUrl?: (value?: string) => void;
onDelete?: () => void;
onCancel?: () => void;
}
export const WebUIItem = (props: Props) => {
const {
value,
onlyEdit = false,
onChange,
onDelete,
onOpenUrl,
onCancel,
} = props;
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
if (editing || onlyEdit) {
return (
<>
<Stack spacing={0.75} direction="row" mt={1} mb={1} alignItems="center">
<TextField
fullWidth
size="small"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
placeholder={`Support %host %port %secret`}
autoComplete="off"
/>
<IconButton
size="small"
title="Save"
color="inherit"
onClick={() => {
onChange(editValue);
setEditing(false);
}}
>
<CheckRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
title="Cancel"
color="inherit"
onClick={() => {
onCancel?.();
setEditing(false);
}}
>
<CloseRounded fontSize="inherit" />
</IconButton>
</Stack>
<Divider />
</>
);
}
const html = value
?.replace("%host", "<span>%host</span>")
.replace("%port", "<span>%port</span>")
.replace("%secret", "<span>%secret</span>");
return (
<>
<Stack spacing={0.75} direction="row" alignItems="center" mt={1} mb={1}>
<Typography
component="div"
width="100%"
title={value}
color={value ? "text.primary" : "text.secondary"}
sx={({ palette }) => ({
"> span": {
color: palette.primary.main,
},
})}
dangerouslySetInnerHTML={{ __html: html || "NULL" }}
/>
<IconButton
size="small"
title="Open URL"
color="inherit"
onClick={() => onOpenUrl?.(value)}
>
<OpenInNewRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
title="Edit"
color="inherit"
onClick={() => {
setEditing(true);
setEditValue(value);
}}
>
<EditRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
title="Delete"
color="inherit"
onClick={onDelete}
>
<DeleteRounded fontSize="inherit" />
</IconButton>
</Stack>
<Divider />
</>
);
};
@@ -1,145 +0,0 @@
import { BaseDialog, BaseEmpty, DialogRef } from "@/components/base";
import { useClashInfo } from "@/hooks/use-clash";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import { openWebUrl } from "@/services/cmds";
import { Box, Button, Typography } from "@mui/material";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { WebUIItem } from "./web-ui-item";
export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { clashInfo } = useClashInfo();
const { verge, patchVerge, mutateVerge } = useVerge();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}));
const webUIList = verge?.web_ui_list || [];
const handleAdd = useLockFn(async (value: string) => {
const newList = [value, ...webUIList];
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
await patchVerge({ web_ui_list: newList });
});
const handleChange = useLockFn(async (index: number, value?: string) => {
const newList = [...webUIList];
newList[index] = value ?? "";
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
await patchVerge({ web_ui_list: newList });
});
const handleDelete = useLockFn(async (index: number) => {
const newList = [...webUIList];
newList.splice(index, 1);
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
await patchVerge({ web_ui_list: newList });
});
const handleOpenUrl = useLockFn(async (value?: string) => {
if (!value) return;
try {
let url = value.trim().replaceAll("%host", "127.0.0.1");
if (url.includes("%port") || url.includes("%secret")) {
if (!clashInfo) throw new Error("failed to get clash info");
if (!clashInfo.server?.includes(":")) {
throw new Error(`failed to parse the server "${clashInfo.server}"`);
}
const port = clashInfo.server
.slice(clashInfo.server.indexOf(":") + 1)
.trim();
url = url.replaceAll("%port", port || "9090");
url = url.replaceAll(
"%secret",
encodeURIComponent(clashInfo.secret || ""),
);
}
await openWebUrl(url);
} catch (e: any) {
useNotification({
title: t("Error"),
body: e.message || e.toString(),
type: NotificationType.Error,
});
}
});
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between">
{t("Web UI")}
<Button
variant="contained"
size="small"
disabled={editing}
onClick={() => setEditing(true)}
>
{t("New")}
</Button>
</Box>
}
contentSx={{
width: 450,
height: 300,
pb: 1,
overflowY: "auto",
userSelect: "text",
}}
cancelBtn={t("Back")}
disableOk
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
{editing && (
<WebUIItem
value=""
onlyEdit
onChange={(v) => {
setEditing(false);
handleAdd(v || "");
}}
onCancel={() => setEditing(false)}
/>
)}
{!editing && webUIList.length === 0 && (
<BaseEmpty
text="Empty List"
extra={
<Typography mt={2} sx={{ fontSize: "12px" }}>
Replace host, port, secret with &quot;%host&quot;
&quot;%port&quot; &quot;%secret&quot;
</Typography>
}
/>
)}
{webUIList.map((item, index) => (
<WebUIItem
key={index}
value={item}
onChange={(v) => handleChange(index, v)}
onDelete={() => handleDelete(index)}
onOpenUrl={handleOpenUrl}
/>
))}
</BaseDialog>
);
});
WebUIViewer.displayName = "WebUIViewer";
@@ -1,235 +0,0 @@
import { DialogRef } from "@/components/base";
import { useClash } from "@/hooks/use-clash";
import { NotificationType, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import { invoke_uwp_tool } from "@/services/cmds";
import getSystem from "@/utils/get-system";
import { ArrowForward, Settings, Shuffle } from "@mui/icons-material";
import {
IconButton,
MenuItem,
Select,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ClashCoreViewer } from "./mods/clash-core-viewer";
import { ClashFieldViewer } from "./mods/clash-field-viewer";
import { ClashPortViewer } from "./mods/clash-port-viewer";
import { ControllerViewer } from "./mods/controller-viewer";
import { GuardState } from "./mods/guard-state";
import { SettingItem, SettingList } from "./mods/setting-comp";
import { WebUIViewer } from "./mods/web-ui-viewer";
import MDYSwitch from "../common/mdy-switch";
const isWIN = getSystem() === "windows";
interface Props {
onError: (err: Error) => void;
}
const SettingClash = ({ onError }: Props) => {
const { t } = useTranslation();
const { clash, version, mutateClash, patchClash } = useClash();
const { verge, mutateVerge, patchVerge } = useVerge();
const { ipv6, "allow-lan": allowLan, "log-level": logLevel } = clash ?? {};
const { enable_random_port = false, verge_mixed_port } = verge ?? {};
const [loading, setLoading] = useState({
ipv6: false,
"allow-lan": false,
"log-level": false,
});
const patchClashWithLoading = async (value: Partial<IConfigData>) => {
try {
setLoading((prevLoading) => ({
...prevLoading,
...Object.fromEntries(Object.keys(value).map((key) => [key, true])),
}));
await patchClash(value);
} finally {
setLoading((prevLoading) => ({
...prevLoading,
...Object.fromEntries(Object.keys(value).map((key) => [key, false])),
}));
}
};
const webRef = useRef<DialogRef>(null);
const fieldRef = useRef<DialogRef>(null);
const portRef = useRef<DialogRef>(null);
const ctrlRef = useRef<DialogRef>(null);
const coreRef = useRef<DialogRef>(null);
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeVerge = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
};
return (
<SettingList title={t("Clash Setting")}>
<WebUIViewer ref={webRef} />
<ClashFieldViewer ref={fieldRef} />
<ClashPortViewer ref={portRef} />
<ControllerViewer ref={ctrlRef} />
<ClashCoreViewer ref={coreRef} />
<SettingItem label={t("Allow Lan")}>
<GuardState
value={allowLan ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => patchClashWithLoading({ "allow-lan": e })}
loading={loading["allow-lan"]}
>
<MDYSwitch edge="end" />
</GuardState>
</SettingItem>
<SettingItem label={t("IPv6")}>
<GuardState
value={ipv6 ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => patchClashWithLoading({ ipv6: e })}
loading={loading["ipv6"]}
>
<MDYSwitch edge="end" />
</GuardState>
</SettingItem>
<SettingItem label={t("Log Level")}>
<GuardState
// clash premium 2022.08.26 值为warn
value={logLevel === "warn" ? "warning" : logLevel ?? "info"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onGuard={(e) => patchClashWithLoading({ "log-level": e })}
loading={loading["log-level"]}
>
<Select size="small" sx={{ width: 100, "> div": { py: "7.5px" } }}>
<MenuItem value="debug">Debug</MenuItem>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="warning">Warn</MenuItem>
<MenuItem value="error">Error</MenuItem>
<MenuItem value="silent">Silent</MenuItem>
</Select>
</GuardState>
</SettingItem>
<SettingItem
label={t("Mixed Port")}
extra={
<Tooltip title={t("Random Port")}>
<IconButton
color={enable_random_port ? "success" : "inherit"}
size="medium"
onClick={() => {
useNotification({
title: `${t("Random Port")}: ${
enable_random_port ? t("Disable") : t("Enable")
}`,
body: t("After restart to take effect"),
});
onChangeVerge({ enable_random_port: !enable_random_port });
patchVerge({ enable_random_port: !enable_random_port });
}}
>
<Shuffle
fontSize="inherit"
style={{ cursor: "pointer", opacity: 0.75 }}
/>
</IconButton>
</Tooltip>
}
>
<TextField
disabled={enable_random_port}
autoComplete="off"
size="small"
value={verge_mixed_port ?? 7890}
sx={{ width: 100, input: { py: "7.5px", cursor: "pointer" } }}
onClick={(e) => {
portRef.current?.open();
(e.target as any).blur();
}}
/>
</SettingItem>
<SettingItem label={t("External")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => ctrlRef.current?.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Web UI")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => webRef.current?.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Clash Field")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => fieldRef.current?.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem
label={t("Clash Core")}
extra={
<IconButton
color="inherit"
size="small"
onClick={() => coreRef.current?.open()}
>
<Settings
fontSize="inherit"
style={{ cursor: "pointer", opacity: 0.75 }}
/>
</IconButton>
}
>
<Typography sx={{ py: "7px", pr: 1 }}>{version}</Typography>
</SettingItem>
{isWIN && (
<SettingItem label={t("Open UWP tool")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={invoke_uwp_tool}
>
<ArrowForward />
</IconButton>
</SettingItem>
)}
</SettingList>
);
};
export default SettingClash;
@@ -1,74 +0,0 @@
import {
NotificationType,
useMessage,
useNotification,
} from "@/hooks/use-notification";
import Paper from "@mui/material/Paper";
import { BaseCard } from "@nyanpasu/ui";
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { LabelSwitch } from "./modules/clash-field";
import Box from "@mui/material/Box";
export const SettingLegacy = () => {
const { t } = useTranslation();
const [show, setShow] = useState(false);
const legacyComponents = [
() => import("./setting-clash"),
() => import("./setting-system"),
() => import("./setting-verge"),
];
const onError = (err: any) => {
useNotification({
title: t("Error"),
body: err.message || err.toString(),
type: NotificationType.Error,
});
};
const handleChange = async () => {
if (!show) {
const content =
"Legacy Settings will be completely removed in a future update. They are retained here solely for debugging purposes. No fixes will be made for any issues encountered when using them.";
await useMessage(content, {
type: "warning",
title: "Warning",
});
}
setShow(!show);
};
return (
<>
<BaseCard label={t("Legacy Settings")}>
<Box sx={{ pt: 1 }}>
<LabelSwitch
label={t("Enable Legacy Settings")}
checked={show}
onChange={() => handleChange()}
/>
</Box>
</BaseCard>
{show &&
legacyComponents.map((item, index) => {
const AsyncComponent = lazy(item);
return (
<Paper key={index}>
<Suspense>
<AsyncComponent onError={onError} />
</Suspense>
</Paper>
);
})}
</>
);
};
export default SettingLegacy;
@@ -47,6 +47,11 @@ export const SettingNyanpasuMisc = () => {
{...createBooleanProps("clash_tray_selector")}
/>
<SwitchItem
label={t("Lighten up Animation Effects")}
{...createBooleanProps("lighten_animation_effects")}
/>
<TextItem
label={t("Default Latency Test")}
placeholder="http://www.gstatic.com/generate_204"
@@ -1,185 +0,0 @@
import useSWR from "swr";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconButton } from "@mui/material";
import { ArrowForward, PrivacyTipRounded, Settings } from "@mui/icons-material";
import { checkService } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { DialogRef } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp";
import { GuardState } from "./mods/guard-state";
import { ServiceViewer } from "./mods/service-viewer";
import { SysproxyViewer } from "./mods/sysproxy-viewer";
import getSystem from "@/utils/get-system";
import MDYSwitch from "../common/mdy-switch";
interface Props {
onError?: (err: Error) => void;
}
const isWIN = getSystem() === "windows";
const SettingSystem = ({ onError }: Props) => {
const { t } = useTranslation();
const { verge, patchVerge } = useVerge();
// service mode
const { data: serviceStatus } = useSWR(
isWIN ? "checkService" : null,
checkService,
{
revalidateIfStale: false,
shouldRetryOnError: false,
focusThrottleInterval: 36e5, // 1 hour
},
);
const serviceRef = useRef<DialogRef>(null);
const sysproxyRef = useRef<DialogRef>(null);
const {
enable_tun_mode,
enable_auto_launch,
enable_service_mode,
enable_silent_start,
enable_system_proxy,
} = verge ?? {};
const [loading, setLoading] = useState({
enable_tun_mode: false,
enable_auto_launch: false,
enable_service_mode: false,
enable_silent_start: false,
enable_system_proxy: false,
});
const patchVergeWithLoading = async (value: Partial<IVergeConfig>) => {
try {
setLoading((prevLoading) => ({
...prevLoading,
...Object.fromEntries(Object.keys(value).map((key) => [key, true])),
}));
await patchVerge(value);
} finally {
setLoading((prevLoading) => ({
...prevLoading,
...Object.fromEntries(Object.keys(value).map((key) => [key, false])),
}));
}
};
const onSwitchFormat = (_e: any, value: boolean) => value;
return (
<SettingList title={t("System Setting")}>
<SysproxyViewer ref={sysproxyRef} />
{isWIN && (
<ServiceViewer ref={serviceRef} enable={!!enable_service_mode} />
)}
<SettingItem label={t("Tun Mode")}>
<GuardState
value={enable_tun_mode ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => patchVergeWithLoading({ enable_tun_mode: e })}
loading={loading["enable_tun_mode"]}
>
<MDYSwitch edge="end" />
</GuardState>
</SettingItem>
{isWIN && (
<SettingItem
label={t("Service Mode")}
extra={
<IconButton
color="inherit"
size="small"
onClick={() => serviceRef.current?.open()}
>
<PrivacyTipRounded
fontSize="inherit"
style={{ cursor: "pointer", opacity: 0.75 }}
/>
</IconButton>
}
>
<GuardState
value={enable_service_mode ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => patchVergeWithLoading({ enable_service_mode: e })}
loading={loading["enable_service_mode"]}
>
<MDYSwitch
edge="end"
disabled={
serviceStatus !== "active" && serviceStatus !== "installed"
}
/>
</GuardState>
</SettingItem>
)}
<SettingItem
label={t("System Proxy")}
extra={
<IconButton
color="inherit"
size="small"
onClick={() => sysproxyRef.current?.open()}
>
<Settings
fontSize="inherit"
style={{ cursor: "pointer", opacity: 0.75 }}
/>
</IconButton>
}
>
<GuardState
value={enable_system_proxy ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => patchVergeWithLoading({ enable_system_proxy: e })}
loading={loading["enable_system_proxy"]}
>
<MDYSwitch edge="end" />
</GuardState>
</SettingItem>
<SettingItem label={t("Auto Launch")}>
<GuardState
value={enable_auto_launch ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => patchVergeWithLoading({ enable_auto_launch: e })}
loading={loading["enable_auto_launch"]}
>
<MDYSwitch edge="end" />
</GuardState>
</SettingItem>
<SettingItem label={t("Silent Start")}>
<GuardState
value={enable_silent_start ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => patchVergeWithLoading({ enable_silent_start: e })}
loading={loading["enable_auto_launch"]}
>
<MDYSwitch edge="end" />
</GuardState>
</SettingItem>
</SettingList>
);
};
export default SettingSystem;
@@ -1,356 +0,0 @@
import { DialogRef } from "@/components/base";
import { useMessage, useNotification } from "@/hooks/use-notification";
import { useVerge } from "@/hooks/use-verge";
import {
collectLogs,
isPortable,
openAppDir,
openCoreDir,
openLogsDir,
restartApplication,
setCustomAppDir,
} from "@/services/cmds";
import { sleep } from "@/utils";
import getSystem from "@/utils/get-system";
import { ArrowForward, IosShare, Settings } from "@mui/icons-material";
import {
Chip,
CircularProgress,
IconButton,
MenuItem,
Select,
Tooltip,
Typography,
} from "@mui/material";
import { open } from "@tauri-apps/api/dialog";
import { checkUpdate } from "@tauri-apps/api/updater";
import { useAsyncEffect, useLockFn } from "ahooks";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { version } from "~/package.json";
import MDYSwitch from "../common/mdy-switch";
import { ConfigViewer } from "./mods/config-viewer";
import { GuardState } from "./mods/guard-state";
import { HotkeyViewer } from "./mods/hotkey-viewer";
import { LayoutViewer } from "./mods/layout-viewer";
import { MiscViewer } from "./mods/misc-viewer";
import { SettingItem, SettingList } from "./mods/setting-comp";
import { TasksViewer } from "./mods/tasks-viewer";
import { ThemeModeSwitch } from "./mods/theme-mode-switch";
import { ThemeViewer } from "./mods/theme-viewer";
import { UpdateViewer } from "./mods/update-viewer";
interface Props {
onError?: (err: Error) => void;
}
const OS = getSystem();
const SettingVerge = ({ onError }: Props) => {
const { t } = useTranslation();
const { verge, patchVerge } = useVerge();
const { theme_mode, language, disable_auto_check_update } = verge ?? {};
const [portable, setPortable] = useState(false);
useAsyncEffect(async () => {
setPortable(await isPortable());
});
const [loading, setLoading] = useState({
theme_mode: false,
language: false,
onCheckUpdate: false,
});
const tipChips = useRef({
onCheckUpdate: "",
});
const configRef = useRef<DialogRef>(null);
const hotkeyRef = useRef<DialogRef>(null);
const miscRef = useRef<DialogRef>(null);
const themeRef = useRef<DialogRef>(null);
const layoutRef = useRef<DialogRef>(null);
const updateRef = useRef<DialogRef>(null);
const tasksRef = useRef<DialogRef>(null);
const onCheckUpdate = useLockFn(async () => {
try {
setLoading((prevLoading) => ({
...prevLoading,
onCheckUpdate: true,
}));
const info = await checkUpdate();
if (!info?.shouldUpdate) {
tipChips.current.onCheckUpdate = t("No update available");
} else {
updateRef.current?.open();
}
} catch (err: any) {
useMessage(err.message || err.toString(), {
title: t("Error"),
type: "error",
});
} finally {
setLoading((prevLoading) => ({
...prevLoading,
onCheckUpdate: false,
}));
}
});
const onSwitchFormat = (_e: any, value: boolean) => value;
const [changingAppDir, setChangingAppDir] = useState(false);
const changeAppDir = useLockFn(async () => {
setChangingAppDir(true);
try {
const selected = await open({ directory: true, multiple: false }); // TODO: use current app dir as defaultPath
if (!selected) return; // user cancelled the selection
if (Array.isArray(selected)) {
useMessage(t("Multiple directories are not supported"), {
title: t("Error"),
type: "error",
});
return;
}
await setCustomAppDir(selected);
useNotification({
title: t("Success"),
body: t("App directory changed successfully"),
});
await sleep(1000);
await restartApplication();
} catch (err: any) {
useMessage(err.message || err.toString(), {
title: t("Error"),
type: "error",
});
} finally {
setChangingAppDir(false);
}
});
return (
<SettingList title={t("Nyanpasu Setting")}>
<ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} />
<HotkeyViewer ref={hotkeyRef} />
<MiscViewer ref={miscRef} />
<LayoutViewer ref={layoutRef} />
<UpdateViewer ref={updateRef} />
<TasksViewer ref={tasksRef} />
<SettingItem label={t("Language")}>
<GuardState
value={language ?? "en"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onGuard={(e) => patchVerge({ language: e })}
loading={loading["language"]}
>
<Select size="small" sx={{ width: 100, "> div": { py: "7.5px" } }}>
<MenuItem value="zh"></MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ru">Русский</MenuItem>
</Select>
</GuardState>
</SettingItem>
<SettingItem label={t("Theme Mode")}>
<GuardState
value={theme_mode}
onCatch={onError}
onGuard={(e) => patchVerge({ theme_mode: e })}
loading={loading["theme_mode"]}
>
<ThemeModeSwitch />
</GuardState>
</SettingItem>
<SettingItem label={t("Theme Setting")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => themeRef.current?.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Layout Setting")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => layoutRef.current?.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Tasks")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => tasksRef.current?.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Miscellaneous")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => miscRef.current?.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Hotkey Setting")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => hotkeyRef.current?.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Runtime Config")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => configRef.current?.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem
label={t("Open App Dir")}
extra={
<IconButton
color="inherit"
size="small"
disabled={changingAppDir}
onClick={changeAppDir}
>
{changingAppDir ? (
<CircularProgress color="inherit" size={20} />
) : (
<Settings
fontSize="inherit"
style={{ cursor: "pointer", opacity: 0.75 }}
/>
)}
</IconButton>
}
>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={openAppDir}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Open Core Dir")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={openCoreDir}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem
label={t("Open Logs Dir")}
extra={
<Tooltip title={t("Collect Logs")}>
<IconButton
color="inherit"
size="small"
onClick={() => {
collectLogs();
}}
>
<IosShare
fontSize="inherit"
style={{ cursor: "pointer", opacity: 0.75 }}
/>
</IconButton>
</Tooltip>
}
>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={openLogsDir}
>
<ArrowForward />
</IconButton>
</SettingItem>
{!(OS === "windows" && WIN_PORTABLE) && (
<>
<SettingItem
label={t("Check for Updates")}
extra={
tipChips.current.onCheckUpdate && (
<Chip label={tipChips.current.onCheckUpdate} size="small" />
)
}
>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={onCheckUpdate}
disabled={loading["onCheckUpdate"]}
>
{loading["onCheckUpdate"] ? (
<CircularProgress color="inherit" size={24} />
) : (
<ArrowForward />
)}
</IconButton>
</SettingItem>
<SettingItem label={t("Auto Check Updates")}>
<GuardState
value={!disable_auto_check_update}
valueProps="checked"
onFormat={onSwitchFormat}
onCatch={onError}
onGuard={(e) => patchVerge({ disable_auto_check_update: !e })}
>
<MDYSwitch edge="end" />
</GuardState>
</SettingItem>
</>
)}
<SettingItem label={t("Nyanpasu Version")}>
<Typography sx={{ py: "7px", pr: 1 }}>v{version}</Typography>
</SettingItem>
</SettingList>
);
};
export default SettingVerge;

Some files were not shown because too many files have changed in this diff Show More