mirror of
https://github.com/bolucat/Archive.git
synced 2026-04-22 16:07:49 +08:00
Update On Thu Jun 13 20:31:37 CEST 2024
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
@@ -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);
|
||||
}
|
||||
}
|
||||
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
@@ -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;
|
||||
}
|
||||
}
|
||||
-4
@@ -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 "%host"
|
||||
"%port" "%secret"
|
||||
</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
Reference in New Issue
Block a user